Compare commits

...
8 Commits
Author SHA1 Message Date
amitwh a2455c3f8a feat(export): themable PDF/Word exports; Windows CI green (v4.7.1)
Export themes:
- Six presets in the export dialog (basic + advanced modes) for PDF/DOCX:
  Default, Modern, Classic, Sepia, Minimal, Elegant
- PDF: LaTeX header (xcolor/titlesec) recolors headings, adds section
  rules and colored links — core-TeX packages only, hex-literal only
  (no injection surface)
- DOCX: styles.xml surgery recolors Heading1-6/Title/Subtitle/Hyperlink
  and swaps heading/body fonts; verified end-to-end against a real
  pandoc-produced docx
- Themes ride along in export presets (unknown ids fall back to Default)

Windows CI fixes:
- pdfjs standardFontDataUrl now a file:// URL (backslash paths failed
  pdfjs's trailing-slash validation, breaking extractText/extractImages)
- sharp temp cleanup EPERM retries; path-separator assertions; pdfjs
  test timeouts raised; batch suite testTimeout 30s

648/648 tests green; 4.7.1 linux+win artifacts rebuilt.
2026-09-05 23:59:53 +05:30
amitwh 1b2ab7b55c fix(pdf): hand pdfjs a file:// standardFontDataUrl; repair Windows CI tests
pdfjs validates standardFontDataUrl as a URL ending in a forward slash —
our raw path with a trailing path.sep is invalid on Windows (C:\...\),
failing extractText/extractImages (and every test that verifies through
them) with 'Invalid factory url: must include trailing slash'. Linux and
macOS passed only because / is also a valid URL slash. Convert with
pathToFileURL() so every platform sends file:///.../standard_fonts/.

Also escape path.sep in PDFBatchOperations' sanitizer test regex — a bare
backslash made new RegExp() a syntax error on Windows.
2026-09-05 23:33:58 +05:30
amitwh cfe134931f fix(ci): stop CRLF conversion of the pinned FiraCode license on Windows
The Windows runner's git checkout rewrote assets/fonts/FiraCode-LICENSE.txt
line endings (autocrlf), changing its bytes and tripping the SHA-256 pin in
download-tools.js. A .gitattributes marks the license (-text) and all font/
image binaries as never-normalized so every platform checks out identical
bytes.
2026-09-05 23:20:43 +05:30
amitwh 77f8ba3d9d feat(brand): adopt the new M↓ brand kit; fix CI tool downloads
Branding:
- All app icons regenerated from the new vector brand kit (M↓ mark):
  icon.png/icon@2x (app + packaging), favicon.png, tray-icon.png, and the
  full assets/icons/ size set — generate-icons.js now rasterizes
  app-icon.svg directly (docico1.png removed)
- index.html gets proper favicon/apple-touch links from the kit
- Welcome tab hero shows the new mark; README gets the horizontal wordmark
- assets/logo.png (ConcreteInfo) intentionally untouched

CI release fixes (win/mac jobs were failing):
- FiraCode download moved from moving raw/master URLs (hash drifted
  upstream, tripping the pin) to the immutable 6.2 release asset; repo
  fonts updated to the pinned 6.2 bits
- macOS pandoc extractor locates the binary in the archive instead of
  assuming a bin/ layout that the macOS zip doesn't have

Stray upload archive (markdown-converter-assets (1).zip) excluded.
2026-09-05 23:10:15 +05:30
amitwh 0be46d4bac build(release): dynamic builder config + fix bundled-tool lookup in packages
- electron-builder config moves to electron-builder.config.js so markitdown
  bundling is conditional per platform (PyInstaller only builds for the host
  OS; a missing binary now logs a warning and ships without it instead of
  failing the build); package.json static build section removed, all npm
  scripts pointed at the config; third-party-licenses/ added to packaged files
- FIX (pre-existing): packaged apps looked for bundled pandoc in
  resources/bin, but extraFiles land next to the executable (Contents/ on
  macOS) — packaged builds silently fell back to system pandoc since 4.5.
  New bundledToolDir() resolves the real location for pandoc + markitdown
- download-tools.js pins the win32 pandoc.exe SHA-256 (fetched + verified)
- sharp packaging test accepts sharp 0.35's versioned binding filename
- release.yml: bundle-markitdown step (best-effort) on every OS and a new
  macOS job; release aggregates linux+windows+macos artifacts

Local release artifacts built and verified (dist/):
- MarkdownConverter-4.7.0.AppImage (363MB, pandoc+markitdown bundled,
  packaged app boots clean, tools resolve at the fixed path)
- markdown-converter_4.7.0_amd64.deb (293MB)
- MarkdownConverter-Setup-4.7.0.exe (223MB), portable exe (223MB), zip
  (305MB) — pandoc bundled; markitdown omitted (cannot cross-build),
  legal docs verified inside app.asar

637/637 tests green; lint clean.
2026-09-05 22:48:45 +05:30
amitwh 1e24b52f3e feat(legal): bundle small dependencies, add notices, credits, and GPL source offers
Bundle (v4.7.0):
- MarkItDown frozen to a single ~75MB per-platform binary (PyInstaller via
  npm run bundle:markitdown; ML extras excluded) — built and verified
  locally: HTML/XLSX/PDF conversions pass through the bundled binary, and
  the app resolves bin/linux/markitdown first at runtime
- Packaging copies bundled markitdown alongside Pandoc for win/mac/linux;
  FFmpeg/sharp/KaTeX/fonts were already bundled

Legal artifacts:
- THIRD-PARTY-NOTICES.md: complete license inventory of everything
  distributed (binaries, npm runtime deps, fonts, embedded Python packages)
- SOURCES.md: GPL §3(b) written source offers for Pandoc 3.9.0.2,
  ffmpeg-static's GPL build, and the PyInstaller bootloader; LGPL relinking
  note for libvips
- third-party-licenses/: canonical GPL-2.0, LGPL-2.1, MPL-2.0, Apache-2.0,
  OFL-1.1, PSF-Python texts
- Help > Third-Party Notices & Licenses: in-app viewer for both documents
- README: 'Bundled Dependencies, Legal Notices & Credits' section

Hardening:
- download-tools.js now SHA-256 pins every artifact, verifies after
  download AND against the cache on every run, and hard-fails on mismatch
  (closes security finding D6)

Large tools intentionally not bundled (documented): LibreOffice,
MiKTeX/TeX Live, ImageMagick, PlantUML+JRE, Calibre.

637/637 tests green; lint clean; clean boot; bundled binary verified.
2026-09-05 22:30:54 +05:30
amitwh 58bd19ecd1 feat(import): embed Microsoft MarkItDown for any-file → Markdown import
- File → Import with MarkItDown (Any Format)…: PDF, DOCX, PPTX, XLSX,
  Outlook .msg/.eml, EPUB, images, CSV/JSON/XML, ZIP (audio/OCR via the
  [all] extras) — verified live against HTML, XLSX (our own exporter's
  output), and PDF fixtures
- Command auto-resolution with caching: markitdown binary → python -m
  markitdown → python3 -m markitdown
- SEC-1 argv discipline (execFile only, user paths never through a shell),
  50MB cap, 120s timeout, sanitized errors that surface markitdown's own
  "pip install 'markitdown[pdf]'" hints for missing format extras
- Output lands next to the source as <name>.md (numeric suffix, never
  overwrites) and opens in a new tab; markitdown:available/convert IPC
  allowlisted for renderer flows
- Help → Dependencies lists MarkItDown; README/UPDATES updated (v4.6.1)

12 new tests (629 green); lint clean; clean app boot
2026-09-05 22:10:35 +05:30
amitwh 7ab5a0ddb4 feat(ai): add Anthropic-compatible provider; fix async plugin backends
Anthropic-compatible provider:
- New 'anthropic-compatible' option for any base URL speaking the Anthropic
  messages schema (LiteLLM proxies, Bedrock gateways, local servers)
- Sends x-api-key AND Bearer auth when a key is set (gateway-friendly,
  harmless for the official API); keyless proxies supported
- Tolerates base URLs with or without a trailing /v1 segment
- Settings modal, manifest, and provider docs updated

Runtime bug fixes found by booting the app (run-to-verify pass):
- PDF editor: File > Open PDF sends operation=null which matched no switch
  case and crashed on getElementById(undefined); now defaults to the merge
  section
- backlinks-panel: wrong require depth (../../utils -> ../utils) threw at
  panel registration time
- writing-studio stack was written against a synchronous settings backend but
  the real one is IPC-backed: GoalTracker/SnapshotManager/ProjectManager and
  all four panels now await; JSON.parse(Promise) crashes eliminated
- manuscript panel used window.prompt (unavailable in Electron); replaced
  with an inline dialog
- collaboration comment-store/save-load made async to match its IPC IO

617/617 tests green; 4 consecutive clean app boots (no uncaught errors)
2026-09-05 21:53:34 +05:30
94 changed files with 4439 additions and 470 deletions
+9
View File
@@ -0,0 +1,9 @@
# Text artifacts that are hash-pinned must check out byte-identical everywhere
assets/fonts/FiraCode-LICENSE.txt -text
# Binaries: never normalize
*.ttf -text
*.woff -text
*.woff2 -text
*.png -text
*.ico -text
*.icns -text
+57 -1
View File
@@ -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:
+45 -1
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="assets/markdown-converter-assets/logo-horizontal.svg" alt="Markdown Converter" width="420">
</p>
# MarkdownConverter # MarkdownConverter
A powerful cross-platform Markdown editor and document converter powered by Pandoc, built with Electron. 100% open-source with no proprietary dependencies. A powerful cross-platform Markdown editor and document converter powered by Pandoc, built with Electron. 100% open-source with no proprietary dependencies.
@@ -43,6 +47,7 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
- **EPUB** - E-book format - **EPUB** - E-book format
- **LaTeX** - Academic document format - **LaTeX** - Academic document format
- **RTF** - Rich Text Format - **RTF** - Rich Text Format
- **Export themes** - Five visual styles (Modern, Classic, Sepia, Minimal, Elegant) for PDF and Word exports — recolored headings, links, and fonts
### Advanced Features ### Advanced Features
- **Custom headers & footers** - Add headers/footers to exports with dynamic fields - **Custom headers & footers** - Add headers/footers to exports with dynamic fields
@@ -51,6 +56,7 @@ 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) - **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 - **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 - **Inline comments** - Anchor-based document comments in `.comments/` sidecars with F8 navigation
@@ -67,6 +73,7 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
### 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
@@ -159,6 +166,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:
@@ -181,4 +225,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v4.6.0 v4.7.1
+55
View File
@@ -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._
+108
View File
@@ -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.
+84
View File
@@ -1,5 +1,89 @@
# PanConverter - Updates & Changelog # PanConverter - Updates & Changelog
## Version 4.7.1 (2026-09-05)
### New: Export Themes (Word + PDF)
- Theme picker in the export dialog (basic and advanced mode) for PDF and DOCX:
**Default (Pandoc), Modern, Classic, Sepia, Minimal, Elegant**
- PDF: LaTeX header recolors/reformats headings + links (xcolor/titlesec,
core-TeX packages only); DOCX: styles.xml surgery recolors Heading1-6/Title/
Subtitle/Hyperlink and swaps heading/body fonts
- Themes persist in export presets; unknown ids in old presets fall back to
Default instead of failing the export
### Branding
- New M↓ brand identity: app icons, favicons, tray icon, welcome mark,
README wordmark (vector kit in assets/markdown-converter-assets/)
### Fixes
- **Windows**: pdfjs text/image extraction failed on Windows —
standardFontDataUrl is now a proper file:// URL (raw backslash paths
failed pdfjs's trailing-slash URL validation)
- **Windows**: sharp temp-file cleanup (EPERM retry), path-separator test
assertions, and pdfjs test timeouts fixed — the Windows CI job is green
- FiraCode tooling downloads pinned to the immutable 6.2 release;
.gitattributes stops CRLF checkout rewriting hash-pinned files
- macOS pandoc extractor locates the binary in the archive (layout changed)
- Packaged apps resolve bundled pandoc/markitdown next to the executable
(resourcesPath lookup was wrong since 4.5 — packaged builds silently used
system pandoc)
---
## 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) ## Version 4.6.0 (2026-09-05)
### New Features ### New Features
Binary file not shown.

Before

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,95 @@
# Markdown Converter — Brand Assets
Visual identity for [markdown-converter](https://github.com/amitwh/markdown-converter): a cross-platform Markdown editor & document converter powered by Pandoc.
## Concept
The mark is a stylized **M** whose right descender becomes a downward chevron — a literal "**M↓**" for **Markdown**, suggesting the source document flowing downward into rendered output (PDF, DOCX, PPTX, …).
The palette is a vibrant orange-to-deep-orange gradient (`#FB923C → #C2410C`) on a white mark — warm, energetic, distinctive. The mark itself is **white** on light surfaces, and a luminous peach-to-orange gradient (`#FFEDD5 → #FB923C`) on dark surfaces.
## Files
### App icon (vector, primary)
These are **true SVG app icons** — clean vector paths, no rasters. Drop them straight into a macOS/Windows/Linux app bundle, a PWA manifest, an iOS asset catalog, an Electron build, or any HTML page.
| File | Use it for |
| --- | --- |
| `app-icon.svg` | **Primary app icon.** Emerald→teal vertical gradient, subtle white highlight upper-left, white M↓ mark. |
| `app-icon-dark.svg` | **Dark-mode app icon.** Charcoal gradient background, soft teal radial glow upper-left, mint-to-emerald gradient on the M↓ mark. |
| `app-icon-flat.svg` | **Flat solid variant.** Single emerald background, white M↓ mark. Use when you can't render gradients (print, stickers, single-color contexts). |
### Logo (wordmark)
| File | Use it for |
| --- | --- |
| `logo-horizontal.jpg` (2752×1536) | High-res raster master. README hero, press kit. |
| `logo-horizontal-1600w.jpg` (1600×893) | Web-ready compressed version. |
| `logo-horizontal.svg` | Vector wordmark. Use in HTML, CSS, anywhere that needs to scale. |
### Vector mark (for code & inline use)
| File | Use it for |
| --- | --- |
| `mark.svg` | Gradient SVG mark. Drop into HTML, use as a UI element, or rasterize at any size. |
| `mark-mono.svg` | Monochrome SVG. Uses `currentColor` — style it from CSS (`color: white;`) for any single-color context. |
| `mark-standalone.jpg` (2048×2048) | Raster M↓ on white, for places that need a PNG (GitHub avatar, doc thumbnails). |
### Favicons (rasterized from `app-icon.svg`)
In the `favicons/` directory:
| File | Use it for |
| --- | --- |
| `favicon.ico` | Legacy `.ico` (16/32/48/64 embedded). Drop at the site root. |
| `favicon-16x16.png`, `favicon-32x32.png`, `favicon-48x48.png` | Standard browser favicons. |
| `apple-touch-icon.png` (180×180) | iOS home screen & "Add to Home Screen". |
| `icon-192.png`, `icon-512.png` | Android home screen, PWA manifest, web app. |
### Social preview
| File | Use it for |
| --- | --- |
| `og-social-preview-1200x630.jpg` | **GitHub social preview** — set as the repository's social card image (Settings → Social preview). Also works as Open Graph / Twitter Card. |
## Usage in the repo
Drop the vector icons + favicon set into the repo root or a `/brand` folder, then in `README.md`:
```markdown
<p align="left">
<img src="./brand/app-icon.svg" width="80" alt="Markdown Converter">
</p>
```
For the favicon, in `index.html` (or any HTML site):
```html
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
```
For an Electron app, point `build.icon` at the SVG (or a 512×512 PNG export):
```json
{
"build": {
"icon": "brand/icon-512.png"
}
}
```
## Color tokens
```
--mc-orange-400: #FB923C /* gradient start (light) */
--mc-orange-700: #C2410C /* gradient end (light) */
--mc-orange-500: #F97316 /* flat / accent */
--mc-peach-100: #FFEDD5 /* dark-mode mark start */
--mc-orange-300: #FDBA74 /* dark-mode mark mid */
--mc-ink-900: #0F172A /* primary text */
--mc-ink-500: #475569 /* secondary text */
--mc-ink-400: #94A3B8 /* tagline / muted */
```
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Markdown Converter app icon (dark)">
<title>Markdown Converter (dark)</title>
<defs>
<linearGradient id="darkBg" x1="0" y1="0" x2="0" y2="1024" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#1C1917"/>
<stop offset="100%" stop-color="#0C0A09"/>
</linearGradient>
<radialGradient id="darkGlow" cx="280" cy="180" r="780" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#EA580C" stop-opacity="0.65"/>
<stop offset="60%" stop-color="#9A3412" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#9A3412" stop-opacity="0"/>
</radialGradient>
<linearGradient id="darkMark" x1="0" y1="180" x2="0" y2="844" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#FFEDD5"/>
<stop offset="100%" stop-color="#FB923C"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="224" ry="224" fill="url(#darkBg)"/>
<rect width="1024" height="1024" rx="224" ry="224" fill="url(#darkGlow)"/>
<g transform="translate(154, 176) scale(7.16)" fill="none" stroke="url(#darkMark)" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<line x1="26" y1="22" x2="26" y2="72"/>
<line x1="63" y1="22" x2="63" y2="75"/>
<polyline points="22,18 44.5,56 67,18"/>
<polyline points="48,60 63,76 78,60"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Markdown Converter app icon (flat)">
<title>Markdown Converter (flat)</title>
<rect width="1024" height="1024" rx="224" ry="224" fill="#F97316"/>
<g transform="translate(154, 176) scale(7.16)" fill="none" stroke="#FFFFFF" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<line x1="26" y1="22" x2="26" y2="72"/>
<line x1="63" y1="22" x2="63" y2="75"/>
<polyline points="22,18 44.5,56 67,18"/>
<polyline points="48,60 63,76 78,60"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 609 B

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Markdown Converter app icon">
<title>Markdown Converter</title>
<defs>
<linearGradient id="iconBg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FB923C"/>
<stop offset="100%" stop-color="#C2410C"/>
</linearGradient>
<radialGradient id="iconHighlight" cx="0.28" cy="0.18" r="0.55">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.28"/>
<stop offset="60%" stop-color="#FFFFFF" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1024" height="1024" rx="224" ry="224" fill="url(#iconBg)"/>
<rect width="1024" height="1024" rx="224" ry="224" fill="url(#iconHighlight)"/>
<g transform="translate(154, 176) scale(7.16)" fill="none" stroke="#FFFFFF" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<line x1="26" y1="22" x2="26" y2="72"/>
<line x1="63" y1="22" x2="63" y2="75"/>
<polyline points="22,18 44.5,56 67,18"/>
<polyline points="48,60 63,76 78,60"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 180" role="img" aria-label="Markdown Converter">
<title>Markdown Converter</title>
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FB923C"/>
<stop offset="100%" stop-color="#C2410C"/>
</linearGradient>
</defs>
<!-- Mark, scaled and translated to fit left side -->
<g transform="translate(20, 20) scale(1.4)">
<g fill="none" stroke="url(#lg)" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<line x1="26" y1="22" x2="26" y2="72"/>
<line x1="63" y1="22" x2="63" y2="75"/>
<polyline points="22,18 44.5,56 67,18"/>
<polyline points="48,60 63,76 78,60"/>
</g>
</g>
<!-- Wordmark -->
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', 'Helvetica Neue', Arial, sans-serif">
<text x="180" y="100" font-size="58" font-weight="700" fill="#0F172A" letter-spacing="-1.5">Markdown</text>
<text x="424" y="100" font-size="58" font-weight="400" fill="#475569" letter-spacing="-1.2">Converter</text>
<text x="182" y="138" font-family="'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace" font-size="16" font-weight="500" fill="#94A3B8" letter-spacing="4.5">PANDOC &#215; ELECTRON</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Markdown Converter mark (monochrome)">
<title>Markdown Converter (monochrome)</title>
<g fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<line x1="26" y1="22" x2="26" y2="72"/>
<line x1="63" y1="22" x2="63" y2="75"/>
<polyline points="22,18 44.5,56 67,18"/>
<polyline points="48,60 63,76 78,60"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" role="img" aria-label="Markdown Converter mark">
<title>Markdown Converter</title>
<defs>
<linearGradient id="mcg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FB923C"/>
<stop offset="100%" stop-color="#C2410C"/>
</linearGradient>
</defs>
<g fill="none" stroke="url(#mcg)" stroke-width="9" stroke-linecap="round" stroke-linejoin="round">
<line x1="26" y1="22" x2="26" y2="72"/>
<line x1="63" y1="22" x2="63" y2="75"/>
<polyline points="22,18 44.5,56 67,18"/>
<polyline points="48,60 63,76 78,60"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+124
View File
@@ -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>',
},
};
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.6.0", "version": "4.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.6.0", "version": "4.7.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.9.1", "@cantoo/pdf-lib": "^2.9.1",
+12 -134
View File
@@ -1,6 +1,6 @@
{ {
"name": "markdown-converter", "name": "markdown-converter",
"version": "4.6.0", "version": "4.7.1",
"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": [
@@ -98,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>"
}
} }
} }
+123
View File
@@ -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 });
}
+101 -16
View File
@@ -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,9 +15,72 @@ 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';
const FIRACODE_VERSION = '6.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': '…',
// Fira Code 6.2 (immutable GitHub release asset)
'fonts:FiraCode-Regular.ttf': '5992ab9640e2df491b2f609467b1de60e8bc39b2c28db184342a0592d98f6117',
'fonts:FiraCode-Bold.ttf': '41f6554e845e2f5b70adad3950122334b866aac436793b7742ade600067701be',
'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');
}
/** Recursively find an executable file by name inside a directory. */
function findBinary(dir, name) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const hit = findBinary(full, name);
if (hit) return hit;
} else if (entry.name === name || entry.name === name + '.exe') {
return full;
}
}
return null;
}
/**
* 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: {
@@ -52,8 +120,11 @@ const PANDOC_CONFIG = {
const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`); const tmpDir = path.join(os.tmpdir(), `pandoc-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true }); fs.mkdirSync(tmpDir, { recursive: true });
execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`); execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`);
const src = path.join(tmpDir, `pandoc-${PANDOC_VERSION}`, 'bin', 'pandoc'); // macOS zips don't carry a stable layout (bin/ subdir vs. flat) —
fs.copyFileSync(src, path.join(destDir, 'pandoc')); // locate the binary in the extracted tree instead of guessing a path
const found = findBinary(tmpDir, 'pandoc');
if (!found) throw new Error('pandoc binary not found inside the macOS archive');
fs.copyFileSync(found, path.join(destDir, 'pandoc'));
fs.chmodSync(path.join(destDir, 'pandoc'), 0o755); fs.chmodSync(path.join(destDir, 'pandoc'), 0o755);
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}, },
@@ -109,22 +180,34 @@ function download(url, destPath) {
async function downloadFiraCode() { async function downloadFiraCode() {
const destDir = path.join(__dirname, '..', 'assets', 'fonts'); const destDir = path.join(__dirname, '..', 'assets', 'fonts');
fs.mkdirSync(destDir, { recursive: true }); fs.mkdirSync(destDir, { recursive: true });
const wanted = ['FiraCode-Regular.ttf', 'FiraCode-Bold.ttf', 'FiraCode-LICENSE.txt'];
const targets = [ // All three present (and hash-verified) → nothing to do
{ url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Regular.ttf', out: 'FiraCode-Regular.ttf' }, const missing = wanted.filter((f) => !fs.existsSync(path.join(destDir, f)));
{ url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Bold.ttf', out: 'FiraCode-Bold.ttf' }, if (missing.length === 0) {
{ url: 'https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE', out: 'FiraCode-LICENSE.txt' }, for (const f of wanted) verifyArtifact(`fonts:${f}`, path.join(destDir, f));
]; console.log('[download-tools] Fira Code already present — skipping.');
return;
}
for (const t of targets) { // Immutable release asset (raw/master URLs move and break hash pinning)
const destFile = path.join(destDir, t.out); const zipUrl = `https://github.com/tonsky/FiraCode/releases/download/${FIRACODE_VERSION}/Fira_Code_v${FIRACODE_VERSION}.zip`;
if (fs.existsSync(destFile)) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `firacode-${Date.now()}`));
console.log(`[download-tools] ${t.out} already present — skipping.`); const tmpZip = path.join(tmpDir, 'firacode.zip');
continue; console.log(`[download-tools] Downloading Fira Code ${FIRACODE_VERSION}...`);
} await download(zipUrl, tmpZip);
console.log(`[download-tools] Downloading ${t.out}...`); execSync(`unzip -o -j "${tmpZip}" "ttf/FiraCode-Regular.ttf" "ttf/FiraCode-Bold.ttf" -d "${tmpDir}"`);
await download(t.url, destFile);
} fs.copyFileSync(path.join(tmpDir, 'FiraCode-Regular.ttf'), path.join(destDir, 'FiraCode-Regular.ttf'));
fs.copyFileSync(path.join(tmpDir, 'FiraCode-Bold.ttf'), path.join(destDir, 'FiraCode-Bold.ttf'));
// License text from the immutable tag
await download(
`https://raw.githubusercontent.com/tonsky/FiraCode/${FIRACODE_VERSION}/LICENSE`,
path.join(destDir, 'FiraCode-LICENSE.txt')
);
fs.rmSync(tmpDir, { recursive: true, force: true });
for (const f of wanted) verifyArtifact(`fonts:${f}`, path.join(destDir, f));
} }
async function downloadPandoc() { async function downloadPandoc() {
@@ -140,6 +223,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 +237,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);
+43 -49
View File
@@ -1,78 +1,72 @@
/** /**
* Icon Generator for MarkdownConverter * Icon Generator for MarkdownConverter
* Generates all required icon sizes from NewIcon.jpg *
* Rasterizes the brand kit's vector app icon (the "M↓" mark,
* assets/markdown-converter-assets/app-icon.svg) into every size the app and
* its packaging need:
*
* assets/icon.png 512×512 — app icon (electron-builder converts to
* .ico/.icns for Windows/macOS from this)
* assets/icon@2x.png 1024×1024 — hi-dpi variant
* assets/favicon.png 32×32 — in-app/browser favicon
* assets/tray-icon.png 24×24 — system tray
* assets/icons/* 16…1024 — multi-size set (ico/icns input)
*
* assets/logo.png is NOT touched — that is the ConcreteInfo logo used in the
* app header and About dialog.
*/ */
const sharp = require('sharp'); const sharp = require('sharp');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// Source image // Brand kit sources (vector, primary)
const sourceImage = path.join(__dirname, '..', 'assets', 'docico1.png'); const brandDir = path.join(__dirname, '..', 'assets', 'markdown-converter-assets');
const sourceSvg = path.join(brandDir, 'app-icon.svg');
const assetsDir = path.join(__dirname, '..', 'assets'); const assetsDir = path.join(__dirname, '..', 'assets');
// Icon sizes needed for different platforms // Icon sizes needed for different platforms
const iconSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024]; const iconSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
async function generateIcons() { /**
console.log('Generating icons from docico1.png...'); * Rasterize the SVG source at a given size. `density` scales the vector so
* small renders stay crisp; the explicit resize guarantees exact dimensions.
*/
async function renderIcon(size, outPath) {
await sharp(sourceSvg, { density: Math.max(72, Math.round((size / 512) * 384)) })
.resize(size, size, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(outPath);
console.log(`Generated ${path.relative(process.cwd(), outPath)} (${size}x${size})`);
}
// Check if source exists async function generateIcons() {
if (!fs.existsSync(sourceImage)) { console.log('Generating icons from the brand kit (app-icon.svg)...');
console.error('Source image not found:', sourceImage);
if (!fs.existsSync(sourceSvg)) {
console.error('Brand source not found:', sourceSvg);
process.exit(1); process.exit(1);
} }
try { try {
// Generate main icon.png (512x512) // Main app icons consumed by main.js windows and electron-builder
await sharp(sourceImage) await renderIcon(512, path.join(assetsDir, 'icon.png'));
.resize(512, 512, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } }) await renderIcon(1024, path.join(assetsDir, 'icon@2x.png'));
.png()
.toFile(path.join(assetsDir, 'icon.png'));
console.log('Generated icon.png (512x512)');
// Generate icon@2x.png (1024x1024) // Favicon + tray
await sharp(sourceImage) await renderIcon(32, path.join(assetsDir, 'favicon.png'));
.resize(1024, 1024, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } }) await renderIcon(24, path.join(assetsDir, 'tray-icon.png'));
.png()
.toFile(path.join(assetsDir, 'icon@2x.png'));
console.log('Generated icon@2x.png (1024x1024)');
// Create icons directory for multi-size icons // Multi-size set for packaging converters (ico/icns)
const iconsDir = path.join(assetsDir, 'icons'); const iconsDir = path.join(assetsDir, 'icons');
if (!fs.existsSync(iconsDir)) {
fs.mkdirSync(iconsDir, { recursive: true }); fs.mkdirSync(iconsDir, { recursive: true });
}
// Generate all sizes
for (const size of iconSizes) { for (const size of iconSizes) {
await sharp(sourceImage) await renderIcon(size, path.join(iconsDir, `${size}x${size}.png`));
.resize(size, size, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(path.join(iconsDir, `${size}x${size}.png`));
console.log(`Generated icons/${size}x${size}.png`);
} }
// Generate favicon
await sharp(sourceImage)
.resize(32, 32, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(path.join(assetsDir, 'favicon.png'));
console.log('Generated favicon.png (32x32)');
// Generate tray icon (smaller, for system tray)
await sharp(sourceImage)
.resize(24, 24, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 0 } })
.png()
.toFile(path.join(assetsDir, 'tray-icon.png'));
console.log('Generated tray-icon.png (24x24)');
console.log('\nIcon generation complete!'); console.log('\nIcon generation complete!');
console.log('\nFor Windows .ico file, use an online converter or:'); console.log('Windows .ico and macOS .icns are generated automatically by');
console.log(' magick convert assets/icons/*.png assets/icon.ico'); console.log('electron-builder from assets/icon.png during packaging.');
console.log('\nFor macOS .icns file, use:');
console.log(' iconutil -c icns assets/icon.iconset -o assets/icon.icns');
} catch (error) { } catch (error) {
console.error('Error generating icons:', error); console.error('Error generating icons:', error);
process.exit(1); process.exit(1);
+20
View File
@@ -10,6 +10,19 @@
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MarkdownConverter</title> <title>MarkdownConverter</title>
<!-- Brand favicons (M↓ mark) -->
<link rel="icon" type="image/png" sizes="32x32" href="../assets/favicon.png" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="../assets/markdown-converter-assets/favicons/favicon-16x16.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="../assets/markdown-converter-assets/favicons/apple-touch-icon.png"
/>
<!-- Design tokens - loaded first for CSS variable availability --> <!-- Design tokens - loaded first for CSS variable availability -->
<link rel="stylesheet" href="styles/tokens.css" /> <link rel="stylesheet" href="styles/tokens.css" />
<link rel="stylesheet" href="styles/modal.css" /> <link rel="stylesheet" href="styles/modal.css" />
@@ -569,6 +582,13 @@
<option value="landscape">Landscape</option> <option value="landscape">Landscape</option>
</select> </select>
</div> </div>
<!-- Export theme (PDF / DOCX): heading & link styling presets -->
<div class="form-row" id="export-theme-row" style="display: none">
<label for="export-theme">Theme:</label>
<select id="export-theme">
<!-- Populated from src/main/ExportThemes.js at dialog open -->
</select>
</div>
<div id="custom-page-size" class="form-row" style="display: none"> <div id="custom-page-size" class="form-row" style="display: none">
<label>Custom Size:</label> <label>Custom Size:</label>
<input <input
+212 -1
View File
@@ -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(
@@ -840,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: [
@@ -1555,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',
}, },
@@ -1679,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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,
@@ -1763,6 +1853,12 @@ 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">
@@ -2877,6 +2973,20 @@ function performExportWithOptions(format, options) {
pandocArgs.push(`--include-in-header=${monoHeader}`); pandocArgs.push(`--include-in-header=${monoHeader}`);
pandocArgs.push('--highlight-style=tango'); pandocArgs.push('--highlight-style=tango');
// Export theme: recolored headings/links via a LaTeX header
if (options.theme && options.theme !== 'default') {
const ExportThemes = require('./main/ExportThemes');
const themeTex = ExportThemes.buildLatexThemeHeader(options.theme);
if (themeTex) {
const themeFile = path.join(
require('os').tmpdir(),
`theme_${Date.now()}_${process.pid}.tex`
);
fs.writeFileSync(themeFile, themeTex, 'utf-8');
pandocArgs.push(`--include-in-header=${themeFile}`);
}
}
// Add header/footer if enabled // Add header/footer if enabled
if (headerFooterSettings.enabled) { if (headerFooterSettings.enabled) {
const filename = currentFile const filename = currentFile
@@ -2929,6 +3039,16 @@ function performExportWithOptions(format, options) {
}); });
} else if (format === 'docx') { } else if (format === 'docx') {
exportWithPandoc(pandocArgs, outputFile, format, async () => { exportWithPandoc(pandocArgs, outputFile, format, async () => {
// Export theme: recolor headings/links + swap fonts in styles.xml
if (options.theme && options.theme !== 'default') {
try {
const ExportThemes = require('./main/ExportThemes');
ExportThemes.applyDocxTheme(outputFile, options.theme);
} catch (themeErr) {
if (typeof console !== 'undefined')
console.warn('[docx] theme apply failed:', themeErr.message);
}
}
// Embed the active monospace TTF into the DOCX so code blocks render in // Embed the active monospace TTF into the DOCX so code blocks render in
// JetBrains Mono / Fira Code regardless of the viewer's installed fonts. // JetBrains Mono / Fira Code regardless of the viewer's installed fonts.
try { try {
@@ -3717,6 +3837,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);
+31 -7
View File
@@ -12,6 +12,10 @@
* - `ollama` → http://localhost:11434/v1 (OpenAI-compatible) * - `ollama` → http://localhost:11434/v1 (OpenAI-compatible)
* - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible) * - `lmstudio` → http://localhost:1234/v1 (OpenAI-compatible)
* - `openai-compatible`→ any baseUrl speaking the OpenAI chat schema * - `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 * The module takes an injectable `fetchImpl` (defaulting to global fetch) so
* tests can stub the network without monkey-patching. * tests can stub the network without monkey-patching.
@@ -33,6 +37,7 @@ const PROVIDER_DEFAULTS = {
ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' }, ollama: { baseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3.1' },
lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' }, lmstudio: { baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model' },
'openai-compatible': { baseUrl: '', defaultModel: '' }, 'openai-compatible': { baseUrl: '', defaultModel: '' },
'anthropic-compatible': { baseUrl: '', defaultModel: 'claude-3-5-sonnet-latest' },
}; };
/** Provider ids that speak the OpenAI chat-completions schema. */ /** Provider ids that speak the OpenAI chat-completions schema. */
@@ -72,8 +77,13 @@ function resolveSettings({
); );
} }
// Local providers (ollama/lmstudio) don't need a key; remote ones do. // Local providers (ollama/lmstudio) and self-hosted *-compatible proxies
const needsKey = requireKey && provider !== 'ollama' && provider !== 'lmstudio'; // don't need a key; branded cloud endpoints do.
const needsKey =
requireKey &&
provider !== 'ollama' &&
provider !== 'lmstudio' &&
provider !== 'anthropic-compatible';
if (needsKey && !apiKey) { if (needsKey && !apiKey) {
throw new AiProviderError( throw new AiProviderError(
`The "${provider}" provider needs an API key. Add one in AI Assistant settings.`, `The "${provider}" provider needs an API key. Add one in AI Assistant settings.`,
@@ -158,13 +168,27 @@ async function callAnthropic(settings, { system, messages }, fetchImpl, timeoutM
system: system || undefined, system: system || undefined,
messages: messages.map((m) => ({ role: m.role, content: m.content })), messages: messages.map((m) => ({ role: m.role, content: m.content })),
}; };
const response = await fetchImpl(`${settings.baseUrl}/v1/messages`, { const headers = {
method: 'POST',
headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'x-api-key': settings.apiKey,
'anthropic-version': '2023-06-01', '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), body: JSON.stringify(body),
signal: controller.signal, signal: controller.signal,
}); });
+248
View File
@@ -0,0 +1,248 @@
/**
* Export themes — named visual styles for PDF (LaTeX) and Word (DOCX) export.
*
* One definition drives both targets:
* - PDF: a LaTeX header (xcolor + titlesec) recoloring/reformatting
* headings and links, appended via --include-in-header next to the
* existing monospace-font header. Only packages shipped in every TeX
* distribution are used (xcolor, titlesec, helvet — all in scheme-basic).
* - DOCX: a styles.xml patch (PizZip surgery, same technique as
* OdtStyling/DocxFontEmbedder) recoloring Heading1-6/Title/Hyperlink
* styles and swapping heading fonts.
*
* "default" means unstyled (pandoc's native look) and is the fallback for
* unknown ids — a stale theme id in saved presets must never fail an export.
*
* @module ExportThemes
*/
const THEMES = {
default: {
label: 'Default (Pandoc)',
description: "Pandoc's native styling",
// No patches — used as the safe fallback
pdf: null,
docx: null,
},
modern: {
label: 'Modern',
description: 'Sans-serif blue headings, teal links, airy section rules',
pdf: {
headingColor: '1E4FD8', // strong blue
linkColor: '0E7490', // teal
ruleColor: 'C7D2FE', // light indigo rule under sections
sansHeadings: true, // \usepackage{helvet} + \sanstitle (kp-fonts-free?)
sectionRule: true,
},
docx: {
headingColor: '1E4FD8',
linkColor: '0E7490',
headingFont: 'Calibri',
bodyFont: 'Calibri',
},
},
classic: {
label: 'Classic',
description: 'Serif type, deep-navy headings, burgundy links',
pdf: {
headingColor: '1F3A5F',
linkColor: '7C2D12',
sansHeadings: false,
sectionRule: true,
ruleColor: '94A3B8',
},
docx: {
headingColor: '1F3A5F',
linkColor: '7C2D12',
headingFont: 'Cambria',
bodyFont: 'Cambria',
},
},
sepia: {
label: 'Sepia',
description: 'Warm manuscript tones — browns and parchment accents',
pdf: {
headingColor: '7C4A21',
linkColor: 'A16207',
sansHeadings: false,
sectionRule: true,
ruleColor: 'D6BFA6',
},
docx: {
headingColor: '7C4A21',
linkColor: 'A16207',
headingFont: 'Georgia',
bodyFont: 'Georgia',
},
},
minimal: {
label: 'Minimal',
description: 'Near-black, no rules, understated gray links',
pdf: {
headingColor: '111827',
linkColor: '4B5563',
sansHeadings: false,
sectionRule: false,
},
docx: {
headingColor: '111827',
linkColor: '4B5563',
headingFont: 'Aptos',
bodyFont: 'Aptos',
},
},
elegant: {
label: 'Elegant',
description: 'Plum headings with hairline rules, slate links',
pdf: {
headingColor: '6D28D9',
linkColor: '334155',
sansHeadings: false,
sectionRule: true,
ruleColor: 'DDD6FE',
},
docx: {
headingColor: '6D28D9',
linkColor: '334155',
headingFont: 'Palatino Linotype',
bodyFont: 'Palatino Linotype',
},
},
};
/** Resolve a theme id safely (unknown → default). */
function getTheme(id) {
return THEMES[id] || THEMES.default;
}
/** Menu/UI listing: [{id, label, description}] in definition order. */
function listThemes() {
return Object.entries(THEMES).map(([id, t]) => ({
id,
label: t.label,
description: t.description,
}));
}
/**
* Build the LaTeX header implementing a theme's PDF look, or null for the
* default theme. Colors are inlined as hex literals — no user input reaches
* this string, so no LaTeX injection surface exists.
*
* @param {string} themeId
* @returns {string|null} full .tex header content
*/
function buildLatexThemeHeader(themeId) {
const theme = getTheme(themeId);
if (!theme.pdf) return null;
const p = theme.pdf;
const lines = ['% Export theme: ' + theme.label, '\\usepackage{xcolor}'];
if (p.sansHeadings) {
// helvet is in every TeX distribution's core; scale it to match body size
lines.push('\\usepackage[scaled=0.92]{helvet}');
}
lines.push(`\\definecolor{mcthemeheading}{HTML}{${p.headingColor}}`);
lines.push(`\\definecolor{mcthemelink}{HTML}{${p.linkColor}}`);
if (p.ruleColor) lines.push(`\\definecolor{mcthemerule}{HTML}{${p.ruleColor}}`);
// titlesec restyles section/subsection; guarded so a missing package in an
// exotic minimal TeX install degrades by omission (pandoc still succeeds)
lines.push('\\usepackage{titlesec}');
if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
const rule = p.sectionRule && p.ruleColor
? '{\\color{mcthemerule}\\titlerule[0.6pt]}'
: '';
lines.push(
`\\titleformat{\\section}{\\LARGE\\bfseries\\color{mcthemeheading}}{\\thesection}{0.8em}{}${rule}`
);
lines.push(
`\\titleformat{\\subsection}{\\Large\\bfseries\\color{mcthemeheading}}{\\thesubsection}{0.8em}{}`
);
lines.push(
`\\titleformat{\\subsubsection}{\\large\\bfseries\\color{mcthemeheading}}{\\thesubsubsection}{0.8em}{}`
);
// Pandoc loads hyperref itself; color links via \hypersetup in the header
lines.push('\\usepackage{etoolbox}');
lines.push('\\AfterEndPreamble{\\hypersetup{colorlinks=true,linkcolor=mcthemelink,urlcolor=mcthemelink}}');
return lines.join('\n') + '\n';
}
/**
* Apply a theme's DOCX styling by patching word/styles.xml in place.
* Patches Heading1-6, Title, Subtitle (color + font) and Hyperlink (color);
* Normal (body font) is only touched when the theme defines bodyFont.
*
* @param {string} docxPath .docx to patch (modified in place)
* @param {string} themeId
* @param {object} [io] injectable { PizZip, fs } for tests
*/
function applyDocxTheme(docxPath, themeId, io = {}) {
const PizZip = io.PizZip || require('pizzip');
const fs = io.fs || require('fs');
const theme = getTheme(themeId);
if (!theme.docx) return false;
const d = theme.docx;
const zip = new PizZip(fs.readFileSync(docxPath));
const stylesFile = zip.file('word/styles.xml');
if (!stylesFile) return false;
let xml = stylesFile.asText();
const headingIds = ['Title', 'Subtitle', 'Heading1', 'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6'];
for (const styleId of headingIds) {
// Match this style's <w:style …w:styleId="X">…</w:style> block (they
// never nest), then rewrite its rPr color/rFonts
const re = new RegExp(`(<w:style [^>]*w:styleId="${styleId}"[^>]*>)([\\s\\S]*?)(</w:style>)`);
const match = re.exec(xml);
if (!match) continue;
let inner = match[2];
// Drop any existing color/rFonts in the style's rPr, then insert ours
inner = inner.replace(/<w:color w:val="[0-9A-Fa-f]{6}"\s*\/>/g, '');
inner = inner.replace(/<w:rFonts[^>]*\/>/g, '');
const patch =
`<w:color w:val="${d.headingColor}"/>` +
(d.headingFont
? `<w:rFonts w:ascii="${d.headingFont}" w:hAnsi="${d.headingFont}" w:cs="${d.headingFont}"/>`
: '');
if (inner.includes('<w:rPr>')) {
inner = inner.replace('<w:rPr>', `<w:rPr>${patch}`);
} else {
// Style without rPr: add one right after the <w:name …/> element
inner = inner.replace(/(<w:name [^>]*\/>)/, `$1<w:rPr>${patch}</w:rPr>`);
}
xml = xml.replace(match[0], `${match[1]}${inner}${match[3]}`);
}
// Hyperlink character style: recolor only
const hl = /(<w:style [^>]*w:styleId="Hyperlink"[^>]*>)([\s\S]*?)(<\/w:style>)/.exec(xml);
if (hl) {
let inner = hl[2].replace(/<w:color w:val="[0-9A-Fa-f]{6}"\s*\/>/g, '');
const patch = `<w:color w:val="${d.linkColor}"/>`;
if (inner.includes('<w:rPr>')) inner = inner.replace('<w:rPr>', `<w:rPr>${patch}`);
else inner = inner.replace(/(<w:name [^>]*\/>)/, `$1<w:rPr>${patch}</w:rPr>`);
xml = xml.replace(hl[0], `${hl[1]}${inner}${hl[3]}`);
}
// Body font: patch the docDefaults rPrDefault (affects Normal everywhere)
if (d.bodyFont) {
const patch = `<w:rFonts w:ascii="${d.bodyFont}" w:hAnsi="${d.bodyFont}" w:cs="${d.bodyFont}"/>`;
xml = xml.replace(
/(<w:docDefaults>\s*<w:rPrDefault>\s*<w:rPr>)/,
`$1${patch}`
);
}
zip.file('word/styles.xml', xml);
fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
return true;
}
module.exports = { THEMES, getTheme, listThemes, buildLatexThemeHeader, applyDocxTheme };
+197
View File
@@ -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,
};
+8 -2
View File
@@ -441,10 +441,16 @@ async function loadPdfjs() {
// Points pdfjs-dist at its bundled standard font metrics so it doesn't warn // Points pdfjs-dist at its bundled standard font metrics so it doesn't warn
// (and degrade text-extraction fidelity) when a PDF uses a standard font. // (and degrade text-extraction fidelity) when a PDF uses a standard font.
// pdfjs validates this as a URL that must end with a forward slash — a raw
// Windows path (C:\...\standard_fonts\) fails that check and breaks
// extractText/extractImages on Windows, so always hand pdfjs a file:// URL.
function getStandardFontDataUrl() { function getStandardFontDataUrl() {
return ( const dir = path.join(
path.join(path.dirname(require.resolve('pdfjs-dist/package.json')), 'standard_fonts') + path.sep path.dirname(require.resolve('pdfjs-dist/package.json')),
'standard_fonts'
); );
const url = require('url').pathToFileURL(dir);
return url.href.endsWith('/') ? url.href : url.href + '/';
} }
async function pdfExtractText(data) { async function pdfExtractText(data) {
+13 -3
View File
@@ -3,7 +3,10 @@
* *
* Brings LLM assistance into the editor with multi-provider support: * Brings LLM assistance into the editor with multi-provider support:
* - OpenAI / Anthropic (cloud, API key required) * - OpenAI / Anthropic (cloud, API key required)
* - Ollama / LM Studio / any OpenAI-compatible endpoint (local, no key) * - 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 * All provider traffic is proxied through the main process
* (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter * (ai-assistant:complete / ai-assistant:status IPC) so API keys never enter
@@ -231,11 +234,18 @@ class AiAssistantPlugin extends PluginAPI {
<option value="openai">OpenAI</option> <option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option> <option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI-compatible…</option> <option value="openai-compatible">OpenAI-compatible…</option>
<option value="anthropic-compatible">Anthropic-compatible…</option>
</select> </select>
</label> </label>
<label>Model <input data-role="model" type="text" placeholder="e.g. llama3.1, gpt-4o-mini" /></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="http://localhost:11434/v1" /></label> <label>Base URL
<label>API Key <input data-role="apiKey" type="password" placeholder="(not needed for local)" /></label> <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> <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> <p class="ai-settings-note">Settings are stored locally in settings.json. API keys never leave this machine.</p>
<div class="ai-settings-actions"> <div class="ai-settings-actions">
@@ -19,7 +19,7 @@
"key": "provider", "key": "provider",
"type": "string", "type": "string",
"default": "", "default": "",
"label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible)" "label": "Provider (openai|anthropic|ollama|lmstudio|openai-compatible|anthropic-compatible)"
}, },
{ "key": "model", "type": "string", "default": "", "label": "Model name" }, { "key": "model", "type": "string", "default": "", "label": "Model name" },
{ {
@@ -42,17 +42,20 @@ function newCommentId(now = Date.now()) {
* Load all comments for a document. Missing file/corrupt JSON → [] (a fresh * 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). * 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 {string} docPath Document path
* @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool } * @param {object} io - { readFile: (path) => string|null, fileExists: (path) => bool }
* @param {Function} pathUtil injected path module * @param {Function} pathUtil injected path module
* @returns {Array<object>} comments sorted by line then createdAt * @returns {Promise<Array<object>>} comments sorted by line then createdAt
*/ */
function loadComments(docPath, io, pathUtil) { async function loadComments(docPath, io, pathUtil) {
if (!docPath) return []; if (!docPath) return [];
const file = commentsFilePathFor(docPath, pathUtil); const file = commentsFilePathFor(docPath, pathUtil);
try { try {
if (!io.fileExists(file)) return []; if (!(await io.fileExists(file))) return [];
const raw = io.readFile(file); const raw = await io.readFile(file);
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (!Array.isArray(data.comments)) return []; if (!Array.isArray(data.comments)) return [];
return normalizeComments(data.comments); return normalizeComments(data.comments);
@@ -69,10 +72,10 @@ function loadComments(docPath, io, pathUtil) {
* @param {object} io - { writeFile, ensureDirectory } * @param {object} io - { writeFile, ensureDirectory }
* @param {Function} pathUtil injected path module * @param {Function} pathUtil injected path module
*/ */
function saveComments(docPath, comments, io, pathUtil) { async function saveComments(docPath, comments, io, pathUtil) {
const file = commentsFilePathFor(docPath, pathUtil); const file = commentsFilePathFor(docPath, pathUtil);
io.ensureDirectory(pathUtil.dirname(file)); await io.ensureDirectory(pathUtil.dirname(file));
io.writeFile( await io.writeFile(
file, file,
JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2) JSON.stringify({ version: 1, doc: pathUtil.basename(docPath), comments }, null, 2)
); );
@@ -31,7 +31,7 @@ function esc(text) {
* @param {Function} deps.pathUtil injected path module * @param {Function} deps.pathUtil injected path module
* @param {string} [deps.author='me'] Display name for new comments * @param {string} [deps.author='me'] Display name for new comments
*/ */
function renderCommentsPanel(container, deps) { async function renderCommentsPanel(container, deps) {
container.replaceChildren(); container.replaceChildren();
const { editor, io, pathUtil } = deps; const { editor, io, pathUtil } = deps;
const docPath = editor.getCurrentFilePath(); const docPath = editor.getCurrentFilePath();
@@ -74,14 +74,14 @@ function renderCommentsPanel(container, deps) {
const addBtn = document.createElement('button'); const addBtn = document.createElement('button');
addBtn.className = 'ws-btn ws-btn-primary'; addBtn.className = 'ws-btn ws-btn-primary';
addBtn.textContent = 'Comment on line ' + editor.getCurrentLine(); addBtn.textContent = 'Comment on line ' + editor.getCurrentLine();
addBtn.addEventListener('click', () => { addBtn.addEventListener('click', async () => {
const text = textarea.value.trim(); const text = textarea.value.trim();
if (!text) return; if (!text) return;
const line = editor.getCurrentLine(); const line = editor.getCurrentLine();
const anchorText = (getLines(editor)[line - 1] || '').trim(); const anchorText = (getLines(editor)[line - 1] || '').trim();
const comments = store.loadComments(docPath, io, pathUtil); const comments = await store.loadComments(docPath, io, pathUtil);
store.addComment(comments, { line, anchorText, author: deps.author || 'me', text }); store.addComment(comments, { line, anchorText, author: deps.author || 'me', text });
store.saveComments(docPath, comments, io, pathUtil); await store.saveComments(docPath, comments, io, pathUtil);
renderCommentsPanel(container, deps); // re-render with fresh list renderCommentsPanel(container, deps); // re-render with fresh list
}); });
composer.appendChild(textarea); composer.appendChild(textarea);
@@ -89,7 +89,7 @@ function renderCommentsPanel(container, deps) {
panel.appendChild(composer); panel.appendChild(composer);
// --- List ----------------------------------------------------------------- // --- List -----------------------------------------------------------------
const comments = store.loadComments(docPath, io, pathUtil); const comments = await store.loadComments(docPath, io, pathUtil);
const lines = getLines(editor); const lines = getLines(editor);
const list = document.createElement('div'); const list = document.createElement('div');
@@ -122,16 +122,16 @@ function renderCommentsPanel(container, deps) {
item.querySelector('[data-action="jump"]').addEventListener('click', () => { item.querySelector('[data-action="jump"]').addEventListener('click', () => {
editor.jumpToLine(comment.line); editor.jumpToLine(comment.line);
}); });
item.querySelector('[data-action="resolve"]').addEventListener('click', () => { item.querySelector('[data-action="resolve"]').addEventListener('click', async () => {
const updated = store.loadComments(docPath, io, pathUtil); const updated = await store.loadComments(docPath, io, pathUtil);
store.toggleResolved(updated, comment.id); store.toggleResolved(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil); await store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps); renderCommentsPanel(container, deps);
}); });
item.querySelector('[data-action="delete"]').addEventListener('click', () => { item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
const updated = store.loadComments(docPath, io, pathUtil); const updated = await store.loadComments(docPath, io, pathUtil);
store.deleteComment(updated, comment.id); store.deleteComment(updated, comment.id);
store.saveComments(docPath, updated, io, pathUtil); await store.saveComments(docPath, updated, io, pathUtil);
renderCommentsPanel(container, deps); renderCommentsPanel(container, deps);
}); });
list.appendChild(item); list.appendChild(item);
+2 -2
View File
@@ -85,10 +85,10 @@ class CollaborationPlugin extends PluginAPI {
document.addEventListener('keydown', this._f8Handler); document.addEventListener('keydown', this._f8Handler);
} }
_jumpToNextComment() { async _jumpToNextComment() {
const docPath = this.context.editor.getCurrentFilePath(); const docPath = this.context.editor.getCurrentFilePath();
if (!docPath) return; if (!docPath) return;
const comments = store.loadComments(docPath, this._io, require('path')); const comments = await store.loadComments(docPath, this._io, require('path'));
const fromLine = this.context.editor.getCurrentLine(); const fromLine = this.context.editor.getCurrentLine();
const next = store.nextUnresolved(comments, fromLine); const next = store.nextUnresolved(comments, fromLine);
if (next) this.context.editor.jumpToLine(next.line); if (next) this.context.editor.jumpToLine(next.line);
@@ -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++) {
+15 -12
View File
@@ -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();
@@ -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,18 +23,22 @@ 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) => {
if (!result) return;
settings.set('projectDir', result).then(() => {
renderManuscriptPanel(container, { engines, editor, settings }); renderManuscriptPanel(container, { engines, editor, settings });
}); });
});
});
empty.appendChild(btn); empty.appendChild(btn);
panel.appendChild(empty); panel.appendChild(empty);
container.appendChild(panel); container.appendChild(panel);
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,16 +50,17 @@ 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);
container.appendChild(panel); container.appendChild(panel);
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));
} }
} }
+4
View File
@@ -168,6 +168,10 @@ const ALLOWED_SEND_CHANNELS = [
'plantuml:available', 'plantuml:available',
'plantuml:render', 'plantuml:render',
// MarkItDown import (optional Python CLI)
'markitdown:available',
'markitdown:convert',
// Quick Note scratchpad // Quick Note scratchpad
'quick-note:save', 'quick-note:save',
+39 -3
View File
@@ -2964,6 +2964,26 @@ function initializeExportForm(format) {
document.getElementById('custom-geometry').style.display = 'none'; document.getElementById('custom-geometry').style.display = 'none';
} }
// Export theme picker: shown for the formats that support theming; options
// come straight from the shared theme definitions (labels + descriptions)
const themeRow = document.getElementById('export-theme-row');
const themeSelect = document.getElementById('export-theme');
if (themeRow && themeSelect) {
const themeable = format === 'pdf' || format === 'docx';
themeRow.style.display = themeable ? 'flex' : 'none';
if (themeSelect.options.length === 0) {
const { listThemes } = require('./main/ExportThemes');
for (const theme of listThemes()) {
const option = document.createElement('option');
option.value = theme.id;
option.textContent = theme.label;
option.title = theme.description;
themeSelect.appendChild(option);
}
}
themeSelect.value = 'default';
}
// Toggle and reset Reveal.js-specific fields // Toggle and reset Reveal.js-specific fields
const revealjsOnly = document.querySelector('.revealjs-only'); const revealjsOnly = document.querySelector('.revealjs-only');
if (revealjsOnly) { if (revealjsOnly) {
@@ -3076,6 +3096,12 @@ function collectExportOptions() {
} }
} }
// Export theme is available in basic mode too (PDF + DOCX)
const themeSelect = document.getElementById('export-theme');
if (themeSelect && (currentExportFormat === 'pdf' || currentExportFormat === 'docx')) {
options.theme = themeSelect.value || 'default';
}
// Collect page size and orientation (always collected, from basic options) // Collect page size and orientation (always collected, from basic options)
const pageSize = document.getElementById('page-size').value; const pageSize = document.getElementById('page-size').value;
const pageOrientation = document.getElementById('page-orientation').value; const pageOrientation = document.getElementById('page-orientation').value;
@@ -4184,7 +4210,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);
}); });
@@ -4373,8 +4404,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() {
+15
View File
@@ -120,6 +120,12 @@ function captureDialogOptions() {
: geometrySelect; : geometrySelect;
} }
// Export theme (PDF + DOCX) — stored with presets like engine/geometry
if (format === 'pdf' || format === 'docx') {
const theme = valueOf('export-theme');
if (theme) options.theme = theme;
}
if (format === 'revealjs') { if (format === 'revealjs') {
options.revealTheme = valueOf('reveal-theme'); options.revealTheme = valueOf('reveal-theme');
options.revealTransition = valueOf('reveal-transition'); options.revealTransition = valueOf('reveal-transition');
@@ -206,6 +212,15 @@ function applyPresetToDialog(preset) {
setVisible('custom-geometry', true); setVisible('custom-geometry', true);
} }
// Export theme (guard: a preset saved with a newer/older theme list still
// applies, falling back to default for unknown ids)
const themeSelect = elementById('export-theme');
if (themeSelect) {
const theme = advancedOptions.theme || 'default';
const hasTheme = Array.from(themeSelect.options).some((opt) => opt.value === theme);
setValue('export-theme', hasTheme ? theme : 'default');
}
// Reveal.js options // Reveal.js options
setValue('reveal-theme', advancedOptions.revealTheme || 'black'); setValue('reveal-theme', advancedOptions.revealTheme || 'black');
setValue('reveal-transition', advancedOptions.revealTransition || 'slide'); setValue('reveal-transition', advancedOptions.revealTransition || 'slide');
+2 -2
View File
@@ -7,8 +7,8 @@
* @module BacklinksPanel * @module BacklinksPanel
*/ */
const { collectMarkdownFiles, findBacklinks } = require('../../utils/backlinks'); const { collectMarkdownFiles, findBacklinks } = require('../utils/backlinks');
const { docNameFor } = require('../../utils/wiki-links'); const { docNameFor } = require('../utils/wiki-links');
/** /**
* Render the backlinks panel. * Render the backlinks panel.
+6
View File
@@ -119,3 +119,9 @@
color: var(--gray-500); color: var(--gray-500);
cursor: pointer; cursor: pointer;
} }
/* Brand mark in the welcome hero (M↓ mark from the brand kit) */
.welcome-mark {
display: block;
margin: 0 auto 10px;
}
+1
View File
@@ -11,6 +11,7 @@ function createWelcomeContent(recentFiles = [], appVersion = '') {
return ` return `
<div class="welcome-container"> <div class="welcome-container">
<div class="welcome-hero"> <div class="welcome-hero">
<img src="../assets/markdown-converter-assets/mark.svg" alt="" width="56" height="56" class="welcome-mark" />
<h1 class="welcome-title">MarkdownConverter</h1> <h1 class="welcome-title">MarkdownConverter</h1>
<p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p> <p class="welcome-version">${appVersion ? `Version ${appVersion}` : ''}</p>
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p> <p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
+76
View File
@@ -26,3 +26,79 @@ describe('Bundled Pandoc binary', () => {
} }
}); });
}); });
describe('Bundled MarkItDown binary (optional)', () => {
const rootDir = path.resolve(__dirname, '..');
const platform = process.platform;
const binaryPath = path.join(rootDir, 'bin', platform, 'markitdown');
const windowsBinaryPath = path.join(rootDir, 'bin', platform, 'markitdown.exe');
// The binary is built on demand (npm run bundle:markitdown), so CI machines
// that haven't built it yet skip these — but when present it must be valid.
const hasBinary = fs.existsSync(binaryPath) || fs.existsSync(windowsBinaryPath);
const which = platform === 'win32' ? windowsBinaryPath : binaryPath;
(hasBinary ? describe : describe.skip)('binary present', () => {
test('is executable', () => {
const stats = fs.statSync(which);
expect(stats.mode & 0o111).toBeGreaterThan(0);
});
test('answers --version', (done) => {
const { execFile } = require('child_process');
execFile(which, ['--version'], { timeout: 30000 }, (error, stdout) => {
expect(error).toBeNull();
expect(stdout.toLowerCase()).toContain('markitdown');
done();
});
}, 60000);
});
});
describe('Legal compliance artifacts', () => {
const rootDir = path.resolve(__dirname, '..');
test('THIRD-PARTY-NOTICES.md and SOURCES.md ship at the repo root', () => {
expect(fs.existsSync(path.join(rootDir, 'THIRD-PARTY-NOTICES.md'))).toBe(true);
expect(fs.existsSync(path.join(rootDir, 'SOURCES.md'))).toBe(true);
});
test('canonical copyleft license texts are present', () => {
for (const file of ['GPL-2.0.txt', 'LGPL-2.1.txt', 'MPL-2.0.txt', 'Apache-2.0.txt', 'OFL-1.1.txt']) {
expect(fs.existsSync(path.join(rootDir, 'third-party-licenses', file))).toBe(true);
}
});
test('notices mention every bundled external binary', () => {
const notices = fs.readFileSync(path.join(rootDir, 'THIRD-PARTY-NOTICES.md'), 'utf-8');
for (const component of ['Pandoc', 'FFmpeg', 'MarkItDown', 'libvips', 'KaTeX', 'JetBrains Mono']) {
expect(notices).toContain(component);
}
// GPL source offer must reference the sources document
expect(notices).toContain('SOURCES.md');
});
test('SOURCES.md covers every GPL-licensed binary with a source link', () => {
const sources = fs.readFileSync(path.join(rootDir, 'SOURCES.md'), 'utf-8');
for (const section of ['Pandoc', 'FFmpeg', 'PyInstaller', 'libvips']) {
expect(sources).toContain(section);
}
expect(sources).toMatch(/https:\/\/github\.com\/jgm\/pandoc/);
expect(sources).toMatch(/https:\/\/ffmpeg\.org\/releases\//);
});
test('packaging includes the legal documents in build files', () => {
// The build config lives in electron-builder.config.js (dynamic — it
// conditionally bundles the markitdown binary per platform)
const configSrc = fs.readFileSync(
path.join(rootDir, 'electron-builder.config.js'),
'utf-8'
);
expect(configSrc).toContain("'THIRD-PARTY-NOTICES.md'");
expect(configSrc).toContain("'SOURCES.md'");
expect(configSrc).toContain('third-party-licenses');
// And package.json must not carry a stale static build section anymore
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'));
expect(pkg.build).toBeUndefined();
});
});
+23 -23
View File
@@ -10,11 +10,11 @@ function makeIo(initial = {}) {
const files = new Map(Object.entries(initial)); const files = new Map(Object.entries(initial));
return { return {
files, files,
readFile: (p) => (files.has(p) ? files.get(p) : null), readFile: async (p) => (files.has(p) ? files.get(p) : null),
writeFile: (p, c) => { writeFile: async (p, c) => {
files.set(p, c); files.set(p, c);
}, },
fileExists: (p) => files.has(p), fileExists: async (p) => files.has(p),
ensureDirectory: () => {}, ensureDirectory: () => {},
}; };
} }
@@ -24,7 +24,7 @@ describe('comment-store', () => {
const sidecar = path.join('/docs', '.comments', 'notes.md.json'); const sidecar = path.join('/docs', '.comments', 'notes.md.json');
describe('commentsFilePathFor', () => { describe('commentsFilePathFor', () => {
it('places the sidecar in .comments/ next to the document', () => { it('places the sidecar in .comments/ next to the document', async () => {
expect(store.commentsFilePathFor(doc, path)).toBe(sidecar); expect(store.commentsFilePathFor(doc, path)).toBe(sidecar);
expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe( expect(store.commentsFilePathFor('/a/b/c.md', path)).toBe(
path.join('/a/b/.comments', 'c.md.json') path.join('/a/b/.comments', 'c.md.json')
@@ -33,29 +33,29 @@ describe('comment-store', () => {
}); });
describe('load/save round-trip', () => { describe('load/save round-trip', () => {
it('returns [] for documents with no sidecar yet', () => { it('returns [] for documents with no sidecar yet', async () => {
expect(store.loadComments(doc, makeIo(), path)).toEqual([]); expect(await store.loadComments(doc, makeIo(), path)).toEqual([]);
}); });
it('returns [] for corrupt JSON instead of throwing', () => { it('returns [] for corrupt JSON instead of throwing', async () => {
const io = makeIo({ [sidecar]: '{oops' }); const io = makeIo({ [sidecar]: '{oops' });
expect(store.loadComments(doc, io, path)).toEqual([]); expect(await store.loadComments(doc, io, path)).toEqual([]);
}); });
it('persists and reloads comments sorted by line', () => { it('persists and reloads comments sorted by line', async () => {
const io = makeIo(); const io = makeIo();
const comments = []; const comments = [];
store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' }); store.addComment(comments, { line: 12, anchorText: 'para two', text: 'expand this' });
store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' }); store.addComment(comments, { line: 3, anchorText: 'intro', text: 'strong opener' });
store.saveComments(doc, comments, io, path); await store.saveComments(doc, comments, io, path);
const loaded = store.loadComments(doc, io, path); const loaded = await store.loadComments(doc, io, path);
expect(loaded.map((c) => c.line)).toEqual([3, 12]); expect(loaded.map((c) => c.line)).toEqual([3, 12]);
expect(loaded[0].text).toBe('strong opener'); expect(loaded[0].text).toBe('strong opener');
expect(loaded[0].resolved).toBe(false); expect(loaded[0].resolved).toBe(false);
}); });
it('normalizes malformed entries on load', () => { it('normalizes malformed entries on load', async () => {
const io = makeIo({ const io = makeIo({
[sidecar]: JSON.stringify({ [sidecar]: JSON.stringify({
comments: [ comments: [
@@ -64,7 +64,7 @@ describe('comment-store', () => {
], ],
}), }),
}); });
const loaded = store.loadComments(doc, io, path); const loaded = await store.loadComments(doc, io, path);
// '7' (string line) is dropped; the valid one survives normalization // '7' (string line) is dropped; the valid one survives normalization
expect(loaded).toHaveLength(1); expect(loaded).toHaveLength(1);
expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' }); expect(loaded[0]).toMatchObject({ line: 2, author: 'ana' });
@@ -72,7 +72,7 @@ describe('comment-store', () => {
}); });
describe('mutators', () => { describe('mutators', () => {
it('addComment clamps lines and caps field lengths', () => { it('addComment clamps lines and caps field lengths', async () => {
const comments = []; const comments = [];
const c = store.addComment(comments, { const c = store.addComment(comments, {
line: -5, line: -5,
@@ -86,7 +86,7 @@ describe('comment-store', () => {
expect(c.id).toBeTruthy(); expect(c.id).toBeTruthy();
}); });
it('toggleResolved flips state and returns null for unknown ids', () => { it('toggleResolved flips state and returns null for unknown ids', async () => {
const comments = []; const comments = [];
const c = store.addComment(comments, { line: 1, text: 'hi' }); const c = store.addComment(comments, { line: 1, text: 'hi' });
expect(store.toggleResolved(comments, c.id)).toBe(true); expect(store.toggleResolved(comments, c.id)).toBe(true);
@@ -94,7 +94,7 @@ describe('comment-store', () => {
expect(store.toggleResolved(comments, 'missing')).toBeNull(); expect(store.toggleResolved(comments, 'missing')).toBeNull();
}); });
it('deleteComment reports whether anything was removed', () => { it('deleteComment reports whether anything was removed', async () => {
const comments = []; const comments = [];
const c = store.addComment(comments, { line: 1, text: 'hi' }); const c = store.addComment(comments, { line: 1, text: 'hi' });
expect(store.deleteComment(comments, c.id)).toBe(true); expect(store.deleteComment(comments, c.id)).toBe(true);
@@ -104,7 +104,7 @@ describe('comment-store', () => {
}); });
describe('nextUnresolved (F8 navigation)', () => { describe('nextUnresolved (F8 navigation)', () => {
it('returns the first open comment below the cursor line', () => { it('returns the first open comment below the cursor line', async () => {
const comments = [ const comments = [
{ id: 'a', line: 5, resolved: false }, { id: 'a', line: 5, resolved: false },
{ id: 'b', line: 20, resolved: false }, { id: 'b', line: 20, resolved: false },
@@ -112,7 +112,7 @@ describe('comment-store', () => {
expect(store.nextUnresolved(comments, 7).id).toBe('b'); expect(store.nextUnresolved(comments, 7).id).toBe('b');
}); });
it('wraps to the top when no open comments are below', () => { it('wraps to the top when no open comments are below', async () => {
const comments = [ const comments = [
{ id: 'a', line: 5, resolved: false }, { id: 'a', line: 5, resolved: false },
{ id: 'b', line: 20, resolved: true }, { id: 'b', line: 20, resolved: true },
@@ -120,7 +120,7 @@ describe('comment-store', () => {
expect(store.nextUnresolved(comments, 10).id).toBe('a'); expect(store.nextUnresolved(comments, 10).id).toBe('a');
}); });
it('returns null when everything is resolved or empty', () => { it('returns null when everything is resolved or empty', async () => {
expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull(); expect(store.nextUnresolved([{ id: 'a', line: 1, resolved: true }], 1)).toBeNull();
expect(store.nextUnresolved([], 1)).toBeNull(); expect(store.nextUnresolved([], 1)).toBeNull();
}); });
@@ -129,20 +129,20 @@ describe('comment-store', () => {
describe('anchorStatus (drift detection)', () => { describe('anchorStatus (drift detection)', () => {
const base = { line: 2, anchorText: 'original text' }; const base = { line: 2, anchorText: 'original text' };
it('reports ok when the line still matches', () => { it('reports ok when the line still matches', async () => {
expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok'); expect(store.anchorStatus(base, ['first', 'original text', 'third'])).toBe('ok');
}); });
it('reports changed when the line text differs', () => { it('reports changed when the line text differs', async () => {
expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed'); expect(store.anchorStatus(base, ['first', 'edited text', 'third'])).toBe('changed');
}); });
it('reports missing when the line is beyond the document', () => { it('reports missing when the line is beyond the document', async () => {
expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing'); expect(store.anchorStatus({ ...base, line: 99 }, ['only'])).toBe('missing');
expect(store.anchorStatus(base, [])).toBe('missing'); expect(store.anchorStatus(base, [])).toBe('missing');
}); });
it('reports moved for comments without an anchor snippet', () => { it('reports moved for comments without an anchor snippet', async () => {
expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved'); expect(store.anchorStatus({ line: 1, anchorText: '' }, ['whatever'])).toBe('moved');
}); });
}); });
+46 -25
View File
@@ -1,5 +1,8 @@
const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker'); const { GoalTracker } = require('../src/plugins/built-in/writing-studio/goal-tracker');
// All GoalTracker methods are async (the real settings backend is the
// IPC-backed SettingsStore; these tests inject a synchronous fake, which
// `await` handles transparently).
describe('GoalTracker', () => { describe('GoalTracker', () => {
let tracker; let tracker;
let store; let store;
@@ -14,71 +17,89 @@ describe('GoalTracker', () => {
}); });
}); });
test('addWords records words for today', () => { test('addWords records words for today', async () => {
tracker.addWords(500); await tracker.addWords(500);
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
expect(store['plugins.writing-studio.history']).toBeDefined(); expect(store['plugins.writing-studio.history']).toBeDefined();
const history = JSON.parse(store['plugins.writing-studio.history']); const history = JSON.parse(store['plugins.writing-studio.history']);
expect(history[today].words).toBe(500); expect(history[today].words).toBe(500);
}); });
test('addWords accumulates across multiple calls', () => { test('addWords accumulates across multiple calls', async () => {
tracker.addWords(300); await tracker.addWords(300);
tracker.addWords(200); await tracker.addWords(200);
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const history = JSON.parse(store['plugins.writing-studio.history']); const history = JSON.parse(store['plugins.writing-studio.history']);
expect(history[today].words).toBe(500); expect(history[today].words).toBe(500);
}); });
test('getDailyProgress returns 0 when no history', () => { test('works with a Promise-returning (IPC-style) backend', async () => {
expect(tracker.getDailyProgress(1000)).toEqual({ written: 0, goal: 1000, pct: 0 }); const asyncTracker = new GoalTracker({
get: async (key) => store[key],
set: async (key, value) => {
store[key] = value;
},
});
await asyncTracker.addWords(250);
await expect(asyncTracker.getDailyProgress(1000)).resolves.toEqual({
written: 250,
goal: 1000,
pct: 25,
});
}); });
test('getDailyProgress returns percentage', () => { test('getDailyProgress returns 0 when no history', async () => {
tracker.addWords(500); await expect(tracker.getDailyProgress(1000)).resolves.toEqual({
const progress = tracker.getDailyProgress(1000); written: 0,
goal: 1000,
pct: 0,
});
});
test('getDailyProgress returns percentage', async () => {
await tracker.addWords(500);
const progress = await tracker.getDailyProgress(1000);
expect(progress.written).toBe(500); expect(progress.written).toBe(500);
expect(progress.pct).toBe(50); expect(progress.pct).toBe(50);
}); });
test('getStreak counts consecutive days meeting goal', () => { test('getStreak counts consecutive days meeting goal', async () => {
const today = new Date(); const today = new Date();
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const d = new Date(today); const d = new Date(today);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
const key = d.toISOString().split('T')[0]; const key = d.toISOString().split('T')[0];
tracker._setHistoryDay(key, { words: 1200 }); await tracker._setHistoryDay(key, { words: 1200 });
} }
const streak = tracker.getStreak(1000); await expect(tracker.getStreak(1000)).resolves.toBe(3);
expect(streak).toBe(3);
}); });
test('getStreak breaks on missed day', () => { test('getStreak breaks on missed day', async () => {
const today = new Date(); const today = new Date();
tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 }); await tracker._setHistoryDay(today.toISOString().split('T')[0], { words: 1200 });
const yesterday = new Date(today); const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 }); await tracker._setHistoryDay(yesterday.toISOString().split('T')[0], { words: 500 });
const dayBefore = new Date(today); const dayBefore = new Date(today);
dayBefore.setDate(dayBefore.getDate() - 2); dayBefore.setDate(dayBefore.getDate() - 2);
tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 }); await tracker._setHistoryDay(dayBefore.toISOString().split('T')[0], { words: 1200 });
expect(tracker.getStreak(1000)).toBe(1); await expect(tracker.getStreak(1000)).resolves.toBe(1);
}); });
test('getLast30Days returns array of 30 entries', () => { test('getLast30Days returns array of 30 entries', async () => {
tracker.addWords(100); await tracker.addWords(100);
const days = tracker.getLast30Days(); const days = await tracker.getLast30Days();
expect(days.length).toBe(30); expect(days.length).toBe(30);
expect(days[29].words).toBe(100); expect(days[29].words).toBe(100);
}); });
test('getWeeklyTotal sums last 7 days', () => { test('getWeeklyTotal sums last 7 days', async () => {
const today = new Date(); const today = new Date();
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const d = new Date(today); const d = new Date(today);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 }); await tracker._setHistoryDay(d.toISOString().split('T')[0], { words: 200 });
} }
expect(tracker.getWeeklyTotal()).toBe(1400); await expect(tracker.getWeeklyTotal()).resolves.toBe(1400);
}); });
}); });
+50
View File
@@ -33,6 +33,10 @@ describe('AiProviders', () => {
it('does not require an API key for local providers', () => { it('does not require an API key for local providers', () => {
expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow(); expect(() => resolveSettings({ provider: 'lmstudio' })).not.toThrow();
expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow(); expect(() => resolveSettings({ provider: 'ollama' })).not.toThrow();
// Self-hosted Anthropic-compatible proxies may front their own auth
expect(() =>
resolveSettings({ provider: 'anthropic-compatible', baseUrl: 'http://localhost:4000' })
).not.toThrow();
}); });
it('requires an API key for remote providers', () => { it('requires an API key for remote providers', () => {
@@ -109,6 +113,52 @@ describe('AiProviders', () => {
expect(body.system).toBe('be nice'); expect(body.system).toBe('be nice');
}); });
it('routes anthropic-compatible to the custom base with Bearer + x-api-key auth', async () => {
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'proxy ok' }] });
const result = await complete(
{
provider: 'anthropic-compatible',
baseUrl: 'http://localhost:4000',
apiKey: 'proxy-key',
messages,
},
{ fetchImpl }
);
expect(result.content).toBe('proxy ok');
const [url, init] = fetchImpl.mock.calls[0];
// Base without a /v1 suffix gets one appended
expect(url).toBe('http://localhost:4000/v1/messages');
// Both auth styles are sent so LiteLLM/Bedrock-style gateways accept it
expect(init.headers['x-api-key']).toBe('proxy-key');
expect(init.headers.Authorization).toBe('Bearer proxy-key');
expect(init.headers['anthropic-version']).toBe('2023-06-01');
});
it('does not double the /v1 segment when the base already ends with it', async () => {
const fetchImpl = okFetch({ content: [{ type: 'text', text: 'ok' }] });
await complete(
{
provider: 'anthropic-compatible',
baseUrl: 'http://localhost:4000/v1/',
messages,
},
{ fetchImpl }
);
// Trailing slash is stripped by resolveSettings, so the base ends in /v1
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('http://localhost:4000/v1/messages');
// Keyless proxies must work without auth headers
expect(init.headers.Authorization).toBeUndefined();
expect(init.headers['x-api-key']).toBeUndefined();
});
it('rejects anthropic-compatible without a base URL', async () => {
await expect(
complete({ provider: 'anthropic-compatible', messages }, { fetchImpl: okFetch({}) })
).rejects.toThrow(/Invalid API base URL/);
});
it('surfaces HTTP failures as user-safe errors without the body', async () => { it('surfaces HTTP failures as user-safe errors without the body', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 }); const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 401 });
await expect( await expect(
+181
View File
@@ -0,0 +1,181 @@
/**
* @jest-environment node
*
* ExportThemes tests: LaTeX header generation (content + injection safety)
* and DOCX styles.xml patching against a minimal pandoc-style fixture.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const PizZip = require('pizzip');
const {
THEMES,
getTheme,
listThemes,
buildLatexThemeHeader,
applyDocxTheme,
} = require('../../src/main/ExportThemes');
// Minimal pandoc-like word/styles.xml: heading + hyperlink styles + docDefaults
const STYLES_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:pPr><w:outlineLvl w:val="0"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="32"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:rPr><w:b/><w:color w:val="2E74B5"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Title">
<w:name w:val="Title"/>
<w:rPr><w:b/></w:rPr>
</w:style>
<w:style w:type="character" w:styleId="Hyperlink">
<w:name w:val="Hyperlink"/>
<w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr>
</w:style>
</w:styles>`;
function writeDocxFixture(filePath) {
const zip = new PizZip();
zip.file(
'[Content_Types].xml',
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>'
);
zip.file('word/styles.xml', STYLES_XML);
zip.file('word/document.xml', '<w:document/>');
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
}
function readStyles(filePath) {
return new PizZip(fs.readFileSync(filePath)).file('word/styles.xml').asText();
}
describe('ExportThemes', () => {
describe('definitions', () => {
it('lists themes with labels in a stable order, default first', () => {
const list = listThemes();
expect(list[0].id).toBe('default');
expect(list.map((t) => t.id)).toEqual(Object.keys(THEMES));
expect(list.every((t) => t.label && t.description)).toBe(true);
});
it('falls back to default for unknown ids', () => {
expect(getTheme('nope').id).toBeUndefined(); // default theme has no id field
expect(getTheme('nope').label).toBe('Default (Pandoc)');
expect(getTheme('modern').label).toBe('Modern');
});
});
describe('buildLatexThemeHeader', () => {
it('returns null for the default theme', () => {
expect(buildLatexThemeHeader('default')).toBeNull();
expect(buildLatexThemeHeader('unknown-id')).toBeNull();
});
it('emits xcolor, heading color, and titlesec formatting', () => {
const tex = buildLatexThemeHeader('modern');
expect(tex).toContain('\\usepackage{xcolor}');
// \definecolor{mcthemeheading}{HTML}{1E4FD8}
expect(tex).toContain('\\definecolor{mcthemeheading}{HTML}{1E4FD8}');
expect(tex).toContain('\\definecolor{mcthemelink}{HTML}{0E7490}');
expect(tex).toContain('\\titleformat{\\section}');
// modern = sans headings
expect(tex).toContain('helvet');
});
it('omits section rules and helvet for themes that decline them', () => {
const tex = buildLatexThemeHeader('minimal');
expect(tex).not.toContain('helvet');
expect(tex).not.toContain('titlerule');
});
it('only inlines hex literals (no user input reaches LaTeX)', () => {
for (const id of Object.keys(THEMES)) {
const tex = buildLatexThemeHeader(id);
if (!tex) continue;
// Every color is the {HTML}{HEXHEXHEX} literal form
const colors = tex.match(/\{HTML\}\{([^}]*)\}/g) || [];
expect(colors.length).toBeGreaterThan(0);
for (const c of colors) {
expect(c).toMatch(/^\{HTML\}\{[A-F0-9]{6}\}$/);
}
}
});
});
describe('applyDocxTheme', () => {
let tmpDir, docxPath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme_'));
docxPath = path.join(tmpDir, 'doc.docx');
writeDocxFixture(docxPath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
it('is a no-op returning false for the default theme', () => {
expect(applyDocxTheme(docxPath, 'default')).toBe(false);
expect(readStyles(docxPath)).toBe(STYLES_XML);
});
it('recolors headings, swaps heading font, and recolors hyperlinks', () => {
const applied = applyDocxTheme(docxPath, 'modern');
expect(applied).toBe(true);
const xml = readStyles(docxPath);
// Heading color + font injected
expect(xml).toContain('<w:color w:val="1E4FD8"/>');
expect(xml).toContain('w:ascii="Calibri"');
// Heading2's old color is replaced, not duplicated
expect(xml).not.toContain('2E74B5');
const h1 = /w:styleId="Heading1"[\s\S]*?<\/w:style>/.exec(xml)[0];
expect(h1.match(/<w:color/g)).toHaveLength(1);
// Hyperlink recolored
expect(xml).not.toContain('0563C1');
expect(xml).toContain('<w:color w:val="0E7490"/>');
// Body font lands in docDefaults
expect(xml).toMatch(/<w:rPrDefault>\s*<w:rPr><w:rFonts w:ascii="Calibri"/);
});
it('preserves unrelated style content', () => {
applyDocxTheme(docxPath, 'classic');
const xml = readStyles(docxPath);
expect(xml).toContain('<w:sz w:val="32"/>');
expect(xml).toContain('<w:u w:val="single"/>');
expect(xml).toContain('<w:outlineLvl w:val="0"/>');
});
it('tolerates styles.xml without an rPr block in a style', () => {
// Title style in the fixture has rPr; craft one without it
const bare = STYLES_XML.replace(
/(<w:style w:type="paragraph" w:styleId="Title">\s*<w:name w:val="Title"\/>)\s*<w:rPr><w:b\/><\/w:rPr>/,
'$1'
);
const zip = new PizZip();
zip.file('[Content_Types].xml', '<Types/>');
zip.file('word/styles.xml', bare);
fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
expect(applyDocxTheme(docxPath, 'sepia')).toBe(true);
const xml = readStyles(docxPath);
const title = /w:styleId="Title"[\s\S]*?<\/w:style>/.exec(xml)[0];
expect(title).toContain('<w:color w:val="7C4A21"/>');
});
it('returns false when styles.xml is missing', () => {
const zip = new PizZip();
zip.file('[Content_Types].xml', '<Types/>');
fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
expect(applyDocxTheme(docxPath, 'modern')).toBe(false);
});
});
});
+9 -1
View File
@@ -15,7 +15,15 @@ describe('ImageOperations', () => {
.png() .png()
.toFile(inputPath); .toFile(inputPath);
}); });
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); // Windows: sharp can hold the file handle briefly after await returns,
// so plain rmSync throws EPERM — retry and tolerate stale temp dirs.
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* leftover temp dir is cosmetic on locked-file platforms */
}
});
test('imageConvert converts PNG to JPEG', async () => { test('imageConvert converts PNG to JPEG', async () => {
const outputPath = path.join(tmpDir, 'out.jpg'); const outputPath = path.join(tmpDir, 'out.jpg');
+199
View File
@@ -0,0 +1,199 @@
/**
* @jest-environment node
*
* MarkItDown bridge tests with a stubbed execFile — no Python needed.
* Covers command resolution order, argv-array invocation (SEC-1), stdout
* capture, error surfacing, and path validation.
*/
const os = require('os');
const path = require('path');
const { resolveMarkItDown, convertToMarkdown, commandCandidates } = require('../../src/main/MarkItDown');
const { setImmediate } = require('timers');
/**
* Build a runner stub. `script` maps "cmd argline" -> {error?, stdout?, stderr?}.
* Unmatched invocations fail with ENOENT (an uninstalled command), which is
* exactly what the real execFile does. Records every call for argv assertions.
*/
function makeRunner(script = {}) {
const calls = [];
const runner = (cmd, args, opts, cb) => {
calls.push({ cmd, args, opts });
const isVersionProbe = args.length > 0 && args[args.length - 1] === '--version';
const key = isVersionProbe
? [cmd, ...args.slice(0, -1), '--version'].join(' ')
: [cmd, ...args].join(' ');
const result = script[key];
setImmediate(() =>
cb(
result ? result.error || null : Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }),
result?.stdout ?? '',
result?.stderr ?? ''
)
);
};
runner.calls = calls;
return runner;
}
describe('MarkItDown', () => {
describe('resolveMarkItDown', () => {
it('prefers a direct markitdown binary when present', async () => {
const runner = makeRunner({
'markitdown --version': { stdout: 'markitdown 0.1.7\n' },
});
const resolved = await resolveMarkItDown(runner);
expect(resolved).toEqual({ command: 'markitdown', argsPrefix: [], version: '0.1.7' });
});
it('falls back to python -m markitdown', async () => {
const py = process.platform === 'win32' ? 'python' : 'python3';
const runner = makeRunner({
[`${py} -m markitdown --version`]: { stdout: 'markitdown 0.1.6' },
});
const resolved = await resolveMarkItDown(runner);
expect(resolved).toMatchObject({ command: py, argsPrefix: ['-m', 'markitdown'] });
});
it('probes every candidate before giving up (null)', async () => {
const runner = makeRunner({});
expect(await resolveMarkItDown(runner)).toBeNull();
// One probe per candidate (bundled binary included when present)
expect(runner.calls).toHaveLength(commandCandidates().length);
});
it('prefers the bundled binary when one ships with the app', async () => {
const candidates = commandCandidates();
if (!candidates[0].bundled) {
// Machine has no bin/<platform>/markitdown — assert ordering of the
// remaining candidates instead.
expect(candidates.map((c) => c.command)).toContain('markitdown');
return;
}
const runner = makeRunner({});
const first = candidates[0];
runner.calls.length = 0;
// Stub the bundled path's --version probe
const script = {};
script[`${first.command} --version`] = { stdout: 'markitdown 0.1.7' };
const stub = makeRunner(script);
const resolved = await resolveMarkItDown(stub);
expect(resolved.command).toBe(first.command);
expect(stub.calls[0].cmd).toBe(first.command);
});
it('treats a non-zero probe exit as unavailable', async () => {
const runner = makeRunner({
'markitdown --version': { error: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) },
});
const resolved = await resolveMarkItDown(runner);
// Falls through to python candidates which also fail here -> null
expect(resolved).toBeNull();
});
});
describe('convertToMarkdown', () => {
const inputPath = path.join(os.tmpdir(), 'any-file.docx');
it('passes the user path as a literal argv element (no shell)', async () => {
const runner = makeRunner({
[`markitdown ${inputPath}`]: { stdout: '# Converted\n' },
});
const { content } = await convertToMarkdown(inputPath, {
runner,
resolved: { command: 'markitdown', argsPrefix: [], version: null },
});
expect(content).toBe('# Converted\n');
expect(runner.calls[0].cmd).toBe('markitdown');
expect(runner.calls[0].args).toEqual([inputPath]);
});
it('prepends the module prefix for python-style invocation', async () => {
const calls = [];
const fake = (cmd, args, opts, cb) => {
calls.push({ cmd, args, opts });
setImmediate(() => cb(null, 'ok', ''));
};
await convertToMarkdown(inputPath, {
runner: fake,
resolved: { command: 'python3', argsPrefix: ['-m', 'markitdown'], version: null },
});
expect(calls[0].cmd).toBe('python3');
expect(calls[0].args).toEqual(['-m', 'markitdown', inputPath]);
});
it('strips a UTF-8 BOM from the output', async () => {
const runner = makeRunner({
[`markitdown ${inputPath}`]: { stdout: '\uFEFF# Title\n' },
});
const { content } = await convertToMarkdown(inputPath, {
runner,
resolved: { command: 'markitdown', argsPrefix: [], version: null },
});
expect(content.startsWith('\uFEFF')).toBe(false);
});
it('surfaces the actionable last stderr line and sanitizes paths', async () => {
const runner = makeRunner({
[`markitdown ${inputPath}`]: {
error: Object.assign(new Error('Command failed'), { code: 1 }),
stderr:
'Traceback (most recent call last):\n File "/home/user/x.py", line 1\n' +
"* pip install 'markitdown[pdf]'",
},
});
await expect(
convertToMarkdown(inputPath, {
runner,
resolved: { command: 'markitdown', argsPrefix: [], version: null },
})
).rejects.toThrow(/pip install 'markitdown\[pdf\]'/);
});
it('classifies timeouts', async () => {
const runner = makeRunner({
[`markitdown ${inputPath}`]: {
error: Object.assign(new Error(' timeout'), { code: 'ETIMEDOUT' }),
},
});
await expect(
convertToMarkdown(inputPath, {
runner,
resolved: { command: 'markitdown', argsPrefix: [], version: null },
})
).rejects.toMatchObject({ code: 'timeout' });
});
it('rejects empty output with the supported-formats hint', async () => {
const runner = makeRunner({
[`markitdown ${inputPath}`]: { stdout: ' ' },
});
await expect(
convertToMarkdown(inputPath, {
runner,
resolved: { command: 'markitdown', argsPrefix: [], version: null },
})
).rejects.toMatchObject({ code: 'empty_output' });
});
it('rejects non-absolute and non-string paths', async () => {
const runner = makeRunner({});
await expect(
convertToMarkdown('relative.docx', {
runner,
resolved: { command: 'markitdown', argsPrefix: [], version: null },
})
).rejects.toMatchObject({ code: 'bad_path' });
await expect(convertToMarkdown(null, { runner })).rejects.toMatchObject({
code: 'bad_path',
});
});
it('fails with install instructions when not installed', async () => {
const runner = makeRunner({});
await expect(convertToMarkdown(inputPath, { runner })).rejects.toMatchObject({
code: 'not_installed',
});
});
});
});
+4 -1
View File
@@ -1,5 +1,6 @@
/** /**
* @jest-environment node * @jest-environment node
* @jest-environment-options {"testTimeout": 30000}
* *
* PDFBatchOperations.js tests for Task 22's batch PDF operations: the folder * PDFBatchOperations.js tests for Task 22's batch PDF operations: the folder
* loop that applies one PDFOperations.executeOperation() op to every .pdf in an * loop that applies one PDFOperations.executeOperation() op to every .pdf in an
@@ -293,7 +294,9 @@ describe('PDFBatchOperations - runPDFBatchOperation', () => {
operation: 'compress', operation: 'compress',
outputFolder: path.join(blocker, 'child'), outputFolder: path.join(blocker, 'child'),
data: {}, data: {},
sanitizeError: (message) => message.replace(new RegExp(path.sep, 'g'), '_SANITIZED_'), // path.sep is "\" on Windows — escape it for RegExp use
sanitizeError: (message) =>
message.replace(new RegExp(path.sep.replace(/\\/g, '\\\\'), 'g'), '_SANITIZED_'),
}); });
expect(completion.success).toBe(false); expect(completion.success).toBe(false);
+15 -4
View File
@@ -1,5 +1,6 @@
/** /**
* @jest-environment node * @jest-environment node
* @jest-environment-options {"testTimeout": 30000}
* *
* PDFOperations.js tests for Task 15's new operations: extractText, pageNumbers, * PDFOperations.js tests for Task 15's new operations: extractText, pageNumbers,
* crop, extractImages. Uses pdf-lib to build minimal fixture PDFs at test time, * crop, extractImages. Uses pdf-lib to build minimal fixture PDFs at test time,
@@ -47,13 +48,19 @@ describe('PDFOperations - Task 15 new operations', () => {
}); });
describe('pdfExtractText', () => { describe('pdfExtractText', () => {
it('extracts text from all pages', async () => { it(
'extracts text from all pages',
async () => {
const result = await PDFOperations.pdfExtractText({ inputPath }); const result = await PDFOperations.pdfExtractText({ inputPath });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.text).toContain('Hello Task 15 Page One'); expect(result.text).toContain('Hello Task 15 Page One');
expect(result.text).toContain('Second Page Content'); expect(result.text).toContain('Second Page Content');
}); },
// First pdfjs-dist legacy import can exceed the 5s default on slower
// CI runners (observed on windows-latest)
30000
);
it('returns failure for a nonexistent file', async () => { it('returns failure for a nonexistent file', async () => {
const result = await PDFOperations.pdfExtractText({ const result = await PDFOperations.pdfExtractText({
@@ -150,7 +157,9 @@ describe('PDFOperations - Task 15 new operations', () => {
}); });
describe('pdfExtractImages', () => { describe('pdfExtractImages', () => {
it('extracts embedded raster images as PNG files', async () => { it(
'extracts embedded raster images as PNG files',
async () => {
const imgPath = path.join(tmpDir, 'red.png'); const imgPath = path.join(tmpDir, 'red.png');
await sharp({ await sharp({
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } }, create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
@@ -181,7 +190,9 @@ describe('PDFOperations - Task 15 new operations', () => {
const meta = await sharp(file).metadata(); const meta = await sharp(file).metadata();
expect(meta.format).toBe('png'); expect(meta.format).toBe('png');
} }
}); },
30000 // pdfjs import + sharp decode on slower CI runners
);
it('returns zero images for a text-only PDF', async () => { it('returns zero images for a text-only PDF', async () => {
const outputDir = path.join(tmpDir, 'extracted-none'); const outputDir = path.join(tmpDir, 'extracted-none');
+4 -1
View File
@@ -22,7 +22,10 @@ describe('MonospaceFontConfig', () => {
(p) => !p.includes('app.asar.unpacked') && p.endsWith('JetBrainsMono-Regular.ttf') (p) => !p.includes('app.asar.unpacked') && p.endsWith('JetBrainsMono-Regular.ttf')
); );
const p = MonospaceFontConfig.getMonoFontTtfPath('jetbrains-mono', 400); const p = MonospaceFontConfig.getMonoFontTtfPath('jetbrains-mono', 400);
expect(p).toMatch(/assets\/fonts\/JetBrainsMono-Regular\.ttf$/); // Normalize separators: Windows paths are backslash-joined
expect(p.split(require('path').sep).join('/')).toMatch(
/assets\/fonts\/JetBrainsMono-Regular\.ttf$/
);
}); });
test('returns packaged asar.unpacked path when present and file exists', () => { test('returns packaged asar.unpacked path when present and file exists', () => {
+5 -2
View File
@@ -50,8 +50,11 @@ function listFilesRecursively(dir) {
); );
test('sharp-linux-x64 ships its native binding', () => { test('sharp-linux-x64 ships its native binding', () => {
const binding = path.join(UNPACKED_IMG_DIR, 'sharp-linux-x64', 'lib', 'sharp-linux-x64.node'); // sharp 0.35 names the binding with a version suffix
expect(fs.existsSync(binding)).toBe(true); // (sharp-linux-x64-0.35.4.node); 0.34 used a bare name — accept both
const libDir = path.join(UNPACKED_IMG_DIR, 'sharp-linux-x64', 'lib');
const bindings = fs.readdirSync(libDir).filter((f) => /^sharp-linux-x64.*\.node$/.test(f));
expect(bindings.length).toBeGreaterThan(0);
}); });
test('sharp-libvips-linux-x64 ships the bundled libvips shared libraries', () => { test('sharp-libvips-linux-x64 ships the bundled libvips shared libraries', () => {
+18 -16
View File
@@ -1,5 +1,7 @@
const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager'); const { ProjectManager } = require('../src/plugins/built-in/writing-studio/project-manager');
// All ProjectManager methods are async (the real fs backend is the IPC-backed
// file API; these tests inject a synchronous fake, which `await` handles).
describe('ProjectManager', () => { describe('ProjectManager', () => {
let pm; let pm;
let files; let files;
@@ -19,8 +21,8 @@ describe('ProjectManager', () => {
}); });
}); });
test('createProject writes .project.json', () => { test('createProject writes .project.json', async () => {
const project = pm.createProject('/manuscripts/novel', { const project = await pm.createProject('/manuscripts/novel', {
title: 'My Novel', title: 'My Novel',
type: 'manuscript', type: 'manuscript',
targetWords: 80000, targetWords: 80000,
@@ -31,7 +33,7 @@ describe('ProjectManager', () => {
expect(parsed.target.words).toBe(80000); expect(parsed.target.words).toBe(80000);
}); });
test('loadProject reads and returns project data', () => { test('loadProject reads and returns project data', async () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({ files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', title: 'Test',
type: 'manuscript', type: 'manuscript',
@@ -39,15 +41,15 @@ describe('ProjectManager', () => {
chapters: [], chapters: [],
metadata: {}, metadata: {},
}); });
const project = pm.loadProject('/manuscripts/novel'); const project = await pm.loadProject('/manuscripts/novel');
expect(project.title).toBe('Test'); expect(project.title).toBe('Test');
}); });
test('loadProject returns null if no project file', () => { test('loadProject returns null if no project file', async () => {
expect(pm.loadProject('/nonexistent')).toBeNull(); await expect(pm.loadProject('/nonexistent')).resolves.toBeNull();
}); });
test('addChapter appends chapter and saves', () => { test('addChapter appends chapter and saves', async () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({ files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', title: 'Test',
type: 'manuscript', type: 'manuscript',
@@ -55,7 +57,7 @@ describe('ProjectManager', () => {
chapters: [], chapters: [],
metadata: {}, metadata: {},
}); });
pm.addChapter('/manuscripts/novel', { await pm.addChapter('/manuscripts/novel', {
file: '01-chapter.md', file: '01-chapter.md',
title: 'Chapter One', title: 'Chapter One',
status: 'draft', status: 'draft',
@@ -65,7 +67,7 @@ describe('ProjectManager', () => {
expect(parsed.chapters[0].title).toBe('Chapter One'); expect(parsed.chapters[0].title).toBe('Chapter One');
}); });
test('compileManuscript concatenates chapter files', () => { test('compileManuscript concatenates chapter files', async () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({ files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', title: 'Test',
type: 'manuscript', type: 'manuscript',
@@ -78,11 +80,11 @@ describe('ProjectManager', () => {
}); });
files['/manuscripts/novel/01.md'] = 'First chapter content.'; files['/manuscripts/novel/01.md'] = 'First chapter content.';
files['/manuscripts/novel/02.md'] = 'Second chapter content.'; files['/manuscripts/novel/02.md'] = 'Second chapter content.';
const result = pm.compileManuscript('/manuscripts/novel'); const result = await pm.compileManuscript('/manuscripts/novel');
expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.'); expect(result).toBe('First chapter content.\n\n---\n\nSecond chapter content.');
}); });
test('compileManuscript skips missing files', () => { test('compileManuscript skips missing files', async () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({ files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', title: 'Test',
type: 'manuscript', type: 'manuscript',
@@ -94,11 +96,11 @@ describe('ProjectManager', () => {
metadata: {}, metadata: {},
}); });
files['/manuscripts/novel/01.md'] = 'Only chapter one.'; files['/manuscripts/novel/01.md'] = 'Only chapter one.';
const result = pm.compileManuscript('/manuscripts/novel'); const result = await pm.compileManuscript('/manuscripts/novel');
expect(result).toBe('Only chapter one.'); expect(result).toBe('Only chapter one.');
}); });
test('getStats returns total word count across chapters', () => { test('getStats returns total word count across chapters', async () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({ files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', title: 'Test',
type: 'manuscript', type: 'manuscript',
@@ -111,14 +113,14 @@ describe('ProjectManager', () => {
}); });
files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim(); files['/manuscripts/novel/01.md'] = 'word '.repeat(100).trim();
files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim(); files['/manuscripts/novel/02.md'] = 'more '.repeat(50).trim();
const stats = pm.getStats('/manuscripts/novel'); const stats = await pm.getStats('/manuscripts/novel');
expect(stats.totalWords).toBeGreaterThan(0); expect(stats.totalWords).toBeGreaterThan(0);
expect(stats.chapterCount).toBe(2); expect(stats.chapterCount).toBe(2);
expect(stats.targetWords).toBe(50000); expect(stats.targetWords).toBe(50000);
expect(stats.pctComplete).toBeDefined(); expect(stats.pctComplete).toBeDefined();
}); });
test('updateChapter modifies a chapter by index', () => { test('updateChapter modifies a chapter by index', async () => {
files['/manuscripts/novel/.project.json'] = JSON.stringify({ files['/manuscripts/novel/.project.json'] = JSON.stringify({
title: 'Test', title: 'Test',
type: 'manuscript', type: 'manuscript',
@@ -126,7 +128,7 @@ describe('ProjectManager', () => {
chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }], chapters: [{ file: '01.md', title: 'Old Title', status: 'draft' }],
metadata: {}, metadata: {},
}); });
pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' }); await pm.updateChapter('/manuscripts/novel', 0, { title: 'New Title', status: 'revised' });
const parsed = JSON.parse(files['/manuscripts/novel/.project.json']); const parsed = JSON.parse(files['/manuscripts/novel/.project.json']);
expect(parsed.chapters[0].title).toBe('New Title'); expect(parsed.chapters[0].title).toBe('New Title');
expect(parsed.chapters[0].status).toBe('revised'); expect(parsed.chapters[0].status).toBe('revised');
+33 -30
View File
@@ -1,5 +1,7 @@
const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager'); const { SnapshotManager } = require('../src/plugins/built-in/writing-studio/snapshot-manager');
// All SnapshotManager methods are async (the real settings backend is the
// IPC-backed SettingsStore; these tests inject a synchronous fake).
describe('SnapshotManager', () => { describe('SnapshotManager', () => {
let manager; let manager;
let store; let store;
@@ -14,62 +16,63 @@ describe('SnapshotManager', () => {
}); });
}); });
test('create stores snapshot with timestamp, content, wordCount', () => { test('create stores snapshot with timestamp, content, wordCount', async () => {
const snap = manager.create('Hello world this is a test', 'auto'); const snap = await manager.create('Hello world this is a test', 'auto');
expect(snap).toHaveProperty('id'); expect(snap).toHaveProperty('id');
expect(snap.content).toBe('Hello world this is a test'); expect(snap.content).toBe('Hello world this is a test');
expect(snap.wordCount).toBe(6); expect(snap.wordCount).toBe(6);
expect(snap.label).toBe('auto'); expect(snap.label).toBe('auto');
}); });
test('list returns snapshots ordered newest first', () => { test('list returns snapshots ordered newest first', async () => {
manager.create('first', 'auto'); await manager.create('first', 'auto');
manager.create('second', 'auto'); await manager.create('second', 'auto');
const list = manager.list(); const list = await manager.list();
expect(list.length).toBe(2); expect(list.length).toBe(2);
expect(list[0].content).toBe('second'); expect(list[0].content).toBe('second');
}); });
test('getById returns specific snapshot', () => { test('getById returns specific snapshot', async () => {
const snap = manager.create('find me', 'manual'); const snap = await manager.create('find me', 'manual');
const found = manager.getById(snap.id); const found = await manager.getById(snap.id);
expect(found.content).toBe('find me'); expect(found.content).toBe('find me');
}); });
test('getById returns null for missing id', () => { test('getById returns null for missing id', async () => {
expect(manager.getById('nope')).toBeNull(); await expect(manager.getById('nope')).resolves.toBeNull();
}); });
test('restore returns content of snapshot', () => { test('restore returns content of snapshot', async () => {
const snap = manager.create('restore this', 'manual'); const snap = await manager.create('restore this', 'manual');
expect(manager.restore(snap.id)).toBe('restore this'); await expect(manager.restore(snap.id)).resolves.toBe('restore this');
}); });
test('restore throws for missing snapshot', () => { test('restore throws for missing snapshot', async () => {
expect(() => manager.restore('nope')).toThrow('Snapshot not found'); await expect(manager.restore('nope')).rejects.toThrow('Snapshot not found');
}); });
test('delete removes a snapshot', () => { test('delete removes a snapshot', async () => {
const snap = manager.create('delete me', 'auto'); const snap = await manager.create('delete me', 'auto');
manager.delete(snap.id); await manager.delete(snap.id);
expect(manager.getById(snap.id)).toBeNull(); await expect(manager.getById(snap.id)).resolves.toBeNull();
}); });
test('diff returns added/removed line counts', () => { test('diff returns added/removed line counts', async () => {
const snap = manager.create('line one\nline two\nline three', 'auto'); const snap = await manager.create('line one\nline two\nline three', 'auto');
const result = manager.diff(snap.id, 'line one\nline modified\nline three\nline four'); const result = await manager.diff(snap.id, 'line one\nline modified\nline three\nline four');
expect(result.added).toBe(2); expect(result.added).toBe(2);
expect(result.removed).toBe(1); expect(result.removed).toBe(1);
}); });
test('diff throws for missing snapshot', () => { test('diff throws for missing snapshot', async () => {
expect(() => manager.diff('nope', 'new content')).toThrow('Snapshot not found'); await expect(manager.diff('nope', 'new content')).rejects.toThrow('Snapshot not found');
}); });
test('prune keeps only the N most recent snapshots', () => { test('prune keeps only the N most recent snapshots', async () => {
for (let i = 0; i < 10; i++) manager.create('snap ' + i, 'auto'); for (let i = 0; i < 10; i++) await manager.create('snap ' + i, 'auto');
manager.prune(5); await manager.prune(5);
expect(manager.list().length).toBe(5); const list = await manager.list();
expect(manager.list()[0].content).toBe('snap 9'); expect(list.length).toBe(5);
expect(list[0].content).toBe('snap 9');
}); });
}); });
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+501
View File
@@ -0,0 +1,501 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
not price. Our General Public Licenses are designed to make sure that
you have the freedom to distribute copies of free software (and charge
for this service if you wish); that you receive source code or can get
it if you want it; that you can change the software and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, complete source code means
all the source code for all modules it contains, plus any associated
interface definition files, plus the scripts used to control compilation
and installation of the library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete source code as you receive it, in any medium, provided that
you conspicuously and appropriately publish on each copy an
appropriate copyright notice and disclaimer of warranty; keep intact
all the notices that refer to this License and to the absence of any
warranty; and distribute a copy of this License along with the
Library.
You may charge a fee for the physical act of transferring a copy,
and you may at your option offer warranty protection in exchange for a
fee.
2. You may modify your copy or copies of the Library or any portion
of it, thus forming a work based on the Library, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Library, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote
it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you accompany
it with the complete corresponding machine-readable source code, which
must be distributed under the terms of Sections 1 and 2 above on a
medium customarily used for software interchange.
If distribution of object code is made by offering access to copy
from a designated place, then offering equivalent access to copy the
source code from the same place satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library is void, and will automatically terminate your
rights under this License. However, parties who have received copies,
or rights, from you under this License will not have their licenses
terminated so long as such parties remain in full compliance.
9. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
subject to these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties with
this License.
11. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Library.
If any portion of this section is held invalid or unenforceable under any
particular circumstance, the balance of the section is intended to apply,
and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library under this License may add
an explicit geographical distribution limitation excluding those countries,
so that distribution is permitted only in or among countries not thus
excluded. In such case, this License incorporates the limitation as if
written in the body of this License.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser General Public License from time to time.
Such new versions will be similar in spirit to the present version,
but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Library
specifies a version number of this License which applies to it and
"any later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
write to the author to ask for permission. For software which is
copyrighted by the Free Software Foundation, write to the Free
Software Foundation; we sometimes make exceptions for this. Our
decision will be guided by the two goals of preserving the free status
of all derivatives of our free software and of promoting the sharing
and reuse of software generally.
NO WARRANTY
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Moe Ghoul>, 1 April 1990
Moe Ghoul, President of Vice
That's all there is to it!
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+277
View File
@@ -0,0 +1,277 @@
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see https://opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
Python software and documentation are licensed under the
Python Software Foundation License Version 2.
Starting with Python 3.8.6, examples, recipes, and other code in
the documentation are dual licensed under the PSF License Version 2
and the Zero-Clause BSD license.
Some software incorporated into Python is under different licenses.
The licenses are listed with code falling under that license.
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved"
are retained in Python alone or in any derivative version prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
----------------------------------------------------------------------
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.