Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3915865029 | ||
|
|
79edb6bb56 | ||
|
|
a971ff870f | ||
|
|
9bd4a959c4 | ||
|
|
ebeb6ac578 | ||
|
|
f60cf1a688 | ||
|
|
2d1b474931 | ||
|
|
c60b7247af | ||
|
|
cf056d2b97 | ||
|
|
3fa366a0d0 | ||
|
|
d0b1816175 | ||
|
|
dfa65c894b | ||
|
|
162ae0ae05 | ||
|
|
879434f14f | ||
|
|
13b5f67de8 | ||
|
|
8d645c0aca | ||
|
|
68ceee83ef | ||
|
|
d244626583 | ||
|
|
375c14b028 | ||
|
|
1a1dbb846b | ||
|
|
0bd326d8b2 | ||
|
|
bd10f7d147 | ||
|
|
33eb0f745e | ||
|
|
cb5e0c6837 | ||
|
|
ffaa123893 | ||
|
|
2b27428849 | ||
|
|
4cbab15429 | ||
|
|
063624ea57 | ||
|
|
7fe30cf8ef | ||
|
|
d607905b58 | ||
|
|
0f25d327c0 | ||
|
|
058582d7af | ||
|
|
f4278d118c | ||
|
|
c2247a57c7 | ||
|
|
d89abf5b78 | ||
|
|
845b3ed205 | ||
|
|
3def598528 | ||
|
|
f580759337 | ||
|
|
94f14851fa | ||
|
|
5edf740fda | ||
|
|
c39262c3fa | ||
|
|
46d0d622ca | ||
|
|
204be178a3 | ||
|
|
f37a2ec141 | ||
|
|
a201b05c9e | ||
|
|
5b54305b9f | ||
|
|
3de8ec978b | ||
|
|
4f74b03324 | ||
|
|
7da39831ab | ||
|
|
ad3348f721 | ||
|
|
d67afd2bc9 | ||
|
|
f79bbfed9f | ||
|
|
61e3c812a0 | ||
|
|
b84b7883fe | ||
|
|
b85eaad0cb | ||
|
|
8cc1ba3532 | ||
|
|
ab280daf63 | ||
|
|
b51a4c46ce | ||
|
|
7229b154c9 | ||
|
|
722c5d2eee | ||
|
|
0b490e2aa9 | ||
|
|
e588f93a19 | ||
|
|
4732c5daf3 | ||
|
|
db2382294e | ||
|
|
58b4182c54 | ||
|
|
88d1fc3efb | ||
|
|
2bb17b170e | ||
|
|
6408040623 | ||
|
|
a3425c36b4 | ||
|
|
5d4925dee4 | ||
|
|
06fba24645 | ||
|
|
fee394f6b7 | ||
|
|
f094bbbd73 | ||
|
|
0ead121327 | ||
|
|
420b32c0f8 | ||
|
|
bb959a4bb9 | ||
|
|
46d12cc829 | ||
|
|
8e2e1f41fb | ||
|
|
a58607f029 | ||
|
|
be88434e44 | ||
|
|
1776089c08 | ||
|
|
31bdbea7b5 | ||
|
|
9e88991f46 | ||
|
|
014b92675d | ||
|
|
b39fe553b2 | ||
|
|
602e70a7e1 | ||
|
|
f23b49c0d1 | ||
|
|
570173cf4f | ||
|
|
91495a3614 | ||
|
|
ce9bd41e72 | ||
|
|
20d69619c2 | ||
|
|
1b16531321 | ||
|
|
2274544ed8 | ||
|
|
6c2c837a83 | ||
|
|
356334dd3e | ||
|
|
8c2229633f | ||
|
|
4311225d50 | ||
|
|
c7038a151a | ||
|
|
05b7713efb | ||
|
|
7196a6d95b | ||
|
|
a998a9eeda | ||
|
|
50fb9927ca | ||
|
|
52fba1f81d |
@@ -2,11 +2,9 @@ name: Template tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
- [ ] Git / workflow
|
||||
- [ ] Other
|
||||
|
||||
## Issue Type
|
||||
|
||||
- [ ] Bug
|
||||
- [ ] Improvement
|
||||
- [ ] Task
|
||||
- [ ] Documentation
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<!-- please delete the unused check box's on the creation of your pr -->
|
||||
|
||||
## Improvement Area
|
||||
|
||||
- [ ] Guide Editor
|
||||
- [ ] Library
|
||||
- [ ] Settings
|
||||
- [ ] Exports
|
||||
- [ ] Documentation
|
||||
- [ ] Tests
|
||||
- [ ] Git / workflow
|
||||
- [ ] Other
|
||||
|
||||
## Issue
|
||||
<!-- Replace the example with the issue number this PR resolves. -->
|
||||
|
||||
- Closes #
|
||||
|
||||
<!-- Replace the example with the issue number this PR resolves. -->
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# Cancel an in-progress run when newer commits are pushed to the same ref.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Unit tests (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
# The capture unit tests require app/capture.js, which require()s the
|
||||
# electron module at load — so the Electron binary must actually be
|
||||
# installed (don't set ELECTRON_SKIP_BINARY_DOWNLOAD here).
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
@@ -0,0 +1,134 @@
|
||||
name: Release
|
||||
|
||||
# Manual release: from the GitHub "Actions" tab pick "Release", click
|
||||
# "Run workflow", enter the version tag, and it builds the Windows installer
|
||||
# .exe and the Linux tarball + .deb, then publishes a GitHub Release with all
|
||||
# artifacts attached and auto-generated notes.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release tag, e.g. v0.1.1 (the tag is created at the current commit on this branch)'
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: 'Mark this release as a pre-release'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
# Needed for the release job to create the tag and the GitHub Release.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows installer
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Stamp the requested version onto package.json so the produced artifact
|
||||
# carries it. Not committed back — it only affects this build.
|
||||
- name: Set version from input
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Configure Windows code signing
|
||||
shell: bash
|
||||
env:
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$WIN_CSC_LINK" ] && [ -n "$WIN_CSC_KEY_PASSWORD" ]; then
|
||||
{
|
||||
printf 'WIN_CSC_LINK=%s\n' "$WIN_CSC_LINK"
|
||||
printf 'WIN_CSC_KEY_PASSWORD=%s\n' "$WIN_CSC_KEY_PASSWORD"
|
||||
} >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "Windows code signing secrets not configured; building unsigned."
|
||||
fi
|
||||
|
||||
- name: Package Windows installer .exe
|
||||
run: npm run package:windows
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: releases/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
build-linux:
|
||||
name: Build Linux tarball + .deb
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Set version from input
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: npm version "${VERSION#v}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Package Linux artifacts (tarball + .deb)
|
||||
shell: bash
|
||||
env:
|
||||
STEPFORGE_PACKAGE_DIR: ${{ github.workspace }}/build/artifacts
|
||||
run: bash scripts/package-linux.sh
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-artifacts
|
||||
path: build/artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs: [build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: List downloaded artifacts
|
||||
run: find dist -type f -printf '%s\t%p\n'
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.version }}
|
||||
PRERELEASE: ${{ inputs.prerelease }}
|
||||
run: |
|
||||
flags=()
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
flags+=(--prerelease)
|
||||
fi
|
||||
gh release create "$TAG" \
|
||||
dist/windows-installer/*.exe \
|
||||
dist/linux-artifacts/* \
|
||||
--title "StepForge $TAG" \
|
||||
--target "${{ github.sha }}" \
|
||||
--generate-notes \
|
||||
"${flags[@]}"
|
||||
@@ -3,11 +3,10 @@
|
||||
StepForge is a **fully offline**, open-source desktop app for Windows and
|
||||
Linux that captures step-by-step workflows as screenshots, lets you annotate
|
||||
and describe each step in a focused three-pane editor, and exports the result
|
||||
to JSON, Markdown, HTML (simple and rich), PDF, animated GIF, image bundles,
|
||||
DOCX, and PPTX.
|
||||
to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP), confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current reconmendations for exporting is Markdown and PDF.
|
||||
|
||||
It is an independent offline desktop guide-capture tool inspired by publicly
|
||||
documented workflow patterns of commercial documentation tools. It contains no
|
||||
documented workflow patterns of commercial documentation tools like Folge. It contains no
|
||||
third-party branding, assets, or code from those tools, and it never talks to
|
||||
the network: no telemetry, no update checks, no license checks, no cloud, no
|
||||
remote AI.
|
||||
@@ -27,21 +26,6 @@ The core workflow:
|
||||
4. **Export** — every exporter renders from the same normalized Render AST,
|
||||
so output is deterministic across formats.
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ StepForge > Reset Password SOP [Capture] [Export] [Save] │
|
||||
├──────────────────┬──────────────────────────────────┬──────────────────────┤
|
||||
│ Steps │ Canvas │ Properties │
|
||||
│ 1 Open Users │ ┌────────────────────────────┐ │ Title │
|
||||
│ 2 Search account │ │ [tooltip] │ │ [Reset password] │
|
||||
│ 3 Click Reset │ │ ↘ ┌────────────┐ │ │ Description │
|
||||
│ 3.1 Warning │ │ │ Reset btn │ │ │ [rich text editor] │
|
||||
│ 4 Done │ │ └────────────┘ │ │ Text blocks │
|
||||
│ [Add Step] │ └────────────────────────────┘ │ Step settings │
|
||||
├──────────────────┴──────────────────────────────────┴──────────────────────┤
|
||||
│ Tools: [Select][Rect][Oval][Line][Arrow][Text][Tooltip][#][Blur][Hi][Crop] │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
@@ -50,8 +34,8 @@ The core workflow:
|
||||
- **Capture engine** — the editor's **Capture ▾** button offers full screen,
|
||||
active window, and region capture (the app hides itself during the shot),
|
||||
plus continuous capture sessions that grab a step on every click where the
|
||||
OS allows it, or on a 3/5/10 s auto-interval everywhere else (the REC bar
|
||||
shows the live count with Shoot/Auto/Pause/Finish controls). Delay, global
|
||||
OS allows it, or on a 3/5/10 s auto-interval everywhere else. The REC bar
|
||||
shows the live count and the start/pause control. Delay, global
|
||||
hotkeys, click markers, clipboard paste, and PNG/JPEG/GIF import included.
|
||||
The full keyboard shortcut list lives under **More ▾ → Keyboard
|
||||
shortcuts** in the editor.
|
||||
@@ -76,7 +60,7 @@ using only Node built-ins.
|
||||
|
||||
## Getting Started
|
||||
|
||||
For a shorter walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
||||
For a windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
||||
|
||||
Requirements: Node.js 20+ and npm (Electron is the only dependency).
|
||||
|
||||
@@ -99,8 +83,8 @@ bash tests/run_test.sh
|
||||
|
||||
The runner executes every `tests/checks/test_*.sh` script; those scripts run
|
||||
the workflow test suites under `tests/unit/` with `node --test`. The tests
|
||||
exercise real workflows — creating guides, round-tripping archives, exporting
|
||||
documents, and validating the bytes of the output — not string matching.
|
||||
exercise real workflows like creating guides, round-tripping archives, exporting
|
||||
documents, and validating the bytes of the output, not string matching.
|
||||
|
||||
## Building & Packaging
|
||||
|
||||
@@ -109,8 +93,8 @@ bash scripts/bootstrap-offline.sh # verify toolchain availability
|
||||
bash scripts/verify.sh # full test suite + smoke checks
|
||||
bash scripts/build-release.sh # assemble runnable app directory
|
||||
bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec)
|
||||
npm run package:windows # portable Windows .exe in releases/
|
||||
pwsh scripts/package-windows.ps1 # same Windows portable build via PowerShell
|
||||
npm run package:windows # Windows installer .exe in releases/
|
||||
pwsh scripts/package-windows.ps1 # same Windows installer build via PowerShell
|
||||
```
|
||||
|
||||
See [build/build_report.md](build/build_report.md) for what was produced on
|
||||
@@ -131,11 +115,12 @@ clean-room rules.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
Project docs live in `docs/`, and prompt handoffs live in `ai_prompts/`.
|
||||
Project docs live in `docs/` and prompt handoffs live in `ai_prompts/`.
|
||||
|
||||
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the repo layout.
|
||||
|
||||
## License
|
||||
|
||||
Application code is licensed under [MPL-2.0](LICENSE). Bundled example
|
||||
guides, templates, and screenshots are CC-BY-4.0 unless noted otherwise.
|
||||
Creative Commons Attribution-NonCommercial
|
||||
|
||||
Basically, do whatever you want with it just don't make money off it or sell it.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats. OCR or local ai would be pretty cool....
|
||||
@@ -3,7 +3,6 @@
|
||||
const path = require('node:path');
|
||||
const { spawn, execFileSync } = require('node:child_process');
|
||||
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
|
||||
const { expandPlaceholders } = require('../core/placeholders');
|
||||
const raster = require('../core/raster');
|
||||
const { encodePng } = require('../core/png');
|
||||
const {
|
||||
@@ -14,6 +13,7 @@ const {
|
||||
DEFAULT_START_SLACK_MS,
|
||||
} = require('./click-frames');
|
||||
const { physicalToDip } = require('./coords');
|
||||
const { DEFAULT_CAPTURE_TITLES } = require('../core/text-intel');
|
||||
|
||||
/**
|
||||
* Capture service: full-screen, active-window, and region capture, plus a
|
||||
@@ -56,9 +56,14 @@ const DEFAULT_CLICK_DEBOUNCE_MS = 200;
|
||||
// representation that carries root coordinates) before firing without them.
|
||||
const LINUX_CLICK_TWIN_MS = 25;
|
||||
// Longest the window stays visible warming up the recorder at recording
|
||||
// start. A slow capture-stream start (Windows can take several seconds) must
|
||||
// not keep the window up and recording un-armed indefinitely.
|
||||
const WARMUP_MAX_MS = 1500;
|
||||
// start. A slow capture-stream start (Windows can take several seconds,
|
||||
// especially on battery) must not keep the window up and recording un-armed
|
||||
// indefinitely — but the window is still visible during warmup, so the user
|
||||
// hasn't begun their workflow yet, and the common case still proceeds the
|
||||
// instant the stream is ready. The cap only bites when startup is slow, where
|
||||
// a little more headroom buys a pre-click frame for the very first click
|
||||
// instead of a post-click fresh shot.
|
||||
const WARMUP_MAX_MS = 3000;
|
||||
// Idle gap between legacy frame-loop grabs. Must stay well above zero:
|
||||
// grabbing back-to-back starves the main-process event loop, which delays
|
||||
// delivery of click events from the OS watcher by whole seconds. (The
|
||||
@@ -109,6 +114,7 @@ class CaptureService {
|
||||
getWindow,
|
||||
notify,
|
||||
screenApi = screen,
|
||||
textIntel = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.settings = settings;
|
||||
@@ -117,6 +123,7 @@ class CaptureService {
|
||||
// Injectable for tests; the click/coordinate paths must never reach for
|
||||
// the global `screen` directly so coordinate handling stays testable.
|
||||
this.screen = screenApi;
|
||||
this.textIntel = textIntel;
|
||||
this.session = null; // { guideId, paused, count, intervalSec }
|
||||
this.intervalTimer = null;
|
||||
this.clickWatcher = null;
|
||||
@@ -128,6 +135,11 @@ class CaptureService {
|
||||
this.clickWatcherErrTail = '';
|
||||
this.linuxEvent = null; // event block currently being parsed
|
||||
this.pendingRawClick = null; // raw press waiting for its coordinate twin
|
||||
this.pendingClickOsPoint = null;
|
||||
this._keyBuffer = ''; // printable chars typed since last capture
|
||||
this._lastShortcut = ''; // last Ctrl+X / Alt+X combination
|
||||
this._keyLastAt = 0; // timestamp of last key event
|
||||
this._pendingWindowContext = null; // buffered CTX+ELEM from click watcher, consumed on CLICK
|
||||
this.clickQueue = Promise.resolve();
|
||||
this.frameLoopInFlight = false;
|
||||
this.frameLoopGrabStartedAt = null;
|
||||
@@ -458,7 +470,7 @@ class CaptureService {
|
||||
if (frame) {
|
||||
clog('click@', clickAt, 'frame', frame.source || 'loop',
|
||||
'started', frame.startedAt - clickAt, 'ms, captured', frame.capturedAt - clickAt, 'ms rel. click');
|
||||
const result = this.storeFrameAsStep(guideId, frame.mode, frame, clickPos);
|
||||
const result = await this.storeFrameAsStep(guideId, frame.mode, frame, clickPos, clickMeta);
|
||||
if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
|
||||
return result;
|
||||
}
|
||||
@@ -791,16 +803,31 @@ using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
public static class SFMouseHook {
|
||||
public static class SFHook {
|
||||
private const int WH_MOUSE_LL = 14;
|
||||
private const int WH_KEYBOARD_LL = 13;
|
||||
private const int WM_LBUTTONDOWN = 0x0201;
|
||||
private const int WM_RBUTTONDOWN = 0x0204;
|
||||
private const int WM_MBUTTONDOWN = 0x0207;
|
||||
private const int WM_XBUTTONDOWN = 0x020B;
|
||||
private const int WM_KEYDOWN = 0x0100;
|
||||
private const int WM_SYSKEYDOWN = 0x0104;
|
||||
private const long UnixEpochMilliseconds = 62135596800000L;
|
||||
// Opting this process out of Windows Power Throttling (EcoQoS). In a
|
||||
// power-saving plan the OS CPU-starves background processes; a starved
|
||||
// low-level mouse hook whose callback exceeds LowLevelHooksTimeout is
|
||||
// silently skipped by Windows (events stop arriving) while the process
|
||||
// stays alive, so clicks are missed with no error — the "only the first
|
||||
// couple of clicks were captured" symptom.
|
||||
private const int ProcessPowerThrottling = 4;
|
||||
private const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
|
||||
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
||||
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
||||
|
||||
private static IntPtr hook = IntPtr.Zero;
|
||||
private static LowLevelMouseProc proc = HookCallback;
|
||||
private static IntPtr keyHook = IntPtr.Zero;
|
||||
private static LowLevelMouseProc proc = MouseHookCallback;
|
||||
private static LowLevelKeyboardProc keyProc = KeyboardHookCallback;
|
||||
private static readonly ConcurrentQueue<string> queue = new ConcurrentQueue<string>();
|
||||
private static readonly AutoResetEvent signal = new AutoResetEvent(false);
|
||||
|
||||
@@ -829,11 +856,41 @@ public static class SFMouseHook {
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_POWER_THROTTLING_STATE {
|
||||
public uint Version;
|
||||
public uint ControlMask;
|
||||
public uint StateMask;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct KBDLLHOOKSTRUCT {
|
||||
public uint vkCode;
|
||||
public uint scanCode;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public UIntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern short GetKeyState(int nVirtKey);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool GetKeyboardState([Out] byte[] lpKeyState);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int ToUnicode(uint wVirtKey, uint wScanCode, byte[] lpKeyState,
|
||||
System.Text.StringBuilder pwszBuff, int cchBuff, uint wFlags);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
@@ -855,7 +912,85 @@ public static class SFMouseHook {
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetProcessDpiAwarenessContext(IntPtr value);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool SetProcessInformation(IntPtr hProcess, int ProcessInformationClass, ref PROCESS_POWER_THROTTLING_STATE ProcessInformation, uint ProcessInformationSize);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr GetCurrentProcess();
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool SetPriorityClass(IntPtr hProcess, uint dwPriorityClass);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern IntPtr OpenProcess(uint processAccess, bool bInheritHandle, uint processId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool QueryFullProcessImageName(IntPtr hProcess, uint dwFlags, System.Text.StringBuilder lpExeName, ref uint lpdwSize);
|
||||
|
||||
private static string GetFwTitle() {
|
||||
try {
|
||||
IntPtr hwnd = GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero) return "";
|
||||
var sb = new System.Text.StringBuilder(512);
|
||||
GetWindowText(hwnd, sb, sb.Capacity);
|
||||
return sb.ToString();
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
private static string GetFwApp() {
|
||||
try {
|
||||
IntPtr hwnd = GetForegroundWindow();
|
||||
if (hwnd == IntPtr.Zero) return "";
|
||||
uint pid = 0;
|
||||
GetWindowThreadProcessId(hwnd, out pid);
|
||||
if (pid == 0) return "";
|
||||
IntPtr hProc = OpenProcess(0x1000u, false, pid);
|
||||
if (hProc == IntPtr.Zero) return "";
|
||||
var sb = new System.Text.StringBuilder(260);
|
||||
uint sz = (uint)sb.Capacity;
|
||||
QueryFullProcessImageName(hProc, 0u, sb, ref sz);
|
||||
CloseHandle(hProc);
|
||||
string path = sb.ToString();
|
||||
return string.IsNullOrEmpty(path) ? "" : System.IO.Path.GetFileNameWithoutExtension(path);
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
// Base64-encodes a string for safe line-protocol transmission; "-" for empty.
|
||||
private static string B64(string s) {
|
||||
if (string.IsNullOrEmpty(s)) return "-";
|
||||
try { return Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(s)); } catch { return "-"; }
|
||||
}
|
||||
|
||||
// Force this process to run at full CPU speed regardless of the power plan,
|
||||
// so the mouse-hook callback never trips LowLevelHooksTimeout and clicks
|
||||
// keep being delivered while the laptop is in eco / power-saving mode.
|
||||
private static void KeepProcessResponsive() {
|
||||
try {
|
||||
PROCESS_POWER_THROTTLING_STATE state = new PROCESS_POWER_THROTTLING_STATE();
|
||||
state.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
|
||||
// Control EXECUTION_SPEED and set its state bit to 0 => throttling off
|
||||
// (the documented way to opt a process out of EcoQoS).
|
||||
state.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED;
|
||||
state.StateMask = 0;
|
||||
SetProcessInformation(GetCurrentProcess(), ProcessPowerThrottling, ref state, (uint)Marshal.SizeOf(state));
|
||||
} catch { }
|
||||
try { SetPriorityClass(GetCurrentProcess(), HIGH_PRIORITY_CLASS); } catch { }
|
||||
}
|
||||
|
||||
public static void Run() {
|
||||
KeepProcessResponsive();
|
||||
try { SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { }
|
||||
|
||||
Thread writer = new Thread(WriterLoop);
|
||||
@@ -866,6 +1001,8 @@ public static class SFMouseHook {
|
||||
if (hook == IntPtr.Zero) {
|
||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
// Keyboard hook is optional — if it fails we still get click titles.
|
||||
try { keyHook = SetWindowsHookEx(WH_KEYBOARD_LL, keyProc, GetModuleHandle(null), 0); } catch { }
|
||||
|
||||
Console.Out.WriteLine("READY");
|
||||
Console.Out.Flush();
|
||||
@@ -877,6 +1014,7 @@ public static class SFMouseHook {
|
||||
}
|
||||
|
||||
UnhookWindowsHookEx(hook);
|
||||
if (keyHook != IntPtr.Zero) UnhookWindowsHookEx(keyHook);
|
||||
}
|
||||
|
||||
private static void WriterLoop() {
|
||||
@@ -890,13 +1028,19 @@ public static class SFMouseHook {
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
|
||||
private static IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
|
||||
if (nCode >= 0) {
|
||||
int message = wParam.ToInt32();
|
||||
string button = ButtonName(message, lParam);
|
||||
if (button != null) {
|
||||
MSLLHOOKSTRUCT data = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
|
||||
long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds;
|
||||
// Capture window context while the user's app is still in foreground.
|
||||
// GetForegroundWindow is synchronous and fast (no IPC overhead).
|
||||
try {
|
||||
string t = GetFwTitle(), a = GetFwApp();
|
||||
queue.Enqueue("CTX " + B64(t) + " " + B64(a) + " " + unixMs);
|
||||
} catch { }
|
||||
queue.Enqueue("CLICK " + data.pt.x + " " + data.pt.y + " " + button + " " + unixMs);
|
||||
signal.Set();
|
||||
}
|
||||
@@ -904,6 +1048,75 @@ public static class SFMouseHook {
|
||||
return CallNextHookEx(hook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
private static IntPtr KeyboardHookCallback(int nCode, IntPtr wParam, IntPtr lParam) {
|
||||
if (nCode >= 0 && (wParam.ToInt32() == WM_KEYDOWN || wParam.ToInt32() == WM_SYSKEYDOWN)) {
|
||||
try {
|
||||
KBDLLHOOKSTRUCT kb = (KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(KBDLLHOOKSTRUCT));
|
||||
int vk = (int)kb.vkCode;
|
||||
bool ctrl = (GetKeyState(0x11) & 0x8000) != 0;
|
||||
bool alt = (GetKeyState(0x12) & 0x8000) != 0;
|
||||
bool shift = (GetKeyState(0x10) & 0x8000) != 0;
|
||||
// Skip standalone modifier keys.
|
||||
bool isMod = vk == 0x10 || vk == 0x11 || vk == 0x12 || vk == 0x5B || vk == 0x5C;
|
||||
if (!isMod) {
|
||||
long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds;
|
||||
if (ctrl || alt) {
|
||||
// Emit shortcut: "KEY Ctrl+T 123456"
|
||||
string name = VkName(vk);
|
||||
if (name != null) {
|
||||
string mods = (ctrl ? "Ctrl" : "") + (ctrl && (alt || shift) ? "+" : "") +
|
||||
(alt ? "Alt" : "") + ((alt || ctrl) && shift ? "+" : "") +
|
||||
(shift ? "Shift" : "");
|
||||
queue.Enqueue("KEY " + mods + "+" + name + " " + unixMs);
|
||||
signal.Set();
|
||||
}
|
||||
} else if (vk == 0x08) {
|
||||
queue.Enqueue("KEY Backspace " + unixMs); signal.Set();
|
||||
} else if (vk == 0x1B) {
|
||||
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
|
||||
} else if (vk == 0x0D) {
|
||||
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
|
||||
} else {
|
||||
// Map to Unicode character using current keyboard layout + shift state.
|
||||
byte[] ks = new byte[256];
|
||||
GetKeyboardState(ks);
|
||||
var sb = new System.Text.StringBuilder(4);
|
||||
int res = ToUnicode(kb.vkCode, kb.scanCode, ks, sb, 4, 0);
|
||||
if (res > 0) {
|
||||
char ch = sb[0];
|
||||
if (ch >= 0x20 && ch < 0x7F) {
|
||||
queue.Enqueue("CHAR " + (int)ch + " " + unixMs);
|
||||
signal.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return CallNextHookEx(keyHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
private static string VkName(int vk) {
|
||||
if (vk >= 0x41 && vk <= 0x5A) return ((char)vk).ToString(); // A-Z
|
||||
if (vk >= 0x30 && vk <= 0x39) return ((char)vk).ToString(); // 0-9
|
||||
if (vk >= 0x70 && vk <= 0x87) return "F" + (vk - 0x6F); // F1-F24
|
||||
switch (vk) {
|
||||
case 0x09: return "Tab";
|
||||
case 0x0D: return "Enter";
|
||||
case 0x1B: return "Escape";
|
||||
case 0x20: return "Space";
|
||||
case 0x21: return "PageUp"; case 0x22: return "PageDown";
|
||||
case 0x23: return "End"; case 0x24: return "Home";
|
||||
case 0x25: return "Left"; case 0x26: return "Up";
|
||||
case 0x27: return "Right"; case 0x28: return "Down";
|
||||
case 0x2E: return "Delete";
|
||||
case 0x6B: case 0xBB: return "Plus";
|
||||
case 0x6D: case 0xBD: return "Minus";
|
||||
case 0xBE: return "Period"; case 0xBF: return "Slash";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ButtonName(int message, IntPtr lParam) {
|
||||
if (message == WM_LBUTTONDOWN) return "left";
|
||||
if (message == WM_RBUTTONDOWN) return "right";
|
||||
@@ -917,7 +1130,7 @@ public static class SFMouseHook {
|
||||
}
|
||||
}
|
||||
'@
|
||||
[SFMouseHook]::Run()
|
||||
[SFHook]::Run()
|
||||
`;
|
||||
this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -1052,11 +1265,33 @@ public static class SFMouseHook {
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
for (const line of lines) {
|
||||
const m = /^CLICK(?:\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+(\d+))?)?\s*$/.exec(line.trim());
|
||||
if (m) {
|
||||
const osPoint = m[1] === undefined ? null : { x: Number(m[1]), y: Number(m[2]) };
|
||||
const eventAt = m[4] === undefined ? Date.now() : Number(m[4]);
|
||||
this.onOsClick(Number.isFinite(eventAt) ? eventAt : Date.now(), osPoint, m[3] || 'mouse');
|
||||
const trimmed = line.trim();
|
||||
const clickM = /^CLICK(?:\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+(\d+))?)?\s*$/.exec(trimmed);
|
||||
if (clickM) {
|
||||
const osPoint = clickM[1] === undefined ? null : { x: Number(clickM[1]), y: Number(clickM[2]) };
|
||||
const eventAt = clickM[4] === undefined ? Date.now() : Number(clickM[4]);
|
||||
this.onOsClick(Number.isFinite(eventAt) ? eventAt : Date.now(), osPoint, clickM[3] || 'mouse');
|
||||
continue;
|
||||
}
|
||||
const keyM = /^KEY\s+(\S+)\s+(\d+)\s*$/.exec(trimmed);
|
||||
if (keyM) {
|
||||
this.onKeyboardEvent('KEY', keyM[1], Number(keyM[2]));
|
||||
continue;
|
||||
}
|
||||
const charM = /^CHAR\s+(\d+)\s+(\d+)\s*$/.exec(trimmed);
|
||||
if (charM) {
|
||||
this.onKeyboardEvent('CHAR', Number(charM[1]), Number(charM[2]));
|
||||
continue;
|
||||
}
|
||||
// CTX is emitted just before its paired CLICK from MouseHookCallback.
|
||||
const ctxM = /^CTX\s+(\S+)\s+(\S+)\s+\d+$/.exec(trimmed);
|
||||
if (ctxM) {
|
||||
const decode = s => s === '-' ? '' : Buffer.from(s, 'base64').toString('utf8');
|
||||
this._pendingWindowContext = {
|
||||
windowTitle: decode(ctxM[1]),
|
||||
appName: decode(ctxM[2]),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1163,6 +1398,9 @@ public static class SFMouseHook {
|
||||
let clickPos = osPoint ? this.osPointToDip(osPoint) : null;
|
||||
if (!clickPos) clickPos = this.screen.getCursorScreenPoint();
|
||||
clog('click@', clickAt, button, 'os', osPoint, '-> dip', clickPos);
|
||||
this.pendingClickOsPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y)
|
||||
? { x: osPoint.x, y: osPoint.y }
|
||||
: null;
|
||||
this.enqueueClickCapture(clickPos, clickAt, button || 'mouse');
|
||||
}
|
||||
|
||||
@@ -1220,7 +1458,24 @@ public static class SFMouseHook {
|
||||
* fast the user clicks or how slow the encoder is.
|
||||
*/
|
||||
enqueueClickCapture(clickPos, clickAt = Date.now(), button = 'mouse') {
|
||||
const clickMeta = { at: Number.isFinite(clickAt) ? clickAt : Date.now(), button: button || 'mouse' };
|
||||
const osPoint = this.pendingClickOsPoint && Number.isFinite(this.pendingClickOsPoint.x) && Number.isFinite(this.pendingClickOsPoint.y)
|
||||
? this.pendingClickOsPoint
|
||||
: null;
|
||||
this.pendingClickOsPoint = null;
|
||||
const clickMeta = {
|
||||
at: Number.isFinite(clickAt) ? clickAt : Date.now(),
|
||||
button: button || 'mouse',
|
||||
};
|
||||
if (osPoint) {
|
||||
clickMeta.osPoint = { x: osPoint.x, y: osPoint.y };
|
||||
}
|
||||
// Snapshot keyboard context accumulated since the last capture.
|
||||
clickMeta.keyContext = this.snapshotKeyContext();
|
||||
// Attach the window + element context emitted by the click watcher.
|
||||
if (this._pendingWindowContext) {
|
||||
clickMeta.windowContext = this._pendingWindowContext;
|
||||
this._pendingWindowContext = null;
|
||||
}
|
||||
if (this.session && !this.session.paused && !this.userIsInApp()) {
|
||||
// The guide id pins the click to its recording so it can still be
|
||||
// stored if the session stops while this click waits in the queue.
|
||||
@@ -1234,6 +1489,46 @@ public static class SFMouseHook {
|
||||
return this.clickQueue;
|
||||
}
|
||||
|
||||
// --- Keyboard context tracking -------------------------------------------
|
||||
|
||||
onKeyboardEvent(type, data, eventAt) {
|
||||
const now = Number.isFinite(eventAt) ? eventAt : Date.now();
|
||||
// Discard stale typing that happened more than 8 seconds ago (user moved on).
|
||||
if (now - this._keyLastAt > 8000) {
|
||||
this._keyBuffer = '';
|
||||
}
|
||||
this._keyLastAt = now;
|
||||
|
||||
if (type === 'CHAR') {
|
||||
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
|
||||
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
|
||||
} else if (type === 'KEY') {
|
||||
const key = String(data);
|
||||
if (key === 'Backspace') {
|
||||
this._keyBuffer = this._keyBuffer.slice(0, -1);
|
||||
} else if (key === 'Escape') {
|
||||
this._keyBuffer = '';
|
||||
} else if (key === 'Enter') {
|
||||
// Keep typed text — Enter often submits what was typed.
|
||||
} else {
|
||||
// It's a modifier+key shortcut (Ctrl+T etc.).
|
||||
this._lastShortcut = key;
|
||||
this._keyBuffer = ''; // a shortcut resets the typed-text buffer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshotKeyContext() {
|
||||
const ctx = {
|
||||
recentTyped: this._keyBuffer.trim(),
|
||||
recentShortcut: this._lastShortcut,
|
||||
};
|
||||
// Reset after snapshot so each step gets its own context.
|
||||
this._keyBuffer = '';
|
||||
this._lastShortcut = '';
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async captureCurrentFrame(mode, capturePoint = null, startedAt = Date.now()) {
|
||||
const grabbed = await this.grab(mode, capturePoint);
|
||||
return {
|
||||
@@ -1252,7 +1547,18 @@ public static class SFMouseHook {
|
||||
};
|
||||
}
|
||||
|
||||
storeFrameAsStep(guideId, mode, frame, clickPos = null) {
|
||||
async buildStepMeta(mode, frame, clickPos = null, clickMeta = null) {
|
||||
try {
|
||||
if (this.textIntel && typeof this.textIntel.buildCaptureContext === 'function') {
|
||||
return await this.textIntel.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
||||
}
|
||||
} catch {
|
||||
// fall back
|
||||
}
|
||||
return { title: this.autoTitle(mode), captureMetadata: null };
|
||||
}
|
||||
|
||||
async storeFrameAsStep(guideId, mode, frame, clickPos = null, clickMeta = null) {
|
||||
if (!frame) return { ok: false, reason: 'no capture frame available' };
|
||||
const annotations = [];
|
||||
// The click position (DIP, read at event time) wins over the frame's
|
||||
@@ -1275,8 +1581,10 @@ public static class SFMouseHook {
|
||||
}
|
||||
}
|
||||
|
||||
const { title, captureMetadata } = await this.buildStepMeta(mode, frame, clickPos, clickMeta);
|
||||
const step = this.store.addStep(guideId, {
|
||||
title: this.autoTitle(mode),
|
||||
title,
|
||||
captureMetadata,
|
||||
annotations,
|
||||
focusedView: {
|
||||
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
|
||||
@@ -1287,14 +1595,7 @@ public static class SFMouseHook {
|
||||
}
|
||||
|
||||
autoTitle(mode) {
|
||||
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return expandPlaceholders(tplStr, {
|
||||
Mode: { fullscreen: 'Screen', window: 'Window', region: 'Region' }[mode] || 'Screen',
|
||||
Time: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
|
||||
Date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
|
||||
});
|
||||
return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
|
||||
}
|
||||
|
||||
/** Grab the screen/window image as { image, display } or throw. */
|
||||
@@ -1403,8 +1704,12 @@ public static class SFMouseHook {
|
||||
const cropped = image.crop(rect);
|
||||
const size = cropped.getSize();
|
||||
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
|
||||
const step = this.store.addStep(guideId, { title: this.autoTitle('region') },
|
||||
cropped.toPNG(), size);
|
||||
const step = await this.storeFrameAsStep(guideId, 'region', {
|
||||
image: cropped,
|
||||
size,
|
||||
display,
|
||||
cursor: null,
|
||||
}, null, null);
|
||||
return { ok: true, step };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const { fork } = require('node:child_process');
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js');
|
||||
|
||||
/**
|
||||
* Run an export job in a forked helper process, so building the render AST
|
||||
* and rendering the output (e.g. a multi-step PDF) never blocks the main
|
||||
* process — and therefore never freezes the editor window — no matter how
|
||||
* large the guide is.
|
||||
*/
|
||||
function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = fork(WORKER_PATH, [], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const finish = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.removeAllListeners();
|
||||
worker.kill();
|
||||
fn(value);
|
||||
};
|
||||
|
||||
worker.once('message', (msg) => {
|
||||
if (msg && msg.ok) finish(resolve, msg.result);
|
||||
else finish(reject, new Error((msg && msg.error) || 'Export worker failed.'));
|
||||
});
|
||||
worker.once('error', (err) => finish(reject, err));
|
||||
worker.once('exit', (code) => {
|
||||
finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`));
|
||||
});
|
||||
|
||||
worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runExportInWorker };
|
||||
@@ -5,19 +5,40 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const {
|
||||
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
|
||||
clipboard, nativeImage, screen,
|
||||
clipboard, nativeImage, screen, powerSaveBlocker,
|
||||
} = require('electron');
|
||||
|
||||
const { GuideStore } = require('../core/store');
|
||||
const { Settings } = require('../core/settings');
|
||||
const { SearchIndex } = require('../core/search');
|
||||
const { TemplateManager, FORMATS } = require('../core/templates');
|
||||
const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates');
|
||||
const { buildRenderAst } = require('../core/renderast');
|
||||
const { runExport, EXPORTERS } = require('../exporters');
|
||||
const { runExportInWorker } = require('./export-runner');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { readLock } = require('../core/locks');
|
||||
const CaptureService = require('./capture');
|
||||
const { TextIntelService } = require('./text-intel');
|
||||
const { keepProcessesResponsive } = require('./win-power');
|
||||
|
||||
const APP_ID = 'com.stepforge.app';
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
app.setAppUserModelId(APP_ID);
|
||||
}
|
||||
|
||||
// Keep capture working on battery. In a power-saving plan on DC power, Windows
|
||||
// applies Power Throttling (EcoQoS) to background work — and StepForge records
|
||||
// with its window hidden, so the frame-capture worker renderer is exactly the
|
||||
// kind of "background" process the OS slows down. A throttled worker can't
|
||||
// sample the screen fast enough, so every click finds no fresh frame and the
|
||||
// recording falls apart (the bug only ever reproduced on battery). These
|
||||
// switches stop Chromium from de-prioritising and timer-throttling the hidden
|
||||
// worker; win-power.js additionally opts the OS processes out of EcoQoS.
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||
|
||||
/**
|
||||
* StepForge main process. Zero network code: no telemetry, no updates, no
|
||||
@@ -39,6 +60,7 @@ let settings;
|
||||
let searchIndex;
|
||||
let templates;
|
||||
let capture;
|
||||
let textIntel;
|
||||
let mainWindow;
|
||||
|
||||
function reindex(guideId) {
|
||||
@@ -419,6 +441,15 @@ function setupIpc() {
|
||||
reindex(guideId);
|
||||
return true;
|
||||
});
|
||||
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
|
||||
const images = {
|
||||
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
|
||||
working: workingBase64 ? Buffer.from(workingBase64, 'base64') : null,
|
||||
};
|
||||
const restored = store.restoreStep(guideId, step, images, position);
|
||||
reindex(guideId);
|
||||
return restored;
|
||||
});
|
||||
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
|
||||
h('step:imagePath', ({ guideId, stepId, which }) => {
|
||||
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
||||
@@ -474,27 +505,107 @@ function setupIpc() {
|
||||
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
||||
return settings.data;
|
||||
});
|
||||
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
|
||||
return textIntel.testAiConnection({
|
||||
enabled,
|
||||
ollama,
|
||||
});
|
||||
});
|
||||
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
|
||||
const result = await textIntel.generateStepPatch({
|
||||
guideId,
|
||||
stepId,
|
||||
target,
|
||||
blockId,
|
||||
});
|
||||
if (result.ok) reindex(guideId);
|
||||
return result;
|
||||
});
|
||||
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
|
||||
return textIntel.rewriteText({ text, guideTitle, stepTitle });
|
||||
});
|
||||
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
|
||||
h('placeholders:globals:set', ({ values }) => settings.setGlobalPlaceholders(values));
|
||||
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
|
||||
|
||||
// capture
|
||||
h('capture:shoot', async ({ guideId, mode, delayMs }) => {
|
||||
const result = await capture.shoot({ guideId, mode, delayMs });
|
||||
if (result.ok) reindex(guideId);
|
||||
if (result.ok) {
|
||||
reindex(guideId);
|
||||
const aiConf = settings.get('ai') || {};
|
||||
if (aiConf.enabled && aiConf.autoDoc && result.step) {
|
||||
const aiResult = await textIntel.generateStepPatch({
|
||||
guideId,
|
||||
stepId: result.step.stepId,
|
||||
target: 'all',
|
||||
}).catch(() => null);
|
||||
if (aiResult?.ok) {
|
||||
reindex(guideId);
|
||||
result.step = aiResult.step;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
h('capture:region', async ({ guideId }) => {
|
||||
const result = await capture.regionCapture(guideId);
|
||||
if (result.ok) reindex(guideId);
|
||||
if (result.ok) {
|
||||
reindex(guideId);
|
||||
const aiConf = settings.get('ai') || {};
|
||||
if (aiConf.enabled && aiConf.autoDoc && result.step) {
|
||||
const aiResult = await textIntel.generateStepPatch({
|
||||
guideId,
|
||||
stepId: result.step.stepId,
|
||||
target: 'all',
|
||||
}).catch(() => null);
|
||||
if (aiResult?.ok) {
|
||||
reindex(guideId);
|
||||
result.step = aiResult.step;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
let capturePowerBlocker = -1;
|
||||
const startCapturePower = () => {
|
||||
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
||||
capturePowerBlocker = powerSaveBlocker.start('prevent-app-suspension');
|
||||
}
|
||||
};
|
||||
const stopCapturePower = () => {
|
||||
if (powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
||||
powerSaveBlocker.stop(capturePowerBlocker);
|
||||
}
|
||||
};
|
||||
|
||||
// Opt every live Electron process (browser, GPU, the screen-capture utility,
|
||||
// any renderers) out of EcoQoS for the duration of a recording. The hidden
|
||||
// capture-worker renderer is created later, during warmup, so it opts itself
|
||||
// out separately (see stream-backend.js); this covers the rest.
|
||||
const keepCaptureProcessesResponsive = () => {
|
||||
try {
|
||||
keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid));
|
||||
} catch { /* metrics unavailable — best effort */ }
|
||||
};
|
||||
|
||||
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
||||
if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||
else if (action === 'pause') capture.togglePause(true);
|
||||
else if (action === 'resume') capture.togglePause(false);
|
||||
else if (action === 'finish') capture.finishSession();
|
||||
else if (action === 'interval') capture.setInterval(intervalSec);
|
||||
else if (action === 'shoot') await capture.sessionCapture('manual');
|
||||
if (action === 'start') {
|
||||
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||
startCapturePower();
|
||||
keepCaptureProcessesResponsive();
|
||||
} else if (action === 'pause') {
|
||||
capture.togglePause(true);
|
||||
stopCapturePower();
|
||||
} else if (action === 'resume') {
|
||||
capture.togglePause(false);
|
||||
startCapturePower();
|
||||
keepCaptureProcessesResponsive();
|
||||
} else if (action === 'finish') {
|
||||
capture.finishSession();
|
||||
stopCapturePower();
|
||||
} else if (action === 'interval') {
|
||||
capture.setInterval(intervalSec);
|
||||
}
|
||||
const state = capture.state();
|
||||
sendToRenderer('capture:state', state);
|
||||
return state;
|
||||
@@ -570,13 +681,17 @@ function setupIpc() {
|
||||
});
|
||||
|
||||
// export + preview
|
||||
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
|
||||
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]).map((format) => ({
|
||||
id: format,
|
||||
label: FORMAT_LABELS[format] || format,
|
||||
})));
|
||||
h('export:defaults', ({ format }) => {
|
||||
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
|
||||
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
|
||||
const mod = {
|
||||
json: '../exporters/json',
|
||||
markdown: '../exporters/markdown',
|
||||
wikijs: '../exporters/wikijs',
|
||||
'html-simple': '../exporters/html',
|
||||
'html-rich': '../exporters/html',
|
||||
confluence: '../exporters/confluence',
|
||||
@@ -599,8 +714,14 @@ function setupIpc() {
|
||||
dir = res.filePaths[0];
|
||||
}
|
||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
|
||||
const result = runExport(format, ast, dir, options || {});
|
||||
const result = await runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId,
|
||||
format,
|
||||
options: options || {},
|
||||
outDir: dir,
|
||||
globals: settings.getGlobalPlaceholders(),
|
||||
});
|
||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||
return { ok: true, ...result };
|
||||
});
|
||||
@@ -667,11 +788,51 @@ if (!gotLock) {
|
||||
settings = new Settings(store.settingsDir);
|
||||
searchIndex = new SearchIndex(store.indexDir);
|
||||
templates = new TemplateManager(store.templatesDir);
|
||||
textIntel = new TextIntelService({
|
||||
store,
|
||||
settings,
|
||||
getWindow: () => mainWindow,
|
||||
dataDir,
|
||||
screenApi: screen,
|
||||
});
|
||||
// Bringing up the desktop-capture stream spawns/upgrades Chromium's GPU
|
||||
// and screen-capture utility processes — which can be born after a session
|
||||
// already started, so the start-time EcoQoS opt-out misses them. Re-apply
|
||||
// it the moment the backend reports it is streaming.
|
||||
let lastClickFrameSource = null;
|
||||
const captureNotify = (channel, payload) => {
|
||||
sendToRenderer(channel, payload);
|
||||
if (channel === 'capture:state' && payload && payload.clickFrameSource !== lastClickFrameSource) {
|
||||
lastClickFrameSource = payload.clickFrameSource;
|
||||
if (payload.clickFrameSource === 'stream') {
|
||||
try { keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
// Auto-document session captures in the background when autoDoc is enabled.
|
||||
// Single-shot captures (capture:shoot) are handled synchronously in the IPC handler.
|
||||
if (channel === 'capture:added' && payload?.step && payload?.guideId) {
|
||||
const aiConf = settings.get('ai') || {};
|
||||
if (aiConf.enabled && aiConf.autoDoc) {
|
||||
textIntel.generateStepPatch({
|
||||
guideId: payload.guideId,
|
||||
stepId: payload.step.stepId,
|
||||
target: 'all',
|
||||
}).then((result) => {
|
||||
if (result.ok) {
|
||||
reindex(payload.guideId);
|
||||
sendToRenderer('step:updated', { guideId: payload.guideId, step: result.step });
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
capture = new CaptureService({
|
||||
store,
|
||||
settings,
|
||||
getWindow: () => mainWindow,
|
||||
notify: sendToRenderer,
|
||||
notify: captureNotify,
|
||||
textIntel,
|
||||
});
|
||||
|
||||
applyTheme();
|
||||
@@ -691,6 +852,9 @@ if (!gotLock) {
|
||||
capture.stopClickWatcher();
|
||||
capture.destroySessionTray();
|
||||
}
|
||||
if (textIntel) {
|
||||
textIntel.shutdown().catch(() => {});
|
||||
}
|
||||
// clean preview temp files on close
|
||||
try {
|
||||
for (const entry of fs.readdirSync(store.tempDir)) {
|
||||
|
||||
@@ -34,6 +34,7 @@ const api = {
|
||||
add: invoke('step:add'),
|
||||
save: invoke('step:save'),
|
||||
delete: invoke('step:delete'),
|
||||
restore: invoke('step:restore'),
|
||||
reorder: invoke('steps:reorder'),
|
||||
imagePath: invoke('step:imagePath'),
|
||||
setWorkingImage: invoke('step:setWorkingImage'),
|
||||
@@ -51,6 +52,11 @@ const api = {
|
||||
globalPlaceholders: invoke('placeholders:globals:get'),
|
||||
setGlobalPlaceholders: invoke('placeholders:globals:set'),
|
||||
},
|
||||
ai: {
|
||||
test: invoke('ai:test'),
|
||||
fillStep: invoke('ai:fillStep'),
|
||||
rewriteText: invoke('ai:rewriteText'),
|
||||
},
|
||||
capture: {
|
||||
shoot: invoke('capture:shoot'),
|
||||
region: invoke('capture:region'),
|
||||
@@ -58,6 +64,7 @@ const api = {
|
||||
state: invoke('capture:state'),
|
||||
onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)),
|
||||
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
|
||||
onStepUpdated: (fn) => ipcRenderer.on('step:updated', (e, payload) => fn(payload)),
|
||||
},
|
||||
archive: {
|
||||
export: invoke('archive:export'),
|
||||
|
||||
@@ -80,6 +80,18 @@ class StepForgeApp {
|
||||
|
||||
api.capture.onAdded((payload) => this.onCaptureAdded(payload));
|
||||
api.capture.onState((payload) => this.updateCaptureState(payload));
|
||||
api.capture.onStepUpdated((payload) => this.onStepUpdated(payload));
|
||||
}
|
||||
|
||||
async onStepUpdated(payload) {
|
||||
if (!payload || !payload.guideId || !payload.step) return;
|
||||
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
|
||||
const currentStep = this.editor.currentStep;
|
||||
if (currentStep && currentStep.stepId === payload.step.stepId) {
|
||||
await this.editor.reload(payload.step.stepId);
|
||||
toast('Documentation generated.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onCaptureAdded(payload) {
|
||||
@@ -117,6 +129,7 @@ class StepForgeApp {
|
||||
guideFolders: library.folders?.guideFolders || {},
|
||||
};
|
||||
this.state.trash = trash;
|
||||
this.editor.setSettings(settings);
|
||||
}
|
||||
|
||||
async refreshLibrary({ keepFilter = true } = {}) {
|
||||
@@ -158,7 +171,7 @@ class StepForgeApp {
|
||||
el('div.welcome', {},
|
||||
el('div.welcome-title', {},
|
||||
el('h1', {}, 'StepForge'),
|
||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides — fully offline.'),
|
||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides, fully offline.'),
|
||||
),
|
||||
el('div.welcome-actions', {},
|
||||
el('button.welcome-btn.primary', {
|
||||
@@ -269,20 +282,6 @@ class StepForgeApp {
|
||||
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
|
||||
: 'hotkey only';
|
||||
|
||||
const shootBtn = el('button', {
|
||||
type: 'button',
|
||||
title: 'Capture a step now (the app hides itself for the shot)',
|
||||
onClick: () => send({ action: 'shoot' }),
|
||||
}, 'Shoot');
|
||||
|
||||
// Cycle interval auto-capture: off -> 3s -> 5s -> 10s -> off.
|
||||
const nextInterval = { 0: 3, 3: 5, 5: 10, 10: 0 }[s.intervalSec ?? 0] ?? 3;
|
||||
const autoBtn = el('button', {
|
||||
type: 'button',
|
||||
title: 'Automatically capture a step on a timer',
|
||||
onClick: () => send({ action: 'interval', intervalSec: nextInterval }),
|
||||
}, s.intervalSec > 0 ? `Auto ${s.intervalSec}s` : 'Auto off');
|
||||
|
||||
const pauseBtn = el('button', {
|
||||
type: 'button',
|
||||
title: notStarted ? 'StepForge tucks away and starts capturing' : '',
|
||||
@@ -293,19 +292,11 @@ class StepForgeApp {
|
||||
}
|
||||
send({ action: s.paused ? 'resume' : 'pause' });
|
||||
},
|
||||
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
|
||||
|
||||
const finishBtn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => send({ action: 'finish' }),
|
||||
}, 'Finish');
|
||||
}, s.paused ? 'Start recording' : 'Stop recording');
|
||||
|
||||
this.captureStatus.append(
|
||||
el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`),
|
||||
shootBtn,
|
||||
autoBtn,
|
||||
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
|
||||
pauseBtn,
|
||||
finishBtn,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,7 +315,7 @@ class StepForgeApp {
|
||||
|
||||
const guide = this.editorMeta?.guide;
|
||||
this.topbarContext.append(
|
||||
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'),
|
||||
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Library'),
|
||||
el('button.primary', {
|
||||
type: 'button',
|
||||
title: 'Capture a screenshot step',
|
||||
@@ -339,8 +330,10 @@ class StepForgeApp {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
contextMenu(rect.left, rect.bottom + 4, [
|
||||
{ label: 'Rename guide…', action: () => this.renameGuide() },
|
||||
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
||||
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
||||
{ label: 'Generate all text fields with AI (experimental)', action: () => this.editor.generateAllTextFieldsWithAi() },
|
||||
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||
'sep',
|
||||
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
|
||||
@@ -768,8 +761,7 @@ class StepForgeApp {
|
||||
await this.refreshLibrary();
|
||||
}
|
||||
|
||||
async createGuide({ armCapture = undefined } = {}) {
|
||||
const shouldArmCapture = armCapture ?? this.state.view === 'library';
|
||||
async createGuide() {
|
||||
const title = await dialogs.promptText({
|
||||
title: 'New Guide',
|
||||
label: 'Title',
|
||||
@@ -779,12 +771,11 @@ class StepForgeApp {
|
||||
if (title == null) return;
|
||||
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
|
||||
await this.refreshLibrary();
|
||||
await this.openGuide(guide.guideId);
|
||||
if (!shouldArmCapture) return;
|
||||
await this.armCaptureSession(guide.guideId, {
|
||||
actionLabel: 'Start recording',
|
||||
headline: 'StepForge will hide after you start recording.',
|
||||
});
|
||||
// Arm a (paused) capture session like every other open path, so the
|
||||
// "Start recording" bar appears and actually controls this new guide.
|
||||
// Without this, a guide created from the library opened with no session,
|
||||
// so Start recording had nothing to resume (or resumed a stale one).
|
||||
await this.openGuideAndArmCapture(guide.guideId);
|
||||
}
|
||||
|
||||
async createFolder() {
|
||||
@@ -882,6 +873,7 @@ class StepForgeApp {
|
||||
const settings = await api.settings.all();
|
||||
const placeholders = await api.settings.globalPlaceholders();
|
||||
await dialogs.showSettingsDialog({
|
||||
api,
|
||||
settings,
|
||||
placeholders,
|
||||
onSave: async (next) => {
|
||||
@@ -889,6 +881,7 @@ class StepForgeApp {
|
||||
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
|
||||
await api.settings.set({ keyPath: 'capture', value: next.capture });
|
||||
await api.settings.set({ keyPath: 'editor', value: next.editor });
|
||||
await api.settings.set({ keyPath: 'ai', value: next.ai });
|
||||
await api.settings.set({ keyPath: 'exports', value: next.exports });
|
||||
await api.settings.set({ keyPath: 'backups', value: next.backups });
|
||||
await api.settings.setGlobalPlaceholders(next.placeholders || {});
|
||||
|
||||
@@ -28,6 +28,7 @@ class AnnotationCanvas {
|
||||
this.selectedId = null;
|
||||
this.drag = null;
|
||||
this.cropRect = null;
|
||||
this.focusedView = null;
|
||||
|
||||
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
|
||||
canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
|
||||
@@ -55,6 +56,14 @@ class AnnotationCanvas {
|
||||
this.render();
|
||||
}
|
||||
|
||||
// The focused view crops and zooms the canvas itself to match what
|
||||
// exports produce. Annotation data stays in full-image-normalized
|
||||
// coordinates; only the on-screen viewport changes.
|
||||
setFocusedView(focusedView) {
|
||||
this.focusedView = focusedView || null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
setTool(tool) {
|
||||
this.tool = tool;
|
||||
this.cropRect = null;
|
||||
@@ -98,30 +107,64 @@ class AnnotationCanvas {
|
||||
}
|
||||
|
||||
// ---- coordinate helpers ----
|
||||
// The focused-view crop region, in coordinates normalized to the full
|
||||
// image (0..1). Identity rect when focused view is off, matching
|
||||
// core/raster.js applyFocusedView. panX/panY are 0..1 fractions of the
|
||||
// available pan range (0 = left/bottom edge, 1 = right/top edge), so the
|
||||
// slider's full range always pans the crop across the whole image
|
||||
// regardless of zoom. Y is inverted so panY increases upward.
|
||||
viewRect() {
|
||||
const fv = this.focusedView;
|
||||
if (!fv || !fv.enabled || !(fv.zoom > 1)) return { x: 0, y: 0, w: 1, h: 1 };
|
||||
const w = 1 / fv.zoom, h = 1 / fv.zoom;
|
||||
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
|
||||
return { x: panX * (1 - w), y: (1 - panY) * (1 - h), w, h };
|
||||
}
|
||||
|
||||
toNorm(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const r = this.viewRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
|
||||
y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
|
||||
};
|
||||
}
|
||||
|
||||
px(ann) {
|
||||
const r = this.viewRect();
|
||||
return {
|
||||
x: ann.x * this.canvas.width,
|
||||
y: ann.y * this.canvas.height,
|
||||
w: ann.w * this.canvas.width,
|
||||
h: ann.h * this.canvas.height,
|
||||
x: (ann.x - r.x) / r.w * this.canvas.width,
|
||||
y: (ann.y - r.y) / r.h * this.canvas.height,
|
||||
w: ann.w / r.w * this.canvas.width,
|
||||
h: ann.h / r.h * this.canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
// Converts a canvas-pixel point to image-pixel coordinates in the
|
||||
// full (uncropped) source image, for sampling `this.image` directly.
|
||||
toImagePx(x, y) {
|
||||
const r = this.viewRect();
|
||||
return {
|
||||
x: (x / this.canvas.width * r.w + r.x) * this.imgW,
|
||||
y: (y / this.canvas.height * r.h + r.y) * this.imgH,
|
||||
};
|
||||
}
|
||||
|
||||
toImageLen(len, axis) {
|
||||
const r = this.viewRect();
|
||||
return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW;
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
render() {
|
||||
const { ctx, canvas } = this;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!this.image) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
|
||||
const r = this.viewRect();
|
||||
ctx.drawImage(this.image,
|
||||
r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH,
|
||||
0, 0, canvas.width, canvas.height);
|
||||
|
||||
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
|
||||
for (const ann of ordered) this.drawAnnotation(ann);
|
||||
@@ -131,12 +174,17 @@ class AnnotationCanvas {
|
||||
if (this.cropRect) this.drawCropOverlay();
|
||||
}
|
||||
|
||||
// Annotation strokes/fonts are sized relative to the full image, then
|
||||
// magnified by the focused-view crop on export (core/raster.js) — divide
|
||||
// by the view rect so the canvas preview matches that magnification.
|
||||
strokePx(ann) {
|
||||
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000);
|
||||
const r = this.viewRect();
|
||||
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w);
|
||||
}
|
||||
|
||||
fontPx(ann) {
|
||||
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height);
|
||||
const r = this.viewRect();
|
||||
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h);
|
||||
}
|
||||
|
||||
drawAnnotation(ann) {
|
||||
@@ -202,10 +250,13 @@ class AnnotationCanvas {
|
||||
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
const sw = w / zoom, sh = h / zoom;
|
||||
const center = this.toImagePx(x + w / 2, y + h / 2);
|
||||
const srcW = this.toImageLen(sw, 'x');
|
||||
const srcH = this.toImageLen(sh, 'y');
|
||||
ctx.drawImage(
|
||||
this.image,
|
||||
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale,
|
||||
sw / this.scale, sh / this.scale,
|
||||
center.x - srcW / 2, center.y - srcH / 2,
|
||||
srcW, srcH,
|
||||
x, y, w, h
|
||||
);
|
||||
ctx.restore();
|
||||
@@ -310,10 +361,11 @@ class AnnotationCanvas {
|
||||
drawCropOverlay() {
|
||||
const { ctx, canvas } = this;
|
||||
const r = this.cropRect;
|
||||
const x = Math.min(r.x0, r.x1) * canvas.width;
|
||||
const y = Math.min(r.y0, r.y1) * canvas.height;
|
||||
const w = Math.abs(r.x1 - r.x0) * canvas.width;
|
||||
const h = Math.abs(r.y1 - r.y0) * canvas.height;
|
||||
const view = this.viewRect();
|
||||
const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
|
||||
const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
|
||||
const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width;
|
||||
const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.beginPath();
|
||||
@@ -486,8 +538,9 @@ class AnnotationCanvas {
|
||||
nudgeSelected(dx, dy) {
|
||||
const sel = this.selected();
|
||||
if (!sel) return false;
|
||||
sel.x += dx / this.canvas.width;
|
||||
sel.y += dy / this.canvas.height;
|
||||
const r = this.viewRect();
|
||||
sel.x += dx / this.canvas.width * r.w;
|
||||
sel.y += dy / this.canvas.height * r.h;
|
||||
this.changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,9 @@
|
||||
maxWidth: physWidth,
|
||||
minHeight: physHeight,
|
||||
maxHeight: physHeight,
|
||||
maxFrameRate: 30,
|
||||
// No maxFrameRate: sampling cadence is controlled by the setInterval
|
||||
// timer below, so the actual capture rate is always sampleMs-driven
|
||||
// regardless of display refresh rate or power mode.
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,151 @@ function makeSelect(value, options) {
|
||||
);
|
||||
}
|
||||
|
||||
const HOTKEY_LABELS = {
|
||||
CommandOrControl: 'Ctrl',
|
||||
Control: 'Ctrl',
|
||||
Command: 'Cmd',
|
||||
Alt: 'Alt',
|
||||
Shift: 'Shift',
|
||||
Super: 'Super',
|
||||
Up: '↑',
|
||||
Down: '↓',
|
||||
Left: '←',
|
||||
Right: '→',
|
||||
Space: 'Space',
|
||||
Escape: 'Esc',
|
||||
Return: 'Enter',
|
||||
};
|
||||
|
||||
function hotkeyLabel(part) {
|
||||
return HOTKEY_LABELS[part] || part;
|
||||
}
|
||||
|
||||
/** Turn a keydown event into accelerator parts, or null if it's a bare modifier. */
|
||||
function hotkeyFromEvent(e) {
|
||||
const modifiers = [];
|
||||
if (e.ctrlKey || e.metaKey) modifiers.push('CommandOrControl');
|
||||
if (e.altKey) modifiers.push('Alt');
|
||||
if (e.shiftKey) modifiers.push('Shift');
|
||||
|
||||
const { key } = e;
|
||||
if (key === 'Control' || key === 'Meta' || key === 'Alt' || key === 'Shift') {
|
||||
return { modifiers, key: null };
|
||||
}
|
||||
|
||||
const SPECIAL_KEYS = {
|
||||
' ': 'Space',
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
Escape: 'Escape',
|
||||
Enter: 'Return',
|
||||
Tab: 'Tab',
|
||||
Backspace: 'Backspace',
|
||||
Delete: 'Delete',
|
||||
};
|
||||
|
||||
let keyName = SPECIAL_KEYS[key];
|
||||
if (!keyName) {
|
||||
if (/^[a-zA-Z0-9]$/.test(key)) keyName = key.toUpperCase();
|
||||
else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) keyName = key;
|
||||
else return { modifiers, key: null };
|
||||
}
|
||||
return { modifiers, key: keyName };
|
||||
}
|
||||
|
||||
/**
|
||||
* A "press to record" shortcut field, styled like a row of keycaps. Exposes
|
||||
* a `.value` getter/setter holding an Electron accelerator string (e.g.
|
||||
* "CommandOrControl+Shift+1"), so it slots in like a text input.
|
||||
*/
|
||||
function makeHotkeyInput(value = '') {
|
||||
let current = value || '';
|
||||
|
||||
const keys = el('div.hotkey-keys');
|
||||
const clearBtn = el('button.hotkey-clear', {
|
||||
type: 'button',
|
||||
title: 'Clear shortcut',
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
current = '';
|
||||
render();
|
||||
},
|
||||
}, '×');
|
||||
|
||||
const wrap = el('div.hotkey-input', { tabindex: '0', role: 'button', title: 'Click, then press a key combination' },
|
||||
keys, clearBtn);
|
||||
|
||||
function renderKeys(parts) {
|
||||
keys.replaceChildren();
|
||||
parts.forEach((part, i) => {
|
||||
if (i > 0) keys.append(el('span.hotkey-sep', {}, '+'));
|
||||
keys.append(el('kbd', {}, hotkeyLabel(part)));
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (wrap.classList.contains('recording')) {
|
||||
keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Press a key combination…'));
|
||||
clearBtn.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (!current) {
|
||||
keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Click to set shortcut'));
|
||||
clearBtn.hidden = true;
|
||||
return;
|
||||
}
|
||||
renderKeys(current.split('+').filter(Boolean));
|
||||
clearBtn.hidden = false;
|
||||
}
|
||||
|
||||
// While recording, a window-level capturing listener intercepts keydown
|
||||
// before the modal's own Escape handler can see it (capture order is
|
||||
// window -> document -> ... -> target), so Escape cancels recording
|
||||
// instead of closing the whole dialog.
|
||||
let recordingKeyHandler = null;
|
||||
|
||||
wrap.addEventListener('focus', () => {
|
||||
wrap.classList.add('recording');
|
||||
render();
|
||||
recordingKeyHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') {
|
||||
wrap.blur();
|
||||
return;
|
||||
}
|
||||
const { modifiers, key } = hotkeyFromEvent(e);
|
||||
if (key) {
|
||||
current = [...modifiers, key].join('+');
|
||||
wrap.blur();
|
||||
} else if (modifiers.length) {
|
||||
renderKeys([...modifiers, '…']);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', recordingKeyHandler, true);
|
||||
});
|
||||
wrap.addEventListener('blur', () => {
|
||||
wrap.classList.remove('recording');
|
||||
if (recordingKeyHandler) {
|
||||
window.removeEventListener('keydown', recordingKeyHandler, true);
|
||||
recordingKeyHandler = null;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
Object.defineProperty(wrap, 'value', {
|
||||
get() { return current; },
|
||||
set(v) { current = v || ''; render(); },
|
||||
});
|
||||
|
||||
render();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const field = multiline
|
||||
@@ -140,6 +285,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC
|
||||
}
|
||||
|
||||
function showSettingsDialog({
|
||||
api,
|
||||
settings,
|
||||
placeholders = {},
|
||||
onSave,
|
||||
@@ -160,14 +306,60 @@ function showSettingsDialog({
|
||||
{ value: 'region', label: 'Region' },
|
||||
]);
|
||||
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
|
||||
const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text');
|
||||
const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text');
|
||||
const captureHotkey = makeHotkeyInput(settings.capture?.hotkeyCapture || '');
|
||||
const pauseHotkey = makeHotkeyInput(settings.capture?.hotkeyPauseResume || '');
|
||||
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
|
||||
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
|
||||
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
|
||||
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
|
||||
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
|
||||
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
|
||||
const aiEnabled = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.enabled) });
|
||||
const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) });
|
||||
const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434');
|
||||
const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b');
|
||||
const aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Vision-capable models can also inspect the screenshot attached to each step.');
|
||||
const testAiBtn = el('button', { type: 'button' }, 'Test connection');
|
||||
const persistOllamaModel = debounce(() => {
|
||||
const model = ollamaModel.value.trim();
|
||||
// Keep the last model choice even if the dialog is dismissed without a full save.
|
||||
void api.settings.set({ keyPath: 'ai.ollama.model', value: model }).catch(() => {});
|
||||
}, 250);
|
||||
|
||||
const updateAiStatus = (message, { error = false } = {}) => {
|
||||
aiStatus.textContent = message;
|
||||
aiStatus.classList.toggle('error', Boolean(error));
|
||||
};
|
||||
|
||||
const testAiConnection = async () => {
|
||||
setButtonLoading(testAiBtn, true, 'Testing…');
|
||||
updateAiStatus('Checking Ollama at the configured host…');
|
||||
try {
|
||||
const result = await api.ai.test({
|
||||
ollama: {
|
||||
host: ollamaHost.value.trim(),
|
||||
model: ollamaModel.value.trim(),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
updateAiStatus(result.reason || 'Could not connect to Ollama.', { error: true });
|
||||
return;
|
||||
}
|
||||
if (result.installed) {
|
||||
updateAiStatus(result.vision
|
||||
? `Connected to ${result.host} with ${result.model}. It can inspect screenshots.`
|
||||
: `Connected to ${result.host} with ${result.model}. This model is text-only, so StepForge will use OCR and metadata only.`);
|
||||
} else {
|
||||
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
|
||||
}
|
||||
} catch (err) {
|
||||
updateAiStatus(err.message || 'Could not connect to Ollama.', { error: true });
|
||||
} finally {
|
||||
setButtonLoading(testAiBtn, false);
|
||||
}
|
||||
};
|
||||
ollamaModel.addEventListener('input', () => persistOllamaModel());
|
||||
ollamaModel.addEventListener('blur', () => persistOllamaModel.flush());
|
||||
|
||||
const placeholderRows = el('div', { className: 'placeholder-rows' });
|
||||
const rows = [];
|
||||
@@ -224,6 +416,20 @@ function showSettingsDialog({
|
||||
el('legend', {}, 'Backups'),
|
||||
labeledRow('Keep last snapshots', keepLast),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'AI'),
|
||||
labeledRow('Enable AI (experimental)', aiEnabled),
|
||||
labeledRow('Auto-document captures', aiAutoDoc),
|
||||
labeledRow('Ollama host', ollamaHost),
|
||||
labeledRow('Ollama model', ollamaModel),
|
||||
el('div.row', { style: { justifyContent: 'space-between' } },
|
||||
aiStatus,
|
||||
testAiBtn,
|
||||
),
|
||||
el('div.muted', {},
|
||||
'When auto-document is on, each capture is automatically documented by AI. Turn it off to use AI manually only.',
|
||||
),
|
||||
),
|
||||
el('fieldset', {},
|
||||
el('legend', {}, 'Global placeholders'),
|
||||
placeholderRows,
|
||||
@@ -267,6 +473,16 @@ function showSettingsDialog({
|
||||
...settings.backups,
|
||||
keepLast: Number(keepLast.value || 0),
|
||||
},
|
||||
ai: {
|
||||
...settings.ai,
|
||||
enabled: aiEnabled.checked,
|
||||
autoDoc: aiAutoDoc.checked,
|
||||
ollama: {
|
||||
...(settings.ai?.ollama || {}),
|
||||
host: ollamaHost.value.trim(),
|
||||
model: ollamaModel.value.trim(),
|
||||
},
|
||||
},
|
||||
placeholders: rows.reduce((acc, row) => {
|
||||
const inputs = row.querySelectorAll('input');
|
||||
const key = inputs[0].value.trim();
|
||||
@@ -285,6 +501,14 @@ function showSettingsDialog({
|
||||
});
|
||||
|
||||
form.addEventListener('submit', (e) => e.preventDefault());
|
||||
testAiBtn.addEventListener('click', testAiConnection);
|
||||
aiEnabled.addEventListener('change', () => {
|
||||
updateAiStatus(
|
||||
aiEnabled.checked
|
||||
? 'AI generation will be available once Ollama is reachable.'
|
||||
: 'AI generation is disabled. The settings are still saved for later.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -413,28 +637,43 @@ function showExportDialog({
|
||||
outDir: outDirInput.value.trim() || null,
|
||||
});
|
||||
|
||||
const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel');
|
||||
const previewBtn = el('button', {
|
||||
onClick: async () => {
|
||||
if (typeof onPreview !== 'function') return;
|
||||
setButtonLoading(previewBtn, true, 'Preview…');
|
||||
try {
|
||||
await onPreview(payload()); // keep dialog open so settings can be tweaked
|
||||
} finally {
|
||||
setButtonLoading(previewBtn, false);
|
||||
}
|
||||
},
|
||||
}, 'Preview');
|
||||
const exportBtn = el('button.primary', {
|
||||
onClick: async () => {
|
||||
if (typeof onExport !== 'function') return;
|
||||
cancelBtn.disabled = true;
|
||||
previewBtn.disabled = true;
|
||||
setButtonLoading(exportBtn, true, 'Exporting…');
|
||||
try {
|
||||
const ok = await onExport(payload());
|
||||
if (ok !== false) {
|
||||
close();
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
cancelBtn.disabled = false;
|
||||
previewBtn.disabled = false;
|
||||
setButtonLoading(exportBtn, false);
|
||||
}
|
||||
},
|
||||
}, 'Export');
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Export',
|
||||
body,
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button', {
|
||||
onClick: async () => {
|
||||
if (typeof onPreview !== 'function') return;
|
||||
await onPreview(payload()); // keep dialog open so settings can be tweaked
|
||||
},
|
||||
}, 'Preview'),
|
||||
el('button.primary', {
|
||||
onClick: async () => {
|
||||
if (typeof onExport !== 'function') return;
|
||||
const ok = await onExport(payload());
|
||||
if (ok !== false) {
|
||||
close();
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
}, 'Export'),
|
||||
],
|
||||
footer: [cancelBtn, previewBtn, exportBtn],
|
||||
wide: true,
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
@@ -639,6 +878,53 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional document metadata (author, co-authors, organization, description)
|
||||
* shown at the top of the guide, below the title, and surfaced on the PDF
|
||||
* cover page and the top of other export formats.
|
||||
*/
|
||||
function showGuideInfoDialog({ values = {}, onSave } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
|
||||
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
|
||||
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
|
||||
const descriptionInput = el('textarea', {
|
||||
rows: 4,
|
||||
placeholder: 'A short summary of this guide.',
|
||||
}, values.description || '');
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Guide information',
|
||||
body: el('div', {},
|
||||
el('div.muted', { style: { marginBottom: '10px' } },
|
||||
'All fields are optional. They appear below the title on the guide and on the PDF cover page.'),
|
||||
labeledRow('Author', authorInput),
|
||||
labeledRow('Co-authors', coAuthorsInput),
|
||||
labeledRow('Organization', organizationInput),
|
||||
labeledRow('Description', descriptionInput, { stacked: true }),
|
||||
el('div.muted', { style: { marginTop: '-4px' } },
|
||||
'Shown on the first page of the PDF and at the top of other export formats.'),
|
||||
),
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button.primary', {
|
||||
onClick: async () => {
|
||||
await onSave?.({
|
||||
author: authorInput.value.trim(),
|
||||
coAuthors: coAuthorsInput.value.trim(),
|
||||
organization: organizationInput.value.trim(),
|
||||
description: descriptionInput.value.trim(),
|
||||
});
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
}, 'Save'),
|
||||
],
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
['Capture & steps', [
|
||||
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
|
||||
@@ -646,10 +932,11 @@ const SHORTCUTS = [
|
||||
['PageUp / PageDown', 'Previous / next step'],
|
||||
['Alt+↑ / Alt+↓', 'Move step up / down'],
|
||||
['Ctrl+Delete', 'Delete current step'],
|
||||
['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step deletion)'],
|
||||
['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
|
||||
]],
|
||||
['Canvas tools', [
|
||||
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'],
|
||||
['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'],
|
||||
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
|
||||
['Ctrl+C', 'Copy selected annotation'],
|
||||
['Delete', 'Delete selected annotation'],
|
||||
@@ -726,6 +1013,7 @@ window.StepForgeDialogs = {
|
||||
showInfoDialog,
|
||||
showBackupsDialog,
|
||||
showPlaceholdersDialog,
|
||||
showGuideInfoDialog,
|
||||
showShortcutsDialog,
|
||||
showTemplateManager,
|
||||
showRecordingReminder,
|
||||
|
||||
@@ -90,6 +90,35 @@ button.tool.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
button.ai {
|
||||
padding: 5px 10px;
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--panel-solid));
|
||||
color: var(--accent-strong);
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
button.ai:hover { background: color-mix(in srgb, var(--accent) 16%, var(--panel-2)); }
|
||||
button.ai:disabled { opacity: 0.6; }
|
||||
button:disabled { opacity: 0.6; cursor: default; }
|
||||
button.loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
opacity: 0.85;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
@@ -487,6 +516,11 @@ kbd {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rich-toolbar button { padding: 4px 8px; font-size: 12px; }
|
||||
.rich-toolbar button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
.rich-editor {
|
||||
min-height: 110px;
|
||||
max-height: 220px;
|
||||
@@ -509,6 +543,12 @@ kbd {
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
.rich-editor blockquote {
|
||||
margin: 6px 0;
|
||||
padding: 2px 0 2px 12px;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.block-card,
|
||||
.annotation-editor-inner {
|
||||
@@ -518,12 +558,17 @@ kbd {
|
||||
}
|
||||
.block-card {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: var(--panel-solid);
|
||||
}
|
||||
.block-card[draggable="true"] { cursor: grab; }
|
||||
.block-card[draggable="true"]:active { cursor: grabbing; }
|
||||
.block-card[data-level="info"] { border-left-color: #3b82f6; }
|
||||
.block-card[data-level="success"] { border-left-color: #10b981; }
|
||||
.block-card[data-level="warn"] { border-left-color: #f59e0b; }
|
||||
.block-card[data-level="error"] { border-left-color: #ef4444; }
|
||||
.annotation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -564,8 +609,82 @@ fieldset legend {
|
||||
}
|
||||
.placeholder-row input { width: 100%; }
|
||||
|
||||
.hotkey-input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 190px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
.hotkey-input:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
}
|
||||
.hotkey-input:focus-visible,
|
||||
.hotkey-input.recording {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--panel-2));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
.hotkey-keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
flex: 1;
|
||||
}
|
||||
.hotkey-keys kbd {
|
||||
min-width: 22px;
|
||||
justify-content: center;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
background: var(--panel-solid);
|
||||
box-shadow: 0 1px 0 0 color-mix(in srgb, var(--text) 8%, transparent);
|
||||
}
|
||||
.hotkey-sep {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.hotkey-placeholder {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.hotkey-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hotkey-clear:hover {
|
||||
background: var(--panel-solid);
|
||||
color: var(--text);
|
||||
}
|
||||
.hotkey-clear[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
width: min(760px, 92vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.quick-actions input {
|
||||
width: 100%;
|
||||
@@ -573,7 +692,6 @@ fieldset legend {
|
||||
padding: 11px 12px;
|
||||
}
|
||||
.qa-results {
|
||||
margin-top: 10px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
@@ -643,6 +761,7 @@ fieldset legend {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ai-status.error { color: var(--danger); }
|
||||
|
||||
#modal-root:not(:empty) {
|
||||
position: fixed;
|
||||
@@ -731,6 +850,25 @@ fieldset legend {
|
||||
}
|
||||
.ctx-menu .mi:hover { background: var(--panel-2); }
|
||||
.ctx-menu .mi.danger { color: var(--danger); }
|
||||
.ctx-menu .mi.disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
.ctx-menu .mi.disabled:hover { background: none; }
|
||||
.ctx-menu .mi.has-submenu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.ctx-menu .mi .submenu-arrow {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctx-submenu {
|
||||
max-height: min(360px, calc(100vh - 24px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ctx-menu hr {
|
||||
margin: 6px 0;
|
||||
border: 0;
|
||||
|
||||
@@ -34,6 +34,27 @@ function clearNode(node) {
|
||||
while (node.firstChild) node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a button into/out of a busy state: disables it and swaps its label
|
||||
* for a spinner + `label` (e.g. "Exporting…"), restoring the original label
|
||||
* afterwards. Used for actions that block on a slow main-process call.
|
||||
*/
|
||||
function setButtonLoading(btn, loading, label) {
|
||||
if (loading) {
|
||||
if (btn.dataset.origLabel === undefined) btn.dataset.origLabel = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
clearNode(btn);
|
||||
btn.append(el('span.spinner'), label || btn.dataset.origLabel);
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
clearNode(btn);
|
||||
btn.append(btn.dataset.origLabel ?? '');
|
||||
delete btn.dataset.origLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
let t = null;
|
||||
const wrapped = (...args) => {
|
||||
@@ -119,15 +140,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */
|
||||
/**
|
||||
* Context menu at (x, y).
|
||||
* items: [{label, danger, action}] | [{label, submenu}] | 'sep'.
|
||||
* `submenu` is an array (or a function returning one) of the same item
|
||||
* shapes, shown in a scrollable panel beside the item on hover.
|
||||
*/
|
||||
function contextMenu(x, y, items) {
|
||||
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
|
||||
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
|
||||
let openSubmenu = null;
|
||||
const closeSubmenu = () => {
|
||||
if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; }
|
||||
};
|
||||
const closeAll = () => { closeSubmenu(); menu.remove(); };
|
||||
for (const item of items) {
|
||||
if (item === 'sep') { menu.append(el('hr')); continue; }
|
||||
if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; }
|
||||
if (item.submenu) {
|
||||
const mi = el('div.mi.has-submenu', {
|
||||
onMouseEnter: () => {
|
||||
closeSubmenu();
|
||||
const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu;
|
||||
const sub = el('div.ctx-menu.ctx-submenu');
|
||||
if (!subItems.length) {
|
||||
sub.append(el('div.mi.disabled', {}, 'Nothing else to choose'));
|
||||
}
|
||||
for (const subItem of subItems) {
|
||||
if (subItem === 'sep') { sub.append(el('hr')); continue; }
|
||||
sub.append(el('div.mi', {
|
||||
className: `mi${subItem.danger ? ' danger' : ''}`,
|
||||
onClick: () => { closeAll(); subItem.action(); },
|
||||
}, subItem.label));
|
||||
}
|
||||
document.body.append(sub);
|
||||
const miRect = mi.getBoundingClientRect();
|
||||
sub.style.left = `${miRect.right + 2}px`;
|
||||
sub.style.top = `${miRect.top}px`;
|
||||
const subRect = sub.getBoundingClientRect();
|
||||
if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`;
|
||||
if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`;
|
||||
openSubmenu = sub;
|
||||
},
|
||||
}, el('span', {}, item.label), el('span.submenu-arrow', {}, '›'));
|
||||
menu.append(mi);
|
||||
continue;
|
||||
}
|
||||
menu.append(el('div.mi', {
|
||||
className: `mi${item.danger ? ' danger' : ''}`,
|
||||
onClick: () => { menu.remove(); item.action(); },
|
||||
onMouseEnter: closeSubmenu,
|
||||
onClick: () => { closeAll(); item.action(); },
|
||||
}, item.label));
|
||||
}
|
||||
document.body.append(menu);
|
||||
@@ -135,7 +196,7 @@ function contextMenu(x, y, items) {
|
||||
if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
|
||||
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', () => menu.remove(), { once: true });
|
||||
document.addEventListener('click', () => closeAll(), { once: true });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -147,3 +208,21 @@ function fmtDate(iso) {
|
||||
|
||||
const escapeHtml = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */
|
||||
function htmlToPlainText(html) {
|
||||
return String(html || '')
|
||||
.replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\'').replace(/&/g, '&')
|
||||
.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
|
||||
}
|
||||
|
||||
/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */
|
||||
function textToHtml(text) {
|
||||
const trimmed = String(text || '').trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.split(/\n{2,}/)
|
||||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -340,6 +340,14 @@ async function createElectronHost(onEvent) {
|
||||
if (!win.isDestroyed()) win.destroy();
|
||||
throw err;
|
||||
}
|
||||
// The worker is a hidden renderer, so on battery Windows would EcoQoS-throttle
|
||||
// it and starve frame sampling. Clear that on its own OS process before it
|
||||
// starts streaming. Best-effort; no-op off Windows.
|
||||
try {
|
||||
// eslint-disable-next-line global-require
|
||||
const { keepProcessesResponsive } = require('./win-power');
|
||||
if (!win.isDestroyed()) keepProcessesResponsive([win.webContents.getOSProcessId()]);
|
||||
} catch { /* throttling tweak is optional */ }
|
||||
return {
|
||||
send(msg) {
|
||||
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, execFile } = require('node:child_process');
|
||||
|
||||
const {
|
||||
DEFAULT_CAPTURE_TITLES,
|
||||
buildCaptureTitle,
|
||||
normalizeOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
displayText,
|
||||
normalizeWhitespace,
|
||||
} = require('../core/text-intel');
|
||||
|
||||
const DEFAULT_TITLE_VALUES = new Set(Object.values(DEFAULT_CAPTURE_TITLES).concat(['Capture']));
|
||||
|
||||
const OCR_CROP = {
|
||||
width: 420,
|
||||
height: 220,
|
||||
};
|
||||
|
||||
function hasBinary(name) {
|
||||
try {
|
||||
execFileSync('which', [name], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v, min, max) {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function modelLooksVisionCapable(model) {
|
||||
const clean = normalizeWhitespace(model).toLowerCase();
|
||||
if (!clean) return false;
|
||||
return clean.includes('vision')
|
||||
|| clean.includes('llava')
|
||||
|| clean.includes('gemma4')
|
||||
|| /(^|[^a-z0-9])qwen[23](?:\.[0-9]+)?vl([^a-z0-9]|$)/.test(clean);
|
||||
}
|
||||
|
||||
let createWorkerImpl = null;
|
||||
function loadCreateWorker() {
|
||||
if (createWorkerImpl) return createWorkerImpl;
|
||||
// OCR is optional at startup; lazy-load it so the app can still boot when
|
||||
// the dependency has not been installed yet.
|
||||
// eslint-disable-next-line global-require
|
||||
({ createWorker: createWorkerImpl } = require('tesseract.js'));
|
||||
return createWorkerImpl;
|
||||
}
|
||||
|
||||
class TextIntelService {
|
||||
constructor({
|
||||
store,
|
||||
settings,
|
||||
getWindow = () => null,
|
||||
dataDir,
|
||||
fetchImpl = global.fetch,
|
||||
screenApi = null,
|
||||
}) {
|
||||
this.store = store;
|
||||
this.settings = settings;
|
||||
this.getWindow = getWindow;
|
||||
this.dataDir = dataDir;
|
||||
this.fetch = fetchImpl;
|
||||
this.screen = screenApi;
|
||||
this.worker = null;
|
||||
this.workerPromise = null;
|
||||
this.workerQueue = Promise.resolve();
|
||||
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
|
||||
this.modelCapabilityCache = new Map();
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this.worker) {
|
||||
try {
|
||||
await this.worker.terminate();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
this.worker = null;
|
||||
this.workerPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
ensureLangData() {
|
||||
const packageDir = path.dirname(require.resolve('@tesseract.js-data/eng/package.json'));
|
||||
const source = path.join(packageDir, '4.0.0_best_int', 'eng.traineddata.gz');
|
||||
const targetDir = this.ocrDataDir;
|
||||
const target = path.join(targetDir, 'eng.traineddata.gz');
|
||||
if (!fs.existsSync(target)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
fs.copyFileSync(source, target);
|
||||
}
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
async getWorker() {
|
||||
if (this.workerPromise) return this.workerPromise;
|
||||
this.workerPromise = (async () => {
|
||||
const workerFactory = loadCreateWorker();
|
||||
const langPath = this.ensureLangData();
|
||||
const worker = await workerFactory('eng', 1, {
|
||||
langPath,
|
||||
});
|
||||
await worker.setParameters({
|
||||
preserve_interword_spaces: '1',
|
||||
});
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
})();
|
||||
this.workerPromise.catch(() => {
|
||||
this.workerPromise = null;
|
||||
});
|
||||
return this.workerPromise;
|
||||
}
|
||||
|
||||
async recognizeCrop(image, rect = null) {
|
||||
const worker = await this.getWorker();
|
||||
const cropped = rect ? image.crop(rect) : image;
|
||||
const buffer = cropped.toPNG();
|
||||
const result = await worker.recognize(buffer);
|
||||
const text = String(result?.data?.text || '').trim();
|
||||
return {
|
||||
text,
|
||||
confidence: Number.isFinite(result?.data?.confidence) ? result.data.confidence : null,
|
||||
raw: result,
|
||||
};
|
||||
}
|
||||
|
||||
cropRectForPoint(frame, clickPos, { width = OCR_CROP.width, height = OCR_CROP.height } = {}) {
|
||||
if (!frame || !frame.size) return null;
|
||||
const bounds = frame.display.bounds || { x: 0, y: 0, width: frame.size.width, height: frame.size.height };
|
||||
const scaleX = frame.size.width / bounds.width;
|
||||
const scaleY = frame.size.height / bounds.height;
|
||||
const point = clickPos || {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height / 2,
|
||||
};
|
||||
const centerX = (point.x - bounds.x) * scaleX;
|
||||
const centerY = (point.y - bounds.y) * scaleY;
|
||||
const rectW = Math.max(1, Math.round(width * scaleX));
|
||||
const rectH = Math.max(1, Math.round(height * scaleY));
|
||||
const rect = {
|
||||
x: Math.round(centerX - rectW / 2),
|
||||
y: Math.round(centerY - rectH / 2),
|
||||
width: rectW,
|
||||
height: rectH,
|
||||
};
|
||||
rect.x = clamp(rect.x, 0, Math.max(0, frame.size.width - rect.width));
|
||||
rect.y = clamp(rect.y, 0, Math.max(0, frame.size.height - rect.height));
|
||||
rect.width = clamp(rect.width, 1, frame.size.width);
|
||||
rect.height = clamp(rect.height, 1, frame.size.height);
|
||||
return rect;
|
||||
}
|
||||
|
||||
async ocrAroundClick(frame, clickPos) {
|
||||
if (!frame || !frame.image) return { text: '', confidence: null };
|
||||
// Use a full-width horizontal strip at the click height. This preserves complete
|
||||
// link text (e.g. "Oracle | Cloud Applications and Cloud Platform") rather than
|
||||
// cropping through it when the element spans more than the 420 px default width.
|
||||
const bounds = frame.display?.bounds || { x: 0, y: 0, width: frame.size.width, height: frame.size.height };
|
||||
const rect = this.cropRectForPoint(frame, clickPos, {
|
||||
width: bounds.width, // full display width → full image width after DPI scaling
|
||||
height: 100, // ~2 lines tall, enough context without too much noise
|
||||
});
|
||||
try {
|
||||
return await this.recognizeCrop(frame.image, rect);
|
||||
} catch {
|
||||
return { text: '', confidence: null };
|
||||
}
|
||||
}
|
||||
|
||||
async collectForegroundWindowContext(osPoint = null) {
|
||||
try {
|
||||
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint);
|
||||
if (process.platform === 'darwin') return this.collectMacWindowContext();
|
||||
if (process.platform === 'linux') return this.collectLinuxWindowContext();
|
||||
} catch {
|
||||
// best effort only
|
||||
}
|
||||
return { appName: '', windowTitle: '' };
|
||||
}
|
||||
|
||||
async collectWindowsWindowContext(osPoint = null) {
|
||||
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
||||
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
||||
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
||||
const script = `
|
||||
$clickX = ${clickX};
|
||||
$clickY = ${clickY};
|
||||
$elementLabel = '';
|
||||
$elementRole = '';
|
||||
$elementClass = '';
|
||||
$elementProcessId = 0;
|
||||
$elementValue = '';
|
||||
if (${hasPoint ? '$true' : '$false'}) {
|
||||
try {
|
||||
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
||||
$point = New-Object System.Windows.Point($clickX, $clickY);
|
||||
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
||||
if ($element) {
|
||||
$current = $element.Current;
|
||||
$elementLabel = $current.Name;
|
||||
$elementRole = $current.LocalizedControlType;
|
||||
$elementClass = $current.ClassName;
|
||||
$elementProcessId = $current.ProcessId;
|
||||
try {
|
||||
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
||||
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
||||
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class Win32 {
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
}
|
||||
"@;
|
||||
$hWnd = [Win32]::GetForegroundWindow();
|
||||
$sb = New-Object System.Text.StringBuilder 512;
|
||||
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
||||
$pid = 0;
|
||||
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
||||
$out = [ordered]@{
|
||||
appName = if ($proc) { $proc.ProcessName } else { '' };
|
||||
windowTitle = $sb.ToString();
|
||||
elementLabel = $elementLabel;
|
||||
elementRole = $elementRole;
|
||||
elementClass = $elementClass;
|
||||
elementValue = $elementValue;
|
||||
elementProcessId = $elementProcessId;
|
||||
pid = $pid;
|
||||
};
|
||||
$out | ConvertTo-Json -Compress;
|
||||
`;
|
||||
return new Promise(resolve => {
|
||||
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
windowsHide: true,
|
||||
}, (err, stdout) => {
|
||||
if (err) { resolve({}); return; }
|
||||
try { resolve(JSON.parse(stdout.trim() || '{}')); }
|
||||
catch { resolve({}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
collectMacWindowContext() {
|
||||
const script = `
|
||||
set appName to ""
|
||||
set windowTitle to ""
|
||||
tell application "System Events"
|
||||
try
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
try
|
||||
set windowTitle to name of front window of frontApp
|
||||
end try
|
||||
end try
|
||||
end tell
|
||||
return appName & linefeed & windowTitle
|
||||
`;
|
||||
const result = execFileSync('osascript', ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
}).trimEnd();
|
||||
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
||||
return { appName, windowTitle };
|
||||
}
|
||||
|
||||
collectLinuxWindowContext() {
|
||||
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
||||
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
});
|
||||
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
||||
if (!activeMatch) return { appName: '', windowTitle: '' };
|
||||
const winId = activeMatch[1];
|
||||
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 1200,
|
||||
});
|
||||
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
||||
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
||||
return {
|
||||
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
||||
windowTitle: titleMatch ? titleMatch[1] : '',
|
||||
};
|
||||
}
|
||||
|
||||
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
|
||||
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
||||
return ctx.title;
|
||||
}
|
||||
|
||||
async buildCaptureContext({ mode, frame, clickPos, clickMeta = null }) {
|
||||
const keyContext = clickMeta?.keyContext || {};
|
||||
const recentTyped = keyContext.recentTyped || '';
|
||||
const recentShortcut = keyContext.recentShortcut || '';
|
||||
// Use window context pre-captured by the click watcher when available.
|
||||
// This avoids a costly PowerShell cold-start (1–3 s) on every capture.
|
||||
const fastContext = clickMeta?.windowContext || null;
|
||||
const [metadata, ocr] = await Promise.all([
|
||||
fastContext
|
||||
? Promise.resolve(fastContext)
|
||||
: this.collectForegroundWindowContext(clickMeta?.osPoint || null),
|
||||
this.ocrAroundClick(frame, clickPos),
|
||||
]);
|
||||
const title = buildCaptureTitle({ mode, metadata, ocrText: ocr.text, recentTyped, recentShortcut });
|
||||
return {
|
||||
title,
|
||||
captureMetadata: {
|
||||
ocrText: ocr.text || '',
|
||||
windowTitle: metadata.windowTitle || '',
|
||||
appName: metadata.appName || '',
|
||||
elementLabel: metadata.elementLabel || '',
|
||||
elementRole: metadata.elementRole || '',
|
||||
elementValue: metadata.elementValue || '',
|
||||
recentTyped,
|
||||
recentShortcut,
|
||||
mode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
aiEnabled() {
|
||||
return Boolean(this.settings.get('ai.enabled'));
|
||||
}
|
||||
|
||||
aiConfig(override = null) {
|
||||
const stored = this.settings.get('ai') || {};
|
||||
const merged = override ? {
|
||||
...stored,
|
||||
...override,
|
||||
ollama: {
|
||||
...(stored.ollama || {}),
|
||||
...(override.ollama || {}),
|
||||
},
|
||||
} : stored;
|
||||
return {
|
||||
...merged,
|
||||
enabled: override && Object.prototype.hasOwnProperty.call(override, 'enabled')
|
||||
? Boolean(override.enabled)
|
||||
: Boolean(stored.enabled),
|
||||
ollama: {
|
||||
host: normalizeOllamaHost(merged.ollama?.host || ''),
|
||||
model: normalizeWhitespace(merged.ollama?.model || ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async testAiConnection(override = null) {
|
||||
const config = this.aiConfig(override);
|
||||
if (!config.ollama.host) {
|
||||
return { ok: false, reason: 'Set an Ollama host first.' };
|
||||
}
|
||||
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`);
|
||||
const res = await this.fetch(tagsUrl, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
return { ok: false, reason: `Ollama check failed (${res.status})` };
|
||||
}
|
||||
const data = await res.json();
|
||||
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
|
||||
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
|
||||
const vision = installed ? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
model: config.ollama.model,
|
||||
}) : false;
|
||||
return {
|
||||
ok: true,
|
||||
installed,
|
||||
vision,
|
||||
models,
|
||||
host: config.ollama.host,
|
||||
model: config.ollama.model,
|
||||
};
|
||||
}
|
||||
|
||||
async modelCapabilities({ host, model }) {
|
||||
const normalizedHost = normalizeOllamaHost(host);
|
||||
const normalizedModel = normalizeWhitespace(model);
|
||||
if (!normalizedHost || !normalizedModel) return [];
|
||||
const cacheKey = `${normalizedHost}::${normalizedModel}`;
|
||||
if (this.modelCapabilityCache.has(cacheKey)) {
|
||||
return this.modelCapabilityCache.get(cacheKey);
|
||||
}
|
||||
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
|
||||
let capabilities = [];
|
||||
try {
|
||||
const response = await this.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: normalizedModel }),
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
capabilities = Array.isArray(payload?.capabilities)
|
||||
? payload.capabilities.map((cap) => normalizeWhitespace(cap).toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
} catch {
|
||||
capabilities = [];
|
||||
}
|
||||
if (!capabilities.includes('vision') && modelLooksVisionCapable(normalizedModel)) {
|
||||
capabilities = [...capabilities, 'vision'];
|
||||
}
|
||||
this.modelCapabilityCache.set(cacheKey, capabilities);
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
async modelSupportsVision({ host, model }) {
|
||||
const capabilities = await this.modelCapabilities({ host, model });
|
||||
return capabilities.includes('vision');
|
||||
}
|
||||
|
||||
readStepImageBase64(guideId, stepId) {
|
||||
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
|
||||
if (!imagePath || !fs.existsSync(imagePath)) return '';
|
||||
return fs.readFileSync(imagePath).toString('base64');
|
||||
}
|
||||
|
||||
async callOllamaText({ host, model, prompt, systemPrompt }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const response = await this.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
options: { temperature: 0.4 },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
|
||||
const payload = await response.json();
|
||||
const content = payload?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) throw new Error('Ollama returned an empty response');
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
|
||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||
const userMessage = { role: 'user', content: prompt };
|
||||
if (Array.isArray(images) && images.length) {
|
||||
userMessage.images = images;
|
||||
}
|
||||
const response = await this.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
format: 'json',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
userMessage,
|
||||
],
|
||||
options: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama request failed (${response.status})`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const content = payload?.message?.content;
|
||||
if (typeof content !== 'string' || !content.trim()) {
|
||||
throw new Error('Ollama returned an empty response');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async generateStepPatch({
|
||||
guideId,
|
||||
stepId,
|
||||
target = 'all',
|
||||
blockId = null,
|
||||
}) {
|
||||
try {
|
||||
const config = this.aiConfig();
|
||||
if (!config.enabled) {
|
||||
return { ok: false, reason: 'Enable AI in settings first.' };
|
||||
}
|
||||
if (!config.ollama.host || !config.ollama.model) {
|
||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||
}
|
||||
|
||||
const guide = this.store.getGuide(guideId);
|
||||
const step = this.store.getStep(guideId, stepId);
|
||||
if (!guide || !step) {
|
||||
return { ok: false, reason: 'Guide or step not found.' };
|
||||
}
|
||||
|
||||
const currentBlock = blockId
|
||||
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
|
||||
: null;
|
||||
if (blockId && target === 'block' && !currentBlock) {
|
||||
return { ok: false, reason: 'Block not found.' };
|
||||
}
|
||||
|
||||
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
|
||||
const screenshotAttached = Boolean(screenshotBase64)
|
||||
? await this.modelSupportsVision({
|
||||
host: config.ollama.host,
|
||||
model: config.ollama.model,
|
||||
})
|
||||
: false;
|
||||
|
||||
let captureContext = null;
|
||||
// Use stored capture metadata when available (best context, from capture time).
|
||||
// Fall back to re-running OCR on the stored image only when metadata is absent.
|
||||
if (step.captureMetadata) {
|
||||
const rawCandidate = buildCaptureTitle({
|
||||
mode: step.captureMetadata.mode || 'fullscreen',
|
||||
metadata: {
|
||||
windowTitle: step.captureMetadata.windowTitle,
|
||||
appName: step.captureMetadata.appName,
|
||||
elementLabel: step.captureMetadata.elementLabel,
|
||||
elementRole: step.captureMetadata.elementRole,
|
||||
elementValue: step.captureMetadata.elementValue,
|
||||
},
|
||||
ocrText: step.captureMetadata.ocrText,
|
||||
recentTyped: step.captureMetadata.recentTyped,
|
||||
recentShortcut: step.captureMetadata.recentShortcut,
|
||||
});
|
||||
captureContext = {
|
||||
...step.captureMetadata,
|
||||
// Don't suggest a generic fallback title — leave it blank so AI generates from context.
|
||||
titleCandidate: DEFAULT_TITLE_VALUES.has(rawCandidate) ? '' : rawCandidate,
|
||||
};
|
||||
} else if (step.image) {
|
||||
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
|
||||
if (imagePath && fs.existsSync(imagePath)) {
|
||||
const { nativeImage } = require('electron');
|
||||
const image = nativeImage.createFromPath(imagePath);
|
||||
if (!image.isEmpty()) {
|
||||
const clickPoint = this.clickPointFromStep(step, image);
|
||||
const [metadata, ocr] = await Promise.all([
|
||||
this.collectForegroundWindowContext(),
|
||||
this.ocrAroundClick({ image, size: image.getSize(), display: { bounds: { x: 0, y: 0, width: image.getSize().width, height: image.getSize().height } } }, clickPoint),
|
||||
]);
|
||||
const rawCandidate2 = buildCaptureTitle({
|
||||
mode: step.kind === 'image' ? 'fullscreen' : 'window',
|
||||
metadata,
|
||||
ocrText: ocr.text,
|
||||
});
|
||||
captureContext = {
|
||||
...metadata,
|
||||
ocrText: ocr.text,
|
||||
titleCandidate: DEFAULT_TITLE_VALUES.has(rawCandidate2) ? '' : rawCandidate2,
|
||||
mode: step.kind === 'image' ? 'fullscreen' : 'content',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { systemPrompt, prompt } = buildAiPrompt({
|
||||
target,
|
||||
guide,
|
||||
step,
|
||||
captureContext,
|
||||
block: currentBlock,
|
||||
screenshotAttached,
|
||||
});
|
||||
|
||||
const raw = await this.callOllama({
|
||||
host: config.ollama.host,
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
images: screenshotAttached ? [screenshotBase64] : [],
|
||||
});
|
||||
const patch = normalizeAiPatch(raw);
|
||||
const updated = applyAiPatchToStep(step, patch, { target, blockId });
|
||||
const saved = this.store.saveStep(guideId, updated);
|
||||
return { ok: true, step: saved, patch };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
async rewriteText({ text, guideTitle = '', stepTitle = '' }) {
|
||||
try {
|
||||
const config = this.aiConfig();
|
||||
if (!config.enabled) return { ok: false, reason: 'Enable AI in settings first.' };
|
||||
if (!config.ollama.host || !config.ollama.model) {
|
||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||
}
|
||||
const trimmed = normalizeWhitespace(text);
|
||||
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
|
||||
|
||||
const contextHint = [
|
||||
guideTitle ? `Guide: ${guideTitle}` : '',
|
||||
stepTitle ? `Step: ${stepTitle}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const prompt = [
|
||||
contextHint,
|
||||
contextHint ? '' : null,
|
||||
'Rewrite the following text to sound professional and clear as step-by-step documentation.',
|
||||
'Keep it concise. Do not add extra information. Return only the rewritten text.',
|
||||
'',
|
||||
trimmed,
|
||||
].filter((l) => l !== null).join('\n');
|
||||
|
||||
const result = await this.callOllamaText({
|
||||
host: config.ollama.host,
|
||||
model: config.ollama.model,
|
||||
prompt,
|
||||
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
|
||||
});
|
||||
return { ok: true, text: result };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err?.message || 'Rewrite failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
clickPointFromStep(step, image = null) {
|
||||
const marker = (step.annotations || []).find((ann) => ann.type === 'oval' && Number.isFinite(ann.x) && Number.isFinite(ann.y) && Number.isFinite(ann.w) && Number.isFinite(ann.h));
|
||||
if (!marker) return null;
|
||||
const size = image ? image.getSize() : step.image?.size || { width: 0, height: 0 };
|
||||
if (!size.width || !size.height) return null;
|
||||
return {
|
||||
x: Math.round((marker.x + marker.w / 2) * size.width),
|
||||
y: Math.round((marker.y + marker.h / 2) * size.height),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TextIntelService };
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
/**
|
||||
* Opt a set of OS processes out of Windows Power Throttling (EcoQoS) and raise
|
||||
* them to high priority, so the capture pipeline keeps running at full CPU
|
||||
* speed while the laptop is on battery in a power-saving plan.
|
||||
*
|
||||
* Why this exists: during a recording StepForge hides its window, so Windows
|
||||
* treats the frame-capture worker renderer (and the GPU / screen-capture
|
||||
* utility processes feeding it) as background work and CPU-throttles them on
|
||||
* DC power. Throttled, the worker can't sample frames fast enough — every
|
||||
* click then finds no fresh pre-click frame and falls back to a slow
|
||||
* post-click shot, which is what broke recordings on battery only.
|
||||
*
|
||||
* The Chromium command-line switches in main.js stop Chromium's own
|
||||
* backgrounding; this goes one level lower and clears the OS EcoQoS flag via
|
||||
* SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED → off), which
|
||||
* has no Node binding, so we drive it through a short-lived PowerShell.
|
||||
*
|
||||
* Best-effort and fire-and-forget: any failure (older Windows without the API,
|
||||
* PowerShell blocked by policy, a process that already exited) is swallowed —
|
||||
* the Chromium switches still apply and capture degrades gracefully.
|
||||
*
|
||||
* No-op on every non-Windows platform.
|
||||
*
|
||||
* @param {number[]} pids OS process ids to keep responsive.
|
||||
*/
|
||||
function keepProcessesResponsive(pids) {
|
||||
if (process.platform !== 'win32') return;
|
||||
// Only integers reach the script, so the interpolation below can't inject.
|
||||
const list = [...new Set((pids || []).filter((p) => Number.isInteger(p) && p > 0))];
|
||||
if (!list.length) return;
|
||||
|
||||
const ps = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class SFPower {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
struct PROCESS_POWER_THROTTLING_STATE { public uint Version; public uint ControlMask; public uint StateMask; }
|
||||
|
||||
const int ProcessPowerThrottling = 4;
|
||||
const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
|
||||
const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
||||
const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
||||
const uint PROCESS_SET_INFORMATION = 0x0200;
|
||||
const uint PROCESS_QUERY_INFORMATION = 0x0400;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
||||
[DllImport("kernel32.dll", SetLastError=true)]
|
||||
static extern bool SetProcessInformation(IntPtr h, int cls, ref PROCESS_POWER_THROTTLING_STATE info, uint size);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool SetPriorityClass(IntPtr h, uint cls);
|
||||
[DllImport("kernel32.dll")]
|
||||
static extern bool CloseHandle(IntPtr h);
|
||||
|
||||
public static void Apply(uint pid) {
|
||||
IntPtr h = OpenProcess(PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid);
|
||||
if (h == IntPtr.Zero) return;
|
||||
try {
|
||||
PROCESS_POWER_THROTTLING_STATE s = new PROCESS_POWER_THROTTLING_STATE();
|
||||
s.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
|
||||
s.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED;
|
||||
s.StateMask = 0; // 0 => throttling off => opt out of EcoQoS
|
||||
SetProcessInformation(h, ProcessPowerThrottling, ref s, (uint)Marshal.SizeOf(s));
|
||||
SetPriorityClass(h, HIGH_PRIORITY_CLASS);
|
||||
} finally { CloseHandle(h); }
|
||||
}
|
||||
}
|
||||
'@
|
||||
${list.map((p) => `[SFPower]::Apply(${p})`).join('\n')}
|
||||
`;
|
||||
|
||||
try {
|
||||
const child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps],
|
||||
{ stdio: 'ignore', windowsHide: true },
|
||||
);
|
||||
child.on('error', () => { /* PowerShell missing/blocked — best effort */ });
|
||||
child.unref();
|
||||
} catch {
|
||||
// spawn itself failed; the Chromium switches remain in effect.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { keepProcessesResponsive };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const { GuideStore } = require('./store');
|
||||
const { buildRenderAst } = require('./renderast');
|
||||
const { runExport } = require('../exporters');
|
||||
|
||||
/**
|
||||
* Entry point for the export helper process (forked via
|
||||
* app/export-runner.js). Runs one export job — building the render AST and
|
||||
* invoking the exporter — off the main process so a large guide's PDF/DOCX/
|
||||
* etc. rendering never blocks the UI.
|
||||
*/
|
||||
|
||||
process.on('message', (job) => {
|
||||
let payload;
|
||||
try {
|
||||
const { dataDir, guideId, format, options, outDir, globals, maxSteps } = job;
|
||||
const store = new GuideStore(dataDir);
|
||||
const ast = buildRenderAst(store, guideId, { globals, maxSteps });
|
||||
const result = runExport(format, ast, outDir, options || {});
|
||||
payload = { ok: true, result };
|
||||
} catch (err) {
|
||||
payload = { ok: false, error: err && err.message ? err.message : String(err) };
|
||||
}
|
||||
process.send(payload, () => process.exit(payload.ok ? 0 : 1));
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
'use strict';
|
||||
|
||||
const { decodeEntities } = require('./util');
|
||||
|
||||
/**
|
||||
* Parse sanitized description HTML (see core/sanitize.js) into a flat list
|
||||
* of blocks with inline formatting runs, for renderers (PDF) that need to
|
||||
* preserve bold/italic/links/lists rather than flattening to plain text.
|
||||
*
|
||||
* Each block: { type, runs, indent, n? }
|
||||
* type: 'p' | 'h1'..'h4' | 'blockquote' | 'li' | 'oli' | 'hr'
|
||||
* runs: [{ text, bold, italic, code, href }] (absent for 'hr')
|
||||
* indent: list/quote nesting depth (0 = top level)
|
||||
* n: list item number, only for 'oli'
|
||||
*/
|
||||
|
||||
const TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
|
||||
const HREF_RE = /href\s*=\s*"([^"]*)"|href\s*=\s*'([^']*)'/i;
|
||||
|
||||
function htmlToBlocks(html) {
|
||||
const blocks = [];
|
||||
let current = null;
|
||||
const styleStack = [{}];
|
||||
const context = []; // nesting of <ul>/<ol>/<blockquote>
|
||||
|
||||
const indentLevel = () => context.length;
|
||||
const currentStyle = () => styleStack[styleStack.length - 1];
|
||||
|
||||
const flushBlock = () => {
|
||||
if (!current) return;
|
||||
if (current.type === 'hr' || current.runs.some((r) => r.text.trim() !== '')) blocks.push(current);
|
||||
current = null;
|
||||
};
|
||||
const startBlock = (type, extra = {}) => {
|
||||
flushBlock();
|
||||
current = { type, runs: [], indent: indentLevel(), ...extra };
|
||||
};
|
||||
const pushText = (text) => {
|
||||
if (!text) return;
|
||||
if (!current) current = { type: 'p', runs: [], indent: indentLevel() };
|
||||
current.runs.push({ text, ...currentStyle() });
|
||||
};
|
||||
|
||||
let last = 0;
|
||||
let m;
|
||||
while ((m = TAG_RE.exec(html)) !== null) {
|
||||
if (m.index > last) pushText(decodeEntities(html.slice(last, m.index)));
|
||||
last = TAG_RE.lastIndex;
|
||||
const closing = Boolean(m[1]);
|
||||
const tag = m[2].toLowerCase();
|
||||
const rawAttrs = m[3];
|
||||
|
||||
switch (tag) {
|
||||
case 'p':
|
||||
case 'div':
|
||||
if (closing) flushBlock();
|
||||
else startBlock('p');
|
||||
break;
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
if (closing) flushBlock();
|
||||
else startBlock(tag);
|
||||
break;
|
||||
case 'blockquote':
|
||||
if (closing) { flushBlock(); context.pop(); }
|
||||
else { context.push({ kind: 'blockquote' }); startBlock('blockquote'); }
|
||||
break;
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
if (closing) context.pop();
|
||||
else context.push({ kind: tag, counter: 0 });
|
||||
break;
|
||||
case 'li':
|
||||
if (closing) flushBlock();
|
||||
else {
|
||||
const ctx = context[context.length - 1];
|
||||
const depth = Math.max(0, indentLevel() - 1);
|
||||
if (ctx && ctx.kind === 'ol') { ctx.counter += 1; startBlock('oli', { n: ctx.counter, indent: depth }); }
|
||||
else startBlock('li', { indent: depth });
|
||||
}
|
||||
break;
|
||||
case 'br':
|
||||
flushBlock();
|
||||
break;
|
||||
case 'hr':
|
||||
flushBlock();
|
||||
blocks.push({ type: 'hr' });
|
||||
break;
|
||||
case 'b':
|
||||
case 'strong':
|
||||
if (closing) styleStack.pop();
|
||||
else styleStack.push({ ...currentStyle(), bold: true });
|
||||
break;
|
||||
case 'i':
|
||||
case 'em':
|
||||
if (closing) styleStack.pop();
|
||||
else styleStack.push({ ...currentStyle(), italic: true });
|
||||
break;
|
||||
case 'code':
|
||||
case 'pre':
|
||||
if (closing) styleStack.pop();
|
||||
else styleStack.push({ ...currentStyle(), code: true });
|
||||
break;
|
||||
case 'a':
|
||||
if (closing) styleStack.pop();
|
||||
else {
|
||||
const hrefM = HREF_RE.exec(rawAttrs);
|
||||
styleStack.push({ ...currentStyle(), href: hrefM ? (hrefM[1] ?? hrefM[2]) : undefined });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Unrecognized/transparent allowed tags (table*, span, u, s, sub, sup):
|
||||
// no block or style change.
|
||||
break;
|
||||
}
|
||||
}
|
||||
pushText(decodeEntities(html.slice(last)));
|
||||
flushBlock();
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
module.exports = { htmlToBlocks };
|
||||
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
|
||||
* points; converted to PDF's bottom-left space internally.
|
||||
*/
|
||||
|
||||
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' };
|
||||
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' };
|
||||
// Approximate average glyph width factors (per 1pt font size) for wrapping.
|
||||
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 };
|
||||
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 };
|
||||
|
||||
function esc(text) {
|
||||
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
// "Smart typography" characters commonly produced by rich-text editors and
|
||||
// pasted content (e.g. from Word/Google Docs) that have a printable glyph in
|
||||
// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte
|
||||
// instead of falling back to "?".
|
||||
const WINANSI_EXTRA = {
|
||||
0x20ac: 0x80, // €
|
||||
0x2026: 0x85, // … ellipsis
|
||||
0x2030: 0x89, // ‰
|
||||
0x2039: 0x8b, // ‹
|
||||
0x203a: 0x9b, // ›
|
||||
0x2018: 0x91, // ' left single quote
|
||||
0x2019: 0x92, // ' right single quote
|
||||
0x201c: 0x93, // " left double quote
|
||||
0x201d: 0x94, // " right double quote
|
||||
0x2022: 0x95, // • bullet
|
||||
0x2013: 0x96, // – en dash
|
||||
0x2014: 0x97, // — em dash
|
||||
0x2122: 0x99, // ™
|
||||
};
|
||||
|
||||
function toLatin1(text) {
|
||||
let out = '';
|
||||
for (const ch of String(text)) {
|
||||
const code = ch.codePointAt(0);
|
||||
out += code <= 0xff ? ch : '?';
|
||||
if (code <= 0xff) out += ch;
|
||||
else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]);
|
||||
else out += '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -38,6 +60,7 @@ class PdfBuilder {
|
||||
this.images = []; // { name, width, height, data (deflated RGB), smask? }
|
||||
this.imageCache = new Map();
|
||||
this.bookmarks = []; // { title, pageIndex, y }
|
||||
this.links = []; // { pageIndex, x, yTop, w, h, target }
|
||||
}
|
||||
|
||||
addPage() {
|
||||
@@ -73,11 +96,42 @@ class PdfBuilder {
|
||||
|
||||
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
|
||||
const y = this.pageHeight - yTop - size;
|
||||
// Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces.
|
||||
const clean = String(str).replace(/\t/g, ' ');
|
||||
this.currentPage.ops.push(
|
||||
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET`
|
||||
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a line made of mixed-font/color segments (e.g. bold/italic runs)
|
||||
* as one continuous text object: consecutive Tj operators without an
|
||||
* intervening Tm advance the cursor by each font's real glyph widths, so
|
||||
* spacing matches the viewer's metrics exactly (unlike our approximate
|
||||
* textWidth(), which is only used for word-wrap decisions).
|
||||
*/
|
||||
textRun(parts, x, yTop, size) {
|
||||
const y = this.pageHeight - yTop - size;
|
||||
const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`];
|
||||
let curFont = null;
|
||||
let curColor = null;
|
||||
for (const part of parts) {
|
||||
if (!part.text) continue;
|
||||
if (part.font !== curFont) {
|
||||
ops.push(`/${part.font} ${size} Tf`);
|
||||
curFont = part.font;
|
||||
}
|
||||
if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) {
|
||||
ops.push(`${col(part.color)} rg`);
|
||||
curColor = part.color;
|
||||
}
|
||||
const clean = String(part.text).replace(/\t/g, ' ');
|
||||
ops.push(`(${esc(toLatin1(clean))}) Tj`);
|
||||
}
|
||||
ops.push('ET');
|
||||
this.currentPage.ops.push(ops.join(' '));
|
||||
}
|
||||
|
||||
rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) {
|
||||
const y = this.pageHeight - yTop - h;
|
||||
const ops = [];
|
||||
@@ -120,6 +174,16 @@ class PdfBuilder {
|
||||
this.bookmarks.push({ title: toLatin1(title), pageIndex });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a clickable region that jumps to another page (e.g. a Contents
|
||||
* entry linking to a step). `target` is either a page index, or an
|
||||
* object whose `pageIndex` is filled in later — once the destination
|
||||
* page is known — and read when `build()` runs.
|
||||
*/
|
||||
linkRect(x, yTop, w, h, target, pageIndex = this.pages.length - 1) {
|
||||
this.links.push({ pageIndex, x, yTop, w, h, target });
|
||||
}
|
||||
|
||||
build() {
|
||||
if (!this.pages.length) this.addPage();
|
||||
const objects = []; // 1-based; objects[i] = body string|Buffer after header
|
||||
@@ -159,6 +223,27 @@ class PdfBuilder {
|
||||
));
|
||||
}
|
||||
|
||||
// Link annotations (e.g. clickable Contents entries that jump to a
|
||||
// step's page). Targets resolved late, after every step has claimed a
|
||||
// page, so `target` may be an object whose `pageIndex` was only just set.
|
||||
const pageAnnots = new Map(); // page index -> [annotation object id, ...]
|
||||
for (const link of this.links) {
|
||||
const targetIndex = typeof link.target === 'number' ? link.target : link.target?.pageIndex;
|
||||
if (targetIndex == null || !pageIds[targetIndex]) continue;
|
||||
const y1 = this.pageHeight - link.yTop - link.h;
|
||||
const y2 = this.pageHeight - link.yTop;
|
||||
const annotId = addObj(
|
||||
`<< /Type /Annot /Subtype /Link /Rect [${link.x.toFixed(2)} ${y1.toFixed(2)} ${(link.x + link.w).toFixed(2)} ${y2.toFixed(2)}] ` +
|
||||
`/Border [0 0 0] /Dest [${pageIds[targetIndex]} 0 R /Fit] >>`
|
||||
);
|
||||
if (!pageAnnots.has(link.pageIndex)) pageAnnots.set(link.pageIndex, []);
|
||||
pageAnnots.get(link.pageIndex).push(annotId);
|
||||
}
|
||||
for (const [pageIndex, annotIds] of pageAnnots) {
|
||||
const id = pageIds[pageIndex];
|
||||
objects[id - 1] = objects[id - 1].replace(/ >>$/, ` /Annots [${annotIds.map((a) => `${a} 0 R`).join(' ')}] >>`);
|
||||
}
|
||||
|
||||
let outlinesRef = '';
|
||||
if (this.bookmarks.length) {
|
||||
const outlineRootId = objects.length + 1;
|
||||
|
||||
@@ -392,13 +392,19 @@ function renderAnnotations(baseImg, annotations = []) {
|
||||
return img;
|
||||
}
|
||||
|
||||
/** Apply focused view (zoom/pan crop, then scale back to original size). */
|
||||
/**
|
||||
* Apply focused view (zoom/pan crop, then scale back to original size).
|
||||
* panX/panY are 0..1 fractions of the available pan range, not absolute
|
||||
* image positions: 0 puts the crop at the left/bottom edge, 1 at the
|
||||
* right/top edge, 0.5 centers it. Y is inverted so panY increases upward.
|
||||
*/
|
||||
function applyFocusedView(img, fv) {
|
||||
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
|
||||
const vw = img.width / fv.zoom, vh = img.height / fv.zoom;
|
||||
const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2);
|
||||
const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2);
|
||||
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh);
|
||||
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
|
||||
const cropX = panX * (img.width - vw);
|
||||
const cropY = (1 - panY) * (img.height - vh);
|
||||
const region = crop(img, cropX, cropY, vw, vh);
|
||||
return resize(region, img.width, img.height);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { sanitizeHtml } = require('./sanitize');
|
||||
const { htmlToText, deepClone } = require('./util');
|
||||
const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
|
||||
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
|
||||
const { decodePng } = require('./png');
|
||||
const { renderAnnotations, applyFocusedView } = require('./raster');
|
||||
@@ -32,6 +32,10 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
|
||||
});
|
||||
const expand = (text) => expandPlaceholders(text, values);
|
||||
// Description fields additionally turn literal "[text](url)" markdown
|
||||
// link syntax (as inserted by the editor's Link button) into real <a>
|
||||
// tags before sanitizing, so exporters render an actual link.
|
||||
const expandDesc = (html) => linkifyMarkdownLinks(expand(html || ''));
|
||||
|
||||
const steps = [];
|
||||
const topCounter = { n: 0 };
|
||||
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
skipped: step.skipped,
|
||||
forceNewPage: Boolean(step.forceNewPage),
|
||||
title: expand(step.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(step.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
|
||||
focusedView: step.focusedView,
|
||||
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
|
||||
textBlocks: (step.textBlocks || []).map((tb) => ({
|
||||
...tb,
|
||||
title: expand(tb.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
|
||||
})),
|
||||
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
|
||||
tableBlocks: (step.tableBlocks || []).map((tb) => ({
|
||||
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
return {
|
||||
...block,
|
||||
title: expand(block.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
|
||||
};
|
||||
}
|
||||
if (block.kind === 'code') return { ...block };
|
||||
@@ -116,11 +120,16 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
guide: {
|
||||
id: guide.guideId,
|
||||
title: expand(guide.title),
|
||||
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(guide.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
|
||||
createdAt: guide.createdAt,
|
||||
updatedAt: guide.updatedAt,
|
||||
flags: guide.flags,
|
||||
metadata: {
|
||||
author: expand(guide.metadata?.author || ''),
|
||||
coAuthors: expand(guide.metadata?.coAuthors || ''),
|
||||
organization: expand(guide.metadata?.organization || ''),
|
||||
},
|
||||
},
|
||||
steps: limited,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
|
||||
title: fields.title || 'Untitled guide',
|
||||
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
|
||||
placeholders: { ...(fields.placeholders || {}) },
|
||||
metadata: {
|
||||
author: '',
|
||||
coAuthors: '',
|
||||
organization: '',
|
||||
...(fields.metadata || {}),
|
||||
},
|
||||
flags: {
|
||||
focusedViewDefault: false,
|
||||
hideSkippedStepsInExports: true,
|
||||
@@ -80,6 +86,9 @@ function createStep(fields = {}) {
|
||||
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
|
||||
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
|
||||
links: fields.links || [], // { id, label, targetStepId }
|
||||
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
|
||||
? { ...fields.captureMetadata }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +158,7 @@ function validateGuide(guide) {
|
||||
if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array');
|
||||
else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates');
|
||||
if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object');
|
||||
if (guide.metadata && typeof guide.metadata !== 'object') errors.push('metadata must be an object');
|
||||
if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`);
|
||||
return guide;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ const DEFAULT_SETTINGS = {
|
||||
focusedViewDefaultForNewSteps: false,
|
||||
autoTitleTemplate: '[[Mode]] capture [[Time]]',
|
||||
},
|
||||
ai: {
|
||||
enabled: false,
|
||||
ollama: {
|
||||
host: 'http://127.0.0.1:11434',
|
||||
model: 'llama3.2:1b',
|
||||
},
|
||||
},
|
||||
exports: {
|
||||
previewStepCount: 3,
|
||||
openFolderAfterExport: true,
|
||||
|
||||
@@ -244,6 +244,27 @@ class GuideStore {
|
||||
this.saveGuide(guide);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate a previously-deleted step, preserving its stepId, and splice it
|
||||
* back into the guide's order. Used to undo step deletion.
|
||||
*/
|
||||
restoreStep(guideId, step, images, position) {
|
||||
const guide = this.getGuide(guideId);
|
||||
const restored = normalizeStep(deepClone(step));
|
||||
validateStep(restored);
|
||||
const dir = this.stepDir(guideId, restored.stepId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (restored.image && images) {
|
||||
if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original);
|
||||
if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working);
|
||||
}
|
||||
writeJsonSync(path.join(dir, 'step.json'), restored);
|
||||
const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length;
|
||||
guide.stepsOrder.splice(at, 0, restored.stepId);
|
||||
this.saveGuide(guide);
|
||||
return restored;
|
||||
}
|
||||
|
||||
reorderSteps(guideId, newOrder) {
|
||||
const guide = this.getGuide(guideId);
|
||||
const current = new Set(guide.stepsOrder);
|
||||
|
||||
@@ -11,7 +11,21 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
|
||||
* defaults, shareable as .sfglt zip files.
|
||||
*/
|
||||
|
||||
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
const FORMATS = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
|
||||
const FORMAT_LABELS = {
|
||||
json: 'JSON',
|
||||
markdown: 'Markdown',
|
||||
wikijs: 'Wiki.js',
|
||||
'html-simple': 'HTML (simple)',
|
||||
'html-rich': 'HTML (rich)',
|
||||
confluence: 'Confluence',
|
||||
pdf: 'PDF',
|
||||
gif: 'GIF',
|
||||
'image-bundle': 'Image bundle',
|
||||
docx: 'DOCX',
|
||||
pptx: 'PPTX',
|
||||
};
|
||||
|
||||
class TemplateManager {
|
||||
constructor(templatesDir) {
|
||||
@@ -96,4 +110,4 @@ class TemplateManager {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TemplateManager, FORMATS };
|
||||
module.exports = { TemplateManager, FORMATS, FORMAT_LABELS };
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
'use strict';
|
||||
const { deepClone, htmlToText, escapeHtml } = require('./util');
|
||||
const { sanitizeHtml } = require('./sanitize');
|
||||
const {
|
||||
TEXTBLOCK_LEVELS,
|
||||
TEXTBLOCK_POSITIONS,
|
||||
normalizeTextBlock,
|
||||
normalizeCodeBlock,
|
||||
normalizeTableBlock,
|
||||
} = require('./schema');
|
||||
|
||||
const DEFAULT_CAPTURE_TITLES = {
|
||||
fullscreen: 'Screen capture',
|
||||
window: 'Window capture',
|
||||
region: 'Region capture',
|
||||
};
|
||||
|
||||
const AI_LEVEL_ALIASES = new Map([
|
||||
['note', 'info'],
|
||||
['info', 'info'],
|
||||
['tip', 'success'],
|
||||
['success', 'success'],
|
||||
['warning', 'warn'],
|
||||
['warn', 'warn'],
|
||||
['important', 'error'],
|
||||
['error', 'error'],
|
||||
]);
|
||||
|
||||
const GENERIC_OCR_PHRASES = new Set([
|
||||
'button',
|
||||
'click',
|
||||
'double click',
|
||||
'menu',
|
||||
'item',
|
||||
'field',
|
||||
'text field',
|
||||
'search',
|
||||
'submit',
|
||||
'cancel',
|
||||
'ok',
|
||||
'open',
|
||||
'select',
|
||||
'enter',
|
||||
'type',
|
||||
]);
|
||||
|
||||
// Generic OS/browser chrome titles that tell us nothing about what the user did.
|
||||
const GENERIC_WINDOW_TITLES = new Set([
|
||||
'new tab', 'new window', 'new incognito window', 'new incognito tab',
|
||||
'new document', 'untitled', 'blank page', 'home page', 'homepage',
|
||||
'start page', 'speed dial', 'loading', 'loading…', 'loading...',
|
||||
]);
|
||||
|
||||
const BROWSER_NAME_PHRASES = new Set([
|
||||
'google chrome',
|
||||
'chrome',
|
||||
'chromium',
|
||||
'microsoft edge',
|
||||
'edge',
|
||||
'brave',
|
||||
'firefox',
|
||||
'safari',
|
||||
'opera',
|
||||
'vivaldi',
|
||||
]);
|
||||
|
||||
// Known search engine page title suffixes (what appears after the query in the window title).
|
||||
const SEARCH_ENGINE_PAGE_NAMES = new Set([
|
||||
'google search',
|
||||
'google',
|
||||
'bing',
|
||||
'duckduckgo',
|
||||
'yahoo search',
|
||||
'yahoo',
|
||||
'startpage',
|
||||
'ecosia',
|
||||
'brave search',
|
||||
]);
|
||||
|
||||
// Common keyboard shortcuts → short action descriptions used as step titles.
|
||||
const SHORTCUT_TITLES = {
|
||||
'Ctrl+T': 'Open new tab',
|
||||
'Ctrl+N': 'Open new window',
|
||||
'Ctrl+W': 'Close tab',
|
||||
'Ctrl+Shift+T': 'Reopen closed tab',
|
||||
'Ctrl+Shift+N': 'Open incognito window',
|
||||
'Ctrl+S': 'Save',
|
||||
'Ctrl+Shift+S': 'Save as',
|
||||
'Ctrl+Z': 'Undo',
|
||||
'Ctrl+Y': 'Redo',
|
||||
'Ctrl+Shift+Z': 'Redo',
|
||||
'Ctrl+C': 'Copy selection',
|
||||
'Ctrl+V': 'Paste',
|
||||
'Ctrl+X': 'Cut selection',
|
||||
'Ctrl+A': 'Select all',
|
||||
'Ctrl+F': 'Open Find',
|
||||
'Ctrl+H': 'Open Find and Replace',
|
||||
'Ctrl+R': 'Reload page',
|
||||
'Ctrl+Shift+R': 'Hard reload page',
|
||||
'Ctrl+L': 'Focus address bar',
|
||||
'Ctrl+D': 'Bookmark page',
|
||||
'Ctrl+Tab': 'Switch to next tab',
|
||||
'Ctrl+Shift+Tab': 'Switch to previous tab',
|
||||
'Ctrl+Plus': 'Zoom in',
|
||||
'Ctrl+Minus': 'Zoom out',
|
||||
'Ctrl+0': 'Reset zoom',
|
||||
'Ctrl+P': 'Print',
|
||||
'Ctrl+O': 'Open file',
|
||||
'Ctrl+E': 'Focus search bar',
|
||||
'Ctrl+K': 'Focus search bar',
|
||||
'Ctrl+G': 'Go to line',
|
||||
'Ctrl+B': 'Toggle sidebar',
|
||||
'Ctrl+Shift+P': 'Open command palette',
|
||||
'Ctrl+Shift+E': 'Show file explorer',
|
||||
'Ctrl+Shift+G': 'Show source control',
|
||||
'Ctrl+Shift+D': 'Show debug panel',
|
||||
'Ctrl+Shift+X': 'Show extensions',
|
||||
'Alt+F4': 'Close window',
|
||||
'Alt+Left': 'Go back',
|
||||
'Alt+Right': 'Go forward',
|
||||
'Alt+Tab': 'Switch application',
|
||||
'F2': 'Rename',
|
||||
'F3': 'Find next',
|
||||
'F4': 'Open address bar',
|
||||
'F5': 'Reload page',
|
||||
'F11': 'Toggle fullscreen',
|
||||
'F12': 'Open developer tools',
|
||||
};
|
||||
|
||||
// Process name → human-readable display name (used to append "in Chrome" etc. to titles).
|
||||
const APP_DISPLAY_NAMES = {
|
||||
chrome: 'Chrome',
|
||||
msedge: 'Edge',
|
||||
firefox: 'Firefox',
|
||||
safari: 'Safari',
|
||||
opera: 'Opera',
|
||||
brave: 'Brave',
|
||||
vivaldi: 'Vivaldi',
|
||||
code: 'VS Code',
|
||||
cursor: 'Cursor',
|
||||
'sublime_text': 'Sublime Text',
|
||||
atom: 'Atom',
|
||||
notepad: 'Notepad',
|
||||
'notepad++': 'Notepad++',
|
||||
winword: 'Word',
|
||||
excel: 'Excel',
|
||||
powerpnt: 'PowerPoint',
|
||||
outlook: 'Outlook',
|
||||
teams: 'Teams',
|
||||
slack: 'Slack',
|
||||
discord: 'Discord',
|
||||
zoom: 'Zoom',
|
||||
figma: 'Figma',
|
||||
postman: 'Postman',
|
||||
insomnia: 'Insomnia',
|
||||
notion: 'Notion',
|
||||
obsidian: 'Obsidian',
|
||||
spotify: 'Spotify',
|
||||
terminal: 'Terminal',
|
||||
cmd: 'Command Prompt',
|
||||
powershell: 'PowerShell',
|
||||
windowsterminal: 'Windows Terminal',
|
||||
wt: 'Windows Terminal',
|
||||
iterm2: 'iTerm',
|
||||
wezterm: 'WezTerm',
|
||||
alacritty: 'Alacritty',
|
||||
kitty: 'Kitty',
|
||||
'gnome-terminal': 'Terminal',
|
||||
konsole: 'Konsole',
|
||||
xterm: 'Terminal',
|
||||
xfce4terminal: 'Terminal',
|
||||
bash: 'Terminal',
|
||||
zsh: 'Terminal',
|
||||
fish: 'Terminal',
|
||||
finder: 'Finder',
|
||||
explorer: 'File Explorer',
|
||||
'files-uwp': 'File Explorer',
|
||||
steam: 'Steam',
|
||||
'steamwebhelper': 'Steam',
|
||||
};
|
||||
|
||||
function cleanAppName(rawName) {
|
||||
if (!rawName) return '';
|
||||
const key = normalizeWhitespace(rawName).toLowerCase().replace(/\.exe$/i, '');
|
||||
return APP_DISPLAY_NAMES[key] || sentenceCase(rawName.replace(/\.exe$/i, ''));
|
||||
}
|
||||
|
||||
function qualifyTitleWithApp(title, appName) {
|
||||
const app = cleanAppName(appName);
|
||||
if (!app) return title;
|
||||
if (new RegExp(`\\b${app.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(title)) return title;
|
||||
return `${title} in ${app}`;
|
||||
}
|
||||
|
||||
const ACTION_PREFIXES = [
|
||||
'click',
|
||||
'select',
|
||||
'open',
|
||||
'choose',
|
||||
'enter',
|
||||
'type',
|
||||
'search',
|
||||
'switch to',
|
||||
'go to',
|
||||
'navigate to',
|
||||
'toggle',
|
||||
'turn on',
|
||||
'turn off',
|
||||
'enable',
|
||||
'disable',
|
||||
'pick',
|
||||
'focus',
|
||||
'launch',
|
||||
'activate',
|
||||
];
|
||||
|
||||
function normalizeWhitespace(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function titleCaseWord(word) {
|
||||
if (!word) return word;
|
||||
if (/^[A-Z0-9]{2,}$/.test(word)) return word;
|
||||
if (/^\d+$/.test(word)) return word;
|
||||
return word[0].toUpperCase() + word.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function displayText(text) {
|
||||
const clean = normalizeWhitespace(text)
|
||||
.replace(/^[\s"'`([{<]+|[\s"'`)}\]>.,;:!?]+$/g, '')
|
||||
.trim();
|
||||
if (!clean) return '';
|
||||
if (clean === clean.toUpperCase()) {
|
||||
return clean.split(/\s+/).map(titleCaseWord).join(' ');
|
||||
}
|
||||
return clean.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function sentenceCase(text) {
|
||||
const clean = displayText(text);
|
||||
if (!clean) return '';
|
||||
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
||||
}
|
||||
|
||||
function isPathOrUrlLike(text) {
|
||||
return /^(?:https?:\/\/|file:\/\/|about:blank|chrome:\/\/|edge:\/\/|moz-extension:\/\/|view-source:|localhost(?:[:/]|$)|www\.)/i.test(text) ||
|
||||
/[A-Za-z]:\\/.test(text) ||
|
||||
/\/(?:[^/\s]+\/){2,}/.test(text) ||
|
||||
/\\/.test(text);
|
||||
}
|
||||
|
||||
function isBrowserNoise(text) {
|
||||
const clean = normalizeWhitespace(text).toLowerCase();
|
||||
if (!clean) return true;
|
||||
if (BROWSER_NAME_PHRASES.has(clean)) return true;
|
||||
if (isPathOrUrlLike(clean)) return true;
|
||||
let foundBrowserName = false;
|
||||
for (const name of BROWSER_NAME_PHRASES) {
|
||||
if (clean.includes(name)) {
|
||||
foundBrowserName = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return foundBrowserName && /[\s|•·*]{2,}|[-–—]|\/|\\/.test(clean);
|
||||
}
|
||||
|
||||
function isUsefulTitleCandidate(text, { source = 'ocr' } = {}) {
|
||||
const clean = displayText(text);
|
||||
if (!clean) return false;
|
||||
const lower = clean.toLowerCase();
|
||||
if (GENERIC_OCR_PHRASES.has(lower)) return false;
|
||||
if (BROWSER_NAME_PHRASES.has(lower)) return false;
|
||||
if (isPathOrUrlLike(clean)) return false;
|
||||
if ((source === 'window' || source === 'app') && isBrowserNoise(clean)) return false;
|
||||
if (source === 'window' && GENERIC_WINDOW_TITLES.has(lower)) return false;
|
||||
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function splitTitleFragments(text) {
|
||||
const clean = normalizeWhitespace(text);
|
||||
if (!clean) return [];
|
||||
return clean
|
||||
.split(/\s*(?:\*\*+|[|•·]+|::|\/+|\\+|\s[-–—]\s|\s{2,})\s*/g)
|
||||
.map((part) => displayText(part))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function candidateWords(text) {
|
||||
const clean = normalizeWhitespace(text);
|
||||
if (!clean) return [];
|
||||
// Exclude standalone punctuation tokens (e.g. "|" in "Oracle | Cloud...") from word count.
|
||||
return clean.split(/\s+/).filter((w) => /[a-zA-Z0-9]/.test(w));
|
||||
}
|
||||
|
||||
// Remove trailing "- Google Chrome", "| Firefox", etc. from a window title.
|
||||
// When appName is supplied, also strips the specific app's display name suffix:
|
||||
// "Document1 - Word" → "Document1" when appName is "winword".
|
||||
function stripBrowserNameSuffix(text, appName) {
|
||||
let clean = normalizeWhitespace(text);
|
||||
// Always strip known browser names first.
|
||||
for (const name of BROWSER_NAME_PHRASES) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
clean = clean.replace(new RegExp(`\\s*[-–—·|•]\\s*${escaped}\\s*$`, 'i'), '').trim();
|
||||
}
|
||||
// Also strip the specific app's display name when provided.
|
||||
if (appName) {
|
||||
const display = cleanAppName(appName);
|
||||
const raw = normalizeWhitespace(appName).replace(/\.exe$/i, '');
|
||||
for (const name of [display, raw].filter(Boolean)) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
clean = clean.replace(new RegExp(`\\s*[-–—·|•]\\s*${escaped}\\s*$`, 'i'), '').trim();
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
// Detect "[query] - Google Search" or "[query] - Bing" patterns in a (already-stripped) page title.
|
||||
// Returns the query word(s) if found, otherwise ''.
|
||||
function extractSearchQuery(pageTitle) {
|
||||
const frags = splitTitleFragments(pageTitle);
|
||||
if (frags.length < 2) return '';
|
||||
const last = frags[frags.length - 1].toLowerCase();
|
||||
if (SEARCH_ENGINE_PAGE_NAMES.has(last)) {
|
||||
const query = frags[0];
|
||||
if (query && isUsefulTitleCandidate(query, { source: 'ocr' })) return query;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function scoreCandidate(text, { source = 'ocr' } = {}) {
|
||||
const clean = displayText(text);
|
||||
if (!clean) return -Infinity;
|
||||
const words = candidateWords(clean);
|
||||
if (!words.length) return -Infinity;
|
||||
let score = 0;
|
||||
score += source === 'ocr' ? 140 : source === 'element' ? 95 : source === 'window' ? 35 : source === 'app' ? 25 : 90;
|
||||
score += Math.min(words.length, 5) * 10;
|
||||
score -= Math.max(0, words.length - 5) * 11;
|
||||
score -= Math.max(0, clean.length - 42) * 0.8;
|
||||
if (GENERIC_OCR_PHRASES.has(clean.toLowerCase())) score -= 50;
|
||||
if (BROWSER_NAME_PHRASES.has(clean.toLowerCase())) score -= 80;
|
||||
if (isBrowserNoise(clean)) score -= 60;
|
||||
if (clean.length <= 24) score += 10;
|
||||
if (/^(click|select|open|choose|enter|type|search|switch to|go to|navigate to|toggle|turn on|turn off|enable|disable|pick|focus|launch|activate)\b/i.test(clean)) score += 12;
|
||||
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) score -= 100;
|
||||
return score;
|
||||
}
|
||||
|
||||
function pickBestOcrPhrase(ocrText) {
|
||||
const text = normalizeWhitespace(ocrText);
|
||||
if (!text) return '';
|
||||
let best = '';
|
||||
let bestScore = -Infinity;
|
||||
for (const rawLine of text.split(/\n+/)) {
|
||||
const line = normalizeWhitespace(rawLine);
|
||||
if (!line) continue;
|
||||
// For short lines (link text, button labels) try the FULL line first before splitting.
|
||||
// This preserves "Oracle | Cloud Applications and Cloud Platform" instead of splitting on |.
|
||||
// Full-line bonus (+35) nudges it ahead of its own fragments.
|
||||
const candidates = line.length <= 80
|
||||
? [[line, 35], ...splitTitleFragments(line).map((f) => [f, 0])]
|
||||
: splitTitleFragments(line).map((f) => [f, 0]);
|
||||
for (const [part, bonus] of candidates) {
|
||||
if (!isUsefulTitleCandidate(part, { source: 'ocr' })) continue;
|
||||
const score = scoreCandidate(part, { source: 'ocr' }) + bonus;
|
||||
if (score > bestScore) {
|
||||
best = part;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function isShortUiLabel(text) {
|
||||
const words = candidateWords(text);
|
||||
return words.length > 0 && words.length <= 2 && text.length <= 24;
|
||||
}
|
||||
|
||||
function isDirectiveTitle(text) {
|
||||
const clean = displayText(text);
|
||||
if (!clean) return false;
|
||||
const lower = clean.toLowerCase();
|
||||
return ACTION_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
}
|
||||
|
||||
function verbForElementRole(role) {
|
||||
const clean = normalizeWhitespace(role).toLowerCase();
|
||||
if (!clean) return null;
|
||||
if (/(tab|menu item|menuitem|option|list item|tree item|radio button|dropdown list|combo box option|hyperlink|link)/.test(clean)) {
|
||||
return 'Select';
|
||||
}
|
||||
if (/(search box|searchbox|search field|search bar|search input)/.test(clean)) {
|
||||
return 'Search for';
|
||||
}
|
||||
if (/(button|check box|checkbox|toggle button|switch|item|command)/.test(clean)) {
|
||||
return 'Click';
|
||||
}
|
||||
if (/(text field|edit|combo box|textbox|text box|input|field)/.test(clean)) {
|
||||
return 'Click';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCaptureTitle(text, { source = 'ocr', metadata = {} } = {}) {
|
||||
const clean = displayText(text);
|
||||
if (!clean) return '';
|
||||
|
||||
if (isDirectiveTitle(clean)) {
|
||||
return sentenceCase(clean);
|
||||
}
|
||||
|
||||
const roleVerb = (source === 'ocr' || source === 'element') ? verbForElementRole(metadata.elementRole) : null;
|
||||
if (roleVerb) {
|
||||
return `${roleVerb} ${sentenceCase(clean)}`;
|
||||
}
|
||||
|
||||
if (source === 'window' || source === 'app') {
|
||||
return `Open ${sentenceCase(clean)}`;
|
||||
}
|
||||
|
||||
if (source === 'ocr' || source === 'element') {
|
||||
return isShortUiLabel(clean) ? `Click ${sentenceCase(clean)}` : sentenceCase(clean);
|
||||
}
|
||||
|
||||
return sentenceCase(clean);
|
||||
}
|
||||
|
||||
function pickBestTitleFragment(text, { source = 'window', metadata = {} } = {}) {
|
||||
const fragments = splitTitleFragments(text).filter((line) => isUsefulTitleCandidate(line, { source }));
|
||||
if (!fragments.length) return '';
|
||||
let best = '';
|
||||
let bestScore = -Infinity;
|
||||
for (const part of fragments) {
|
||||
const score = scoreCandidate(part, { source });
|
||||
if (score > bestScore) {
|
||||
best = part;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best ? formatCaptureTitle(best, { source, metadata }) : '';
|
||||
}
|
||||
|
||||
function buildCaptureTitle({ mode = 'fullscreen', metadata = {}, ocrText = '', recentTyped = '', recentShortcut = '' } = {}) {
|
||||
const app = cleanAppName(metadata.appName);
|
||||
|
||||
// 1. Keyboard shortcut → most reliable signal for "what action did the user take".
|
||||
if (recentShortcut && SHORTCUT_TITLES[recentShortcut]) {
|
||||
const base = SHORTCUT_TITLES[recentShortcut];
|
||||
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
|
||||
}
|
||||
|
||||
// 2. UIAutomation element value — what's actually typed inside the clicked field.
|
||||
const elementValue = normalizeWhitespace(metadata.elementValue || '');
|
||||
if (elementValue) {
|
||||
const roleLower = normalizeWhitespace(metadata.elementRole || '').toLowerCase();
|
||||
const labelLower = normalizeWhitespace(metadata.elementLabel || '').toLowerCase();
|
||||
const looksLikeSearch = /(search|find|query|omnibox|address bar)/.test(roleLower + ' ' + labelLower);
|
||||
const action = looksLikeSearch ? 'Search for' : 'Type';
|
||||
const base = `${action} "${elementValue}"`;
|
||||
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
|
||||
}
|
||||
|
||||
// 3. Keyboard-buffer text (typed between captures) + input role context.
|
||||
const typed = normalizeWhitespace(recentTyped || '');
|
||||
if (typed) {
|
||||
const roleLower = normalizeWhitespace(metadata.elementRole || '').toLowerCase();
|
||||
const labelLower = normalizeWhitespace(metadata.elementLabel || '').toLowerCase();
|
||||
const isSearchRole = /(search box|searchbox|search field|search bar|search input)/.test(roleLower);
|
||||
const looksLikeSearch = isSearchRole || /(search|find|query|omnibox|address bar)/.test(roleLower + ' ' + labelLower);
|
||||
const isAnyInput = /(text field|edit|input|field|combo box|textbox|text box)/.test(roleLower);
|
||||
if (looksLikeSearch) {
|
||||
const base = `Search for "${typed}"`;
|
||||
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
|
||||
}
|
||||
if (isAnyInput) {
|
||||
const base = `Type "${typed}"`;
|
||||
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. OCR text around the click — link text, button labels, menu items.
|
||||
const ocrPhrase = pickBestOcrPhrase(ocrText);
|
||||
if (ocrPhrase) {
|
||||
const title = formatCaptureTitle(ocrPhrase, { source: 'ocr', metadata });
|
||||
return app ? qualifyTitleWithApp(title, metadata.appName) : title;
|
||||
}
|
||||
|
||||
// 5. UIAutomation element label.
|
||||
const elementPhrase = pickBestTitleFragment(metadata.elementLabel, { source: 'element', metadata });
|
||||
if (elementPhrase) {
|
||||
return app ? qualifyTitleWithApp(elementPhrase, metadata.appName) : elementPhrase;
|
||||
}
|
||||
|
||||
// 6. Window title (browser suffix + app name stripped) → page title or search query.
|
||||
const strippedWindowTitle = stripBrowserNameSuffix(metadata.windowTitle || '', metadata.appName);
|
||||
if (strippedWindowTitle) {
|
||||
const searchQuery = extractSearchQuery(strippedWindowTitle);
|
||||
if (searchQuery) {
|
||||
// Only claim this step IS the search action when the user was actually typing
|
||||
// (recentTyped). Without typing context, the search page title is from the
|
||||
// PREVIOUS step — the current step is a click ON the search results page.
|
||||
if (recentTyped) {
|
||||
const base = `Search for ${sentenceCase(searchQuery)}`;
|
||||
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
|
||||
}
|
||||
// User is clicking something on the search results page — don't claim they searched.
|
||||
const base = `Select a ${sentenceCase(searchQuery)} result`;
|
||||
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
|
||||
}
|
||||
const windowPhrase = pickBestTitleFragment(strippedWindowTitle, { source: 'window', metadata });
|
||||
if (windowPhrase) return windowPhrase;
|
||||
}
|
||||
|
||||
// 7. App name alone as last resort.
|
||||
const appPhrase = pickBestTitleFragment(metadata.appName, { source: 'app', metadata });
|
||||
if (appPhrase) return appPhrase;
|
||||
|
||||
return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
|
||||
}
|
||||
|
||||
function plainTextToHtml(text) {
|
||||
const trimmed = normalizeWhitespace(text);
|
||||
if (!trimmed) return '';
|
||||
return trimmed
|
||||
.split(/\n{2,}/)
|
||||
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function normalizeOllamaHost(host) {
|
||||
const raw = normalizeWhitespace(host);
|
||||
if (!raw) return '';
|
||||
if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '');
|
||||
return `http://${raw.replace(/\/+$/, '')}`;
|
||||
}
|
||||
|
||||
function normalizeAiLevel(level) {
|
||||
const key = normalizeWhitespace(level).toLowerCase();
|
||||
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
||||
}
|
||||
|
||||
function normalizeAiPosition(position) {
|
||||
const key = normalizeWhitespace(position).toLowerCase();
|
||||
return TEXTBLOCK_POSITIONS.includes(key) ? key : 'after-description';
|
||||
}
|
||||
|
||||
function normalizeAiBlock(block) {
|
||||
if (!block || typeof block !== 'object') return null;
|
||||
const kind = normalizeWhitespace(block.kind).toLowerCase();
|
||||
if (kind === 'text') {
|
||||
const normalized = normalizeTextBlock({
|
||||
id: block.id,
|
||||
order: Number.isFinite(block.order) ? block.order : null,
|
||||
position: normalizeAiPosition(block.position),
|
||||
level: normalizeAiLevel(block.level),
|
||||
title: displayText(block.title),
|
||||
descriptionHtml: plainTextToHtml(block.body ?? block.description ?? block.text ?? ''),
|
||||
}, Number.isFinite(block.order) ? block.order : null);
|
||||
return { ...normalized, kind: 'text' };
|
||||
}
|
||||
if (kind === 'code') {
|
||||
return {
|
||||
...normalizeCodeBlock({
|
||||
id: block.id,
|
||||
order: Number.isFinite(block.order) ? block.order : null,
|
||||
language: displayText(block.language).toLowerCase(),
|
||||
code: String(block.code ?? ''),
|
||||
}, Number.isFinite(block.order) ? block.order : null),
|
||||
kind: 'code',
|
||||
};
|
||||
}
|
||||
if (kind === 'table') {
|
||||
const rows = Array.isArray(block.rows)
|
||||
? block.rows.map((row) => (Array.isArray(row) ? row.map((cell) => displayText(cell)) : []))
|
||||
: [];
|
||||
return {
|
||||
...normalizeTableBlock({
|
||||
id: block.id,
|
||||
order: Number.isFinite(block.order) ? block.order : null,
|
||||
rows,
|
||||
}, Number.isFinite(block.order) ? block.order : null),
|
||||
kind: 'table',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeAiPatch(raw) {
|
||||
let data = raw;
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
const jsonText = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
|
||||
data = JSON.parse(jsonText);
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error('AI response must be a JSON object');
|
||||
}
|
||||
const out = {
|
||||
title: displayText(data.title),
|
||||
descriptionHtml: plainTextToHtml(data.description ?? data.descriptionText ?? ''),
|
||||
blocks: Array.isArray(data.blocks)
|
||||
? data.blocks.map((block) => normalizeAiBlock(block)).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeBlocks(step = {}) {
|
||||
const parts = [];
|
||||
for (const block of step.textBlocks || []) {
|
||||
const body = htmlToText(block.descriptionHtml || '');
|
||||
parts.push(`- Text (${block.level || 'info'}, ${block.position || 'after-description'}): ${block.title || ''}${body ? ` — ${body}` : ''}`.trim());
|
||||
}
|
||||
for (const block of step.codeBlocks || []) {
|
||||
const code = String(block.code || '').trim();
|
||||
parts.push(`- Code (${block.language || 'plain'}):\n${code || '(empty)'}`);
|
||||
}
|
||||
for (const block of step.tableBlocks || []) {
|
||||
const rows = Array.isArray(block.rows) ? block.rows.length : 0;
|
||||
const cols = rows > 0 && Array.isArray(block.rows[0]) ? block.rows[0].length : 0;
|
||||
parts.push(`- Table (${rows}x${cols})`);
|
||||
}
|
||||
return parts.length ? parts.join('\n') : '(none)';
|
||||
}
|
||||
|
||||
const DEFAULT_PLACEHOLDER_TITLES = new Set(
|
||||
Object.values(DEFAULT_CAPTURE_TITLES).concat(['Capture', 'Untitled step']),
|
||||
);
|
||||
|
||||
function isPlaceholderTitle(title) {
|
||||
return !title || DEFAULT_PLACEHOLDER_TITLES.has(title);
|
||||
}
|
||||
|
||||
function summarizeStepForAi(step = {}) {
|
||||
const titleLine = isPlaceholderTitle(step.title)
|
||||
? 'Step title: (not set — generate a specific action title from the capture context)'
|
||||
: `Step title: ${step.title}`;
|
||||
const descText = htmlToText(step.descriptionHtml || '');
|
||||
return [
|
||||
titleLine,
|
||||
`Step description: ${descText || '(empty)'}`,
|
||||
`Step status: ${step.status || 'todo'}`,
|
||||
`Blocks:\n${summarizeBlocks(step)}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeGuideForAi(guide = {}) {
|
||||
return [
|
||||
`Guide title: ${guide.title || '(untitled)'}`,
|
||||
`Guide description: ${htmlToText(guide.descriptionHtml || '') || '(empty)'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function hasRichCaptureContext(captureContext) {
|
||||
if (!captureContext) return false;
|
||||
const ocr = normalizeWhitespace(captureContext.ocrText || '');
|
||||
const win = normalizeWhitespace(captureContext.windowTitle || '');
|
||||
const app = normalizeWhitespace(captureContext.appName || '');
|
||||
const element = normalizeWhitespace(captureContext.elementLabel || '');
|
||||
// Any non-trivial context signal is enough — even just an app name.
|
||||
return ocr.length > 3 || win.length > 2 || app.length > 1 || element.length > 1;
|
||||
}
|
||||
|
||||
function buildAiPrompt({
|
||||
target = 'all',
|
||||
guide = null,
|
||||
step = null,
|
||||
captureContext = null,
|
||||
block = null,
|
||||
screenshotAttached = false,
|
||||
} = {}) {
|
||||
const hasDraftTitle = step && !isPlaceholderTitle(step.title);
|
||||
const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || ''));
|
||||
|
||||
const targetText = {
|
||||
title: hasDraftTitle
|
||||
? 'improve the user\'s draft step title — keep their intent, make it read like professional documentation'
|
||||
: 'write a specific action title for this step using the capture context',
|
||||
description: hasDraftDesc
|
||||
? 'improve the user\'s draft description — keep their intent, make it read like professional documentation'
|
||||
: 'write a 1–2 sentence description of what the user does in this step, using the capture context',
|
||||
block: 'rewrite only the target block',
|
||||
all: 'write the step title and description from the capture context',
|
||||
}[target] || 'rewrite the step';
|
||||
|
||||
const richContext = hasRichCaptureContext(captureContext);
|
||||
|
||||
const allowedBlockNote = target === 'block' ? [
|
||||
'Use block.kind = "text" with level in [info, warn, error, success] for note / warning / important / tip blocks.',
|
||||
'Use block.kind = "code" for code snippets.',
|
||||
'Use block.kind = "table" for tables, with rows as arrays of strings.',
|
||||
'Use block.position values from [before-title, after-title, before-image, after-image, before-description, after-description].',
|
||||
].join(' ') : null;
|
||||
|
||||
// When the user already has a draft, surface it prominently so the model
|
||||
// knows exactly what text to polish rather than generating from scratch.
|
||||
const descText = htmlToText(step?.descriptionHtml || '');
|
||||
const draftTitleLine = hasDraftTitle && (target === 'title' || target === 'all')
|
||||
? `User's draft title (rewrite this): "${step.title}"` : null;
|
||||
const draftDescLine = hasDraftDesc && (target === 'description' || target === 'all')
|
||||
? `User's draft description (rewrite this): "${descText}"` : null;
|
||||
|
||||
const contextLines = [
|
||||
...(captureContext ? [
|
||||
captureContext.windowTitle ? `Active window: ${captureContext.windowTitle}` : null,
|
||||
captureContext.appName ? `App: ${captureContext.appName}` : null,
|
||||
captureContext.elementLabel ? `UI element: ${captureContext.elementLabel}${captureContext.elementRole ? ` (${captureContext.elementRole})` : ''}` : null,
|
||||
captureContext.elementValue ? `Element content (what was typed): ${captureContext.elementValue}` : null,
|
||||
captureContext.recentTyped ? `Keyboard input before this step: ${captureContext.recentTyped}` : null,
|
||||
captureContext.recentShortcut ? `Keyboard shortcut used: ${captureContext.recentShortcut}` : null,
|
||||
captureContext.ocrText ? `OCR text near click:\n${captureContext.ocrText}` : null,
|
||||
(!hasDraftTitle || target === 'description') && captureContext.titleCandidate
|
||||
? `Suggested title: ${captureContext.titleCandidate}` : null,
|
||||
] : []),
|
||||
screenshotAttached ? 'Screenshot: attached to this request.' : null,
|
||||
draftTitleLine,
|
||||
draftDescLine,
|
||||
].filter(Boolean);
|
||||
|
||||
const prompt = [
|
||||
'You write concise, action-focused step-by-step documentation for a desktop application guide.',
|
||||
'Return JSON only. No markdown fences, no commentary, no extra keys outside the schema below.',
|
||||
'Schema:',
|
||||
target === 'block' ? [
|
||||
'{',
|
||||
' "title": string,',
|
||||
' "description": string,',
|
||||
' "blocks": [{',
|
||||
' "kind": "text" | "code" | "table",',
|
||||
' "position"?: "before-title" | "after-title" | "before-image" | "after-image" | "before-description" | "after-description",',
|
||||
' "level"?: "info" | "warn" | "error" | "success",',
|
||||
' "title"?: string,',
|
||||
' "body"?: string,',
|
||||
' "language"?: string,',
|
||||
' "code"?: string,',
|
||||
' "rows"?: string[][]',
|
||||
' }]',
|
||||
'}',
|
||||
].join('\n') : '{ "title": string, "description": string }',
|
||||
'',
|
||||
`Target: ${targetText}.`,
|
||||
allowedBlockNote,
|
||||
'',
|
||||
guide ? summarizeGuideForAi(guide) : 'Guide: (not provided)',
|
||||
'',
|
||||
step ? summarizeStepForAi(step) : 'Step: (not provided)',
|
||||
'',
|
||||
contextLines.length
|
||||
? `Capture context:\n${contextLines.join('\n')}`
|
||||
: 'Capture context: (not available)',
|
||||
'',
|
||||
block ? `Target block:\n${JSON.stringify(block, null, 2)}` : null,
|
||||
'',
|
||||
'Rules:',
|
||||
'- Titles must be short imperative actions: "Click Save", "Select New document", "Open Settings".',
|
||||
'- NEVER output "Screen capture", "Window capture", "Region capture", or "Capture" as a title — always produce something specific.',
|
||||
hasDraftTitle && (target === 'title' || target === 'all')
|
||||
? '- The user wrote their own title (shown above). Your only job is to polish its grammar and phrasing. Do NOT replace it with something different. Do NOT change what action or subject it describes.'
|
||||
: '- No title yet. Use the capture context (OCR text, window, app) to write a specific action title.',
|
||||
hasDraftDesc && (target === 'description' || target === 'all')
|
||||
? '- The user wrote their own description (shown above). Polish the wording to sound professional but preserve every fact and intent they stated.'
|
||||
: '- No description yet. Write 1–2 sentences describing exactly what the user does.',
|
||||
target === 'block'
|
||||
? '- Only include blocks that provide genuinely useful supplemental information (warnings, tips, code).'
|
||||
: '- Do NOT add any blocks array. Only output "title" and "description".',
|
||||
richContext
|
||||
? '- Use the OCR text, window title, app name, and element info to make the documentation specific.'
|
||||
: '- Context is limited. Use the app name or window title if available; generate a reasonable action title.',
|
||||
screenshotAttached
|
||||
? '- A screenshot is attached. Use it together with the OCR and metadata to resolve visual details, but do not mention the screenshot in the output.'
|
||||
: '- No screenshot is attached. Rely on OCR, the window title, app name, and element info.',
|
||||
'- Do NOT generate blocks that describe the technical capture process or mention OCR.',
|
||||
'- Do NOT invent details not supported by the capture context.',
|
||||
'- If the target is one block, only rewrite that block.',
|
||||
].filter((l) => l !== null).join('\n');
|
||||
|
||||
return {
|
||||
systemPrompt: 'You are a technical documentation writer. Emit only valid JSON matching the schema. Never add commentary or markdown.',
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAiPatchToStep(step, patch, { target = 'all', blockId = null } = {}) {
|
||||
const next = deepClone(step);
|
||||
if ((target === 'all' || target === 'title') && patch.title) {
|
||||
next.title = displayText(patch.title);
|
||||
}
|
||||
if ((target === 'all' || target === 'description') && patch.descriptionHtml) {
|
||||
next.descriptionHtml = sanitizeHtml(patch.descriptionHtml);
|
||||
}
|
||||
|
||||
if (target === 'all' && Array.isArray(patch.blocks) && patch.blocks.length) {
|
||||
const textBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const tableBlocks = [];
|
||||
let nextOrder = 1;
|
||||
for (const block of patch.blocks) {
|
||||
const clone = deepClone(block);
|
||||
clone.order = nextOrder++;
|
||||
if (clone.kind === 'text') textBlocks.push(clone);
|
||||
else if (clone.kind === 'code') codeBlocks.push(clone);
|
||||
else if (clone.kind === 'table') tableBlocks.push(clone);
|
||||
}
|
||||
next.textBlocks = textBlocks;
|
||||
next.codeBlocks = codeBlocks;
|
||||
next.tableBlocks = tableBlocks;
|
||||
} else if (target === 'block' && blockId && Array.isArray(patch.blocks) && patch.blocks.length) {
|
||||
const replacement = patch.blocks[0];
|
||||
const textBlock = (next.textBlocks || []).find((block) => block.id === blockId);
|
||||
const codeBlock = (next.codeBlocks || []).find((block) => block.id === blockId);
|
||||
const tableBlock = (next.tableBlocks || []).find((block) => block.id === blockId);
|
||||
if (textBlock && replacement.kind === 'text') {
|
||||
if (replacement.position) textBlock.position = replacement.position;
|
||||
if (replacement.level) textBlock.level = replacement.level;
|
||||
if (replacement.title) textBlock.title = replacement.title;
|
||||
if (replacement.descriptionHtml) textBlock.descriptionHtml = sanitizeHtml(replacement.descriptionHtml);
|
||||
} else if (codeBlock && replacement.kind === 'code') {
|
||||
if (replacement.language) codeBlock.language = replacement.language;
|
||||
if (replacement.code) codeBlock.code = replacement.code;
|
||||
} else if (tableBlock && replacement.kind === 'table') {
|
||||
if (replacement.rows) tableBlock.rows = replacement.rows;
|
||||
}
|
||||
}
|
||||
if (!next.image) {
|
||||
const hasBody = Boolean(
|
||||
next.title ||
|
||||
htmlToText(next.descriptionHtml || '') ||
|
||||
(next.textBlocks || []).length ||
|
||||
(next.codeBlocks || []).length ||
|
||||
(next.tableBlocks || []).length,
|
||||
);
|
||||
if (hasBody) next.kind = 'content';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CAPTURE_TITLES,
|
||||
buildCaptureTitle,
|
||||
plainTextToHtml,
|
||||
normalizeOllamaHost,
|
||||
normalizeAiPatch,
|
||||
buildAiPrompt,
|
||||
applyAiPatchToStep,
|
||||
summarizeStepForAi,
|
||||
summarizeGuideForAi,
|
||||
displayText,
|
||||
normalizeWhitespace,
|
||||
scoreCandidate,
|
||||
pickBestOcrPhrase,
|
||||
};
|
||||
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
// Matches literal markdown-style links typed in the description editor,
|
||||
// e.g. "[Settings](https://example.com)". Excludes < > so it never spans
|
||||
// across an existing HTML tag boundary.
|
||||
const MD_LINK_RE = /\[([^[\]<>]*)\]\(([^()<>]+)\)/g;
|
||||
|
||||
/**
|
||||
* Turn literal "[text](url)" markdown link syntax (as inserted by the
|
||||
* description editor's Link button) into real <a href> tags, so exporters
|
||||
* that consume HTML render an actual link instead of the raw brackets.
|
||||
*/
|
||||
function linkifyMarkdownLinks(html) {
|
||||
return String(html || '').replace(MD_LINK_RE, (m, label, href) => `<a href="${escapeHtml(href)}">${label}</a>`);
|
||||
}
|
||||
|
||||
/** Filesystem-safe slug for export folder names like steps-<title>. */
|
||||
function slugify(text, fallback = 'untitled') {
|
||||
const slug = String(text || '')
|
||||
@@ -116,6 +130,7 @@ module.exports = {
|
||||
readJsonSync,
|
||||
readJsonIfExists,
|
||||
htmlToText,
|
||||
linkifyMarkdownLinks,
|
||||
decodeEntities,
|
||||
escapeHtml,
|
||||
escapeXml,
|
||||
|
||||
@@ -81,6 +81,7 @@ guide.json + step.json + settings
|
||||
▼ hidden/skipped, focused-view geometry)
|
||||
Render AST ──► exporters/json.js .json + steps-<title>/ images
|
||||
──► exporters/markdown.js .md + steps-<title>/ images
|
||||
──► exporters/wikijs.js .md + steps-<title>/ images
|
||||
──► exporters/html-simple.js single self-contained .html
|
||||
──► exporters/html-rich.js checkboxes + floating TOC
|
||||
──► exporters/pdf.js native PDF writer (core/pdf.js)
|
||||
|
||||
@@ -96,14 +96,14 @@ Initial release.
|
||||
Existing Workspace (guide library), and Settings. The brand button
|
||||
returns to the welcome screen from any view.
|
||||
- Capture menu in the editor topbar: full screen / window / region /
|
||||
3-second delay, paste image as step, import images, and capture
|
||||
session start/finish — capture no longer requires the global hotkey.
|
||||
paste image as step, import images, and capture session start/finish
|
||||
— capture no longer requires the global hotkey.
|
||||
- Continuous capture sessions: steps are grabbed on every OS click where
|
||||
the platform supports it (xinput on X11, PowerShell on Windows), with
|
||||
interval auto-capture (3/5/10 s) as the always-works fallback when
|
||||
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
|
||||
The REC bar shows the live count and trigger, with Shoot / Auto /
|
||||
Pause / Finish controls.
|
||||
The REC bar shows the live count and trigger, with the start/pause
|
||||
control.
|
||||
- Recording sessions tuck the window away once and control everything
|
||||
from a red tray icon (capture now / pause / open / finish) instead of
|
||||
hiding the window for every shot — the app stays reachable
|
||||
@@ -161,7 +161,7 @@ Initial release.
|
||||
session now starts paused and the window only tucks away once the user
|
||||
presses "Start recording" in the capture bar, so the app doesn't vanish
|
||||
out from under you.
|
||||
- The capture status bar (REC count / Shoot / Auto / Pause / Finish) is
|
||||
- The capture status bar (REC count / start/pause control) is
|
||||
now shown only in the editor view; it no longer appears over the
|
||||
library when a session is still running in the background.
|
||||
- Click-triggered captures now grab the cursor position at the instant of
|
||||
|
||||
@@ -16,9 +16,8 @@ filing issues — is expected to:
|
||||
|
||||
Maintainers may edit, hide, or remove comments, commits, issues, and PRs that
|
||||
violate this standard, and may temporarily or permanently ban contributors
|
||||
for repeated or egregious behavior.
|
||||
for egregious behavior. We may do this at any time without warning and without chance for appeal.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report unacceptable behavior privately to the maintainers via the contact
|
||||
listed on the project page. Reports are handled confidentially.
|
||||
Report unacceptable behavior privately to the maintainers via `[email protected]`. Reports are handled confidentially.
|
||||
|
||||
@@ -29,9 +29,10 @@ Origin sign-off (`git commit -s`).
|
||||
## Offline Rules
|
||||
|
||||
- No network code paths in the application. No telemetry, update checks,
|
||||
license checks, remote fonts, or remote APIs — ever.
|
||||
license checks, remote fonts, or remote APIs — **ever**.
|
||||
- No new runtime dependencies without prior maintainer agreement; prefer
|
||||
internal implementations using Node built-ins.
|
||||
internal implementations using Node built-ins. This is due to all the
|
||||
security issues that have arrose lately with NPM dependencies.
|
||||
|
||||
## Branching
|
||||
|
||||
@@ -46,8 +47,7 @@ Origin sign-off (`git commit -s`).
|
||||
`Closes #123`, `Fixes #123`, or `Relates to #123`.
|
||||
- Summarize the change clearly and call out anything a reviewer should
|
||||
verify manually.
|
||||
- Update docs when behavior changes, and add a CHANGELOG entry for every
|
||||
user-visible change.
|
||||
- Update docs when behavior changes.
|
||||
- Every exporter or storage change **requires tests**; output changes
|
||||
require updated snapshot fixtures under `tests/fixtures/`.
|
||||
|
||||
@@ -64,10 +64,9 @@ automatically. The shell checks invoke the `node --test` workflow suites in
|
||||
`tests/unit/`.
|
||||
|
||||
Write tests that exercise **real workflows and verify actual output** —
|
||||
create a guide, export it, parse the bytes that came out — rather than
|
||||
grepping for magic strings.
|
||||
create a guide, export it, parse the bytes that came out. DO NOT WRITE A TEST THAT GREPS FOR CODE.
|
||||
|
||||
The Gitea workflow in `.gitea/workflows/tests.yaml` runs the same command
|
||||
The Gitea workflow in `.gitea/workflows/tests.yaml` and `.github/workflows/ci.yaml` runs the same command
|
||||
automatically on pushes and pull requests.
|
||||
|
||||
Please add lots of tests to each of your PR's and be descriptive with the
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
StepForge is a fully offline desktop app. Nothing is uploaded or synced, and
|
||||
all guides stay on your machine.
|
||||
|
||||
# Windows installation
|
||||
|
||||
For the windows installation, please see [windows_installation](windows_installation.md)
|
||||
|
||||
# Developer install
|
||||
|
||||
## 1. Install
|
||||
|
||||
From the repository root:
|
||||
@@ -28,7 +34,7 @@ usually under `~/.local/share/stepforge`. On Windows it is usually under
|
||||
In the library view:
|
||||
|
||||
1. Click `New guide`.
|
||||
2. Give the guide a clear title.
|
||||
2. Give the guide a clear title (more -> rename guide).
|
||||
3. Open the guide to enter the editor.
|
||||
|
||||
You can also import a guide archive with `Import archive` if you already have
|
||||
@@ -36,10 +42,11 @@ one.
|
||||
|
||||
## 4. Add content
|
||||
|
||||
There are two simple ways to start:
|
||||
There are three simple ways to start:
|
||||
|
||||
1. Import screenshots with the `Import` button in the editor.
|
||||
2. Paste an image from the clipboard if you already copied one.
|
||||
1. Record your workflow by clicking on the record button (reconmmended).
|
||||
2. Import screenshots with the `Import` button in the editor.
|
||||
3. Paste an image from the clipboard if you already copied one.
|
||||
|
||||
If you want to capture new screenshots, open `Quick` actions and start a
|
||||
capture session. Use `Settings` to set the capture hotkey and other capture
|
||||
@@ -50,7 +57,7 @@ options.
|
||||
The editor is split into three panes:
|
||||
|
||||
1. Steps on the left
|
||||
2. Canvas in the center
|
||||
2. Editing canvas in the center
|
||||
3. Properties on the right
|
||||
|
||||
Use the canvas tools to add shapes, arrows, text, blur, highlight, numbers,
|
||||
@@ -86,6 +93,6 @@ If you want to find commands quickly, press `Ctrl+/` for Quick Actions.
|
||||
## Optional builds
|
||||
|
||||
1. `bash scripts/build-release.sh` assembles the offline release layout.
|
||||
2. `npm run package:windows` creates the portable Windows `.exe` in
|
||||
2. `npm run package:windows` creates the Windows installer `.exe` in
|
||||
`releases/`.
|
||||
3. `bash scripts/package-linux.sh` creates Linux release artifacts.
|
||||
|
||||
@@ -1,373 +1,9 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
Creative Commons Attribution-NonCommercial
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
Copyright (c) 2026 Tyler Westbrook
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
Permission is granted to use, copy, modify, and distribute this software for non-commercial purposes only.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
You may not sell this software, sell modified versions of it, or use it as part of a commercial product or service without written permission from the copyright holder.
|
||||
|
||||
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 http://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.
|
||||
This software is provided "as is", without warranty of any kind.
|
||||
@@ -64,6 +64,5 @@ URLs) before storage and again before rendering or export.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report vulnerabilities by opening an issue marked `security` (this is a
|
||||
local-only tool, so coordinated disclosure pressure is low; do not include
|
||||
exploit archives directly — describe the structure instead).
|
||||
Report vulnerabilities by sending an email to `[email protected]`
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Getting Started With AI
|
||||
|
||||
StepForge keeps AI local. It talks to your own Ollama server on your machine and does not send guide content to the cloud.
|
||||
|
||||
## 1. Install Ollama
|
||||
|
||||
Install Ollama from https://ollama.com and make sure the service is running.
|
||||
|
||||
On most systems you can verify it with:
|
||||
|
||||
```bash
|
||||
ollama --version
|
||||
```
|
||||
|
||||
## 2. Pull a lightweight model
|
||||
|
||||
The recommended default is:
|
||||
|
||||
```bash
|
||||
ollama pull llama3.2:1b
|
||||
```
|
||||
|
||||
That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks.
|
||||
|
||||
If you want StepForge to send the screenshot itself to the model, pull a vision-capable model instead:
|
||||
|
||||
```bash
|
||||
ollama pull gemma3
|
||||
```
|
||||
|
||||
That model can inspect pictures as well as text, so it is better when you want the AI to read the UI directly from the screenshot.
|
||||
|
||||
If you need something even smaller, try:
|
||||
|
||||
```bash
|
||||
ollama pull qwen3:0.6b
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```bash
|
||||
ollama pull gemma3:270m
|
||||
```
|
||||
|
||||
Those are lighter, but they are usually weaker at writing polished step text.
|
||||
|
||||
## 3. Open StepForge settings
|
||||
|
||||
In StepForge, open `Settings` and find the `AI` section.
|
||||
|
||||
Set:
|
||||
|
||||
* `Enable AI text filling` to on
|
||||
* `Ollama host` to your local Ollama server
|
||||
* `Ollama model` to `llama3.2:1b` for text-only mode, or `llama3.2-vision` if you want screenshot-aware AI
|
||||
|
||||
The default host is:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:11434
|
||||
```
|
||||
|
||||
## 4. Test the connection
|
||||
|
||||
Use the `Test connection` button in the AI settings section.
|
||||
|
||||
If the model is installed, StepForge should confirm the host and model.
|
||||
|
||||
## 5. Use AI manually
|
||||
|
||||
AI is never automatic. After capture, use the `AI` button next to:
|
||||
|
||||
* the step title
|
||||
* the step description
|
||||
* each text, code, and table block
|
||||
|
||||
You can also use `More -> Generate all text fields with AI` to fill the whole step in one pass.
|
||||
|
||||
## Notes
|
||||
|
||||
* Capture titles are still generated automatically without AI.
|
||||
* AI generation only works when `Enable AI text filling` is turned on.
|
||||
* The app always uses local OCR around the click area first, then local AI only when you ask for it.
|
||||
* When the selected Ollama model supports vision, StepForge also sends the screenshot to the model so it can cross-check OCR and visual context.
|
||||
|
After Width: | Height: | Size: 8.8 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 616 KiB |
|
After Width: | Height: | Size: 603 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 939 KiB |
|
After Width: | Height: | Size: 867 KiB |
|
After Width: | Height: | Size: 997 KiB |
|
After Width: | Height: | Size: 768 KiB |
@@ -0,0 +1,100 @@
|
||||
# Windows_installation
|
||||
|
||||
<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>
|
||||
|
||||
Author: Tyler Westbrook
|
||||
|
||||
*10 steps · generated 2026-06-23*
|
||||
|
||||
Welcome to StepForge the easy documentation writer! This guide shows you how to setup StepForge on your windows computer step by step. As a demo of this project, this guide was 100% made with StepForge with no outside editing. Enjoy!
|
||||
|
||||
## Contents
|
||||
|
||||
- [1. Navigate to the git repo.](#step-1)
|
||||
- [1.1. Click on releases](#step-1-1)
|
||||
- [1.1.1. Select the latest release for StepForge](#step-1-1-1)
|
||||
- [2. Run the installer.](#step-2)
|
||||
- [2.1. Click More Info](#step-2-1)
|
||||
- [2.1.1. Select Run anyway](#step-2-1-1)
|
||||
- [2.2. Select install for me or install for all users](#step-2-2)
|
||||
- [2.3. Select next](#step-2-3)
|
||||
- [2.4. Select install](#step-2-4)
|
||||
- [2.5. Select Finish](#step-2-5)
|
||||
|
||||
<a id="step-1"></a>
|
||||
|
||||
## 1. Navigate to the git repo.
|
||||
|
||||
Navigate to the [Github Repository](https://github.com/Twest2/StepForge) located in the link.
|
||||
|
||||

|
||||
|
||||
<a id="step-1-1"></a>
|
||||
|
||||
### 1.1. Click on releases
|
||||
|
||||

|
||||
|
||||
<a id="step-1-1-1"></a>
|
||||
|
||||
### 1.1.1. Select the latest release for StepForge
|
||||
|
||||

|
||||
|
||||
<div class="sf-callout sf-callout-note" style="border-left: 4px solid #2563eb; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
|
||||
<div style="font-weight: 700; color: #1d4ed8; margin-bottom: 6px;">Note: Newest version</div>
|
||||
<div style="color: inherit;"><p>Please make sure you select the newest version of StepForge, not v0.2.0. The .exe is an installer that will install the program for you.</p></div>
|
||||
</div>
|
||||
|
||||
<a id="step-2"></a>
|
||||
|
||||
## 2. Run the installer.
|
||||
|
||||

|
||||
|
||||
<a id="step-2-1"></a>
|
||||
|
||||
### 2.1. Click More Info
|
||||
|
||||

|
||||
|
||||
<div class="sf-callout sf-callout-important" style="border-left: 4px solid #ef4444; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
|
||||
<div style="font-weight: 700; color: #b91c1c; margin-bottom: 6px;">Important: Select More Info</div>
|
||||
<div style="color: inherit;"><p>Windows is warning you because this installer is new and hasn’t built up enough Microsoft SmartScreen reputation yet.</p></div>
|
||||
</div>
|
||||
|
||||
<a id="step-2-1-1"></a>
|
||||
|
||||
### 2.1.1. Select Run anyway
|
||||
|
||||

|
||||
|
||||
<a id="step-2-2"></a>
|
||||
|
||||
### 2.2. Select install for me or install for all users
|
||||
|
||||

|
||||
|
||||
<a id="step-2-3"></a>
|
||||
|
||||
### 2.3. Select next
|
||||
|
||||

|
||||
|
||||
<a id="step-2-4"></a>
|
||||
|
||||
### 2.4. Select install
|
||||
|
||||

|
||||
|
||||
<a id="step-2-5"></a>
|
||||
|
||||
### 2.5. Select Finish
|
||||
|
||||

|
||||
|
||||
<div class="sf-callout sf-callout-tip" style="border-left: 4px solid #10b981; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
|
||||
<div style="font-weight: 700; color: #047857; margin-bottom: 6px;">Tip: You're finished!</div>
|
||||
<div style="color: inherit;"><p>Go ahead and play around with StepForge and make some docs!</p></div>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,55 @@ function stepBlocks(step) {
|
||||
return step.blocks || orderedBlocks(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a step's blocks into the layout buckets exporters use around the
|
||||
* step title, description, and image. Text blocks keep their block-list
|
||||
* ordering inside each bucket; code/table blocks stay in `rest`.
|
||||
*/
|
||||
function stepContentGroups(step) {
|
||||
const all = stepBlocks(step);
|
||||
const groups = {
|
||||
beforeTitle: [],
|
||||
afterTitle: [],
|
||||
beforeDescription: [],
|
||||
afterDescription: [],
|
||||
beforeImage: [],
|
||||
afterImage: [],
|
||||
rest: [],
|
||||
};
|
||||
|
||||
for (const block of all) {
|
||||
if (block.kind !== 'text') {
|
||||
groups.rest.push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (block.position) {
|
||||
case 'before-title':
|
||||
groups.beforeTitle.push(block);
|
||||
break;
|
||||
case 'after-title':
|
||||
groups.afterTitle.push(block);
|
||||
break;
|
||||
case 'before-image':
|
||||
groups.beforeImage.push(block);
|
||||
break;
|
||||
case 'after-image':
|
||||
groups.afterImage.push(block);
|
||||
break;
|
||||
case 'before-description':
|
||||
groups.beforeDescription.push(block);
|
||||
break;
|
||||
case 'after-description':
|
||||
default:
|
||||
groups.afterDescription.push(block);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function codeBlockText(block) {
|
||||
return blockText(block);
|
||||
}
|
||||
@@ -67,6 +116,7 @@ module.exports = {
|
||||
writeStepImages,
|
||||
renderAllImages,
|
||||
stepBlocks,
|
||||
stepContentGroups,
|
||||
codeBlockText,
|
||||
LEVEL_LABEL,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { slugify, escapeXml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./common');
|
||||
const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { anchorFor, guideMetaLines, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* Confluence storage-format export. Writes a single XHTML document plus a
|
||||
@@ -14,6 +15,7 @@ const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./com
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
toc: true,
|
||||
};
|
||||
|
||||
const MACRO_FOR_LEVEL = {
|
||||
@@ -23,10 +25,6 @@ const MACRO_FOR_LEVEL = {
|
||||
success: 'tip',
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function stepLinkRewrite(html, ast) {
|
||||
return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => {
|
||||
const target = ast.steps.find((s) => s.stepId === id);
|
||||
@@ -64,10 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
}
|
||||
|
||||
const stepXml = ast.steps.map((step) => {
|
||||
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`];
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
const parts = [`<a id="${anchorFor(step)}"></a>`];
|
||||
for (const tb of beforeTitle) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
parts.push(`<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`);
|
||||
if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
|
||||
|
||||
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'before-description')) {
|
||||
for (const tb of afterTitle) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
for (const tb of beforeDescription) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
|
||||
@@ -75,13 +88,26 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
|
||||
}
|
||||
|
||||
for (const tb of afterDescription) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
for (const tb of beforeImage) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
|
||||
const attachment = attachmentNames.get(step.stepId);
|
||||
if (attachment) {
|
||||
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
|
||||
}
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const tb of afterImage) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
parts.push(blockMacro(block, ast));
|
||||
} else if (block.kind === 'code') {
|
||||
const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : '';
|
||||
parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`);
|
||||
} else if (block.kind === 'table') {
|
||||
@@ -97,13 +123,6 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-description')) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-image')) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
|
||||
return `<div class="step">${parts.join('\n')}</div>`;
|
||||
}).join('\n');
|
||||
|
||||
@@ -116,7 +135,11 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeXml(ast.guide.title)}</h1>
|
||||
<div style="border-bottom: 4px solid #2563eb; margin: 14px 0 18px;"></div>
|
||||
${guideMetaLines(ast).map((line) => `<p><strong>${escapeXml(line.split(': ')[0])}:</strong> ${escapeXml(line.slice(line.indexOf(': ') + 2))}</p>`).join('\n')}
|
||||
<p><em>${escapeXml(guideSummary(ast))}</em></p>
|
||||
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
|
||||
${tpl.toc && ast.steps.length > 1 ? '<ac:structured-macro ac:name="toc"></ac:structured-macro>' : ''}
|
||||
${stepXml}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
function anchorFor(stepOrNumber) {
|
||||
const number = typeof stepOrNumber === 'string'
|
||||
? stepOrNumber
|
||||
: stepOrNumber && stepOrNumber.number;
|
||||
return `step-${String(number || '').replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function tocEntries(ast, { maxDepth = Infinity } = {}) {
|
||||
return (ast.steps || []).map((step) => ({
|
||||
step,
|
||||
anchor: anchorFor(step),
|
||||
number: step.number,
|
||||
title: step.title || 'Untitled step',
|
||||
depth: Math.min(Number.isFinite(step.depth) ? step.depth : 0, maxDepth),
|
||||
}));
|
||||
}
|
||||
|
||||
function guideMetaLines(ast) {
|
||||
const meta = ast?.guide?.metadata || {};
|
||||
return [
|
||||
meta.author && `Author: ${meta.author}`,
|
||||
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
|
||||
meta.organization && `Organization: ${meta.organization}`,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function guideSummary(ast) {
|
||||
const count = ast?.steps?.length || 0;
|
||||
const generated = String(ast?.generatedAt || '').slice(0, 10);
|
||||
return `${count} step${count === 1 ? '' : 's'} · generated ${generated}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
anchorFor,
|
||||
tocEntries,
|
||||
guideMetaLines,
|
||||
guideSummary,
|
||||
};
|
||||
@@ -5,19 +5,30 @@ const path = require('node:path');
|
||||
const { zipSync } = require('../core/zip');
|
||||
const { escapeXml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
||||
* heading + description + screenshot per step, text blocks, code blocks
|
||||
* (Courier), and tables.
|
||||
* heading per step with positioned text blocks around the description and
|
||||
* screenshot, plus code blocks (Courier) and tables.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
includeToc: true,
|
||||
imageWidthTwips: 9000, // ~15.9cm inside A4 margins
|
||||
};
|
||||
|
||||
// Callout styling per text-block level, matching the colors used in the
|
||||
// HTML/PDF exports so a "Tip" looks distinct from a "Warning" at a glance.
|
||||
const LEVEL_STYLE = {
|
||||
info: { fill: 'EFF6FF', color: '1D4ED8' }, // blue — Note
|
||||
success: { fill: 'ECFDF5', color: '047857' }, // green — Tip
|
||||
warn: { fill: 'FFFBEB', color: 'B45309' }, // amber — Warning
|
||||
error: { fill: 'FEF2F2', color: 'B91C1C' }, // red — Important
|
||||
};
|
||||
|
||||
const EMU_PER_PX = 9525; // 96 dpi
|
||||
|
||||
function p(children, props = '') {
|
||||
@@ -55,6 +66,74 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) {
|
||||
`</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
|
||||
}
|
||||
|
||||
function pageBreak() {
|
||||
return p('<w:r><w:br w:type="page"/></w:r>');
|
||||
}
|
||||
|
||||
// Width (in twips) of the text column inside the A4 page margins used by
|
||||
// <w:sectPr> below (11906 - 1134*2), i.e. where TOC page-number tabs land.
|
||||
const TOC_TAB_POS = 9638;
|
||||
|
||||
/** Bookmark name anchoring a step's heading, referenced by its TOC entry. */
|
||||
function bookmarkName(step) {
|
||||
return `toc_${String(step.number).replace(/\./g, '_')}`;
|
||||
}
|
||||
|
||||
/** A `PAGEREF <anchor>` field, cached as "1" until Word recalculates it. */
|
||||
function pageRefField(anchor) {
|
||||
return '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
|
||||
`<w:r><w:instrText xml:space="preserve"> PAGEREF ${anchor} \\h </w:instrText></w:r>` +
|
||||
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
|
||||
'<w:r><w:t>1</w:t></w:r>' +
|
||||
'<w:r><w:fldChar w:fldCharType="end"/></w:r>';
|
||||
}
|
||||
|
||||
/** One TOC line: hyperlink to the step's heading, dot leader, page number. */
|
||||
function tocEntryContent(entry) {
|
||||
const anchor = bookmarkName(entry.step);
|
||||
return `<w:hyperlink w:anchor="${anchor}">${run(`${entry.number}. ${entry.title}`, { size: 20 })}</w:hyperlink>` +
|
||||
'<w:r><w:tab/></w:r>' +
|
||||
pageRefField(anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOC as real, navigable entries (one per step) rather than a bare
|
||||
* "Update contents in Word" placeholder, so the table is correct on first
|
||||
* open. Still wrapped in a `TOC` field (spanning the first..last paragraph)
|
||||
* so Word can refresh page numbers via Update Field / the updateFields prompt.
|
||||
*/
|
||||
function tocFieldParagraphs(ast) {
|
||||
const entries = tocEntries(ast);
|
||||
const beginField = '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
|
||||
'<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>' +
|
||||
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>';
|
||||
const endField = '<w:r><w:fldChar w:fldCharType="end"/></w:r>';
|
||||
|
||||
return entries.map((entry, i) => {
|
||||
const pPr = `<w:pStyle w:val="TOC${Math.min(3, Math.max(1, entry.depth + 1))}"/>` +
|
||||
`<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>`;
|
||||
const lead = i === 0 ? beginField : '';
|
||||
const trail = i === entries.length - 1 ? endField : '';
|
||||
return `<w:p><w:pPr>${pPr}</w:pPr>${lead}${tocEntryContent(entry)}${trail}</w:p>`;
|
||||
});
|
||||
}
|
||||
|
||||
function headingStyleForDepth(depth) {
|
||||
return `Heading${Math.min(3, depth + 1)}`;
|
||||
}
|
||||
|
||||
function headingOutlineLevelForDepth(depth) {
|
||||
return Math.min(2, Math.max(0, depth));
|
||||
}
|
||||
|
||||
function headingParagraphProps(depth, forceNewPage = false) {
|
||||
const parts = [];
|
||||
if (forceNewPage) parts.push('<w:pageBreakBefore/>');
|
||||
parts.push(`<w:pStyle w:val="${headingStyleForDepth(depth)}"/>`);
|
||||
parts.push(`<w:outlineLvl w:val="${headingOutlineLevelForDepth(depth)}"/>`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function table(rows) {
|
||||
const cols = Math.max(...rows.map((r) => r.length));
|
||||
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
|
||||
@@ -72,57 +151,179 @@ function table(rows) {
|
||||
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`;
|
||||
}
|
||||
|
||||
function stylesXml() {
|
||||
const headingStyle = (styleId, name, outlineLvl, size, color) => `
|
||||
<w:style w:type="paragraph" w:styleId="${styleId}">
|
||||
<w:name w:val="${name}"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:next w:val="Normal"/>
|
||||
<w:uiPriority w:val="${9 + outlineLvl}"/>
|
||||
<w:qFormat/>
|
||||
<w:unhideWhenUsed/>
|
||||
<w:pPr>
|
||||
<w:keepNext/>
|
||||
<w:keepLines/>
|
||||
<w:spacing w:before="${outlineLvl === 0 ? 360 : outlineLvl === 1 ? 240 : 180}" w:after="120"/>
|
||||
<w:outlineLvl w:val="${outlineLvl}"/>
|
||||
</w:pPr>
|
||||
<w:rPr>
|
||||
<w:b/>
|
||||
<w:sz w:val="${size}"/>
|
||||
<w:szCs w:val="${size}"/>
|
||||
<w:color w:val="${color}"/>
|
||||
</w:rPr>
|
||||
</w:style>`;
|
||||
|
||||
const tocStyle = (level) => `
|
||||
<w:style w:type="paragraph" w:styleId="TOC${level}">
|
||||
<w:name w:val="toc ${level}"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:next w:val="Normal"/>
|
||||
<w:autoRedefine/>
|
||||
<w:uiPriority w:val="39"/>
|
||||
<w:unhideWhenUsed/>
|
||||
<w:pPr>
|
||||
<w:spacing w:after="60"/>
|
||||
<w:ind w:left="${(level - 1) * 360}"/>
|
||||
<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>
|
||||
</w:pPr>
|
||||
<w:rPr>
|
||||
<w:sz w:val="20"/>
|
||||
<w:szCs w:val="20"/>
|
||||
</w:rPr>
|
||||
</w:style>`;
|
||||
|
||||
return `<?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:szCs w:val="22"/>
|
||||
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
|
||||
</w:rPr>
|
||||
</w:rPrDefault>
|
||||
<w:pPrDefault>
|
||||
<w:pPr>
|
||||
<w:spacing w:after="160"/>
|
||||
</w:pPr>
|
||||
</w:pPrDefault>
|
||||
</w:docDefaults>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
<w:qFormat/>
|
||||
<w:uiPriority w:val="1"/>
|
||||
<w:rPr>
|
||||
<w:sz w:val="22"/>
|
||||
<w:szCs w:val="22"/>
|
||||
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
|
||||
</w:rPr>
|
||||
</w:style>
|
||||
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
|
||||
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
|
||||
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
|
||||
${tocStyle(1)}
|
||||
${tocStyle(2)}
|
||||
${tocStyle(3)}
|
||||
</w:styles>`;
|
||||
}
|
||||
|
||||
function exportDocx(ast, outDir, template = {}) {
|
||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
const media = []; // { name, data }
|
||||
const rels = []; // relationship XML strings
|
||||
let relCounter = 0;
|
||||
let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
|
||||
let bookmarkCounter = 0;
|
||||
let stepImageCount = 0;
|
||||
|
||||
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
|
||||
|
||||
const body = [];
|
||||
body.push(p(run(ast.guide.title, { bold: true, size: 48 })));
|
||||
body.push(p(
|
||||
run(ast.guide.title, { bold: true, size: 48 }),
|
||||
'<w:pBdr><w:bottom w:val="single" w:sz="24" w:space="12" w:color="2563EB"/></w:pBdr>'
|
||||
));
|
||||
if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' })));
|
||||
body.push(p(run(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 18, color: '888888' })));
|
||||
for (const line of guideMetaLines(ast)) body.push(p(run(line, { size: 20, color: '6B7280' })));
|
||||
body.push(p(run(guideSummary(ast), { size: 18, color: '888888' })));
|
||||
|
||||
body.push(pageBreak());
|
||||
|
||||
if (tpl.includeToc && ast.steps.length > 1) {
|
||||
body.push(p(
|
||||
run('Contents', { bold: true, size: 28 }),
|
||||
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
|
||||
));
|
||||
body.push(...tocFieldParagraphs(ast));
|
||||
body.push(pageBreak());
|
||||
}
|
||||
|
||||
const emitTextBlock = (tb) => {
|
||||
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
body.push(p(
|
||||
`${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`,
|
||||
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
|
||||
));
|
||||
};
|
||||
|
||||
for (const step of ast.steps) {
|
||||
const headSize = step.depth > 0 ? 26 : 30;
|
||||
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
|
||||
step.forceNewPage ? '<w:pageBreakBefore/>' : ''));
|
||||
|
||||
emitTextBlocks(step, 'before-description');
|
||||
if (step.descriptionText) body.push(p(run(step.descriptionText)));
|
||||
const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
|
||||
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
||||
const bookmarkId = ++bookmarkCounter;
|
||||
const anchor = bookmarkName(step);
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
for (const tb of beforeTitle) emitTextBlock(tb);
|
||||
body.push(p(
|
||||
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
|
||||
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
|
||||
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
|
||||
headingParagraphProps(step.depth, step.forceNewPage)
|
||||
));
|
||||
for (const tb of afterTitle) emitTextBlock(tb);
|
||||
for (const tb of beforeDescription) emitTextBlock(tb);
|
||||
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
|
||||
for (const tb of afterDescription) emitTextBlock(tb);
|
||||
for (const tb of beforeImage) emitTextBlock(tb);
|
||||
|
||||
const img = images.get(step.stepId);
|
||||
if (img) {
|
||||
relCounter += 1;
|
||||
const relId = ++relCounter;
|
||||
const name = `image${relCounter}.png`;
|
||||
media.push({ name, data: encodePng(img) });
|
||||
rels.push(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
|
||||
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
|
||||
rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
|
||||
body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips)));
|
||||
stepImageCount += 1;
|
||||
}
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const tb of afterImage) emitTextBlock(tb);
|
||||
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitTextBlock(block);
|
||||
} else if (block.kind === 'code') {
|
||||
body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }),
|
||||
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
|
||||
} else if (block.kind === 'table') {
|
||||
if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
|
||||
}
|
||||
}
|
||||
emitTextBlocks(step, 'after-description');
|
||||
emitTextBlocks(step, 'after-image');
|
||||
}
|
||||
|
||||
function emitTextBlocks(step, position) {
|
||||
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
body.push(p(
|
||||
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
|
||||
'<w:shd w:val="clear" w:fill="F9FAFB"/>'
|
||||
));
|
||||
}
|
||||
}
|
||||
const settingsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:updateFields w:val="true"/>
|
||||
</w:settings>`;
|
||||
|
||||
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
@@ -143,6 +344,8 @@ ${body.join('\n')}
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Default Extension="png" ContentType="image/png"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
|
||||
</Types>`,
|
||||
},
|
||||
{
|
||||
@@ -157,16 +360,19 @@ ${body.join('\n')}
|
||||
name: 'word/_rels/document.xml.rels',
|
||||
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
|
||||
${rels.join('\n')}
|
||||
</Relationships>`,
|
||||
},
|
||||
{ name: 'word/settings.xml', data: settingsXml },
|
||||
{ name: 'word/styles.xml', data: stylesXml() },
|
||||
...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })),
|
||||
];
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const file = path.join(outDir, `${guideSlug(ast)}.docx`);
|
||||
fs.writeFileSync(file, zipSync(entries));
|
||||
return { file, imageCount: media.length };
|
||||
return { file, imageCount: stepImageCount };
|
||||
}
|
||||
|
||||
module.exports = { exportDocx, DEFAULT_TEMPLATE };
|
||||
|
||||
@@ -4,7 +4,8 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { escapeHtml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { anchorFor, tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* HTML exporters. Both variants are fully self-contained single files:
|
||||
@@ -18,14 +19,11 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = r
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
toc: true,
|
||||
accentColor: '#2563eb',
|
||||
customCss: '',
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function dataUri(img) {
|
||||
return `data:image/png;base64,${encodePng(img).toString('base64')}`;
|
||||
}
|
||||
@@ -38,56 +36,477 @@ function stepLinkRewrite(html, ast) {
|
||||
});
|
||||
}
|
||||
|
||||
function blocksHtml(step, position) {
|
||||
return stepBlocks(step)
|
||||
.filter((tb) => tb.position === position)
|
||||
.map((tb) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`)
|
||||
.join('\n');
|
||||
function blockHtml(tb) {
|
||||
return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
|
||||
}
|
||||
|
||||
function stepBodyHtml(step, ast, images, tpl) {
|
||||
function renderBlockListHtml(blocks) {
|
||||
return blocks.map((block) => blockHtml(block)).join('\n');
|
||||
}
|
||||
|
||||
function renderMetaChips(ast) {
|
||||
return [
|
||||
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
|
||||
`<span class="chip muted">${escapeHtml(guideSummary(ast))}</span>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
function renderTocList(ast) {
|
||||
return tocEntries(ast).map((entry) => `
|
||||
<li class="d${entry.depth}">
|
||||
<a href="#${entry.anchor}">
|
||||
<span class="num">${escapeHtml(entry.number)}</span>
|
||||
<span class="label">${escapeHtml(entry.title)}</span>
|
||||
</a>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderCover(ast, tpl) {
|
||||
return `
|
||||
<section class="cover">
|
||||
<div class="eyebrow">StepForge export</div>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
<div class="rule" style="background:${tpl.accentColor}"></div>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
|
||||
<div class="meta">${renderMetaChips(ast)}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderStepCard(step, ast, images, tpl, { rich = false, selected = false } = {}) {
|
||||
const title = `${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}`;
|
||||
const statusText = step.skipped
|
||||
? 'Skipped'
|
||||
: step.status === 'in-progress'
|
||||
? 'In progress'
|
||||
: step.status === 'done'
|
||||
? 'Done'
|
||||
: 'Todo';
|
||||
const head = rich ? `
|
||||
<h2>
|
||||
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
|
||||
<span class="step-num">${escapeHtml(step.number)}</span>
|
||||
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
|
||||
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
|
||||
</h2>` : `<h2>${title}</h2>`;
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
return `
|
||||
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
|
||||
${renderBlockListHtml(beforeTitle)}
|
||||
${head}
|
||||
${renderBlockListHtml(afterTitle)}
|
||||
<div class="step-body">
|
||||
${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderRichToc(ast) {
|
||||
return tocEntries(ast).map((entry) => `
|
||||
<li class="d${entry.depth}">
|
||||
<a href="#${entry.anchor}">
|
||||
<span class="num">${escapeHtml(entry.number)}</span>
|
||||
<span class="label">${escapeHtml(entry.title)}</span>
|
||||
</a>
|
||||
</li>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
||||
if (!m) return [37, 99, 235];
|
||||
const n = Number.parseInt(m[1], 16);
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
function bodyStyle(tpl) {
|
||||
const [r, g, b] = hexToRgb(tpl.accentColor);
|
||||
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
|
||||
}
|
||||
|
||||
function renderStepBodyHtml(groups, step, ast, images, tpl) {
|
||||
const parts = [];
|
||||
parts.push(blocksHtml(step, 'before-description'));
|
||||
const {
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = groups;
|
||||
for (const tb of beforeDescription) parts.push(blockHtml(tb));
|
||||
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
|
||||
for (const tb of afterDescription) parts.push(blockHtml(tb));
|
||||
for (const tb of beforeImage) parts.push(blockHtml(tb));
|
||||
const img = images.get(step.stepId);
|
||||
if (img && tpl.includeImages) {
|
||||
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
|
||||
}
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const tb of afterImage) parts.push(blockHtml(tb));
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
parts.push(blockHtml(block));
|
||||
} else if (block.kind === 'code') {
|
||||
parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`);
|
||||
} else if (block.kind === 'table') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
const [head, ...rest] = block.rows;
|
||||
const [head, ...bodyRows] = block.rows;
|
||||
parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>'
|
||||
+ rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
|
||||
+ bodyRows.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
|
||||
+ '</tbody></table>');
|
||||
}
|
||||
}
|
||||
parts.push(blocksHtml(step, 'after-description'));
|
||||
parts.push(blocksHtml(step, 'after-image'));
|
||||
return parts.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
const BASE_CSS = `
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px;
|
||||
padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; }
|
||||
h1 { font-size: 1.7em; margin-bottom: .2em; }
|
||||
h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; }
|
||||
img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; }
|
||||
pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
||||
table { border-collapse: collapse; margin: .6em 0; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; }
|
||||
.block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; }
|
||||
:root {
|
||||
--bg: #f4f7fb;
|
||||
--bg-2: #eef3f9;
|
||||
--panel: rgba(255, 255, 255, 0.92);
|
||||
--panel-strong: #ffffff;
|
||||
--panel-soft: #f8fbff;
|
||||
--text: #152033;
|
||||
--muted: #637084;
|
||||
--border: rgba(119, 134, 156, 0.22);
|
||||
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.16), transparent 26%),
|
||||
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.11), transparent 22%),
|
||||
linear-gradient(180deg, var(--bg), var(--bg-2));
|
||||
line-height: 1.6;
|
||||
font-family: "Aptos", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
img.shot {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
margin: 16px 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
pre.code {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
overflow-x: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
pre.code code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 14px 0;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid rgba(119, 134, 156, 0.16);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
thead th {
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
font-size: .88rem;
|
||||
letter-spacing: .02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
.doc { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; }
|
||||
.cover, .toc-card, .step-card, .progress-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.cover {
|
||||
padding: 34px 36px 28px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.cover .eyebrow {
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .16em;
|
||||
font-size: .74rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 1.02;
|
||||
margin: 0;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.cover .rule {
|
||||
width: 132px;
|
||||
height: 6px;
|
||||
margin: 18px 0 16px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.22);
|
||||
}
|
||||
.cover .desc {
|
||||
max-width: 76ch;
|
||||
color: var(--muted);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
.cover .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb), 0.10);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.14);
|
||||
font-size: .86rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
.chip.muted {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: var(--muted);
|
||||
border-color: rgba(119, 134, 156, 0.16);
|
||||
}
|
||||
.toc-card {
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.toc-card h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.08rem;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toc-list li { margin: 0; }
|
||||
.toc-list a {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 9px 12px;
|
||||
border-radius: 14px;
|
||||
color: inherit;
|
||||
}
|
||||
.toc-list a:hover {
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
.toc-list .num {
|
||||
min-width: 44px;
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
.toc-list .label { flex: 1; }
|
||||
.toc-list li.d1 a { padding-left: 24px; }
|
||||
.toc-list li.d2 a { padding-left: 44px; }
|
||||
.toc-list li.d3 a { padding-left: 64px; }
|
||||
.step-card {
|
||||
margin: 20px 0;
|
||||
padding: 22px 24px 18px;
|
||||
border-left: 6px solid var(--accent);
|
||||
}
|
||||
.step-card h2 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.28rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.step-card h2 .step-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb), 0.11);
|
||||
color: var(--accent);
|
||||
font-size: .9rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: .02em;
|
||||
flex: none;
|
||||
}
|
||||
.step-card h2 .step-title { flex: 1; }
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1.65rem;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: .72rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: #334155;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid rgba(119, 134, 156, 0.18);
|
||||
}
|
||||
.status-chip.status-done {
|
||||
color: #047857;
|
||||
background: #ecfdf5;
|
||||
border-color: rgba(16, 185, 129, 0.22);
|
||||
}
|
||||
.status-chip.status-in-progress {
|
||||
color: #b45309;
|
||||
background: #fffbeb;
|
||||
border-color: rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
.status-chip.status-todo {
|
||||
color: #475569;
|
||||
}
|
||||
.status-chip.skipped {
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border-color: rgba(245, 158, 11, 0.24);
|
||||
}
|
||||
.step-card.skipped { opacity: .76; }
|
||||
.step-body > .desc {
|
||||
color: #243044;
|
||||
}
|
||||
.block {
|
||||
position: relative;
|
||||
border-left: 4px solid var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.08);
|
||||
padding: 14px 16px 14px 54px;
|
||||
margin: 14px 0;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.block::before {
|
||||
content: 'i';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 14px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--accent-rgb), 0.15);
|
||||
color: var(--accent);
|
||||
font-weight: 900;
|
||||
font-size: 13px;
|
||||
}
|
||||
.block strong { color: var(--text); }
|
||||
.block-warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.block-warn::before { content: '!'; background: rgba(245, 158, 11, 0.16); color: #b45309; }
|
||||
.block-warn strong { color: #92400e; }
|
||||
.block-error { border-color: #ef4444; background: #fef2f2; }
|
||||
.block-error::before { content: '!'; background: rgba(239, 68, 68, 0.14); color: #b91c1c; }
|
||||
.block-error strong { color: #b91c1c; }
|
||||
.block-success { border-color: #10b981; background: #ecfdf5; }
|
||||
.block-success::before { content: '✓'; background: rgba(16, 185, 129, 0.14); color: #047857; }
|
||||
.block-success strong { color: #047857; }
|
||||
.footer-note {
|
||||
margin-top: 20px;
|
||||
color: var(--muted);
|
||||
font-size: .84rem;
|
||||
}
|
||||
.skipped { opacity: .55; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #111827; color: #e5e7eb; }
|
||||
h2 { border-color: #374151; }
|
||||
pre.code, .block { background: #1f2937; }
|
||||
th, td { border-color: #4b5563; }
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--bg-2: #0f172a;
|
||||
--panel: rgba(17, 24, 39, 0.9);
|
||||
--panel-strong: #111827;
|
||||
--panel-soft: #162033;
|
||||
--text: #e5e7eb;
|
||||
--muted: #98a2b3;
|
||||
--border: rgba(148, 163, 184, 0.18);
|
||||
--shadow: 0 16px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.status-chip { color: #d1d5db; }
|
||||
.status-chip.skipped { color: #fbbf24; background: rgba(245, 158, 11, 0.12); }
|
||||
.cover .desc, .step-body > .desc { color: #cbd5e1; }
|
||||
.chip.muted { background: rgba(15, 23, 42, 0.75); color: #cbd5e1; }
|
||||
.block { background: rgba(37, 99, 235, 0.12); }
|
||||
.block-warn { background: rgba(245, 158, 11, 0.12); }
|
||||
.block-error { background: rgba(239, 68, 68, 0.12); }
|
||||
.block-success { background: rgba(16, 185, 129, 0.12); }
|
||||
table { background: var(--panel-strong); }
|
||||
th, td { border-bottom-color: rgba(148, 163, 184, 0.12); }
|
||||
}
|
||||
`;
|
||||
|
||||
const RICH_CSS = `
|
||||
.layout-rich {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.toc-panel {
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
align-self: start;
|
||||
}
|
||||
.toc-card {
|
||||
margin: 0;
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
.progress-card {
|
||||
margin-bottom: 22px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.progress {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
}
|
||||
.progress .label {
|
||||
font-size: .86rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.progress .bar {
|
||||
height: 8px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress .fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
transition: width .2s ease;
|
||||
}
|
||||
label.check { margin-right: 0; }
|
||||
label.check input { transform: translateY(1px); }
|
||||
.step-card h2 .step-title { min-width: 0; }
|
||||
@media (max-width: 960px) {
|
||||
.layout-rich { grid-template-columns: 1fr; }
|
||||
.toc-panel { position: static; }
|
||||
.toc-card { max-height: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -95,12 +514,16 @@ function exportHtmlSimple(ast, outDir, template = {}) {
|
||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
const stepsHtml = ast.steps.map((step) => `
|
||||
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
|
||||
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2>
|
||||
${stepBodyHtml(step, ast, images, tpl)}
|
||||
</section>`).join('\n');
|
||||
const toc = tpl.toc && ast.steps.length > 1
|
||||
? `
|
||||
<section class="toc-card">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
${renderTocList(ast)}
|
||||
</ul>
|
||||
</section>`
|
||||
: '';
|
||||
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -110,11 +533,13 @@ function exportHtmlSimple(ast, outDir, template = {}) {
|
||||
<title>${escapeHtml(ast.guide.title)}</title>
|
||||
<style>${BASE_CSS}${tpl.customCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
|
||||
<body style="${bodyStyle(tpl)}">
|
||||
<div class="doc doc-simple">
|
||||
${renderCover(ast, tpl)}
|
||||
${toc}
|
||||
${stepsHtml}
|
||||
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps</small></footer>
|
||||
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -128,39 +553,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
const storageKey = `stepforge-progress-${ast.guide.id}`;
|
||||
const tocHtml = tpl.toc && ast.steps.length > 1
|
||||
? `
|
||||
<aside class="toc-panel">
|
||||
<section class="toc-card">
|
||||
<h2>Contents</h2>
|
||||
<ul class="toc-list">
|
||||
${renderRichToc(ast)}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>`
|
||||
: '';
|
||||
|
||||
const tocHtml = ast.steps.map((step) =>
|
||||
`<li class="d${step.depth}"><a href="#${anchorFor(step)}">${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</a></li>`
|
||||
).join('\n');
|
||||
|
||||
const stepsHtml = ast.steps.map((step) => `
|
||||
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
|
||||
<h2>
|
||||
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
|
||||
${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
|
||||
</h2>
|
||||
${stepBodyHtml(step, ast, images, tpl)}
|
||||
</section>`).join('\n');
|
||||
|
||||
const richCss = `
|
||||
.layout { display: flex; gap: 28px; max-width: 1180px; margin: 0 auto; }
|
||||
nav.toc { position: sticky; top: 16px; align-self: flex-start; min-width: 220px; max-width: 280px;
|
||||
max-height: calc(100vh - 32px); overflow-y: auto; font-size: .92em;
|
||||
border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
|
||||
nav.toc ul { list-style: none; margin: 0; padding: 0; }
|
||||
nav.toc li { margin: .25em 0; }
|
||||
nav.toc li.d1 { padding-left: 14px; } nav.toc li.d2 { padding-left: 28px; }
|
||||
nav.toc a { color: inherit; text-decoration: none; }
|
||||
nav.toc a:hover { color: ${tpl.accentColor}; }
|
||||
main { flex: 1; min-width: 0; }
|
||||
.progress { position: sticky; top: 0; background: inherit; padding: 8px 0; z-index: 2; }
|
||||
.progress .bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
|
||||
.progress .fill { height: 100%; width: 0; background: ${tpl.accentColor}; transition: width .2s; }
|
||||
label.check { margin-right: 8px; }
|
||||
section.step.done h2 { text-decoration: line-through; opacity: .6; }
|
||||
@media (max-width: 900px) { .layout { flex-direction: column; } nav.toc { position: static; max-width: none; } }
|
||||
@media (prefers-color-scheme: dark) { nav.toc { border-color: #374151; } .progress .bar { background: #374151; } }
|
||||
`;
|
||||
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n');
|
||||
|
||||
const script = `
|
||||
(function () {
|
||||
@@ -197,19 +602,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(ast.guide.title)}</title>
|
||||
<style>${BASE_CSS}${richCss}${tpl.customCss}</style>
|
||||
<style>${BASE_CSS}${RICH_CSS}${tpl.customCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
<nav class="toc"><strong>Contents</strong><ul>
|
||||
<body style="${bodyStyle(tpl)}">
|
||||
<div class="doc layout-rich">
|
||||
${tocHtml}
|
||||
</ul></nav>
|
||||
<main>
|
||||
<h1>${escapeHtml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
|
||||
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div>
|
||||
${renderCover(ast, tpl)}
|
||||
<div class="progress progress-card">
|
||||
<div class="label"></div>
|
||||
<div class="bar"><div class="fill"></div></div>
|
||||
</div>
|
||||
${stepsHtml}
|
||||
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps</small></footer>
|
||||
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
|
||||
</main>
|
||||
</div>
|
||||
<script>${script}</script>
|
||||
|
||||
@@ -42,6 +42,8 @@ function htmlToMarkdown(html) {
|
||||
.replace(/<hr\s*\/?>/gi, '\n---\n')
|
||||
.replace(/<p>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<div>/gi, '\n')
|
||||
.replace(/<\/div>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '');
|
||||
|
||||
return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages } = require('./common');
|
||||
const { tocEntries, guideSummary } = require('./document-layout');
|
||||
const raster = require('../core/raster');
|
||||
const { decodePng, encodePng } = require('../core/png');
|
||||
|
||||
@@ -39,7 +40,8 @@ function exportImageBundle(ast, outDir, template = {}) {
|
||||
const meta = {
|
||||
format: 'stepforge-image-bundle',
|
||||
version: 1,
|
||||
guide: { title: ast.guide.title, generatedAt: ast.generatedAt },
|
||||
guide: { title: ast.guide.title, generatedAt: ast.generatedAt, summary: guideSummary(ast) },
|
||||
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
|
||||
steps: ast.steps.map((step) => ({
|
||||
number: step.number,
|
||||
title: step.title,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const { exportJson } = require('./json');
|
||||
const { exportMarkdown } = require('./markdown');
|
||||
const { exportWikiJs } = require('./wikijs');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('./html');
|
||||
const { exportConfluence } = require('./confluence');
|
||||
const { exportPdf } = require('./pdf');
|
||||
@@ -14,6 +15,7 @@ const { exportPptx } = require('./pptx');
|
||||
const EXPORTERS = {
|
||||
json: exportJson,
|
||||
markdown: exportMarkdown,
|
||||
wikijs: exportWikiJs,
|
||||
'html-simple': exportHtmlSimple,
|
||||
'html-rich': exportHtmlRich,
|
||||
confluence: exportConfluence,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common');
|
||||
const { tocEntries, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* JSON exporter: structured guide + steps, annotated screenshots written to
|
||||
@@ -29,7 +30,9 @@ function exportJson(ast, outDir, template = {}) {
|
||||
descriptionHtml: ast.guide.descriptionHtml,
|
||||
createdAt: ast.guide.createdAt,
|
||||
updatedAt: ast.guide.updatedAt,
|
||||
summary: guideSummary(ast),
|
||||
},
|
||||
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
|
||||
steps: ast.steps.map((step) => ({
|
||||
number: step.number,
|
||||
kind: step.kind,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { escapeHtml } = require('../core/util');
|
||||
const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { htmlToMarkdown } = require('./htmlmd');
|
||||
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
toc: true,
|
||||
includeImages: true,
|
||||
azureWiki: false,
|
||||
imageMaxWidth: 0, // 0 = natural size
|
||||
};
|
||||
|
||||
const WIKIJS_CALLOUT_CLASS = {
|
||||
info: 'is-info',
|
||||
success: 'is-success',
|
||||
warn: 'is-warning',
|
||||
error: 'is-danger',
|
||||
};
|
||||
|
||||
const HTML_CALLOUT_THEME = {
|
||||
info: { label: 'Note', border: '#2563eb', fg: '#1d4ed8', kind: 'note' },
|
||||
success: { label: 'Tip', border: '#10b981', fg: '#047857', kind: 'tip' },
|
||||
warn: { label: 'Warning', border: '#f59e0b', fg: '#b45309', kind: 'warning' },
|
||||
error: { label: 'Important', border: '#ef4444', fg: '#b91c1c', kind: 'important' },
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function quoteBody(text) {
|
||||
return text ? text.replace(/\n/g, '\n> ') : '';
|
||||
}
|
||||
|
||||
function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) {
|
||||
const body = htmlToMarkdown(tb.descriptionHtml);
|
||||
if (alertStyle === 'html') {
|
||||
const theme = HTML_CALLOUT_THEME[tb.level] || HTML_CALLOUT_THEME.info;
|
||||
const label = theme.label;
|
||||
const title = tb.title ? `${label}: ${tb.title}` : label;
|
||||
const style = `border-left: 4px solid ${theme.border}; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;`;
|
||||
lines.push(
|
||||
`<div class="sf-callout sf-callout-${theme.kind}" style="${style}">`,
|
||||
`<div style="font-weight: 700; color: ${theme.fg}; margin-bottom: ${body ? '6px' : '0'};">${escapeHtml(title)}</div>`,
|
||||
);
|
||||
if (body) lines.push(`<div style="color: inherit;">${tb.descriptionHtml || ''}</div>`);
|
||||
lines.push('</div>', '');
|
||||
return;
|
||||
}
|
||||
if (alertStyle === 'wikijs') {
|
||||
const label = tb.title || LEVEL_LABEL[tb.level] || 'Note';
|
||||
const className = WIKIJS_CALLOUT_CLASS[tb.level] || 'is-info';
|
||||
lines.push(`> **${label}**`);
|
||||
if (body) lines.push(`> ${quoteBody(body)}`);
|
||||
lines.push(`{.${className}}`, '');
|
||||
return;
|
||||
}
|
||||
|
||||
const label = LEVEL_LABEL[tb.level] || 'Note';
|
||||
lines.push(`> [!${label.toUpperCase()}]`);
|
||||
if (tb.title) lines.push(`> **${tb.title}**`);
|
||||
if (body) lines.push(`> ${quoteBody(body)}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
function renderMarkdownGuide(ast, outDir, template = {}, {
|
||||
defaults = DEFAULT_TEMPLATE,
|
||||
alertStyle = 'gfm',
|
||||
tocTitle = 'Contents',
|
||||
fileExt = '.md',
|
||||
} = {}) {
|
||||
const tpl = { ...defaults, ...template };
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
|
||||
const lines = [];
|
||||
|
||||
lines.push(`# ${ast.guide.title}`, '');
|
||||
lines.push('<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>', '');
|
||||
const metaLines = guideMetaLines(ast);
|
||||
if (metaLines.length) lines.push(metaLines.join(' · '), '');
|
||||
lines.push(`*${guideSummary(ast)}*`, '');
|
||||
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
|
||||
|
||||
if (tpl.toc && ast.steps.length > 1) {
|
||||
lines.push(`## ${tocTitle}`, '');
|
||||
for (const entry of tocEntries(ast)) {
|
||||
const indent = ' '.repeat(entry.depth);
|
||||
lines.push(`${indent}- [${entry.number}. ${entry.title}](#${entry.anchor})`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const step of ast.steps) {
|
||||
const heading = step.depth > 0 ? '###' : '##';
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
|
||||
for (const tb of beforeTitle) emitBlock(lines, tb, { alertStyle });
|
||||
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
|
||||
if (step.skipped) lines.push('*(skipped)*', '');
|
||||
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
|
||||
for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
|
||||
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
|
||||
for (const tb of afterDescription) emitBlock(lines, tb, { alertStyle });
|
||||
|
||||
for (const tb of beforeImage) emitBlock(lines, tb, { alertStyle });
|
||||
const img = images.get(step.stepId);
|
||||
if (img) {
|
||||
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
|
||||
lines.push(``, '');
|
||||
} else {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
|
||||
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitBlock(lines, block, { alertStyle });
|
||||
} else if (block.kind === 'code') {
|
||||
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
|
||||
} else if (block.kind === 'table') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
const width = Math.max(...block.rows.map((r) => r.length));
|
||||
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
|
||||
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
|
||||
lines.push(`|${' --- |'.repeat(width)}`);
|
||||
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const file = path.join(outDir, `${guideSlug(ast)}${fileExt}`);
|
||||
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
|
||||
return { file, imageCount: images.size };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TEMPLATE,
|
||||
anchorFor,
|
||||
renderMarkdownGuide,
|
||||
};
|
||||
@@ -1,94 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
const { htmlToMarkdown } = require('./htmlmd');
|
||||
const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown-guide');
|
||||
|
||||
/**
|
||||
* Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder.
|
||||
* azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
toc: true,
|
||||
includeImages: true,
|
||||
azureWiki: false,
|
||||
imageMaxWidth: 0, // 0 = natural size
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function exportMarkdown(ast, outDir, template = {}) {
|
||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
|
||||
const lines = [];
|
||||
|
||||
lines.push(`# ${ast.guide.title}`, '');
|
||||
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
|
||||
|
||||
if (tpl.toc && ast.steps.length > 1) {
|
||||
lines.push('## Contents', '');
|
||||
for (const step of ast.steps) {
|
||||
const indent = ' '.repeat(step.depth);
|
||||
lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const step of ast.steps) {
|
||||
const heading = step.depth > 0 ? '###' : '##';
|
||||
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
|
||||
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
|
||||
if (step.skipped) lines.push('*(skipped)*', '');
|
||||
|
||||
emitBlocks(lines, step, 'before-description');
|
||||
|
||||
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
|
||||
|
||||
const img = images.get(step.stepId);
|
||||
if (img) {
|
||||
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
|
||||
lines.push(``, '');
|
||||
} else {
|
||||
lines.push(``, '');
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
|
||||
} else if (block.kind === 'table') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
const width = Math.max(...block.rows.map((r) => r.length));
|
||||
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
|
||||
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
|
||||
lines.push(`|${' --- |'.repeat(width)}`);
|
||||
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
emitBlocks(lines, step, 'after-description');
|
||||
emitBlocks(lines, step, 'after-image');
|
||||
}
|
||||
|
||||
const file = path.join(outDir, `${guideSlug(ast)}.md`);
|
||||
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
|
||||
return { file, imageCount: images.size };
|
||||
}
|
||||
|
||||
function emitBlocks(lines, step, position) {
|
||||
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
|
||||
const label = LEVEL_LABEL[tb.level] || 'Note';
|
||||
lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`);
|
||||
const body = htmlToMarkdown(tb.descriptionHtml);
|
||||
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
return renderMarkdownGuide(ast, outDir, template, {
|
||||
defaults: DEFAULT_TEMPLATE,
|
||||
alertStyle: 'html',
|
||||
tocTitle: 'Contents',
|
||||
fileExt: '.md',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
|
||||
|
||||
@@ -3,8 +3,100 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { PdfBuilder } = require('../core/pdf');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||
const { htmlToText } = require('../core/util');
|
||||
const { htmlToBlocks } = require('../core/htmlblocks');
|
||||
|
||||
const LIST_INDENT = 14;
|
||||
const QUOTE_INDENT = 10;
|
||||
const HEADING_BUMP = { h1: 2.5, h2: 2, h3: 1.5, h4: 1 };
|
||||
|
||||
// Callout styling per text-block level, matching the colors used in the
|
||||
// HTML/editor UI so a "Tip" looks distinct from a "Warning" at a glance.
|
||||
const LEVEL_STYLE = {
|
||||
info: { accent: [59, 130, 246], tint: [239, 246, 255] }, // blue — Note
|
||||
success: { accent: [16, 185, 129], tint: [236, 253, 245] }, // green — Tip
|
||||
warn: { accent: [245, 158, 11], tint: [255, 251, 235] }, // amber — Warning
|
||||
error: { accent: [239, 68, 68], tint: [254, 242, 242] }, // red — Important
|
||||
};
|
||||
|
||||
function fontForRun(run) {
|
||||
if (run.code) return 'F3';
|
||||
if (run.bold && run.italic) return 'F5';
|
||||
if (run.bold) return 'F2';
|
||||
if (run.italic) return 'F4';
|
||||
return 'F1';
|
||||
}
|
||||
|
||||
/** Split formatted runs into words (font/link tagged) and greedily wrap to maxWidth. */
|
||||
function wrapRuns(pdf, runs, size, maxWidth) {
|
||||
const words = [];
|
||||
for (const run of runs) {
|
||||
const font = fontForRun(run);
|
||||
for (const part of run.text.split(/(\s+)/)) {
|
||||
if (part === '') continue;
|
||||
words.push({ text: part, font, href: run.href });
|
||||
}
|
||||
}
|
||||
const lines = [];
|
||||
let line = [];
|
||||
let w = 0;
|
||||
for (const word of words) {
|
||||
const isSpace = /^\s+$/.test(word.text);
|
||||
const ww = pdf.textWidth(word.text, size, word.font);
|
||||
if (!isSpace && line.length && w + ww > maxWidth) {
|
||||
lines.push(line);
|
||||
line = [];
|
||||
w = 0;
|
||||
}
|
||||
if (isSpace && !line.length) continue;
|
||||
line.push(word);
|
||||
w += ww;
|
||||
}
|
||||
if (line.length) lines.push(line);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out description HTML into render-ready items, preserving bold,
|
||||
* italic, links, lists, blockquotes and headings.
|
||||
* Returns { items, height }; items: { kind: 'hr', width } | { kind: 'text',
|
||||
* lines, size, lineHeight, indent, prefix, muted }.
|
||||
*/
|
||||
function layoutDescription(pdf, html, maxWidth, baseSize) {
|
||||
const items = [];
|
||||
let height = 0;
|
||||
for (const block of htmlToBlocks(html || '')) {
|
||||
if (block.type === 'hr') {
|
||||
items.push({ kind: 'hr', width: maxWidth });
|
||||
height += 12;
|
||||
continue;
|
||||
}
|
||||
let size = baseSize;
|
||||
let runs = block.runs;
|
||||
let indent = (block.indent || 0) * LIST_INDENT;
|
||||
let prefix = null;
|
||||
let muted = false;
|
||||
if (HEADING_BUMP[block.type]) {
|
||||
size = baseSize + HEADING_BUMP[block.type];
|
||||
runs = runs.map((r) => ({ ...r, bold: true }));
|
||||
} else if (block.type === 'li') {
|
||||
prefix = '•';
|
||||
indent += LIST_INDENT;
|
||||
} else if (block.type === 'oli') {
|
||||
prefix = `${block.n}.`;
|
||||
indent += LIST_INDENT;
|
||||
} else if (block.type === 'blockquote') {
|
||||
indent += QUOTE_INDENT;
|
||||
muted = true;
|
||||
}
|
||||
const lines = wrapRuns(pdf, runs, size, maxWidth - indent);
|
||||
const lineHeight = size * 1.35;
|
||||
items.push({ kind: 'text', lines, size, lineHeight, indent, prefix, muted });
|
||||
height += lines.length * lineHeight + 4;
|
||||
}
|
||||
return { items, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF exporter: cover block, optional TOC, one section per step with the
|
||||
@@ -38,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
let y = M;
|
||||
// Never start a page break when already at the top of a page — an item
|
||||
// taller than a full page (e.g. a step's combined head height) must
|
||||
// still render and overflow naturally rather than push out a blank page.
|
||||
const ensure = (needed) => {
|
||||
if (y + needed > size.height - M) {
|
||||
if (y > M && y + needed > size.height - M) {
|
||||
pdf.addPage();
|
||||
y = M;
|
||||
}
|
||||
@@ -51,47 +146,188 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
y += fs_ * leading;
|
||||
}
|
||||
};
|
||||
/** Render laid-out description items, preserving bold/italic/links/lists. */
|
||||
const writeDescription = (html, { size: baseSize = 10.5, color = [0, 0, 0], indent: indentBase = 0 } = {}) => {
|
||||
const { items } = layoutDescription(pdf, html, usableW - indentBase, baseSize);
|
||||
for (const item of items) {
|
||||
if (item.kind === 'hr') {
|
||||
ensure(12);
|
||||
pdf.rect(M + indentBase, y + 5, item.width, 0.8, { fill: [225, 228, 232] });
|
||||
y += 12;
|
||||
continue;
|
||||
}
|
||||
item.lines.forEach((line, idx) => {
|
||||
ensure(item.lineHeight);
|
||||
const textX = M + indentBase + item.indent;
|
||||
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color });
|
||||
const parts = line.map((word) => ({
|
||||
text: word.text,
|
||||
font: word.font,
|
||||
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color),
|
||||
}));
|
||||
pdf.textRun(parts, textX, y, item.size);
|
||||
y += item.lineHeight;
|
||||
});
|
||||
y += 4;
|
||||
}
|
||||
};
|
||||
|
||||
/** Height a callout block (emitBlock) will occupy, including its trailing gap. */
|
||||
const measureBlock = (tb) => {
|
||||
const { height: bodyH } = tb.descriptionHtml
|
||||
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
|
||||
: { height: 0 };
|
||||
return 16 + bodyH + 6;
|
||||
};
|
||||
|
||||
/** Height an image will occupy on the page, including its trailing gap. */
|
||||
const measureImage = (stepId) => {
|
||||
const img = images.get(stepId);
|
||||
if (!img) return 0;
|
||||
const maxH = usableH * tpl.imageMaxHeightRatio;
|
||||
let h = (img.height / img.width) * usableW;
|
||||
if (h > maxH) h = maxH;
|
||||
return h + 10;
|
||||
};
|
||||
|
||||
const measureCode = (block) => {
|
||||
const lines = String(codeBlockText(block) || '').split('\n');
|
||||
const lineH = 9 * 1.3;
|
||||
return lines.length * lineH + 16;
|
||||
};
|
||||
|
||||
const measureTable = (block) => {
|
||||
if (!block.rows || !block.rows.length) return 0;
|
||||
return block.rows.length * 16 + 8;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the vertical space a step will need: `head` is the title, the
|
||||
* accent rule, positioned text blocks, the description, and the image —
|
||||
* kept together on one page — and `total` adds the remaining blocks plus
|
||||
* the trailing gap, used to decide whether the whole step fits on a
|
||||
* fresh page.
|
||||
*/
|
||||
const measureStep = (step) => {
|
||||
const headSize = step.depth > 0 ? 12 : 14;
|
||||
const titleText = `${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`;
|
||||
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
|
||||
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
|
||||
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) {
|
||||
head += measureBlock(tb);
|
||||
}
|
||||
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
|
||||
head += measureImage(step.stepId);
|
||||
|
||||
let restHeight = 0;
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') restHeight += measureBlock(block);
|
||||
else if (block.kind === 'code') restHeight += measureCode(block);
|
||||
else if (block.kind === 'table') restHeight += measureTable(block);
|
||||
}
|
||||
|
||||
return { head, total: head + restHeight + 10 };
|
||||
};
|
||||
|
||||
pdf.addPage();
|
||||
|
||||
if (tpl.includeCover) {
|
||||
y = M + usableH * 0.18;
|
||||
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor });
|
||||
y += 6;
|
||||
writeLines(ast.guide.title, { size: 26, font: 'F2' });
|
||||
y += 8;
|
||||
if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] });
|
||||
// Keep the cover title near the top edge instead of vertically centering it.
|
||||
y = M;
|
||||
writeLines(ast.guide.title, { size: 28, font: 'F2' });
|
||||
y += 10;
|
||||
pdf.rect(M, y, usableW, 3, { fill: tpl.accentColor });
|
||||
y += 16;
|
||||
const meta = ast.guide.metadata || {};
|
||||
const metaLines = [
|
||||
meta.author && `Author: ${meta.author}`,
|
||||
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
|
||||
meta.organization && `Organization: ${meta.organization}`,
|
||||
].filter(Boolean);
|
||||
for (const line of metaLines) writeLines(line, { size: 11, color: [90, 90, 90] });
|
||||
if (metaLines.length) y += 8;
|
||||
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
|
||||
y += 14;
|
||||
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
|
||||
pdf.addPage();
|
||||
y = M;
|
||||
}
|
||||
|
||||
// Filled in below as each step claims its page, so the Contents entries
|
||||
// above (already laid out before pagination starts) can link to it.
|
||||
const tocTargets = new Map(); // stepId -> { pageIndex }
|
||||
|
||||
if (tpl.includeToc && ast.steps.length > 1) {
|
||||
writeLines('Contents', { size: 16, font: 'F2' });
|
||||
y += 4;
|
||||
const tocSize = 10.5;
|
||||
const lineH = tocSize * 1.35;
|
||||
for (const step of ast.steps) {
|
||||
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, {
|
||||
size: 10.5, indent: 14 * step.depth,
|
||||
});
|
||||
const indent = 14 * step.depth;
|
||||
const target = {};
|
||||
tocTargets.set(step.stepId, target);
|
||||
for (const line of pdf.wrapText(`${step.number}. ${step.title || 'Untitled step'}`, tocSize, usableW - indent, 'F1')) {
|
||||
ensure(lineH);
|
||||
pdf.text(line, M + indent, y, { size: tocSize, color: tpl.accentColor });
|
||||
pdf.linkRect(M + indent, y, usableW - indent, lineH, target);
|
||||
y += lineH;
|
||||
}
|
||||
}
|
||||
pdf.addPage();
|
||||
y = M;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
let forcedFresh = false;
|
||||
const pageBottom = size.height - M;
|
||||
for (const step of ast.steps) {
|
||||
if (step.forceNewPage && !first) { pdf.addPage(); y = M; }
|
||||
const { head: headHeight, total: totalHeight } = measureStep(step);
|
||||
// Start a fresh page when: the step's "New page" toggle is set; the
|
||||
// previous step overflowed past one page (so this step shouldn't share
|
||||
// the spillover page); or this step doesn't fit in what's left of the
|
||||
// current page. The last check is skipped when already at the top of a
|
||||
// page, so an overlong step doesn't push out a blank page first.
|
||||
const needsFreshPage = !first && (
|
||||
step.forceNewPage || forcedFresh || (y > M && y + totalHeight > pageBottom)
|
||||
);
|
||||
if (needsFreshPage) { pdf.addPage(); y = M; }
|
||||
first = false;
|
||||
ensure(40);
|
||||
// Keep the title, accent rule, lead-in blocks, description, and image
|
||||
// together — never split across a page boundary.
|
||||
ensure(headHeight);
|
||||
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
|
||||
const tocTarget = tocTargets.get(step.stepId);
|
||||
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
rest,
|
||||
} = stepContentGroups(step);
|
||||
for (const tb of beforeTitle) emitBlock(tb);
|
||||
const headSize = step.depth > 0 ? 12 : 14;
|
||||
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
|
||||
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
|
||||
y += 8;
|
||||
|
||||
emitBlocks(step, 'before-description');
|
||||
if (step.descriptionText) { writeLines(step.descriptionText); y += 4; }
|
||||
for (const tb of afterTitle) emitBlock(tb);
|
||||
for (const tb of beforeDescription) emitBlock(tb);
|
||||
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
|
||||
for (const tb of afterDescription) emitBlock(tb);
|
||||
for (const tb of beforeImage) emitBlock(tb);
|
||||
|
||||
const img = images.get(step.stepId);
|
||||
if (img) {
|
||||
@@ -103,9 +339,12 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
pdf.image(img, M, y, w, h);
|
||||
y += h + 10;
|
||||
}
|
||||
for (const tb of afterImage) emitBlock(tb);
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitBlock(block);
|
||||
} else if (block.kind === 'code') {
|
||||
const lines = String(codeBlockText(block) || '').split('\n');
|
||||
const lineH = 9 * 1.3;
|
||||
ensure(Math.min(lines.length, 4) * lineH + 12);
|
||||
@@ -138,26 +377,42 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
emitBlocks(step, 'after-description');
|
||||
emitBlocks(step, 'after-image');
|
||||
y += 10;
|
||||
forcedFresh = totalHeight > usableH;
|
||||
}
|
||||
|
||||
function emitBlocks(step, position) {
|
||||
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : [];
|
||||
const blockH = 16 + bodyLines.length * 13;
|
||||
ensure(blockH + 4);
|
||||
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
|
||||
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
|
||||
let by = y + 16;
|
||||
for (const line of bodyLines) {
|
||||
pdf.text(line, M + 10, by, { size: 9.5 });
|
||||
by += 13;
|
||||
function emitBlock(tb) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const { items, height: bodyH } = tb.descriptionHtml
|
||||
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
|
||||
: { items: [], height: 0 };
|
||||
const blockH = 16 + bodyH;
|
||||
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
|
||||
ensure(blockH + 4);
|
||||
pdf.rect(M, y, usableW, blockH, { fill: style.tint });
|
||||
pdf.rect(M, y, 3, blockH, { fill: style.accent });
|
||||
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent });
|
||||
let by = y + 16;
|
||||
for (const item of items) {
|
||||
if (item.kind === 'hr') {
|
||||
pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] });
|
||||
by += 12;
|
||||
continue;
|
||||
}
|
||||
y += blockH + 6;
|
||||
item.lines.forEach((line, idx) => {
|
||||
const textX = M + 10 + item.indent;
|
||||
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' });
|
||||
const parts = line.map((word) => ({
|
||||
text: word.text,
|
||||
font: word.font,
|
||||
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]),
|
||||
}));
|
||||
pdf.textRun(parts, textX, by, item.size);
|
||||
by += item.lineHeight;
|
||||
});
|
||||
by += 4;
|
||||
}
|
||||
y += blockH + 6;
|
||||
}
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
@@ -5,21 +5,41 @@ const path = require('node:path');
|
||||
const { zipSync } = require('../core/zip');
|
||||
const { escapeXml } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
const { guideSlug, renderAllImages } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('./common');
|
||||
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
|
||||
|
||||
/**
|
||||
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar +
|
||||
* screenshot + description). PresentationML written directly.
|
||||
* PPTX exporter: a title slide plus one 16:9 slide per step, with
|
||||
* positioned text blocks laid out around the step title, description, and
|
||||
* screenshot. PresentationML written directly.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
titleSlide: true,
|
||||
includeToc: true,
|
||||
};
|
||||
|
||||
const SLIDE_W = 12192000; // EMU, 16:9
|
||||
const SLIDE_H = 6858000;
|
||||
const EMU_PER_PX = 9525;
|
||||
const SLIDE_MARGIN = 914400;
|
||||
const TITLE_Y = 420000;
|
||||
const TITLE_H = 620000;
|
||||
const TITLE_RULE_Y = 1120000;
|
||||
const CONTENT_Y = 1500000;
|
||||
const CONTENT_FOOTER_Y = 720000;
|
||||
const CALL_OUT_HEIGHT = 620000;
|
||||
const CALL_OUT_GAP = 90000;
|
||||
const CALL_OUT_BAR_W = 24000;
|
||||
const TOC_ENTRY_START_Y = 2300000;
|
||||
const TOC_ENTRY_SPACING = 255000;
|
||||
const TOC_ENTRY_HEIGHT = 220000;
|
||||
const TOC_BOTTOM_MARGIN = 500000;
|
||||
const TOC_MAX_ENTRIES_PER_SLIDE = Math.max(
|
||||
1,
|
||||
Math.floor((SLIDE_H - TOC_BOTTOM_MARGIN - TOC_ENTRY_START_Y - TOC_ENTRY_HEIGHT) / TOC_ENTRY_SPACING) + 1,
|
||||
);
|
||||
|
||||
let shapeIdCounter = 10; // reset per export for deterministic output
|
||||
|
||||
@@ -29,6 +49,12 @@ function textBox(x, y, w, h, runsXml) {
|
||||
`<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`;
|
||||
}
|
||||
|
||||
function rectShape(x, y, w, h, fill) {
|
||||
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeIdCounter++}" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
|
||||
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>` +
|
||||
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="${fill}"/></a:solidFill><a:ln><a:noFill/></a:ln></p:spPr></p:sp>`;
|
||||
}
|
||||
|
||||
function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
|
||||
return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`;
|
||||
}
|
||||
@@ -39,6 +65,49 @@ function picture(relId, x, y, w, h) {
|
||||
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
|
||||
}
|
||||
|
||||
const CALLOUT_STYLE = {
|
||||
info: { fill: 'EFF6FF', accent: '2563EB', label: 'Note', color: '1D4ED8' },
|
||||
success: { fill: 'ECFDF5', accent: '10B981', label: 'Tip', color: '047857' },
|
||||
warn: { fill: 'FFFBEB', accent: 'F59E0B', label: 'Warning', color: 'B45309' },
|
||||
error: { fill: 'FEF2F2', accent: 'EF4444', label: 'Important', color: 'B91C1C' },
|
||||
};
|
||||
|
||||
function estimateWrappedLines(text, charsPerLine) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) return 0;
|
||||
return raw.split(/\n+/).reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
|
||||
}
|
||||
|
||||
function calloutHeight(tb) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const lines = estimateWrappedLines(label, 46) + estimateWrappedLines(tb.descriptionText, 72);
|
||||
return Math.max(CALL_OUT_HEIGHT, 360000 + (lines * 150000));
|
||||
}
|
||||
|
||||
function calloutXml(tb, x, y, w) {
|
||||
const style = CALLOUT_STYLE[tb.level] || CALLOUT_STYLE.info;
|
||||
const height = calloutHeight(tb);
|
||||
const label = `${style.label}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const titlePara = para(label, { size: 1400, bold: true, color: style.color });
|
||||
const bodyPara = tb.descriptionText ? para(tb.descriptionText.slice(0, 400), { size: 1250, color: '374151' }) : '';
|
||||
const innerX = x + CALL_OUT_BAR_W + 24000;
|
||||
const innerW = Math.max(0, w - CALL_OUT_BAR_W - 72000);
|
||||
return {
|
||||
height,
|
||||
xml: [
|
||||
rectShape(x, y, w, height, style.fill),
|
||||
rectShape(x, y, CALL_OUT_BAR_W, height, style.accent),
|
||||
textBox(innerX, y + 12000, innerW, Math.max(0, height - 24000), `${titlePara}${bodyPara}`),
|
||||
].join(''),
|
||||
};
|
||||
}
|
||||
|
||||
function descriptionHeight(text) {
|
||||
const lines = estimateWrappedLines(text, 95);
|
||||
if (!lines) return 0;
|
||||
return Math.max(360000, 260000 + (lines * 130000));
|
||||
}
|
||||
|
||||
function slideXml(content) {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
@@ -50,6 +119,33 @@ ${content}
|
||||
</p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
|
||||
}
|
||||
|
||||
function chunkArray(items, size) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function tocSlideXml(ast, entries, { continued = false } = {}) {
|
||||
let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
|
||||
tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000,
|
||||
para(continued ? 'Contents (continued)' : 'Contents', { size: 3000, bold: true }));
|
||||
tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB');
|
||||
tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000,
|
||||
para(guideSummary(ast), { size: 1500, color: '6B7280' }));
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const x = 914400 + (entry.depth * 220000);
|
||||
const y = TOC_ENTRY_START_Y + (index * TOC_ENTRY_SPACING);
|
||||
tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB');
|
||||
tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, TOC_ENTRY_HEIGHT,
|
||||
para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 }));
|
||||
});
|
||||
|
||||
return slideXml(tocContent);
|
||||
}
|
||||
|
||||
const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
|
||||
<a:themeElements>
|
||||
@@ -85,22 +181,91 @@ function exportPptx(ast, outDir, template = {}) {
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] }
|
||||
const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : [];
|
||||
|
||||
if (tpl.titleSlide) {
|
||||
const metaLines = guideMetaLines(ast);
|
||||
let titleContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
|
||||
titleContent += textBox(SLIDE_MARGIN, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true }));
|
||||
titleContent += rectShape(SLIDE_MARGIN, 3300000, 2200000, 14000, '2563EB');
|
||||
titleContent += textBox(SLIDE_MARGIN, 3500000, SLIDE_W - 1828800, 1100000,
|
||||
[para(guideSummary(ast), { size: 1800, color: '6B7280' }),
|
||||
...metaLines.map((line) => para(line, { size: 1500, color: '6B7280' }))].join(''));
|
||||
slides.push({
|
||||
xml: slideXml(
|
||||
textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) +
|
||||
textBox(914400, 3600000, SLIDE_W - 1828800, 800000,
|
||||
para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' }))
|
||||
),
|
||||
xml: slideXml(titleContent),
|
||||
rels: [], media: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (toc.length) {
|
||||
const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE);
|
||||
tocPages.forEach((page, index) => {
|
||||
slides.push({
|
||||
xml: tocSlideXml(ast, page, { continued: index > 0 }),
|
||||
rels: [], media: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let mediaCounter = 0;
|
||||
for (const step of ast.steps) {
|
||||
let content = textBox(457200, 274638, SLIDE_W - 914400, 700000,
|
||||
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true }));
|
||||
const {
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
beforeDescription,
|
||||
afterDescription,
|
||||
beforeImage,
|
||||
afterImage,
|
||||
} = stepContentGroups(step);
|
||||
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
|
||||
const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
|
||||
const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
|
||||
const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y;
|
||||
const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y;
|
||||
const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y;
|
||||
|
||||
let y = beforeTitleY;
|
||||
for (const tb of beforeTitle) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
|
||||
content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
|
||||
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
|
||||
content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB');
|
||||
|
||||
y = contentY;
|
||||
for (const tb of afterTitle) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
for (const tb of beforeDescription) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
|
||||
if (step.descriptionText) {
|
||||
const descH = descriptionHeight(step.descriptionText);
|
||||
content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000),
|
||||
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
|
||||
y += Math.max(360000, descH || 420000) + 90000;
|
||||
}
|
||||
|
||||
for (const tb of afterDescription) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
for (const tb of beforeImage) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
|
||||
const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
|
||||
const rels = [];
|
||||
const media = [];
|
||||
|
||||
@@ -111,16 +276,20 @@ function exportPptx(ast, outDir, template = {}) {
|
||||
media.push({ name, data: encodePng(img) });
|
||||
const relId = 2; // rId1 = layout, rId2 = image
|
||||
rels.push({ id: relId, name });
|
||||
// Fit image into a centered region below the title.
|
||||
const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000);
|
||||
// Fit image into the remaining centered region before the trailing blocks.
|
||||
const maxW = SLIDE_W - 1219200;
|
||||
const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 100000);
|
||||
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
|
||||
const scale = Math.min(maxW / w, maxH / h, 1);
|
||||
w = Math.round(w * scale); h = Math.round(h * scale);
|
||||
content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h);
|
||||
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
|
||||
y += h + 100000;
|
||||
}
|
||||
if (step.descriptionText) {
|
||||
content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000,
|
||||
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
|
||||
|
||||
for (const tb of afterImage) {
|
||||
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
|
||||
content += block.xml;
|
||||
y += block.height + CALL_OUT_GAP;
|
||||
}
|
||||
slides.push({ xml: slideXml(content), rels, media });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use strict';
|
||||
|
||||
const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide');
|
||||
|
||||
/**
|
||||
* Wiki.js markdown exporter. Same step/body structure as the generic
|
||||
* Markdown exporter, but uses Wiki.js-friendly callout blocks.
|
||||
*/
|
||||
|
||||
const WIKIJS_TEMPLATE = {
|
||||
toc: true,
|
||||
includeImages: true,
|
||||
imageMaxWidth: 0,
|
||||
};
|
||||
|
||||
function exportWikiJs(ast, outDir, template = {}) {
|
||||
return renderMarkdownGuide(ast, outDir, template, {
|
||||
defaults: WIKIJS_TEMPLATE,
|
||||
alertStyle: 'wikijs',
|
||||
tocTitle: 'Contents',
|
||||
fileExt: '.md',
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { exportWikiJs, DEFAULT_TEMPLATE: WIKIJS_TEMPLATE };
|
||||
@@ -8,6 +8,10 @@
|
||||
"name": "stepforge",
|
||||
"version": "0.1.0",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@tesseract.js-data/eng": "^1.0.0",
|
||||
"tesseract.js": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.2"
|
||||
@@ -624,6 +628,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tesseract.js-data/eng": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tesseract.js-data/eng/-/eng-1.0.0.tgz",
|
||||
"integrity": "sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||
@@ -1057,6 +1067,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bmp-js": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@@ -2512,6 +2528,12 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idb-keyval": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz",
|
||||
"integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -2541,6 +2563,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-url": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
@@ -2903,6 +2931,26 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp": {
|
||||
"version": "12.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||
@@ -3024,6 +3072,15 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/opencollective-postinstall": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"opencollective-postinstall": "index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
@@ -3314,6 +3371,12 @@
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -3728,6 +3791,30 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bmp-js": "^0.1.0",
|
||||
"idb-keyval": "^6.2.0",
|
||||
"is-url": "^1.2.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"opencollective-postinstall": "^2.0.3",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"tesseract.js-core": "^7.0.0",
|
||||
"wasm-feature-detect": "^1.8.0",
|
||||
"zlibjs": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tesseract.js-core": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tiny-async-pool": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||
@@ -3785,6 +3872,12 @@
|
||||
"tmp": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/truncate-utf8-bytes": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||
@@ -3909,6 +4002,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webcrypto-core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
||||
@@ -3923,6 +4022,22 @@
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
@@ -4043,6 +4158,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zlibjs": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"verify": "bash scripts/verify.sh",
|
||||
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tesseract.js-data/eng": "^1.0.0",
|
||||
"tesseract.js": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^41.7.1",
|
||||
"electron-builder": "^26.15.2"
|
||||
|
||||
@@ -97,7 +97,7 @@ Host: ${process.platform} ${process.arch} (node ${process.version})
|
||||
- Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'}
|
||||
- Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'}
|
||||
- Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'}
|
||||
- Sample exports (9 formats): see examples/sample-exports/
|
||||
- Sample exports (10 formats): see examples/sample-exports/
|
||||
- Full artifact list with sha256 checksums: artifacts_manifest.json
|
||||
|
||||
## Packaging tool availability
|
||||
@@ -109,7 +109,7 @@ ${toolRows}
|
||||
Fallback policy: when a packaging tool is missing the build still produces
|
||||
the runnable app (portable tarball with launcher) plus whatever package
|
||||
formats the available tools allow. Windows artifacts are produced by
|
||||
\`npm run package:windows\` (electron-builder, portable .exe); .msi/.rpm/
|
||||
\`npm run package:windows\` (electron-builder, installer .exe); .msi/.rpm/
|
||||
AppImage require the tools listed above and are skipped on this host.
|
||||
|
||||
## Offline guarantee
|
||||
|
||||
@@ -10,6 +10,11 @@ const ELECTRON_SKIP_ENV_KEYS = [
|
||||
'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD',
|
||||
];
|
||||
|
||||
const NPM_IGNORE_SCRIPTS_ENV_KEYS = [
|
||||
'npm_config_ignore_scripts',
|
||||
'NPM_CONFIG_IGNORE_SCRIPTS',
|
||||
];
|
||||
|
||||
function resolveElectronPackageRoot() {
|
||||
try {
|
||||
return path.dirname(require.resolve('electron/package.json'));
|
||||
@@ -48,6 +53,9 @@ function sanitizeElectronEnv(baseEnv = process.env) {
|
||||
for (const key of ELECTRON_SKIP_ENV_KEYS) {
|
||||
delete env[key];
|
||||
}
|
||||
for (const key of NPM_IGNORE_SCRIPTS_ENV_KEYS) {
|
||||
delete env[key];
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
@@ -67,8 +75,10 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
|
||||
return candidatePaths;
|
||||
}
|
||||
|
||||
function runNpmRebuild({
|
||||
function runNpmCommand({
|
||||
packageRoot,
|
||||
npmArgs,
|
||||
errorLabel,
|
||||
npmExecPath = process.env.npm_execpath || null,
|
||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||
}) {
|
||||
@@ -76,31 +86,63 @@ function runNpmRebuild({
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
npmNodeExecPath,
|
||||
[npmExecPath, 'rebuild', 'electron', '--force', '--foreground-scripts'],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
env: sanitizeElectronEnv(),
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
|
||||
cwd: packageRoot,
|
||||
env: sanitizeElectronEnv(),
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
throw new Error(`Electron repair was interrupted by ${result.signal}`);
|
||||
throw new Error(`${errorLabel} was interrupted by ${result.signal}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Electron rebuild failed with exit code ${result.status ?? 1}`);
|
||||
throw new Error(`${errorLabel} failed with exit code ${result.status ?? 1}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function runNpmRebuild({
|
||||
packageRoot,
|
||||
npmExecPath = process.env.npm_execpath || null,
|
||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||
}) {
|
||||
return runNpmCommand({
|
||||
packageRoot,
|
||||
npmArgs: ['rebuild', 'electron', '--force', '--foreground-scripts'],
|
||||
errorLabel: 'Electron rebuild',
|
||||
npmExecPath,
|
||||
npmNodeExecPath,
|
||||
});
|
||||
}
|
||||
|
||||
function runNpmInstall({
|
||||
packageRoot,
|
||||
npmExecPath = process.env.npm_execpath || null,
|
||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||
}) {
|
||||
return runNpmCommand({
|
||||
packageRoot,
|
||||
npmArgs: [
|
||||
'install',
|
||||
'--include=dev',
|
||||
'--ignore-scripts=false',
|
||||
'--foreground-scripts',
|
||||
'--no-audit',
|
||||
'--no-fund',
|
||||
'--package-lock=false',
|
||||
],
|
||||
errorLabel: 'Electron dependency install',
|
||||
npmExecPath,
|
||||
npmNodeExecPath,
|
||||
});
|
||||
}
|
||||
|
||||
function repairElectronInstall({
|
||||
packageRoot,
|
||||
}) {
|
||||
@@ -153,45 +195,93 @@ function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
|
||||
|
||||
function resolveElectronBinary({
|
||||
packageRoot = resolveElectronPackageRoot(),
|
||||
projectRoot = process.cwd(),
|
||||
platform = process.platform,
|
||||
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
|
||||
} = {}) {
|
||||
if (!packageRoot && !overrideDistPath) {
|
||||
const repairErrors = [];
|
||||
|
||||
function resolveCurrentPackageRoot() {
|
||||
if (packageRoot) return packageRoot;
|
||||
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
|
||||
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
|
||||
packageRoot = conventionalRoot;
|
||||
return packageRoot;
|
||||
}
|
||||
packageRoot = resolveElectronPackageRoot();
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
function tryRepair(label, repairFn) {
|
||||
try {
|
||||
if (!repairFn()) {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
repairErrors.push(`${label}: ${error && error.message ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPackageRoot = resolveCurrentPackageRoot();
|
||||
if (!currentPackageRoot && !overrideDistPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
|
||||
return electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform }).find((candidate) =>
|
||||
fs.existsSync(candidate)
|
||||
);
|
||||
}
|
||||
|
||||
let currentPackageRoot = resolveCurrentPackageRoot();
|
||||
if (!currentPackageRoot && !overrideDistPath) {
|
||||
const installed = tryRepair('Electron dependency install', () =>
|
||||
runNpmInstall({ packageRoot: projectRoot })
|
||||
);
|
||||
if (installed) {
|
||||
return installed;
|
||||
}
|
||||
|
||||
currentPackageRoot = resolveCurrentPackageRoot();
|
||||
}
|
||||
|
||||
if (!currentPackageRoot && !overrideDistPath) {
|
||||
throw new Error(
|
||||
'Electron could not be started because node_modules/electron is not installed.\n\n' +
|
||||
'Run `npm install` from the repo root, then try `npm start` again.'
|
||||
);
|
||||
}
|
||||
|
||||
const distDir = overrideDistPath || path.join(packageRoot, 'dist');
|
||||
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
|
||||
|
||||
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||
if (!resolved) {
|
||||
if (packageRoot) {
|
||||
if (runNpmRebuild({ packageRoot })) {
|
||||
const rebuilt = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) =>
|
||||
fs.existsSync(candidate)
|
||||
);
|
||||
if (rebuilt) {
|
||||
return rebuilt;
|
||||
}
|
||||
}
|
||||
|
||||
if (repairElectronInstall({ packageRoot })) {
|
||||
const repaired = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) =>
|
||||
fs.existsSync(candidate)
|
||||
);
|
||||
if (repaired) {
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
|
||||
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
|
||||
let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
|
||||
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
const repairAttempts = [
|
||||
['Electron rebuild', () => runNpmRebuild({ packageRoot: currentPackageRoot })],
|
||||
['Electron install repair', () => repairElectronInstall({ packageRoot: currentPackageRoot })],
|
||||
['Electron dependency install', () => runNpmInstall({ packageRoot: projectRoot })],
|
||||
];
|
||||
|
||||
for (const [label, repairFn] of repairAttempts) {
|
||||
const repaired = tryRepair(label, repairFn);
|
||||
if (repaired) {
|
||||
return repaired;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
buildMissingElectronError({
|
||||
packageRoot: currentPackageRoot,
|
||||
distDir,
|
||||
candidatePaths,
|
||||
}) +
|
||||
(repairErrors.length
|
||||
? `\n\nAutomatic repair attempts failed:\n${repairErrors.map((error) => ` - ${error}`).join('\n')}`
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -200,6 +290,7 @@ module.exports = {
|
||||
readElectronPathHint,
|
||||
repairElectronInstall,
|
||||
runNpmRebuild,
|
||||
runNpmInstall,
|
||||
sanitizeElectronEnv,
|
||||
resolveElectronBinary,
|
||||
resolveElectronPackageRoot,
|
||||
|
||||
@@ -223,7 +223,7 @@ function createGuide(store) {
|
||||
|
||||
function exportOutputs(store, guideId, root, manifest) {
|
||||
const ast = buildRenderAst(store, guideId);
|
||||
const formats = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
const formats = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
const outputs = {};
|
||||
for (const format of formats) {
|
||||
const outDir = path.join(root, 'sample-exports', format);
|
||||
|
||||
@@ -9,12 +9,9 @@ const { build, Platform } = require('electron-builder');
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json'));
|
||||
const RELEASE_DIR = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases'));
|
||||
const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-'));
|
||||
const OUTPUT_DIR = path.join(WORK_DIR, 'output');
|
||||
const ARTIFACT_NAME = 'stepforge-windows-x64-portable.exe';
|
||||
const APP_ID = 'com.stepforge.app';
|
||||
|
||||
function findPortableExe(dir) {
|
||||
function findInstallerExe(dir) {
|
||||
if (!fs.existsSync(dir)) return null;
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
@@ -31,15 +28,12 @@ function findPortableExe(dir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(RELEASE_DIR, { recursive: true });
|
||||
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
|
||||
|
||||
const config = {
|
||||
appId: 'com.stepforge.app',
|
||||
function createWindowsInstallerConfig(outputDir) {
|
||||
return {
|
||||
appId: APP_ID,
|
||||
productName: 'StepForge',
|
||||
directories: {
|
||||
output: OUTPUT_DIR,
|
||||
output: outputDir,
|
||||
},
|
||||
files: [
|
||||
'app/**/*',
|
||||
@@ -49,33 +43,63 @@ async function main() {
|
||||
],
|
||||
asar: true,
|
||||
compression: 'normal',
|
||||
artifactName: ARTIFACT_NAME,
|
||||
win: {
|
||||
target: ['portable'],
|
||||
target: ['nsis'],
|
||||
},
|
||||
nsis: {
|
||||
oneClick: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true,
|
||||
createStartMenuShortcut: true,
|
||||
shortcutName: 'StepForge',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await build({
|
||||
targets: Platform.WINDOWS.createTarget('portable'),
|
||||
config,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Windows portable build failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const builtExe = findPortableExe(OUTPUT_DIR);
|
||||
if (!builtExe) {
|
||||
throw new Error(`No .exe artifact was produced in ${OUTPUT_DIR}`);
|
||||
}
|
||||
|
||||
const releaseExe = path.join(RELEASE_DIR, path.basename(builtExe));
|
||||
fs.copyFileSync(builtExe, releaseExe);
|
||||
|
||||
console.log(`StepForge ${PACKAGE_JSON.version} Windows portable build written to ${releaseExe}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message || err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
function createWindowsInstallerBuildOptions(outputDir) {
|
||||
return {
|
||||
targets: Platform.WINDOWS.createTarget('nsis'),
|
||||
config: createWindowsInstallerConfig(outputDir),
|
||||
publish: 'never',
|
||||
};
|
||||
}
|
||||
|
||||
async function buildWindowsInstaller() {
|
||||
const releaseDir = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases'));
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-'));
|
||||
const outputDir = path.join(workDir, 'output');
|
||||
|
||||
fs.mkdirSync(releaseDir, { recursive: true });
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
await build(createWindowsInstallerBuildOptions(outputDir));
|
||||
} catch (err) {
|
||||
throw new Error(`Windows installer build failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const builtInstaller = findInstallerExe(outputDir);
|
||||
if (!builtInstaller) {
|
||||
throw new Error(`No installer .exe artifact was produced in ${outputDir}`);
|
||||
}
|
||||
|
||||
const releaseInstaller = path.join(releaseDir, path.basename(builtInstaller));
|
||||
fs.copyFileSync(builtInstaller, releaseInstaller);
|
||||
|
||||
console.log(`StepForge ${PACKAGE_JSON.version} Windows installer written to ${releaseInstaller}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
buildWindowsInstaller().catch((err) => {
|
||||
console.error(err.message || err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APP_ID,
|
||||
createWindowsInstallerConfig,
|
||||
createWindowsInstallerBuildOptions,
|
||||
findInstallerExe,
|
||||
buildWindowsInstaller,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ for f in sample-manifest.json sample-guide.sfgz; do
|
||||
done
|
||||
|
||||
for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \
|
||||
sample-exports/html-rich sample-exports/pdf sample-exports/gif \
|
||||
sample-exports/wikijs sample-exports/html-rich sample-exports/pdf sample-exports/gif \
|
||||
sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do
|
||||
if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then
|
||||
echo "Sample export directory is empty: $dir" >&2
|
||||
@@ -34,7 +34,7 @@ const fs = require('node:fs');
|
||||
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8'));
|
||||
if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format');
|
||||
if (!manifest.guideId) throw new Error('missing guideId');
|
||||
if (!manifest.exports || Object.keys(manifest.exports).length < 9) throw new Error('missing sample exports');
|
||||
if (!manifest.exports || Object.keys(manifest.exports).length < 10) throw new Error('missing sample exports');
|
||||
NODE
|
||||
|
||||
echo "sample artifacts OK"
|
||||
|
||||
@@ -375,7 +375,7 @@ test('queued click captures preserve the original event time and button', async
|
||||
assert.deepEqual(seen, [{
|
||||
trigger: 'click',
|
||||
clickPos: { x: 7, y: 8 },
|
||||
clickMeta: { at: 1770000000456, button: 'left' },
|
||||
clickMeta: { at: 1770000000456, button: 'left', keyContext: { recentTyped: '', recentShortcut: '' } },
|
||||
}]);
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,46 @@ test('rebuilds Electron through npm when the binary is missing', (t) => {
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to npm install when rebuild does not repair the runtime', (t) => {
|
||||
const root = makeTmpDir('electron-install-fallback');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
fs.mkdirSync(path.join(root, 'dist'), { recursive: true });
|
||||
const fakeNpmCli = path.join(root, 'fake-npm-cli.js');
|
||||
fs.writeFileSync(
|
||||
fakeNpmCli,
|
||||
[
|
||||
"const fs = require('node:fs');",
|
||||
"const path = require('node:path');",
|
||||
"const command = process.argv[2];",
|
||||
"if (command === 'rebuild') process.exit(1);",
|
||||
"if (command === 'install') {",
|
||||
" fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true });",
|
||||
" fs.writeFileSync(path.join(__dirname, 'dist', 'electron.exe'), 'binary');",
|
||||
" fs.writeFileSync(path.join(__dirname, 'path.txt'), 'electron.exe');",
|
||||
" process.exit(0);",
|
||||
"}",
|
||||
"process.exit(1);",
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const originalNpmExecPath = process.env.npm_execpath;
|
||||
const originalNpmNodeExecPath = process.env.npm_node_execpath;
|
||||
process.env.npm_execpath = fakeNpmCli;
|
||||
process.env.npm_node_execpath = process.execPath;
|
||||
t.after(() => {
|
||||
if (originalNpmExecPath === undefined) delete process.env.npm_execpath;
|
||||
else process.env.npm_execpath = originalNpmExecPath;
|
||||
if (originalNpmNodeExecPath === undefined) delete process.env.npm_node_execpath;
|
||||
else process.env.npm_node_execpath = originalNpmNodeExecPath;
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
resolveElectronBinary({ packageRoot: root, projectRoot: root, platform: 'win32' }),
|
||||
path.join(root, 'dist', 'electron.exe')
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a helpful error when the runtime is missing', (t) => {
|
||||
const root = makeTmpDir('electron-missing');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runExportInWorker } = require('../../app/export-runner');
|
||||
const { buildRenderAst } = require('../../core/renderast');
|
||||
const { runExport } = require('../../exporters');
|
||||
const { buildFixtureGuide } = require('./fixture-guide');
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
|
||||
test('export helper process produces the same result as an in-process export', async (t) => {
|
||||
const root = makeTmpDir('exportworker');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
const expected = runExport('json', buildRenderAst(store, guide.guideId), path.join(root, 'inproc'));
|
||||
|
||||
const result = await runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId: guide.guideId,
|
||||
format: 'json',
|
||||
options: {},
|
||||
outDir: path.join(root, 'worker'),
|
||||
globals: {},
|
||||
});
|
||||
|
||||
assert.equal(result.imageCount, expected.imageCount);
|
||||
assert.ok(fs.existsSync(result.file));
|
||||
const fromWorker = JSON.parse(fs.readFileSync(result.file, 'utf8'));
|
||||
const fromInProcess = JSON.parse(fs.readFileSync(expected.file, 'utf8'));
|
||||
// Each build stamps its own generatedAt; everything else must match exactly.
|
||||
delete fromWorker.generatedAt;
|
||||
delete fromInProcess.generatedAt;
|
||||
assert.deepEqual(fromWorker, fromInProcess);
|
||||
});
|
||||
|
||||
test('export helper process rejects on an unknown format', async (t) => {
|
||||
const root = makeTmpDir('exportworkerbadfmt');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
await assert.rejects(
|
||||
runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId: guide.guideId,
|
||||
format: 'exe',
|
||||
options: {},
|
||||
outDir: path.join(root, 'out'),
|
||||
globals: {},
|
||||
}),
|
||||
/unknown export format/,
|
||||
);
|
||||
});
|
||||
|
||||
test('export helper process rejects on an unknown guide id', async (t) => {
|
||||
const root = makeTmpDir('exportworkerbadguide');
|
||||
t.after(() => rmrf(root));
|
||||
const { store } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
await assert.rejects(
|
||||
runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId: 'guide_does_not_exist',
|
||||
format: 'json',
|
||||
options: {},
|
||||
outDir: path.join(root, 'out'),
|
||||
globals: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,10 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zlib = require('node:zlib');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { GuideStore } = require('../../core/store');
|
||||
const { buildRenderAst } = require('../../core/renderast');
|
||||
const { exportPdf } = require('../../exporters/pdf');
|
||||
const { exportGifGuide } = require('../../exporters/gif');
|
||||
@@ -25,6 +27,52 @@ function hasTool(cmd) {
|
||||
try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Inflate each page's content stream, in page order, for op-level assertions. */
|
||||
function pageContents(buf) {
|
||||
const text = buf.toString('latin1');
|
||||
const out = [];
|
||||
const re = /\d+ 0 obj\n<< \/Filter \/FlateDecode \/Length (\d+) >>\nstream\n/g;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const len = Number(m[1]);
|
||||
const start = m.index + m[0].length;
|
||||
out.push(zlib.inflateSync(buf.subarray(start, start + len)).toString('latin1'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map each step bookmark's title to the 0-based page index it lands on. */
|
||||
function bookmarkPages(buf) {
|
||||
const text = buf.toString('latin1');
|
||||
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
|
||||
.map((m) => Number(m[1]));
|
||||
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
|
||||
const out = [];
|
||||
for (const m of text.matchAll(/\/Title \(([^)]*)\)([^>]*)/g)) {
|
||||
const dest = /\/Dest \[(\d+) 0 R/.exec(m[2]);
|
||||
if (dest) out.push({ title: m[1], pageIndex: pageIndexOf.get(Number(dest[1])) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resolve each Link annotation's `/Dest` on the page at `pageIndex` to a 0-based page index. */
|
||||
function tocLinkTargets(buf, pageIndex) {
|
||||
const text = buf.toString('latin1');
|
||||
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
|
||||
.map((m) => Number(m[1]));
|
||||
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
|
||||
const objBody = (id) => new RegExp(`(?:^|\\n)${id} 0 obj\\n([\\s\\S]*?)\\nendobj`).exec(text)[1];
|
||||
|
||||
const annots = /\/Annots \[([^\]]+)\]/.exec(objBody(kids[pageIndex]));
|
||||
if (!annots) return [];
|
||||
return [...annots[1].matchAll(/(\d+) 0 R/g)].map((m) => {
|
||||
const body = objBody(Number(m[1]));
|
||||
assert.match(body, /\/Subtype \/Link/);
|
||||
const dest = /\/Dest \[(\d+) 0 R/.exec(body);
|
||||
return pageIndexOf.get(Number(dest[1]));
|
||||
});
|
||||
}
|
||||
|
||||
/** Tiny XML well-formedness check: balanced tags, single root. */
|
||||
function assertWellFormedXml(xml, label) {
|
||||
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim();
|
||||
@@ -73,6 +121,20 @@ test('PDF export: valid document, bookmarks per step, images embedded', (t) => {
|
||||
assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2);
|
||||
});
|
||||
|
||||
test('PDF Contents: each entry is a clickable link to its step\'s page', (t) => {
|
||||
const { ast, root } = fixtureAst(t, 'pdftoc');
|
||||
const buf = fs.readFileSync(exportPdf(ast, path.join(root, 'out')).file);
|
||||
|
||||
const bookmarks = bookmarkPages(buf); // one per step, in document order
|
||||
const tocTargets = tocLinkTargets(buf, 1); // page 0 = cover, page 1 = Contents
|
||||
const tocPage = pageContents(buf)[1];
|
||||
const blueTocLines = [...tocPage.matchAll(/BT \/F1 10\.5 Tf 0\.145 0\.388 0\.922 rg 1 0 0 1 [\d.]+ [\d.]+ Tm \(([^)]*)\) Tj ET/g)]
|
||||
.map((m) => m[1]);
|
||||
|
||||
assert.deepEqual(tocTargets, bookmarks.map((b) => b.pageIndex));
|
||||
assert.deepEqual(blueTocLines, ast.steps.map((step) => `${step.number}. ${step.title || 'Untitled step'}`));
|
||||
});
|
||||
|
||||
test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => {
|
||||
const { ast, root } = fixtureAst(t, 'pdfgs');
|
||||
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));
|
||||
@@ -80,6 +142,83 @@ test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) =
|
||||
assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`));
|
||||
});
|
||||
|
||||
test('PDF cover: title in big text above the accent rule, guide metadata below it', (t) => {
|
||||
const root = makeTmpDir('pdfcover');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
// No metadata set: cover has no Author/Co-authors/Organization lines.
|
||||
const astNoMeta = buildRenderAst(store, bare.guideId);
|
||||
const { file: fileNoMeta } = exportPdf(astNoMeta, path.join(root, 'out1'));
|
||||
const coverNoMeta = pageContents(fs.readFileSync(fileNoMeta))[0];
|
||||
assert.ok(!coverNoMeta.includes('Author:'));
|
||||
assert.ok(!coverNoMeta.includes('Co-authors:'));
|
||||
assert.ok(!coverNoMeta.includes('Organization:'));
|
||||
|
||||
// Set guide metadata, then re-export.
|
||||
const guide = store.getGuide(bare.guideId);
|
||||
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee', organization: 'Acme Corp' };
|
||||
store.saveGuide(guide);
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const { file } = exportPdf(ast, path.join(root, 'out2'));
|
||||
const cover = pageContents(fs.readFileSync(file))[0];
|
||||
|
||||
assert.ok(cover.includes('Author: Jane Doe'));
|
||||
assert.ok(cover.includes('Co-authors: Alex Lee'));
|
||||
assert.ok(cover.includes('Organization: Acme Corp'));
|
||||
|
||||
// Title (28pt, F2) sits above the accent rule (a 3pt-tall filled rect),
|
||||
// which sits above the metadata lines (11pt, F1). PDF y increases
|
||||
// upward, so items higher on the page have larger y values.
|
||||
const titleY = Number(/\/F2 28 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Configure AcmeSync backups\) Tj/.exec(cover)[1]);
|
||||
const ruleY = Number(/[\d.]+ [\d.]+ [\d.]+ rg ([\d.]+) ([\d.]+) [\d.]+ 3\.00 re f/.exec(cover)[2]);
|
||||
const authorY = Number(/\/F1 11 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Author: Jane Doe\) Tj/.exec(cover)[1]);
|
||||
assert.ok(titleY > 740, 'title starts near the top edge');
|
||||
assert.ok(titleY > ruleY, 'title sits above the accent rule');
|
||||
assert.ok(ruleY > authorY, 'metadata sits below the accent rule');
|
||||
});
|
||||
|
||||
test('PDF pagination: short steps pack onto a page; a step that does not fit moves to a fresh page', (t) => {
|
||||
const root = makeTmpDir('pdfpage');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(path.join(root, 'data'));
|
||||
const guide = store.createGuide({ title: 'Pagination test' });
|
||||
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
|
||||
// Doesn't fit in what's left of page 1, but fits comfortably on its own
|
||||
// page — with enough room left for step D to pack onto that same page.
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(95) });
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
|
||||
const pages = bookmarkPages(fs.readFileSync(file));
|
||||
|
||||
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 1]);
|
||||
assert.equal(pageCount, 2);
|
||||
});
|
||||
|
||||
test('PDF pagination: a step longer than one page starts fresh and overflows; the next step starts on a new page', (t) => {
|
||||
const root = makeTmpDir('pdfpage2');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(path.join(root, 'data'));
|
||||
const guide = store.createGuide({ title: 'Pagination overflow test' });
|
||||
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
|
||||
// Longer than a full page: starts on its own page and overflows onto the next.
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(120) });
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
|
||||
const pages = bookmarkPages(fs.readFileSync(file));
|
||||
|
||||
// A and B pack onto page 0; C starts fresh on page 1 and overflows onto
|
||||
// page 2; D is forced onto a fresh page 3 rather than sharing C's spillover.
|
||||
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 3]);
|
||||
assert.equal(pageCount, 4);
|
||||
});
|
||||
|
||||
test('GIF export: title card + one frame per image step, valid animation', (t) => {
|
||||
const { ast, root } = fixtureAst(t, 'gifx');
|
||||
const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 });
|
||||
@@ -146,35 +285,105 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (
|
||||
|
||||
assert.equal(imageCount, 2);
|
||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
||||
for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels']) {
|
||||
for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels', 'word/settings.xml', 'word/styles.xml']) {
|
||||
assert.ok(entries.has(required), `missing ${required}`);
|
||||
}
|
||||
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
|
||||
assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types');
|
||||
assertWellFormedXml(entries.get('word/settings.xml').toString('utf8'), 'settings.xml');
|
||||
assertWellFormedXml(entries.get('word/styles.xml').toString('utf8'), 'styles.xml');
|
||||
|
||||
// Every relationship target exists in the package, every embed has a rel.
|
||||
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
|
||||
const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.equal(relTargets.length, 2);
|
||||
for (const target of relTargets) {
|
||||
assert.equal(relTargets.length, 4);
|
||||
assert.ok(relTargets.includes('settings.xml'));
|
||||
assert.ok(relTargets.includes('styles.xml'));
|
||||
|
||||
const mediaTargets = relTargets.filter((target) => target.startsWith('media/'));
|
||||
assert.equal(mediaTargets.length, 2);
|
||||
const iconTargets = mediaTargets.filter((target) => target.includes('callout-'));
|
||||
const imageTargets = mediaTargets.filter((target) => target.includes('image'));
|
||||
assert.equal(iconTargets.length, 0);
|
||||
assert.equal(imageTargets.length, 2);
|
||||
|
||||
for (const target of iconTargets) {
|
||||
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
|
||||
const img = decodePng(entries.get(`word/${target}`));
|
||||
assert.equal(img.width, 24);
|
||||
}
|
||||
for (const target of imageTargets) {
|
||||
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
|
||||
const img = decodePng(entries.get(`word/${target}`));
|
||||
assert.equal(img.width, 320);
|
||||
}
|
||||
const docXml = entries.get('word/document.xml').toString('utf8');
|
||||
const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]);
|
||||
const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual(embeds.sort(), relIds.sort());
|
||||
const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]).filter((id) => id !== 'rId1');
|
||||
for (const id of embeds) {
|
||||
assert.ok(relIds.includes(id), `missing relationship for ${id}`);
|
||||
}
|
||||
assert.ok(docXml.includes('w:fldChar w:fldCharType="begin" w:dirty="true"'));
|
||||
assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u'));
|
||||
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
|
||||
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
|
||||
assert.ok(docXml.includes('w:outlineLvl w:val="0"'));
|
||||
assert.ok(docXml.includes('w:outlineLvl w:val="1"'));
|
||||
|
||||
const stylesXml = entries.get('word/styles.xml').toString('utf8');
|
||||
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading1"'));
|
||||
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading2"'));
|
||||
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading3"'));
|
||||
|
||||
// unzip CLI also accepts the package (it is a plain zip).
|
||||
assert.ok(entries.size >= 6);
|
||||
});
|
||||
|
||||
test('DOCX export: TOC contains a real, navigable entry per step', (t) => {
|
||||
const { ast, root } = fixtureAst(t, 'docxtoc');
|
||||
const { file } = exportDocx(ast, path.join(root, 'out'));
|
||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
||||
const docXml = entries.get('word/document.xml').toString('utf8');
|
||||
const stylesXml = entries.get('word/styles.xml').toString('utf8');
|
||||
|
||||
// One TOC entry per step, in document order, hyperlinked by step number.
|
||||
const tocLinks = [...docXml.matchAll(/<w:hyperlink w:anchor="([^"]+)">(.*?)<\/w:hyperlink>/g)]
|
||||
.map((m) => ({ anchor: m[1], text: /<w:t[^>]*>([^<]*)<\/w:t>/.exec(m[2])[1] }));
|
||||
assert.deepEqual(tocLinks.map((e) => e.text), ast.steps.map((step) => `${step.number}. ${step.title || 'Untitled step'}`));
|
||||
|
||||
// Each TOC entry's anchor matches a bookmark wrapping that step's heading.
|
||||
for (const { anchor } of tocLinks) {
|
||||
assert.match(docXml, new RegExp(`<w:bookmarkStart w:id="\\d+" w:name="${anchor}"/>`), `bookmark for ${anchor}`);
|
||||
}
|
||||
// Bookmark ids are unique.
|
||||
const bookmarkIds = [...docXml.matchAll(/<w:bookmarkStart w:id="(\d+)"/g)].map((m) => m[1]);
|
||||
assert.equal(new Set(bookmarkIds).size, bookmarkIds.length);
|
||||
|
||||
// Page numbers are real PAGEREF fields (one per entry), not static text.
|
||||
const pageRefs = [...docXml.matchAll(/PAGEREF (\S+) \\h/g)].map((m) => m[1]);
|
||||
assert.deepEqual(pageRefs, tocLinks.map((e) => e.anchor));
|
||||
|
||||
// The outer TOC field still wraps the whole table (so Word can refresh it).
|
||||
assert.ok(docXml.includes('w:fldChar w:fldCharType="begin" w:dirty="true"'));
|
||||
assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u'));
|
||||
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
|
||||
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
|
||||
assert.ok(docXml.includes('w:outlineLvl w:val="0"'));
|
||||
assert.ok(docXml.includes('w:outlineLvl w:val="1"'));
|
||||
|
||||
// TOC entry styles are defined and indent deeper levels further.
|
||||
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC1"'));
|
||||
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC2"'));
|
||||
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC3"'));
|
||||
|
||||
assertWellFormedXml(docXml, 'document.xml');
|
||||
});
|
||||
|
||||
test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => {
|
||||
const { ast, root } = fixtureAst(t, 'pptx');
|
||||
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));
|
||||
|
||||
assert.equal(slideCount, 4, 'title slide + 3 steps');
|
||||
assert.equal(slideCount, 5, 'title slide + contents slide + 3 steps');
|
||||
assert.equal(imageCount, 2);
|
||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
||||
for (const required of [
|
||||
@@ -192,6 +401,10 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
|
||||
// presentation.xml references each slide and the count matches.
|
||||
const pres = entries.get('ppt/presentation.xml').toString('utf8');
|
||||
assert.equal((pres.match(/<p:sldId /g) || []).length, slideCount);
|
||||
assert.ok(entries.get('ppt/slides/slide2.xml').toString('utf8').includes('Contents'));
|
||||
const slide4 = entries.get('ppt/slides/slide4.xml').toString('utf8');
|
||||
assert.ok(slide4.includes('Warning: Access'));
|
||||
assert.ok(slide4.includes('Admins only.'));
|
||||
// image rels on slides resolve to media files.
|
||||
for (let i = 1; i <= slideCount; i++) {
|
||||
const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8');
|
||||
@@ -199,6 +412,31 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve',
|
||||
assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`);
|
||||
}
|
||||
}
|
||||
const mediaTargets = [...entries.keys()].filter((name) => name.startsWith('ppt/media/'));
|
||||
assert.equal(mediaTargets.length, 2);
|
||||
assert.ok(!mediaTargets.some((name) => name.includes('callout-')));
|
||||
});
|
||||
|
||||
test('PPTX export: TOC paginates onto additional slides before it would overflow', (t) => {
|
||||
const root = makeTmpDir('pptxtoc');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(path.join(root, 'data'));
|
||||
const guide = store.createGuide({ title: 'Large TOC test' });
|
||||
|
||||
for (let i = 1; i <= 40; i++) {
|
||||
store.addStep(guide.guideId, { kind: 'empty', title: `Step ${i}` });
|
||||
}
|
||||
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const { file, slideCount } = exportPptx(ast, path.join(root, 'out'));
|
||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
||||
const slideXmls = Array.from({ length: slideCount }, (_, i) => entries.get(`ppt/slides/slide${i + 1}.xml`).toString('utf8'));
|
||||
const tocSlides = slideXmls.filter((xml) => xml.includes('Contents'));
|
||||
|
||||
assert.ok(tocSlides.length >= 2, 'large TOC should span multiple slides');
|
||||
assert.ok(tocSlides[0].includes('1. Step 1'));
|
||||
assert.ok(!tocSlides[0].includes('40. Step 40'));
|
||||
assert.ok(tocSlides.at(-1).includes('40. Step 40'));
|
||||
});
|
||||
|
||||
test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => {
|
||||
@@ -237,6 +475,6 @@ test('a saved template changes exporter behavior through runExport', (t) => {
|
||||
const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover'));
|
||||
assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages');
|
||||
|
||||
assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired');
|
||||
assert.equal(Object.keys(EXPORTERS).length, 11, 'all eleven formats wired');
|
||||
assert.throws(() => runExport('exe', ast, path.join(root, 'out3')));
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ const path = require('node:path');
|
||||
const { buildRenderAst, renderStepImage } = require('../../core/renderast');
|
||||
const { exportJson } = require('../../exporters/json');
|
||||
const { exportMarkdown } = require('../../exporters/markdown');
|
||||
const { exportWikiJs } = require('../../exporters/wikijs');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
|
||||
const { exportConfluence } = require('../../exporters/confluence');
|
||||
const { htmlToMarkdown } = require('../../exporters/htmlmd');
|
||||
const { stepContentGroups } = require('../../exporters/common');
|
||||
const { decodePng } = require('../../core/png');
|
||||
const { buildFixtureGuide } = require('./fixture-guide');
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
@@ -44,6 +46,33 @@ test('render AST: numbering, placeholder expansion, hidden/skipped filtering', (
|
||||
assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]);
|
||||
});
|
||||
|
||||
test('render AST: guide metadata defaults to empty strings and expands placeholders', (t) => {
|
||||
const root = makeTmpDir('astmeta');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
// Fixture guide has no metadata set: all fields default to ''.
|
||||
const noMeta = buildRenderAst(store, bare.guideId);
|
||||
assert.deepEqual(noMeta.guide.metadata, { author: '', coAuthors: '', organization: '' });
|
||||
|
||||
// Set metadata with placeholders and re-check expansion against guide + global scope.
|
||||
const guide = store.getGuide(bare.guideId);
|
||||
guide.metadata = {
|
||||
author: '[[Author]]',
|
||||
coAuthors: 'Alex Lee, [[CoAuthor]]',
|
||||
organization: '[[Org]]',
|
||||
};
|
||||
store.saveGuide(guide);
|
||||
|
||||
const ast = buildRenderAst(store, guide.guideId, { globals: { CoAuthor: 'Sam Patel', Org: 'GlobalOrg' } });
|
||||
// Guide-level placeholder (Author -> Casey) wins over global; CoAuthor/Org fall back to globals.
|
||||
assert.deepEqual(ast.guide.metadata, {
|
||||
author: 'Casey',
|
||||
coAuthors: 'Alex Lee, Sam Patel',
|
||||
organization: 'GlobalOrg',
|
||||
});
|
||||
});
|
||||
|
||||
test('JSON export produces a parseable document with real image files', (t) => {
|
||||
const root = makeTmpDir('expjson');
|
||||
t.after(() => rmrf(root));
|
||||
@@ -109,10 +138,103 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
|
||||
assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup');
|
||||
assert.equal(lines[fenceStart + 2], '```');
|
||||
assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row');
|
||||
// Warning text block became a blockquote with its content.
|
||||
const warnIdx = lines.findIndex((l) => l.startsWith('> **Warning: Access**'));
|
||||
assert.ok(warnIdx > 0);
|
||||
assert.equal(lines[warnIdx + 1], '> Admins only.');
|
||||
// Warning text block became a styled HTML callout with its content.
|
||||
assert.ok(md.includes('<div class="sf-callout sf-callout-warning"'));
|
||||
assert.ok(md.includes('border-left: 4px solid #f59e0b'));
|
||||
assert.ok(!md.includes('background: #fffbeb'));
|
||||
assert.ok(md.includes('color: inherit;'));
|
||||
assert.ok(md.includes('Warning: Access'));
|
||||
assert.ok(md.includes('<p>Admins only.</p>'));
|
||||
});
|
||||
|
||||
test('text block positions render around the title, description, and image', (t) => {
|
||||
const root = makeTmpDir('exppositions');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide, s1 } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
const step = store.getStep(guide.guideId, s1.stepId);
|
||||
step.textBlocks = [
|
||||
{ id: 'tb-before-title', order: 1, position: 'before-title', level: 'info', title: 'Before title', descriptionHtml: '<p>Before title.</p>' },
|
||||
{ id: 'tb-after-title', order: 2, position: 'after-title', level: 'info', title: 'After title', descriptionHtml: '<p>After title.</p>' },
|
||||
{ id: 'tb-before-description', order: 3, position: 'before-description', level: 'info', title: 'Before description', descriptionHtml: '<p>Before description.</p>' },
|
||||
{ id: 'tb-after-description', order: 4, position: 'after-description', level: 'info', title: 'After description', descriptionHtml: '<p>After description.</p>' },
|
||||
{ id: 'tb-before-image', order: 5, position: 'before-image', level: 'info', title: 'Before image', descriptionHtml: '<p>Before image.</p>' },
|
||||
{ id: 'tb-after-image', order: 6, position: 'after-image', level: 'info', title: 'After image', descriptionHtml: '<p>After image.</p>' },
|
||||
];
|
||||
store.saveStep(guide.guideId, step);
|
||||
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const groups = stepContentGroups(ast.steps[0]);
|
||||
assert.equal(groups.beforeTitle.length, 1);
|
||||
assert.equal(groups.afterTitle.length, 1);
|
||||
assert.equal(groups.beforeDescription.length, 1);
|
||||
assert.equal(groups.afterDescription.length, 1);
|
||||
assert.equal(groups.beforeImage.length, 1);
|
||||
assert.equal(groups.afterImage.length, 1);
|
||||
|
||||
const assertIncreasingOrder = (text, markers) => {
|
||||
let previous = -1;
|
||||
for (const marker of markers) {
|
||||
const index = text.indexOf(marker);
|
||||
assert.ok(index >= 0, `missing marker: ${marker}`);
|
||||
assert.ok(index > previous, `marker out of order: ${marker}`);
|
||||
previous = index;
|
||||
}
|
||||
};
|
||||
|
||||
const mdOut = path.join(root, 'md');
|
||||
const md = fs.readFileSync(exportMarkdown(ast, mdOut).file, 'utf8');
|
||||
assertIncreasingOrder(md, [
|
||||
'Before title',
|
||||
'## 1. Open AcmeSync settings',
|
||||
'After title',
|
||||
'Before description',
|
||||
'docs.example.com',
|
||||
'After description',
|
||||
'Before image',
|
||||
';
|
||||
|
||||
const htmlOut = path.join(root, 'html');
|
||||
const html = fs.readFileSync(exportHtmlSimple(ast, htmlOut).file, 'utf8');
|
||||
assertIncreasingOrder(html, [
|
||||
'Before title',
|
||||
'<h2>1. Open AcmeSync settings</h2>',
|
||||
'After title',
|
||||
'Before description',
|
||||
'docs.example.com',
|
||||
'After description',
|
||||
'Before image',
|
||||
'alt="Step 1"',
|
||||
'After image',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => {
|
||||
const root = makeTmpDir('expwikijs');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
|
||||
const out = path.join(root, 'out');
|
||||
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const { file } = exportWikiJs(ast, out);
|
||||
const md = fs.readFileSync(file, 'utf8');
|
||||
|
||||
const lines = md.split('\n');
|
||||
assert.equal(lines[0], '# Configure AcmeSync backups');
|
||||
assert.ok(lines.some((l) => l === '## Contents'));
|
||||
assert.ok(lines.some((l) => l.startsWith('## 1. Open AcmeSync settings')));
|
||||
assert.ok(lines.some((l) => l.startsWith('> **Access**')));
|
||||
assert.ok(lines.includes('> Admins only.'));
|
||||
assert.ok(lines.includes('{.is-warning}'));
|
||||
|
||||
const imgRefs = [...md.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((m) => m[1]);
|
||||
assert.equal(imgRefs.length, 2);
|
||||
for (const rel of imgRefs) {
|
||||
const img = decodePng(fs.readFileSync(path.join(out, rel)));
|
||||
assert.equal(img.width, 320);
|
||||
}
|
||||
});
|
||||
|
||||
test('Confluence export writes storage-format XML and image attachments', (t) => {
|
||||
@@ -183,7 +305,7 @@ test('Rich HTML export: TOC matches sections, checkboxes per step, local-only pe
|
||||
const { file } = exportHtmlRich(ast, out);
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
|
||||
const tocAnchors = [...html.matchAll(/<li class="d\d"><a href="#([^"]+)"/g)].map((m) => m[1]);
|
||||
const tocAnchors = [...html.matchAll(/<li class="d\d">\s*<a href="#([^"]+)"/g)].map((m) => m[1]);
|
||||
const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual(tocAnchors, sectionIds);
|
||||
assert.equal(sectionIds.length, 3);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const {
|
||||
createWindowsInstallerConfig,
|
||||
createWindowsInstallerBuildOptions,
|
||||
findInstallerExe,
|
||||
} = require('../../scripts/package-windows');
|
||||
|
||||
test('Windows packaging uses an assisted NSIS installer', (t) => {
|
||||
const config = createWindowsInstallerConfig('/tmp/stepforge-output');
|
||||
|
||||
assert.deepEqual(config.win.target, ['nsis']);
|
||||
assert.equal(config.nsis.oneClick, false);
|
||||
assert.equal(config.nsis.allowToChangeInstallationDirectory, true);
|
||||
assert.equal(config.nsis.createDesktopShortcut, true);
|
||||
assert.equal(config.nsis.createStartMenuShortcut, true);
|
||||
assert.equal(config.nsis.shortcutName, 'StepForge');
|
||||
assert.equal(config.asar, true);
|
||||
assert.ok(config.files.includes('app/**/*'));
|
||||
assert.ok(config.files.includes('core/**/*'));
|
||||
assert.ok(config.files.includes('exporters/**/*'));
|
||||
assert.ok(!config.files.includes('assets/**/*'));
|
||||
|
||||
const buildOptions = createWindowsInstallerBuildOptions('/tmp/stepforge-output');
|
||||
assert.equal(buildOptions.publish, 'never');
|
||||
assert.equal(buildOptions.config.publish, undefined);
|
||||
|
||||
const tmp = makeTmpDir('windows-installer');
|
||||
t.after(() => rmrf(tmp));
|
||||
fs.mkdirSync(path.join(tmp, 'nested', 'deeper'), { recursive: true });
|
||||
const installer = path.join(tmp, 'nested', 'deeper', 'StepForge Setup 0.2.0.exe');
|
||||
fs.writeFileSync(installer, Buffer.from('fake installer'));
|
||||
assert.equal(findInstallerExe(tmp), installer);
|
||||
});
|
||||
@@ -56,12 +56,14 @@ test('settings persist, deep-merge with defaults, and store global placeholders'
|
||||
assert.equal(s1.get('appearance'), DEFAULT_SETTINGS.appearance);
|
||||
s1.set('appearance', 'dark');
|
||||
s1.set('capture.delayMs', 1500);
|
||||
s1.set('ai.ollama.model', 'qwen3:0.6b');
|
||||
s1.setGlobalPlaceholders({ Company: 'Acme', Author: 'Tyler' });
|
||||
|
||||
// A fresh instance reads back the changed values merged over defaults.
|
||||
const s2 = new Settings(dir);
|
||||
assert.equal(s2.get('appearance'), 'dark');
|
||||
assert.equal(s2.get('capture.delayMs'), 1500);
|
||||
assert.equal(s2.get('ai.ollama.model'), 'qwen3:0.6b');
|
||||
assert.equal(s2.get('capture.clickMarker'), DEFAULT_SETTINGS.capture.clickMarker);
|
||||
assert.deepEqual(s2.getGlobalPlaceholders(), { Company: 'Acme', Author: 'Tyler' });
|
||||
});
|
||||
|
||||
@@ -69,6 +69,31 @@ test('step reorder, delete with substep reparenting, and order integrity', (t) =
|
||||
assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null);
|
||||
});
|
||||
|
||||
test('restoreStep recreates a deleted step with its original id, data, and images', (t) => {
|
||||
const root = makeTmpDir('restore');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'Restore test' });
|
||||
const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' });
|
||||
const b = store.addStep(guide.guideId, { title: 'B', annotations: [{ id: 'ann1', type: 'arrow', x: 1, y: 2 }] }, TINY_PNG, { width: 1, height: 1 });
|
||||
const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C' });
|
||||
|
||||
const deleted = store.getStep(guide.guideId, b.stepId);
|
||||
store.deleteStep(guide.guideId, b.stepId);
|
||||
assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [a.stepId, c.stepId]);
|
||||
|
||||
const restored = store.restoreStep(guide.guideId, deleted, { original: TINY_PNG, working: TINY_PNG }, 1);
|
||||
assert.equal(restored.stepId, b.stepId);
|
||||
assert.equal(restored.title, 'B');
|
||||
assert.equal(restored.annotations[0].type, 'arrow');
|
||||
|
||||
const after = store.getGuide(guide.guideId);
|
||||
assert.deepEqual(after.stepsOrder, [a.stepId, b.stepId, c.stepId]);
|
||||
assert.deepEqual(store.getStep(guide.guideId, b.stepId).annotations, deleted.annotations);
|
||||
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'original')), TINY_PNG);
|
||||
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'working')), TINY_PNG);
|
||||
});
|
||||
|
||||
test('duplicate guide produces independent deep copy with fresh ids', (t) => {
|
||||
const root = makeTmpDir('dup');
|
||||
t.after(() => rmrf(root));
|
||||
@@ -188,3 +213,27 @@ test('guide ids are validated against path traversal', (t) => {
|
||||
assert.throws(() => store.guideDir('a/b'));
|
||||
assert.throws(() => store.stepDir('ok', '..'));
|
||||
});
|
||||
|
||||
test('guide metadata: optional fields default to empty strings and round-trip via save/load', (t) => {
|
||||
const root = makeTmpDir('metadata');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'Metadata test' });
|
||||
assert.deepEqual(guide.metadata, { author: '', coAuthors: '', organization: '' });
|
||||
|
||||
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' };
|
||||
store.saveGuide(guide);
|
||||
|
||||
const fresh = new GuideStore(root);
|
||||
const loaded = fresh.getGuide(guide.guideId);
|
||||
assert.deepEqual(loaded.metadata, { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' });
|
||||
|
||||
// A guide saved before metadata existed normalizes to the same defaults.
|
||||
const legacy = fresh.getGuide(guide.guideId);
|
||||
delete legacy.metadata;
|
||||
store.saveGuide(legacy);
|
||||
assert.deepEqual(fresh.getGuide(guide.guideId).metadata, { author: '', coAuthors: '', organization: '' });
|
||||
|
||||
loaded.metadata = 'not an object';
|
||||
assert.throws(() => store.saveGuide(loaded), /metadata must be an object/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const { createStep } = require('../../core/schema');
|
||||
const {
|
||||
buildCaptureTitle,
|
||||
buildAiPrompt,
|
||||
normalizeAiPatch,
|
||||
applyAiPatchToStep,
|
||||
} = require('../../core/text-intel');
|
||||
const { TextIntelService } = require('../../app/text-intel');
|
||||
|
||||
function makeSettings(values = {}) {
|
||||
const data = {
|
||||
ai: {
|
||||
enabled: true,
|
||||
ollama: {
|
||||
host: 'http://127.0.0.1:11434',
|
||||
model: 'llama3.2:1b',
|
||||
},
|
||||
},
|
||||
...values,
|
||||
};
|
||||
return {
|
||||
get(key) {
|
||||
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('capture titles prefer semantic metadata before OCR fallback', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { windowTitle: 'Reset user password in admin portal' },
|
||||
ocrText: 'Save',
|
||||
});
|
||||
assert.equal(title, 'Click Save');
|
||||
});
|
||||
|
||||
test('capture titles prefer element metadata before window chrome and OCR', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'window',
|
||||
metadata: {
|
||||
elementLabel: 'Open advanced settings',
|
||||
windowTitle: 'Preferences',
|
||||
},
|
||||
ocrText: 'Cancel',
|
||||
});
|
||||
assert.equal(title, 'Open advanced settings');
|
||||
});
|
||||
|
||||
test('capture titles ignore browser chrome noise in favor of OCR', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'window',
|
||||
metadata: {
|
||||
windowTitle: 'Google Chrome ** PR reviews ** /chrome/tyler/autodoc',
|
||||
appName: 'Google Chrome',
|
||||
},
|
||||
ocrText: 'New tab',
|
||||
});
|
||||
// OCR wins over the noisy window title; app name is appended for context.
|
||||
assert.equal(title, 'Click New tab in Google Chrome');
|
||||
});
|
||||
|
||||
test('tab-like roles use select when OCR identifies a tab label', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'window',
|
||||
metadata: {
|
||||
elementLabel: 'New tab',
|
||||
elementRole: 'tab item',
|
||||
windowTitle: 'Google Chrome - PR reviews',
|
||||
},
|
||||
ocrText: 'New tab',
|
||||
});
|
||||
assert.equal(title, 'Select New tab');
|
||||
});
|
||||
|
||||
test('capture titles fall back to OCR when metadata is absent', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'window',
|
||||
metadata: {},
|
||||
ocrText: 'Save changes',
|
||||
});
|
||||
assert.equal(title, 'Click Save changes');
|
||||
});
|
||||
|
||||
test('browser window title strips browser name and falls back to page title', () => {
|
||||
// OCR fails; browser window title should give something useful, not "Screen capture".
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: {
|
||||
windowTitle: 'Oracle | Cloud Applications and Cloud Platform - Google Chrome',
|
||||
appName: 'chrome',
|
||||
},
|
||||
ocrText: '',
|
||||
});
|
||||
// Stripped title "Oracle | Cloud Applications and Cloud Platform" → best fragment
|
||||
assert.ok(title !== 'Screen capture', `Expected smart title, got: ${title}`);
|
||||
assert.ok(title.toLowerCase().includes('oracle') || title.toLowerCase().includes('cloud'), `Expected oracle/cloud in title, got: ${title}`);
|
||||
});
|
||||
|
||||
test('search query is extracted when user was typing (search step)', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { windowTitle: 'oracle - Google Search - Google Chrome', appName: 'chrome' },
|
||||
ocrText: '',
|
||||
recentTyped: 'oracle', // user was typing → this IS the search step
|
||||
});
|
||||
assert.equal(title, 'Search for Oracle in Chrome');
|
||||
});
|
||||
|
||||
test('search results window title produces select-result title when no typing (click on results page)', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { windowTitle: 'oracle - Google Search - Google Chrome', appName: 'chrome' },
|
||||
ocrText: '',
|
||||
recentTyped: '', // no recent typing → user is clicking a result, not searching
|
||||
});
|
||||
assert.equal(title, 'Select a Oracle result in Chrome');
|
||||
});
|
||||
|
||||
test('full link text with pipe separator is preserved in OCR phrases', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { elementRole: 'hyperlink' },
|
||||
ocrText: 'Oracle | Cloud Applications and Cloud Platform',
|
||||
});
|
||||
assert.equal(title, 'Select Oracle | Cloud Applications and Cloud Platform');
|
||||
});
|
||||
|
||||
test('link element role uses Select verb', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { elementLabel: 'Sign in', elementRole: 'hyperlink' },
|
||||
ocrText: '',
|
||||
});
|
||||
assert.equal(title, 'Select Sign in');
|
||||
});
|
||||
|
||||
test('search box element role uses Search for verb', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { elementLabel: 'oracle', elementRole: 'search box' },
|
||||
ocrText: '',
|
||||
});
|
||||
assert.equal(title, 'Search for Oracle');
|
||||
});
|
||||
|
||||
test('keyboard shortcut produces action title qualified with app', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { appName: 'chrome' },
|
||||
ocrText: '',
|
||||
recentShortcut: 'Ctrl+T',
|
||||
});
|
||||
assert.equal(title, 'Open new tab in Chrome');
|
||||
});
|
||||
|
||||
test('keyboard shortcut title without app name', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: {},
|
||||
ocrText: '',
|
||||
recentShortcut: 'Ctrl+S',
|
||||
});
|
||||
assert.equal(title, 'Save');
|
||||
});
|
||||
|
||||
test('typed text with search input role produces Search for title', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { elementRole: 'search box', appName: 'chrome' },
|
||||
ocrText: '',
|
||||
recentTyped: 'oracle',
|
||||
});
|
||||
assert.equal(title, 'Search for "oracle" in Chrome');
|
||||
});
|
||||
|
||||
test('UIAutomation element value takes priority over keyboard buffer', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { elementRole: 'edit', elementValue: 'oracle', appName: 'chrome' },
|
||||
ocrText: '',
|
||||
recentTyped: 'ignored',
|
||||
});
|
||||
// elementValue (from UIAutomation) wins over the keyboard buffer
|
||||
assert.ok(title.includes('oracle'), `expected oracle in title, got: ${title}`);
|
||||
});
|
||||
|
||||
test('app-qualified OCR title includes app name', () => {
|
||||
const title = buildCaptureTitle({
|
||||
mode: 'fullscreen',
|
||||
metadata: { appName: 'code' },
|
||||
ocrText: 'Save',
|
||||
});
|
||||
assert.equal(title, 'Click Save in VS Code');
|
||||
});
|
||||
|
||||
test('ai prompts include the deterministic OCR-backed title candidate', () => {
|
||||
const { prompt } = buildAiPrompt({
|
||||
captureContext: {
|
||||
windowTitle: 'Google Chrome ** PR reviews ** /chrome/tyler/autodoc',
|
||||
appName: 'Google Chrome',
|
||||
ocrText: 'New tab',
|
||||
titleCandidate: 'Click New tab',
|
||||
mode: 'content',
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(prompt, /Suggested title: Click New tab/);
|
||||
});
|
||||
|
||||
test('ocr crop rectangles clamp to the image bounds', (t) => {
|
||||
const root = makeTmpDir('text-intel-crop');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings(),
|
||||
getWindow: () => null,
|
||||
dataDir: root,
|
||||
fetchImpl: global.fetch,
|
||||
});
|
||||
|
||||
const frame = {
|
||||
size: { width: 1000, height: 500 },
|
||||
display: { bounds: { x: 0, y: 0, width: 1000, height: 500 } },
|
||||
};
|
||||
|
||||
const topLeft = service.cropRectForPoint(frame, { x: 5, y: 5 });
|
||||
assert.deepEqual(topLeft, { x: 0, y: 0, width: 420, height: 220 });
|
||||
|
||||
const bottomRight = service.cropRectForPoint(frame, { x: 995, y: 495 });
|
||||
assert.deepEqual(bottomRight, { x: 580, y: 280, width: 420, height: 220 });
|
||||
});
|
||||
|
||||
test('ocr failures fall back to empty text instead of crashing', async (t) => {
|
||||
const root = makeTmpDir('text-intel-ocr-fallback');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings(),
|
||||
getWindow: () => null,
|
||||
dataDir: root,
|
||||
fetchImpl: global.fetch,
|
||||
});
|
||||
service.getWorker = async () => {
|
||||
throw new Error('tesseract missing');
|
||||
};
|
||||
|
||||
const result = await service.ocrAroundClick({
|
||||
image: {},
|
||||
size: { width: 100, height: 100 },
|
||||
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||
}, { x: 50, y: 50 });
|
||||
|
||||
assert.deepEqual(result, { text: '', confidence: null });
|
||||
});
|
||||
|
||||
test('ai response normalization and application keeps fields structured', () => {
|
||||
const patch = normalizeAiPatch(JSON.stringify({
|
||||
title: 'Open settings',
|
||||
description: 'Pick the AI tab.',
|
||||
blocks: [
|
||||
{
|
||||
kind: 'text',
|
||||
position: 'after-description',
|
||||
level: 'tip',
|
||||
title: 'Tip',
|
||||
body: 'Use the local Ollama model.',
|
||||
},
|
||||
{
|
||||
kind: 'code',
|
||||
language: 'bash',
|
||||
code: 'ollama pull llama3.2:1b',
|
||||
},
|
||||
{
|
||||
kind: 'table',
|
||||
rows: [['Name', 'Value'], ['Host', '127.0.0.1']],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const step = createStep({
|
||||
title: 'Old title',
|
||||
descriptionHtml: '<p>Old text</p>',
|
||||
textBlocks: [{ id: 'tb1', order: 1, position: 'after-description', level: 'info', title: 'Old tip', descriptionHtml: '<p>Old body</p>' }],
|
||||
codeBlocks: [{ id: 'cb1', order: 2, language: 'text', code: 'old' }],
|
||||
tableBlocks: [{ id: 'tbl1', order: 3, rows: [['x']] }],
|
||||
});
|
||||
|
||||
const updated = applyAiPatchToStep(step, patch, { target: 'all' });
|
||||
assert.equal(updated.title, 'Open settings');
|
||||
assert.equal(updated.descriptionHtml, '<p>Pick the AI tab.</p>');
|
||||
assert.equal(updated.textBlocks.length, 1);
|
||||
assert.equal(updated.textBlocks[0].level, 'success');
|
||||
assert.equal(updated.textBlocks[0].descriptionHtml, '<p>Use the local Ollama model.</p>');
|
||||
assert.equal(updated.codeBlocks[0].code, 'ollama pull llama3.2:1b');
|
||||
assert.deepEqual(updated.tableBlocks[0].rows, [['Name', 'Value'], ['Host', '127.0.0.1']]);
|
||||
});
|
||||
|
||||
test('ollama connection test reports installed models', async (t) => {
|
||||
const root = makeTmpDir('text-intel-ai');
|
||||
t.after(() => rmrf(root));
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: makeSettings(),
|
||||
getWindow: () => null,
|
||||
dataDir: root,
|
||||
fetchImpl: async (url) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
if (pathname === '/api/tags') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ name: 'llama3.2:1b' },
|
||||
{ name: 'qwen3:0.6b' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (pathname === '/api/show') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
capabilities: ['completion', 'vision'],
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${pathname}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.testAiConnection();
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.installed, true);
|
||||
assert.equal(result.model, 'llama3.2:1b');
|
||||
assert.equal(result.vision, true);
|
||||
});
|
||||
|
||||
test('vision-capable models receive the screenshot in the chat request', async (t) => {
|
||||
const root = makeTmpDir('text-intel-ai-vision');
|
||||
t.after(() => rmrf(root));
|
||||
const imagePath = path.join(root, 'step.png');
|
||||
fs.writeFileSync(imagePath, Buffer.from('fake screenshot bytes'));
|
||||
|
||||
const step = createStep({
|
||||
title: 'Old title',
|
||||
descriptionHtml: '<p>Old text</p>',
|
||||
image: {
|
||||
originalPath: 'original.png',
|
||||
workingPath: 'working.png',
|
||||
size: { width: 10, height: 10 },
|
||||
},
|
||||
captureMetadata: {
|
||||
windowTitle: 'Settings',
|
||||
appName: 'chrome',
|
||||
ocrText: 'Open settings',
|
||||
titleCandidate: 'Open settings',
|
||||
mode: 'fullscreen',
|
||||
},
|
||||
});
|
||||
const fetchCalls = [];
|
||||
const service = new TextIntelService({
|
||||
store: {
|
||||
settingsDir: root,
|
||||
getGuide: () => ({ guideId: 'g1', title: 'Guide', descriptionHtml: '', stepsOrder: ['s1'] }),
|
||||
getStep: () => step,
|
||||
stepImagePath: () => imagePath,
|
||||
saveStep: (_, next) => next,
|
||||
},
|
||||
settings: makeSettings(),
|
||||
getWindow: () => null,
|
||||
dataDir: root,
|
||||
fetchImpl: async (url, init = {}) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
fetchCalls.push({ pathname, init });
|
||||
if (pathname === '/api/show') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
capabilities: ['completion', 'vision'],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (pathname === '/api/chat') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
title: 'Open settings',
|
||||
description: 'Use the AI tab.',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${pathname}`);
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.generateStepPatch({
|
||||
guideId: 'g1',
|
||||
stepId: 's1',
|
||||
target: 'all',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
const chatCall = fetchCalls.find((call) => call.pathname === '/api/chat');
|
||||
assert.ok(chatCall, 'expected an Ollama chat request');
|
||||
const body = JSON.parse(chatCall.init.body);
|
||||
assert.deepEqual(body.messages[1].images, [fs.readFileSync(imagePath).toString('base64')]);
|
||||
assert.match(body.messages[1].content, /Screenshot: attached/i);
|
||||
});
|
||||
|
||||
test('invalid ollama output fails safely without saving the step', async (t) => {
|
||||
const root = makeTmpDir('text-intel-ai-invalid');
|
||||
t.after(() => rmrf(root));
|
||||
let saveCalls = 0;
|
||||
const step = createStep({
|
||||
title: 'Old title',
|
||||
descriptionHtml: '<p>Old text</p>',
|
||||
});
|
||||
const service = new TextIntelService({
|
||||
store: {
|
||||
settingsDir: root,
|
||||
getGuide: () => ({ guideId: 'g1', title: 'Guide', descriptionHtml: '', stepsOrder: ['s1'] }),
|
||||
getStep: () => step,
|
||||
stepImagePath: () => null,
|
||||
saveStep: () => {
|
||||
saveCalls += 1;
|
||||
throw new Error('save should not be called');
|
||||
},
|
||||
},
|
||||
settings: makeSettings(),
|
||||
getWindow: () => null,
|
||||
dataDir: root,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: {
|
||||
content: 'not json at all',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await service.generateStepPatch({
|
||||
guideId: 'g1',
|
||||
stepId: 's1',
|
||||
target: 'all',
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.reason, /JSON/i);
|
||||
assert.equal(saveCalls, 0);
|
||||
assert.equal(step.title, 'Old title');
|
||||
});
|
||||