Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebeb6ac578 | ||
|
|
f60cf1a688 | ||
|
|
2d1b474931 | ||
|
|
c60b7247af | ||
|
|
cf056d2b97 | ||
|
|
3fa366a0d0 | ||
|
|
d0b1816175 | ||
|
|
dfa65c894b | ||
|
|
162ae0ae05 | ||
|
|
879434f14f | ||
|
|
13b5f67de8 | ||
|
|
8d645c0aca | ||
|
|
68ceee83ef | ||
|
|
d244626583 | ||
|
|
375c14b028 | ||
|
|
1a1dbb846b | ||
|
|
0bd326d8b2 | ||
|
|
bd10f7d147 | ||
|
|
33eb0f745e | ||
|
|
2b27428849 | ||
|
|
4cbab15429 | ||
|
|
063624ea57 | ||
|
|
7fe30cf8ef | ||
|
|
d607905b58 | ||
|
|
0f25d327c0 | ||
|
|
058582d7af | ||
|
|
f4278d118c | ||
|
|
c2247a57c7 | ||
|
|
d89abf5b78 | ||
|
|
845b3ed205 | ||
|
|
3def598528 | ||
|
|
f580759337 | ||
|
|
94f14851fa | ||
|
|
5edf740fda | ||
|
|
c39262c3fa | ||
|
|
46d0d622ca | ||
|
|
204be178a3 | ||
|
|
f37a2ec141 |
@@ -7,12 +7,6 @@
|
|||||||
- [ ] Git / workflow
|
- [ ] Git / workflow
|
||||||
- [ ] Other
|
- [ ] Other
|
||||||
|
|
||||||
## Issue Type
|
|
||||||
|
|
||||||
- [ ] Bug
|
|
||||||
- [ ] Improvement
|
|
||||||
- [ ] Task
|
|
||||||
- [ ] Documentation
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
<!-- please delete the unused check box's on the creation of your pr -->
|
||||||
|
|
||||||
## Improvement Area
|
## Improvement Area
|
||||||
|
|
||||||
|
- [ ] Guide Editor
|
||||||
|
- [ ] Library
|
||||||
|
- [ ] Settings
|
||||||
|
- [ ] Exports
|
||||||
- [ ] Documentation
|
- [ ] Documentation
|
||||||
- [ ] Tests
|
- [ ] Tests
|
||||||
- [ ] Git / workflow
|
- [ ] Git / workflow
|
||||||
- [ ] Other
|
- [ ] Other
|
||||||
|
|
||||||
## Issue
|
## Issue
|
||||||
|
<!-- Replace the example with the issue number this PR resolves. -->
|
||||||
|
|
||||||
- Closes #
|
- Closes #
|
||||||
|
|
||||||
<!-- Replace the example with the issue number this PR resolves. -->
|
|
||||||
|
|
||||||
## Summary
|
## 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
|
StepForge is a **fully offline**, open-source desktop app for Windows and
|
||||||
Linux that captures step-by-step workflows as screenshots, lets you annotate
|
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
|
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,
|
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.
|
||||||
DOCX, and PPTX.
|
|
||||||
|
|
||||||
It is an independent offline desktop guide-capture tool inspired by publicly
|
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
|
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
|
the network: no telemetry, no update checks, no license checks, no cloud, no
|
||||||
remote AI.
|
remote AI.
|
||||||
@@ -27,21 +26,6 @@ The core workflow:
|
|||||||
4. **Export** — every exporter renders from the same normalized Render AST,
|
4. **Export** — every exporter renders from the same normalized Render AST,
|
||||||
so output is deterministic across formats.
|
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
|
## What's Included
|
||||||
|
|
||||||
@@ -76,7 +60,7 @@ using only Node built-ins.
|
|||||||
|
|
||||||
## Getting Started
|
## 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).
|
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 runner executes every `tests/checks/test_*.sh` script; those scripts run
|
||||||
the workflow test suites under `tests/unit/` with `node --test`. The tests
|
the workflow test suites under `tests/unit/` with `node --test`. The tests
|
||||||
exercise real workflows — creating guides, round-tripping archives, exporting
|
exercise real workflows like creating guides, round-tripping archives, exporting
|
||||||
documents, and validating the bytes of the output — not string matching.
|
documents, and validating the bytes of the output, not string matching.
|
||||||
|
|
||||||
## Building & Packaging
|
## 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/verify.sh # full test suite + smoke checks
|
||||||
bash scripts/build-release.sh # assemble runnable app directory
|
bash scripts/build-release.sh # assemble runnable app directory
|
||||||
bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec)
|
bash scripts/package-linux.sh # portable tar.gz + .deb (+ AppDir spec)
|
||||||
npm run package:windows # portable Windows .exe in releases/
|
npm run package:windows # Windows installer .exe in releases/
|
||||||
pwsh scripts/package-windows.ps1 # same Windows portable build via PowerShell
|
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
|
See [build/build_report.md](build/build_report.md) for what was produced on
|
||||||
@@ -131,11 +115,12 @@ clean-room rules.
|
|||||||
|
|
||||||
## Repository Layout
|
## 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.
|
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the repo layout.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Application code is licensed under [MPL-2.0](LICENSE). Bundled example
|
Creative Commons Attribution-NonCommercial
|
||||||
guides, templates, and screenshots are CC-BY-4.0 unless noted otherwise.
|
|
||||||
|
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.
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { spawn, execFileSync } = require('node:child_process');
|
const { spawn, execFileSync } = require('node:child_process');
|
||||||
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
|
const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron');
|
||||||
const { expandPlaceholders } = require('../core/placeholders');
|
|
||||||
const raster = require('../core/raster');
|
const raster = require('../core/raster');
|
||||||
const { encodePng } = require('../core/png');
|
const { encodePng } = require('../core/png');
|
||||||
const {
|
const {
|
||||||
@@ -14,6 +13,7 @@ const {
|
|||||||
DEFAULT_START_SLACK_MS,
|
DEFAULT_START_SLACK_MS,
|
||||||
} = require('./click-frames');
|
} = require('./click-frames');
|
||||||
const { physicalToDip } = require('./coords');
|
const { physicalToDip } = require('./coords');
|
||||||
|
const { DEFAULT_CAPTURE_TITLES } = require('../core/text-intel');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture service: full-screen, active-window, and region capture, plus a
|
* 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.
|
// representation that carries root coordinates) before firing without them.
|
||||||
const LINUX_CLICK_TWIN_MS = 25;
|
const LINUX_CLICK_TWIN_MS = 25;
|
||||||
// Longest the window stays visible warming up the recorder at recording
|
// Longest the window stays visible warming up the recorder at recording
|
||||||
// start. A slow capture-stream start (Windows can take several seconds) must
|
// start. A slow capture-stream start (Windows can take several seconds,
|
||||||
// not keep the window up and recording un-armed indefinitely.
|
// especially on battery) must not keep the window up and recording un-armed
|
||||||
const WARMUP_MAX_MS = 1500;
|
// 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:
|
// Idle gap between legacy frame-loop grabs. Must stay well above zero:
|
||||||
// grabbing back-to-back starves the main-process event loop, which delays
|
// grabbing back-to-back starves the main-process event loop, which delays
|
||||||
// delivery of click events from the OS watcher by whole seconds. (The
|
// delivery of click events from the OS watcher by whole seconds. (The
|
||||||
@@ -109,6 +114,7 @@ class CaptureService {
|
|||||||
getWindow,
|
getWindow,
|
||||||
notify,
|
notify,
|
||||||
screenApi = screen,
|
screenApi = screen,
|
||||||
|
textIntel = null,
|
||||||
}) {
|
}) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -117,6 +123,7 @@ class CaptureService {
|
|||||||
// Injectable for tests; the click/coordinate paths must never reach for
|
// Injectable for tests; the click/coordinate paths must never reach for
|
||||||
// the global `screen` directly so coordinate handling stays testable.
|
// the global `screen` directly so coordinate handling stays testable.
|
||||||
this.screen = screenApi;
|
this.screen = screenApi;
|
||||||
|
this.textIntel = textIntel;
|
||||||
this.session = null; // { guideId, paused, count, intervalSec }
|
this.session = null; // { guideId, paused, count, intervalSec }
|
||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
this.clickWatcher = null;
|
this.clickWatcher = null;
|
||||||
@@ -128,6 +135,11 @@ class CaptureService {
|
|||||||
this.clickWatcherErrTail = '';
|
this.clickWatcherErrTail = '';
|
||||||
this.linuxEvent = null; // event block currently being parsed
|
this.linuxEvent = null; // event block currently being parsed
|
||||||
this.pendingRawClick = null; // raw press waiting for its coordinate twin
|
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.clickQueue = Promise.resolve();
|
||||||
this.frameLoopInFlight = false;
|
this.frameLoopInFlight = false;
|
||||||
this.frameLoopGrabStartedAt = null;
|
this.frameLoopGrabStartedAt = null;
|
||||||
@@ -458,7 +470,7 @@ class CaptureService {
|
|||||||
if (frame) {
|
if (frame) {
|
||||||
clog('click@', clickAt, 'frame', frame.source || 'loop',
|
clog('click@', clickAt, 'frame', frame.source || 'loop',
|
||||||
'started', frame.startedAt - clickAt, 'ms, captured', frame.capturedAt - clickAt, 'ms rel. click');
|
'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);
|
if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -791,16 +803,31 @@ using System.Collections.Concurrent;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
public static class SFMouseHook {
|
public static class SFHook {
|
||||||
private const int WH_MOUSE_LL = 14;
|
private const int WH_MOUSE_LL = 14;
|
||||||
|
private const int WH_KEYBOARD_LL = 13;
|
||||||
private const int WM_LBUTTONDOWN = 0x0201;
|
private const int WM_LBUTTONDOWN = 0x0201;
|
||||||
private const int WM_RBUTTONDOWN = 0x0204;
|
private const int WM_RBUTTONDOWN = 0x0204;
|
||||||
private const int WM_MBUTTONDOWN = 0x0207;
|
private const int WM_MBUTTONDOWN = 0x0207;
|
||||||
private const int WM_XBUTTONDOWN = 0x020B;
|
private const int WM_XBUTTONDOWN = 0x020B;
|
||||||
|
private const int WM_KEYDOWN = 0x0100;
|
||||||
|
private const int WM_SYSKEYDOWN = 0x0104;
|
||||||
private const long UnixEpochMilliseconds = 62135596800000L;
|
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 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 ConcurrentQueue<string> queue = new ConcurrentQueue<string>();
|
||||||
private static readonly AutoResetEvent signal = new AutoResetEvent(false);
|
private static readonly AutoResetEvent signal = new AutoResetEvent(false);
|
||||||
|
|
||||||
@@ -829,11 +856,41 @@ public static class SFMouseHook {
|
|||||||
public POINT pt;
|
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 LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||||
|
private delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
[DllImport("user32.dll", SetLastError = true)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
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)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||||
|
|
||||||
@@ -855,7 +912,85 @@ public static class SFMouseHook {
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern bool SetProcessDpiAwarenessContext(IntPtr value);
|
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() {
|
public static void Run() {
|
||||||
|
KeepProcessResponsive();
|
||||||
try { SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { }
|
try { SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { }
|
||||||
|
|
||||||
Thread writer = new Thread(WriterLoop);
|
Thread writer = new Thread(WriterLoop);
|
||||||
@@ -866,6 +1001,8 @@ public static class SFMouseHook {
|
|||||||
if (hook == IntPtr.Zero) {
|
if (hook == IntPtr.Zero) {
|
||||||
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
|
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.WriteLine("READY");
|
||||||
Console.Out.Flush();
|
Console.Out.Flush();
|
||||||
@@ -877,6 +1014,7 @@ public static class SFMouseHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UnhookWindowsHookEx(hook);
|
UnhookWindowsHookEx(hook);
|
||||||
|
if (keyHook != IntPtr.Zero) UnhookWindowsHookEx(keyHook);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WriterLoop() {
|
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) {
|
if (nCode >= 0) {
|
||||||
int message = wParam.ToInt32();
|
int message = wParam.ToInt32();
|
||||||
string button = ButtonName(message, lParam);
|
string button = ButtonName(message, lParam);
|
||||||
if (button != null) {
|
if (button != null) {
|
||||||
MSLLHOOKSTRUCT data = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
|
MSLLHOOKSTRUCT data = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT));
|
||||||
long unixMs = DateTime.UtcNow.Ticks / TimeSpan.TicksPerMillisecond - UnixEpochMilliseconds;
|
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);
|
queue.Enqueue("CLICK " + data.pt.x + " " + data.pt.y + " " + button + " " + unixMs);
|
||||||
signal.Set();
|
signal.Set();
|
||||||
}
|
}
|
||||||
@@ -904,6 +1048,75 @@ public static class SFMouseHook {
|
|||||||
return CallNextHookEx(hook, nCode, wParam, lParam);
|
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) {
|
private static string ButtonName(int message, IntPtr lParam) {
|
||||||
if (message == WM_LBUTTONDOWN) return "left";
|
if (message == WM_LBUTTONDOWN) return "left";
|
||||||
if (message == WM_RBUTTONDOWN) return "right";
|
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], {
|
this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
@@ -1052,11 +1265,33 @@ public static class SFMouseHook {
|
|||||||
}
|
}
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const m = /^CLICK(?:\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+(\d+))?)?\s*$/.exec(line.trim());
|
const trimmed = line.trim();
|
||||||
if (m) {
|
const clickM = /^CLICK(?:\s+(-?\d+)\s+(-?\d+)(?:\s+([A-Za-z0-9_-]+))?(?:\s+(\d+))?)?\s*$/.exec(trimmed);
|
||||||
const osPoint = m[1] === undefined ? null : { x: Number(m[1]), y: Number(m[2]) };
|
if (clickM) {
|
||||||
const eventAt = m[4] === undefined ? Date.now() : Number(m[4]);
|
const osPoint = clickM[1] === undefined ? null : { x: Number(clickM[1]), y: Number(clickM[2]) };
|
||||||
this.onOsClick(Number.isFinite(eventAt) ? eventAt : Date.now(), osPoint, m[3] || 'mouse');
|
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;
|
let clickPos = osPoint ? this.osPointToDip(osPoint) : null;
|
||||||
if (!clickPos) clickPos = this.screen.getCursorScreenPoint();
|
if (!clickPos) clickPos = this.screen.getCursorScreenPoint();
|
||||||
clog('click@', clickAt, button, 'os', osPoint, '-> dip', clickPos);
|
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');
|
this.enqueueClickCapture(clickPos, clickAt, button || 'mouse');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,7 +1458,24 @@ public static class SFMouseHook {
|
|||||||
* fast the user clicks or how slow the encoder is.
|
* fast the user clicks or how slow the encoder is.
|
||||||
*/
|
*/
|
||||||
enqueueClickCapture(clickPos, clickAt = Date.now(), button = 'mouse') {
|
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()) {
|
if (this.session && !this.session.paused && !this.userIsInApp()) {
|
||||||
// The guide id pins the click to its recording so it can still be
|
// 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.
|
// stored if the session stops while this click waits in the queue.
|
||||||
@@ -1234,6 +1489,46 @@ public static class SFMouseHook {
|
|||||||
return this.clickQueue;
|
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()) {
|
async captureCurrentFrame(mode, capturePoint = null, startedAt = Date.now()) {
|
||||||
const grabbed = await this.grab(mode, capturePoint);
|
const grabbed = await this.grab(mode, capturePoint);
|
||||||
return {
|
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' };
|
if (!frame) return { ok: false, reason: 'no capture frame available' };
|
||||||
const annotations = [];
|
const annotations = [];
|
||||||
// The click position (DIP, read at event time) wins over the frame's
|
// 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, {
|
const step = this.store.addStep(guideId, {
|
||||||
title: this.autoTitle(mode),
|
title,
|
||||||
|
captureMetadata,
|
||||||
annotations,
|
annotations,
|
||||||
focusedView: {
|
focusedView: {
|
||||||
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
|
enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')),
|
||||||
@@ -1287,14 +1595,7 @@ public static class SFMouseHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
autoTitle(mode) {
|
autoTitle(mode) {
|
||||||
const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]';
|
return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
|
||||||
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())}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Grab the screen/window image as { image, display } or throw. */
|
/** Grab the screen/window image as { image, display } or throw. */
|
||||||
@@ -1403,8 +1704,12 @@ public static class SFMouseHook {
|
|||||||
const cropped = image.crop(rect);
|
const cropped = image.crop(rect);
|
||||||
const size = cropped.getSize();
|
const size = cropped.getSize();
|
||||||
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
|
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
|
||||||
const step = this.store.addStep(guideId, { title: this.autoTitle('region') },
|
const step = await this.storeFrameAsStep(guideId, 'region', {
|
||||||
cropped.toPNG(), size);
|
image: cropped,
|
||||||
|
size,
|
||||||
|
display,
|
||||||
|
cursor: null,
|
||||||
|
}, null, null);
|
||||||
return { ok: true, step };
|
return { ok: true, step };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const fs = require('node:fs');
|
|||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const {
|
const {
|
||||||
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
|
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
|
||||||
clipboard, nativeImage, screen,
|
clipboard, nativeImage, screen, powerSaveBlocker,
|
||||||
} = require('electron');
|
} = require('electron');
|
||||||
|
|
||||||
const { GuideStore } = require('../core/store');
|
const { GuideStore } = require('../core/store');
|
||||||
@@ -19,6 +19,26 @@ const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } =
|
|||||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||||
const { readLock } = require('../core/locks');
|
const { readLock } = require('../core/locks');
|
||||||
const CaptureService = require('./capture');
|
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
|
* StepForge main process. Zero network code: no telemetry, no updates, no
|
||||||
@@ -40,6 +60,7 @@ let settings;
|
|||||||
let searchIndex;
|
let searchIndex;
|
||||||
let templates;
|
let templates;
|
||||||
let capture;
|
let capture;
|
||||||
|
let textIntel;
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
|
|
||||||
function reindex(guideId) {
|
function reindex(guideId) {
|
||||||
@@ -484,26 +505,107 @@ function setupIpc() {
|
|||||||
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
||||||
return settings.data;
|
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:get', () => settings.getGlobalPlaceholders());
|
||||||
h('placeholders:globals:set', ({ values }) => settings.setGlobalPlaceholders(values));
|
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
|
||||||
|
|
||||||
// capture
|
// capture
|
||||||
h('capture:shoot', async ({ guideId, mode, delayMs }) => {
|
h('capture:shoot', async ({ guideId, mode, delayMs }) => {
|
||||||
const result = await capture.shoot({ 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;
|
return result;
|
||||||
});
|
});
|
||||||
h('capture:region', async ({ guideId }) => {
|
h('capture:region', async ({ guideId }) => {
|
||||||
const result = await capture.regionCapture(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;
|
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 }) => {
|
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
||||||
if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
if (action === 'start') {
|
||||||
else if (action === 'pause') capture.togglePause(true);
|
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||||
else if (action === 'resume') capture.togglePause(false);
|
startCapturePower();
|
||||||
else if (action === 'finish') capture.finishSession();
|
keepCaptureProcessesResponsive();
|
||||||
else if (action === 'interval') capture.setInterval(intervalSec);
|
} 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();
|
const state = capture.state();
|
||||||
sendToRenderer('capture:state', state);
|
sendToRenderer('capture:state', state);
|
||||||
return state;
|
return state;
|
||||||
@@ -686,11 +788,51 @@ if (!gotLock) {
|
|||||||
settings = new Settings(store.settingsDir);
|
settings = new Settings(store.settingsDir);
|
||||||
searchIndex = new SearchIndex(store.indexDir);
|
searchIndex = new SearchIndex(store.indexDir);
|
||||||
templates = new TemplateManager(store.templatesDir);
|
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({
|
capture = new CaptureService({
|
||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
getWindow: () => mainWindow,
|
getWindow: () => mainWindow,
|
||||||
notify: sendToRenderer,
|
notify: captureNotify,
|
||||||
|
textIntel,
|
||||||
});
|
});
|
||||||
|
|
||||||
applyTheme();
|
applyTheme();
|
||||||
@@ -710,6 +852,9 @@ if (!gotLock) {
|
|||||||
capture.stopClickWatcher();
|
capture.stopClickWatcher();
|
||||||
capture.destroySessionTray();
|
capture.destroySessionTray();
|
||||||
}
|
}
|
||||||
|
if (textIntel) {
|
||||||
|
textIntel.shutdown().catch(() => {});
|
||||||
|
}
|
||||||
// clean preview temp files on close
|
// clean preview temp files on close
|
||||||
try {
|
try {
|
||||||
for (const entry of fs.readdirSync(store.tempDir)) {
|
for (const entry of fs.readdirSync(store.tempDir)) {
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ const api = {
|
|||||||
globalPlaceholders: invoke('placeholders:globals:get'),
|
globalPlaceholders: invoke('placeholders:globals:get'),
|
||||||
setGlobalPlaceholders: invoke('placeholders:globals:set'),
|
setGlobalPlaceholders: invoke('placeholders:globals:set'),
|
||||||
},
|
},
|
||||||
|
ai: {
|
||||||
|
test: invoke('ai:test'),
|
||||||
|
fillStep: invoke('ai:fillStep'),
|
||||||
|
rewriteText: invoke('ai:rewriteText'),
|
||||||
|
},
|
||||||
capture: {
|
capture: {
|
||||||
shoot: invoke('capture:shoot'),
|
shoot: invoke('capture:shoot'),
|
||||||
region: invoke('capture:region'),
|
region: invoke('capture:region'),
|
||||||
@@ -59,6 +64,7 @@ const api = {
|
|||||||
state: invoke('capture:state'),
|
state: invoke('capture:state'),
|
||||||
onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)),
|
onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)),
|
||||||
onState: (fn) => ipcRenderer.on('capture:state', (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: {
|
archive: {
|
||||||
export: invoke('archive:export'),
|
export: invoke('archive:export'),
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ class StepForgeApp {
|
|||||||
|
|
||||||
api.capture.onAdded((payload) => this.onCaptureAdded(payload));
|
api.capture.onAdded((payload) => this.onCaptureAdded(payload));
|
||||||
api.capture.onState((payload) => this.updateCaptureState(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) {
|
async onCaptureAdded(payload) {
|
||||||
@@ -117,6 +129,7 @@ class StepForgeApp {
|
|||||||
guideFolders: library.folders?.guideFolders || {},
|
guideFolders: library.folders?.guideFolders || {},
|
||||||
};
|
};
|
||||||
this.state.trash = trash;
|
this.state.trash = trash;
|
||||||
|
this.editor.setSettings(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshLibrary({ keepFilter = true } = {}) {
|
async refreshLibrary({ keepFilter = true } = {}) {
|
||||||
@@ -320,6 +333,7 @@ class StepForgeApp {
|
|||||||
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
||||||
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||||
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
{ 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() },
|
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||||
'sep',
|
'sep',
|
||||||
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
|
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
|
||||||
@@ -757,7 +771,11 @@ class StepForgeApp {
|
|||||||
if (title == null) return;
|
if (title == null) return;
|
||||||
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
|
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
|
||||||
await this.refreshLibrary();
|
await this.refreshLibrary();
|
||||||
await this.openGuide(guide.guideId);
|
// 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() {
|
async createFolder() {
|
||||||
@@ -855,6 +873,7 @@ class StepForgeApp {
|
|||||||
const settings = await api.settings.all();
|
const settings = await api.settings.all();
|
||||||
const placeholders = await api.settings.globalPlaceholders();
|
const placeholders = await api.settings.globalPlaceholders();
|
||||||
await dialogs.showSettingsDialog({
|
await dialogs.showSettingsDialog({
|
||||||
|
api,
|
||||||
settings,
|
settings,
|
||||||
placeholders,
|
placeholders,
|
||||||
onSave: async (next) => {
|
onSave: async (next) => {
|
||||||
@@ -862,6 +881,7 @@ class StepForgeApp {
|
|||||||
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
|
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
|
||||||
await api.settings.set({ keyPath: 'capture', value: next.capture });
|
await api.settings.set({ keyPath: 'capture', value: next.capture });
|
||||||
await api.settings.set({ keyPath: 'editor', value: next.editor });
|
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: 'exports', value: next.exports });
|
||||||
await api.settings.set({ keyPath: 'backups', value: next.backups });
|
await api.settings.set({ keyPath: 'backups', value: next.backups });
|
||||||
await api.settings.setGlobalPlaceholders(next.placeholders || {});
|
await api.settings.setGlobalPlaceholders(next.placeholders || {});
|
||||||
|
|||||||
@@ -80,7 +80,9 @@
|
|||||||
maxWidth: physWidth,
|
maxWidth: physWidth,
|
||||||
minHeight: physHeight,
|
minHeight: physHeight,
|
||||||
maxHeight: 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 } = {}) {
|
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const field = multiline
|
const field = multiline
|
||||||
@@ -140,6 +285,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSettingsDialog({
|
function showSettingsDialog({
|
||||||
|
api,
|
||||||
settings,
|
settings,
|
||||||
placeholders = {},
|
placeholders = {},
|
||||||
onSave,
|
onSave,
|
||||||
@@ -160,14 +306,51 @@ function showSettingsDialog({
|
|||||||
{ value: 'region', label: 'Region' },
|
{ value: 'region', label: 'Region' },
|
||||||
]);
|
]);
|
||||||
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
|
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
|
||||||
const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text');
|
const captureHotkey = makeHotkeyInput(settings.capture?.hotkeyCapture || '');
|
||||||
const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text');
|
const pauseHotkey = makeHotkeyInput(settings.capture?.hotkeyPauseResume || '');
|
||||||
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
|
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
|
||||||
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
|
const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 });
|
||||||
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
|
const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) });
|
||||||
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
|
const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) });
|
||||||
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
|
const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) });
|
||||||
const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 });
|
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. Nothing is sent to the cloud.');
|
||||||
|
const testAiBtn = el('button', { type: 'button' }, 'Test connection');
|
||||||
|
|
||||||
|
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(`Connected to ${result.host} with ${result.model}.`);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const placeholderRows = el('div', { className: 'placeholder-rows' });
|
const placeholderRows = el('div', { className: 'placeholder-rows' });
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -224,6 +407,20 @@ function showSettingsDialog({
|
|||||||
el('legend', {}, 'Backups'),
|
el('legend', {}, 'Backups'),
|
||||||
labeledRow('Keep last snapshots', keepLast),
|
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('fieldset', {},
|
||||||
el('legend', {}, 'Global placeholders'),
|
el('legend', {}, 'Global placeholders'),
|
||||||
placeholderRows,
|
placeholderRows,
|
||||||
@@ -267,6 +464,16 @@ function showSettingsDialog({
|
|||||||
...settings.backups,
|
...settings.backups,
|
||||||
keepLast: Number(keepLast.value || 0),
|
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) => {
|
placeholders: rows.reduce((acc, row) => {
|
||||||
const inputs = row.querySelectorAll('input');
|
const inputs = row.querySelectorAll('input');
|
||||||
const key = inputs[0].value.trim();
|
const key = inputs[0].value.trim();
|
||||||
@@ -285,6 +492,14 @@ function showSettingsDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', (e) => e.preventDefault());
|
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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ class GuideEditor {
|
|||||||
this.descriptionDirty = false;
|
this.descriptionDirty = false;
|
||||||
this.titleDirty = false;
|
this.titleDirty = false;
|
||||||
this.active = true;
|
this.active = true;
|
||||||
|
this.settings = {};
|
||||||
|
|
||||||
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
|
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
|
||||||
this.saveGuideDebounced = debounce(() => this.flushGuide(), 180);
|
this.saveGuideDebounced = debounce(() => this.flushGuide(), 180);
|
||||||
@@ -152,6 +153,149 @@ class GuideEditor {
|
|||||||
this.active = Boolean(active);
|
this.active = Boolean(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSettings(settings) {
|
||||||
|
this.settings = settings || {};
|
||||||
|
this.updateAiButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
isAiEnabled() {
|
||||||
|
return Boolean(this.settings?.ai?.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAiButtonState() {
|
||||||
|
const enabled = this.isAiEnabled();
|
||||||
|
const buttons = [
|
||||||
|
this.dom?.titleAiBtn,
|
||||||
|
this.dom?.descAiBtn,
|
||||||
|
...(this.dom?.blocksList ? [...this.dom.blocksList.querySelectorAll('button[data-ai-action]')] : []),
|
||||||
|
].filter(Boolean);
|
||||||
|
for (const button of buttons) {
|
||||||
|
button.disabled = !enabled;
|
||||||
|
button.title = enabled
|
||||||
|
? button.dataset.aiTitle || 'Generate with AI'
|
||||||
|
: 'Enable AI in Settings first.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAiGeneration(target, { blockId = null, button = null } = {}) {
|
||||||
|
if (!this.currentStep) {
|
||||||
|
this.onToast('Select a step first.', { error: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.isAiEnabled()) {
|
||||||
|
this.onToast('Enable AI in Settings first.', { error: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (this.pendingSave) await this.flushStep();
|
||||||
|
if (button) setButtonLoading(button, true, 'Generating…');
|
||||||
|
try {
|
||||||
|
const result = await api.ai.fillStep({
|
||||||
|
guideId: this.guideId,
|
||||||
|
stepId: this.currentStep.stepId,
|
||||||
|
target,
|
||||||
|
blockId,
|
||||||
|
});
|
||||||
|
if (!result || !result.ok) {
|
||||||
|
this.onToast(result?.reason || 'AI generation failed.', { error: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.reload(result.step.stepId);
|
||||||
|
this.onToast('AI text filled.');
|
||||||
|
return result.step;
|
||||||
|
} catch (err) {
|
||||||
|
this.onToast(err.message || 'AI generation failed.', { error: true });
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (button) setButtonLoading(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateTitleWithAi(button = null) {
|
||||||
|
return this.runAiGeneration('title', { button });
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateDescriptionWithAi(button = null) {
|
||||||
|
return this.runAiGeneration('description', { button });
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateAllTextFieldsWithAi(button = null) {
|
||||||
|
if (!this.steps.length) {
|
||||||
|
this.onToast('No steps to generate.', { error: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isAiEnabled()) {
|
||||||
|
this.onToast('Enable AI in Settings first.', { error: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.pendingSave) await this.flushStep();
|
||||||
|
|
||||||
|
// Only fill fields that are actually empty — never overwrite user-written content.
|
||||||
|
const PLACEHOLDER_TITLES = new Set([
|
||||||
|
'', 'screen capture', 'window capture', 'region capture', 'capture', 'untitled step',
|
||||||
|
]);
|
||||||
|
const isEmptyDesc = (html) => !(html || '').replace(/<[^>]*>/g, '').trim();
|
||||||
|
|
||||||
|
const queue = this.steps
|
||||||
|
.map((step) => {
|
||||||
|
const titleEmpty = !step.title || PLACEHOLDER_TITLES.has(step.title.toLowerCase());
|
||||||
|
const descEmpty = isEmptyDesc(step.descriptionHtml);
|
||||||
|
if (!titleEmpty && !descEmpty) return null;
|
||||||
|
const target = titleEmpty && descEmpty ? 'all' : titleEmpty ? 'title' : 'description';
|
||||||
|
return { stepId: step.stepId, target };
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!queue.length) {
|
||||||
|
this.onToast('All steps already have titles and descriptions.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button) setButtonLoading(button, true, 'Generating…');
|
||||||
|
let done = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const total = queue.length;
|
||||||
|
try {
|
||||||
|
for (const { stepId, target } of queue) {
|
||||||
|
this.onToast(`AI: filling step ${done + failed + 1} of ${total}…`);
|
||||||
|
try {
|
||||||
|
const result = await api.ai.fillStep({ guideId: this.guideId, stepId, target });
|
||||||
|
if (result?.ok) done++;
|
||||||
|
else failed++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reload re-fetches all steps from the store so the editor and list both reflect the new text.
|
||||||
|
await this.reload(this.selectedStepId);
|
||||||
|
const msg = failed
|
||||||
|
? `AI filled ${done} step${done === 1 ? '' : 's'} (${failed} failed).`
|
||||||
|
: `AI filled ${done} step${done === 1 ? '' : 's'}.`;
|
||||||
|
this.onToast(msg, failed ? { error: true } : undefined);
|
||||||
|
} finally {
|
||||||
|
if (button) setButtonLoading(button, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateBlockWithAi(kind, block, button = null) {
|
||||||
|
if (!block) return null;
|
||||||
|
return this.runAiGeneration('block', { blockId: block.id, button });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAiButtonHints() {
|
||||||
|
const PLACEHOLDER_TITLES = new Set([
|
||||||
|
'', 'screen capture', 'window capture', 'region capture', 'capture',
|
||||||
|
]);
|
||||||
|
if (this.dom.titleAiBtn) {
|
||||||
|
const val = (this.dom.titleInput?.value || '').trim();
|
||||||
|
const hasDraft = Boolean(val) && !PLACEHOLDER_TITLES.has(val.toLowerCase());
|
||||||
|
this.dom.titleAiBtn.title = hasDraft ? 'Rewrite step title with AI' : 'Generate step title with AI';
|
||||||
|
}
|
||||||
|
if (this.dom.descAiBtn) {
|
||||||
|
const hasDesc = Boolean((this.dom.descEditor?.innerText || '').trim());
|
||||||
|
this.dom.descAiBtn.title = hasDesc ? 'Rewrite description with AI' : 'Generate description with AI';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get currentStep() {
|
get currentStep() {
|
||||||
return this.stepMap.get(this.selectedStepId) || null;
|
return this.stepMap.get(this.selectedStepId) || null;
|
||||||
}
|
}
|
||||||
@@ -271,7 +415,14 @@ class GuideEditor {
|
|||||||
el('aside.pane-props', {},
|
el('aside.pane-props', {},
|
||||||
el('section', {},
|
el('section', {},
|
||||||
el('h3', {}, 'Step'),
|
el('h3', {}, 'Step'),
|
||||||
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title' }),
|
el('div.row', {},
|
||||||
|
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title', style: { flex: 1 } }),
|
||||||
|
this.dom.titleAiBtn = el('button.ai', {
|
||||||
|
type: 'button',
|
||||||
|
title: 'Generate the step title with AI',
|
||||||
|
dataset: { aiAction: 'title', aiTitle: 'Generate the step title with AI' },
|
||||||
|
}, 'AI'),
|
||||||
|
),
|
||||||
this.dom.statusSelect = makeSelect('todo', [
|
this.dom.statusSelect = makeSelect('todo', [
|
||||||
{ value: 'todo', label: 'Todo' },
|
{ value: 'todo', label: 'Todo' },
|
||||||
{ value: 'in-progress', label: 'In progress' },
|
{ value: 'in-progress', label: 'In progress' },
|
||||||
@@ -296,7 +447,14 @@ class GuideEditor {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
el('section', {},
|
el('section', {},
|
||||||
el('h3', {}, 'Description'),
|
el('div.row', { style: { justifyContent: 'space-between', alignItems: 'center' } },
|
||||||
|
el('h3', { style: { margin: 0 } }, 'Description'),
|
||||||
|
this.dom.descAiBtn = el('button.ai', {
|
||||||
|
type: 'button',
|
||||||
|
title: 'Generate the description with AI',
|
||||||
|
dataset: { aiAction: 'description', aiTitle: 'Generate the description with AI' },
|
||||||
|
}, 'AI'),
|
||||||
|
),
|
||||||
this.dom.richToolbar = el('div.rich-toolbar', {},
|
this.dom.richToolbar = el('div.rich-toolbar', {},
|
||||||
this.toolbarBtn('bold', 'Bold'),
|
this.toolbarBtn('bold', 'Bold'),
|
||||||
this.toolbarBtn('italic', 'Italic'),
|
this.toolbarBtn('italic', 'Italic'),
|
||||||
@@ -415,7 +573,9 @@ class GuideEditor {
|
|||||||
this.saveStepDebounced();
|
this.saveStepDebounced();
|
||||||
this.renderStepList();
|
this.renderStepList();
|
||||||
this.emitMeta();
|
this.emitMeta();
|
||||||
|
this.updateAiButtonHints();
|
||||||
});
|
});
|
||||||
|
this.dom.titleAiBtn.addEventListener('click', () => this.generateTitleWithAi(this.dom.titleAiBtn));
|
||||||
|
|
||||||
this.dom.statusSelect.addEventListener('change', () => {
|
this.dom.statusSelect.addEventListener('change', () => {
|
||||||
if (!this.currentStep) return;
|
if (!this.currentStep) return;
|
||||||
@@ -485,12 +645,14 @@ class GuideEditor {
|
|||||||
this.saveStepDebounced();
|
this.saveStepDebounced();
|
||||||
this.emitMeta();
|
this.emitMeta();
|
||||||
this.updateToolbarState();
|
this.updateToolbarState();
|
||||||
|
this.updateAiButtonHints();
|
||||||
});
|
});
|
||||||
this.dom.descEditor.addEventListener('keyup', () => this.updateToolbarState());
|
this.dom.descEditor.addEventListener('keyup', () => this.updateToolbarState());
|
||||||
this.dom.descEditor.addEventListener('mouseup', () => this.updateToolbarState());
|
this.dom.descEditor.addEventListener('mouseup', () => this.updateToolbarState());
|
||||||
document.addEventListener('selectionchange', () => {
|
document.addEventListener('selectionchange', () => {
|
||||||
if (document.activeElement === this.dom.descEditor) this.updateToolbarState();
|
if (document.activeElement === this.dom.descEditor) this.updateToolbarState();
|
||||||
});
|
});
|
||||||
|
this.dom.descAiBtn.addEventListener('click', () => this.generateDescriptionWithAi(this.dom.descAiBtn));
|
||||||
|
|
||||||
this.dom.descEditor.addEventListener('paste', (e) => {
|
this.dom.descEditor.addEventListener('paste', (e) => {
|
||||||
// Keep pasted text simple; backend sanitization will handle the rest.
|
// Keep pasted text simple; backend sanitization will handle the rest.
|
||||||
@@ -504,6 +666,8 @@ class GuideEditor {
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
this.canvas.select(item.dataset.annId);
|
this.canvas.select(item.dataset.annId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.updateAiButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAll() {
|
renderAll() {
|
||||||
@@ -513,6 +677,7 @@ class GuideEditor {
|
|||||||
this.renderCanvas();
|
this.renderCanvas();
|
||||||
this.renderAnnotationPanel();
|
this.renderAnnotationPanel();
|
||||||
this.renderBlocksPanel();
|
this.renderBlocksPanel();
|
||||||
|
this.updateAiButtonState();
|
||||||
this.emitMeta();
|
this.emitMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,6 +835,15 @@ class GuideEditor {
|
|||||||
el('span.spacer'),
|
el('span.spacer'),
|
||||||
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'),
|
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp, dataset: { blockMove: 'up' } }, '↑'),
|
||||||
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'),
|
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown, dataset: { blockMove: 'down' } }, '↓'),
|
||||||
|
(() => {
|
||||||
|
const aiBtn = el('button.ai', {
|
||||||
|
type: 'button',
|
||||||
|
title: 'Generate this block with AI',
|
||||||
|
dataset: { aiAction: 'block', aiTitle: 'Generate this block with AI' },
|
||||||
|
onClick: () => this.generateBlockWithAi(kind, block, aiBtn),
|
||||||
|
}, 'AI');
|
||||||
|
return aiBtn;
|
||||||
|
})(),
|
||||||
removeBtn(() => {
|
removeBtn(() => {
|
||||||
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
|
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
|
||||||
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
|
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
|
||||||
@@ -761,6 +935,7 @@ class GuideEditor {
|
|||||||
if (!blocks.length) {
|
if (!blocks.length) {
|
||||||
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.'));
|
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.'));
|
||||||
}
|
}
|
||||||
|
this.updateAiButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStepList() {
|
renderStepList() {
|
||||||
@@ -915,6 +1090,7 @@ class GuideEditor {
|
|||||||
if (document.activeElement !== this.dom.titleInput) this.dom.titleInput.value = step.title || '';
|
if (document.activeElement !== this.dom.titleInput) this.dom.titleInput.value = step.title || '';
|
||||||
if (document.activeElement !== this.dom.descEditor) this.dom.descEditor.innerHTML = step.descriptionHtml || '';
|
if (document.activeElement !== this.dom.descEditor) this.dom.descEditor.innerHTML = step.descriptionHtml || '';
|
||||||
this.dom.statusSelect.value = step.status || 'todo';
|
this.dom.statusSelect.value = step.status || 'todo';
|
||||||
|
this.updateAiButtonHints();
|
||||||
this.dom.hiddenToggle.querySelector('input').checked = Boolean(step.hidden);
|
this.dom.hiddenToggle.querySelector('input').checked = Boolean(step.hidden);
|
||||||
this.dom.skippedToggle.querySelector('input').checked = Boolean(step.skipped);
|
this.dom.skippedToggle.querySelector('input').checked = Boolean(step.skipped);
|
||||||
this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage);
|
this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage);
|
||||||
@@ -1651,6 +1827,7 @@ class GuideEditor {
|
|||||||
const settings = await api.settings.all();
|
const settings = await api.settings.all();
|
||||||
const placeholders = await api.settings.globalPlaceholders();
|
const placeholders = await api.settings.globalPlaceholders();
|
||||||
await dialogs.showSettingsDialog({
|
await dialogs.showSettingsDialog({
|
||||||
|
api,
|
||||||
settings,
|
settings,
|
||||||
placeholders,
|
placeholders,
|
||||||
onSave: async (next) => {
|
onSave: async (next) => {
|
||||||
@@ -1658,6 +1835,7 @@ class GuideEditor {
|
|||||||
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
|
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
|
||||||
await api.settings.set({ keyPath: 'capture', value: next.capture });
|
await api.settings.set({ keyPath: 'capture', value: next.capture });
|
||||||
await api.settings.set({ keyPath: 'editor', value: next.editor });
|
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: 'exports', value: next.exports });
|
||||||
await api.settings.set({ keyPath: 'backups', value: next.backups });
|
await api.settings.set({ keyPath: 'backups', value: next.backups });
|
||||||
await api.settings.setGlobalPlaceholders(next.placeholders || {});
|
await api.settings.setGlobalPlaceholders(next.placeholders || {});
|
||||||
|
|||||||
@@ -90,6 +90,16 @@ button.tool.active {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent-fg);
|
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:disabled { opacity: 0.6; cursor: default; }
|
||||||
button.loading {
|
button.loading {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -599,8 +609,82 @@ fieldset legend {
|
|||||||
}
|
}
|
||||||
.placeholder-row input { width: 100%; }
|
.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 {
|
.quick-actions {
|
||||||
width: min(760px, 92vw);
|
width: min(760px, 92vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.quick-actions input {
|
.quick-actions input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -608,7 +692,6 @@ fieldset legend {
|
|||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
}
|
}
|
||||||
.qa-results {
|
.qa-results {
|
||||||
margin-top: 10px;
|
|
||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -678,6 +761,7 @@ fieldset legend {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.ai-status.error { color: var(--danger); }
|
||||||
|
|
||||||
#modal-root:not(:empty) {
|
#modal-root:not(:empty) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -340,6 +340,14 @@ async function createElectronHost(onEvent) {
|
|||||||
if (!win.isDestroyed()) win.destroy();
|
if (!win.isDestroyed()) win.destroy();
|
||||||
throw err;
|
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 {
|
return {
|
||||||
send(msg) {
|
send(msg) {
|
||||||
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
|
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
'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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
installed,
|
||||||
|
models,
|
||||||
|
host: config.ollama.host,
|
||||||
|
model: config.ollama.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
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,
|
||||||
|
format: 'json',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: prompt },
|
||||||
|
],
|
||||||
|
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.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await this.callOllama({
|
||||||
|
host: config.ollama.host,
|
||||||
|
model: config.ollama.model,
|
||||||
|
prompt,
|
||||||
|
systemPrompt,
|
||||||
|
});
|
||||||
|
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 };
|
||||||
@@ -86,6 +86,9 @@ function createStep(fields = {}) {
|
|||||||
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
|
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
|
||||||
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
|
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
|
||||||
links: fields.links || [], // { id, label, targetStepId }
|
links: fields.links || [], // { id, label, targetStepId }
|
||||||
|
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
|
||||||
|
? { ...fields.captureMetadata }
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ const DEFAULT_SETTINGS = {
|
|||||||
focusedViewDefaultForNewSteps: false,
|
focusedViewDefaultForNewSteps: false,
|
||||||
autoTitleTemplate: '[[Mode]] capture [[Time]]',
|
autoTitleTemplate: '[[Mode]] capture [[Time]]',
|
||||||
},
|
},
|
||||||
|
ai: {
|
||||||
|
enabled: false,
|
||||||
|
ollama: {
|
||||||
|
host: 'http://127.0.0.1:11434',
|
||||||
|
model: 'llama3.2:1b',
|
||||||
|
},
|
||||||
|
},
|
||||||
exports: {
|
exports: {
|
||||||
previewStepCount: 3,
|
previewStepCount: 3,
|
||||||
openFolderAfterExport: true,
|
openFolderAfterExport: true,
|
||||||
|
|||||||
@@ -0,0 +1,852 @@
|
|||||||
|
'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,
|
||||||
|
} = {}) {
|
||||||
|
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,
|
||||||
|
] : []),
|
||||||
|
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.',
|
||||||
|
'- 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,
|
||||||
|
};
|
||||||
@@ -16,9 +16,8 @@ filing issues — is expected to:
|
|||||||
|
|
||||||
Maintainers may edit, hide, or remove comments, commits, issues, and PRs that
|
Maintainers may edit, hide, or remove comments, commits, issues, and PRs that
|
||||||
violate this standard, and may temporarily or permanently ban contributors
|
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
|
## Reporting
|
||||||
|
|
||||||
Report unacceptable behavior privately to the maintainers via the contact
|
Report unacceptable behavior privately to the maintainers via `[email protected]`. Reports are handled confidentially.
|
||||||
listed on the project page. Reports are handled confidentially.
|
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ Origin sign-off (`git commit -s`).
|
|||||||
## Offline Rules
|
## Offline Rules
|
||||||
|
|
||||||
- No network code paths in the application. No telemetry, update checks,
|
- 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
|
- 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
|
## Branching
|
||||||
|
|
||||||
@@ -46,8 +47,7 @@ Origin sign-off (`git commit -s`).
|
|||||||
`Closes #123`, `Fixes #123`, or `Relates to #123`.
|
`Closes #123`, `Fixes #123`, or `Relates to #123`.
|
||||||
- Summarize the change clearly and call out anything a reviewer should
|
- Summarize the change clearly and call out anything a reviewer should
|
||||||
verify manually.
|
verify manually.
|
||||||
- Update docs when behavior changes, and add a CHANGELOG entry for every
|
- Update docs when behavior changes.
|
||||||
user-visible change.
|
|
||||||
- Every exporter or storage change **requires tests**; output changes
|
- Every exporter or storage change **requires tests**; output changes
|
||||||
require updated snapshot fixtures under `tests/fixtures/`.
|
require updated snapshot fixtures under `tests/fixtures/`.
|
||||||
|
|
||||||
@@ -64,10 +64,9 @@ automatically. The shell checks invoke the `node --test` workflow suites in
|
|||||||
`tests/unit/`.
|
`tests/unit/`.
|
||||||
|
|
||||||
Write tests that exercise **real workflows and verify actual output** —
|
Write tests that exercise **real workflows and verify actual output** —
|
||||||
create a guide, export it, parse the bytes that came out — rather than
|
create a guide, export it, parse the bytes that came out. DO NOT WRITE A TEST THAT GREPS FOR CODE.
|
||||||
grepping for magic strings.
|
|
||||||
|
|
||||||
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.
|
automatically on pushes and pull requests.
|
||||||
|
|
||||||
Please add lots of tests to each of your PR's and be descriptive with the
|
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
|
StepForge is a fully offline desktop app. Nothing is uploaded or synced, and
|
||||||
all guides stay on your machine.
|
all guides stay on your machine.
|
||||||
|
|
||||||
|
# Windows installation
|
||||||
|
|
||||||
|
For the windows installation, please see [windows_installation](windows_installation.md)
|
||||||
|
|
||||||
|
# Developer install
|
||||||
|
|
||||||
## 1. Install
|
## 1. Install
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
@@ -28,7 +34,7 @@ usually under `~/.local/share/stepforge`. On Windows it is usually under
|
|||||||
In the library view:
|
In the library view:
|
||||||
|
|
||||||
1. Click `New guide`.
|
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.
|
3. Open the guide to enter the editor.
|
||||||
|
|
||||||
You can also import a guide archive with `Import archive` if you already have
|
You can also import a guide archive with `Import archive` if you already have
|
||||||
@@ -36,10 +42,11 @@ one.
|
|||||||
|
|
||||||
## 4. Add content
|
## 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.
|
1. Record your workflow by clicking on the record button (reconmmended).
|
||||||
2. Paste an image from the clipboard if you already copied one.
|
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
|
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
|
capture session. Use `Settings` to set the capture hotkey and other capture
|
||||||
@@ -50,7 +57,7 @@ options.
|
|||||||
The editor is split into three panes:
|
The editor is split into three panes:
|
||||||
|
|
||||||
1. Steps on the left
|
1. Steps on the left
|
||||||
2. Canvas in the center
|
2. Editing canvas in the center
|
||||||
3. Properties on the right
|
3. Properties on the right
|
||||||
|
|
||||||
Use the canvas tools to add shapes, arrows, text, blur, highlight, numbers,
|
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
|
## Optional builds
|
||||||
|
|
||||||
1. `bash scripts/build-release.sh` assembles the offline release layout.
|
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/`.
|
`releases/`.
|
||||||
3. `bash scripts/package-linux.sh` creates Linux release artifacts.
|
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"
|
Permission is granted to use, copy, modify, and distribute this software for non-commercial purposes only.
|
||||||
means each individual or legal entity that creates, contributes to
|
|
||||||
the creation of, or owns Covered Software.
|
|
||||||
|
|
||||||
1.2. "Contributor Version"
|
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.
|
||||||
means the combination of the Contributions of others (if any) used
|
|
||||||
by a Contributor and that particular Contributor's Contribution.
|
|
||||||
|
|
||||||
1.3. "Contribution"
|
This software is provided "as is", without warranty of any kind.
|
||||||
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.
|
|
||||||
@@ -64,6 +64,5 @@ URLs) before storage and again before rendering or export.
|
|||||||
|
|
||||||
## Reporting
|
## Reporting
|
||||||
|
|
||||||
Report vulnerabilities by opening an issue marked `security` (this is a
|
Report vulnerabilities by sending an email to `[email protected]`
|
||||||
local-only tool, so coordinated disclosure pressure is low; do not include
|
|
||||||
exploit archives directly — describe the structure instead).
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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 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` or the smaller model you pulled
|
||||||
|
|
||||||
|
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.
|
||||||
|
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>
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ const { zipSync } = require('../core/zip');
|
|||||||
const { escapeXml } = require('../core/util');
|
const { escapeXml } = require('../core/util');
|
||||||
const { encodePng } = require('../core/png');
|
const { encodePng } = require('../core/png');
|
||||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
|
||||||
const { guideMetaLines, guideSummary } = require('./document-layout');
|
const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
||||||
@@ -70,14 +70,52 @@ function pageBreak() {
|
|||||||
return p('<w:r><w:br w:type="page"/></w:r>');
|
return p('<w:r><w:br w:type="page"/></w:r>');
|
||||||
}
|
}
|
||||||
|
|
||||||
function tocFieldParagraph() {
|
// Width (in twips) of the text column inside the A4 page margins used by
|
||||||
return p([
|
// <w:sectPr> below (11906 - 1134*2), i.e. where TOC page-number tabs land.
|
||||||
'<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>',
|
const TOC_TAB_POS = 9638;
|
||||||
'<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>',
|
/** Bookmark name anchoring a step's heading, referenced by its TOC entry. */
|
||||||
'<w:r><w:rPr><w:i/></w:rPr><w:t xml:space="preserve">Update contents in Word</w:t></w:r>',
|
function bookmarkName(step) {
|
||||||
'<w:r><w:fldChar w:fldCharType="end"/></w:r>',
|
return `toc_${String(step.number).replace(/\./g, '_')}`;
|
||||||
].join(''));
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
function headingStyleForDepth(depth) {
|
||||||
@@ -136,6 +174,25 @@ function stylesXml() {
|
|||||||
</w:rPr>
|
</w:rPr>
|
||||||
</w:style>`;
|
</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"?>
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
<w:docDefaults>
|
<w:docDefaults>
|
||||||
@@ -165,6 +222,9 @@ function stylesXml() {
|
|||||||
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
|
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
|
||||||
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
|
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
|
||||||
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
|
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
|
||||||
|
${tocStyle(1)}
|
||||||
|
${tocStyle(2)}
|
||||||
|
${tocStyle(3)}
|
||||||
</w:styles>`;
|
</w:styles>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +235,7 @@ function exportDocx(ast, outDir, template = {}) {
|
|||||||
const media = []; // { name, data }
|
const media = []; // { name, data }
|
||||||
const rels = []; // relationship XML strings
|
const rels = []; // relationship XML strings
|
||||||
let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
|
let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
|
||||||
|
let bookmarkCounter = 0;
|
||||||
let stepImageCount = 0;
|
let stepImageCount = 0;
|
||||||
|
|
||||||
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
|
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
|
||||||
@@ -195,7 +256,7 @@ function exportDocx(ast, outDir, template = {}) {
|
|||||||
run('Contents', { bold: true, size: 28 }),
|
run('Contents', { bold: true, size: 28 }),
|
||||||
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
|
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
|
||||||
));
|
));
|
||||||
body.push(tocFieldParagraph());
|
body.push(...tocFieldParagraphs(ast));
|
||||||
body.push(pageBreak());
|
body.push(pageBreak());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +272,12 @@ function exportDocx(ast, outDir, template = {}) {
|
|||||||
for (const step of ast.steps) {
|
for (const step of ast.steps) {
|
||||||
const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
|
const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
|
||||||
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
||||||
|
const bookmarkId = ++bookmarkCounter;
|
||||||
|
const anchor = bookmarkName(step);
|
||||||
body.push(p(
|
body.push(p(
|
||||||
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
|
`<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)
|
headingParagraphProps(step.depth, step.forceNewPage)
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
"name": "stepforge",
|
"name": "stepforge",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tesseract.js-data/eng": "^1.0.0",
|
||||||
|
"tesseract.js": "^7.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^41.7.1",
|
"electron": "^41.7.1",
|
||||||
"electron-builder": "^26.15.2"
|
"electron-builder": "^26.15.2"
|
||||||
@@ -624,6 +628,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/@types/cacheable-request": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||||
@@ -1057,6 +1067,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/boolean": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||||
@@ -2512,6 +2528,12 @@
|
|||||||
"node": ">= 14"
|
"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": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@@ -2541,6 +2563,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
@@ -2903,6 +2931,26 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/node-gyp": {
|
||||||
"version": "12.4.0",
|
"version": "12.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||||
@@ -3024,6 +3072,15 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/p-cancelable": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||||
@@ -3314,6 +3371,12 @@
|
|||||||
"util-deprecate": "~1.0.1"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -3728,6 +3791,30 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/tiny-async-pool": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||||
@@ -3785,6 +3872,12 @@
|
|||||||
"tmp": "^0.2.0"
|
"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": {
|
"node_modules/truncate-utf8-bytes": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||||
@@ -3909,6 +4002,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/webcrypto-core": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
||||||
@@ -3923,6 +4022,22 @@
|
|||||||
"tslib": "^2.8.1"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||||
@@ -4043,6 +4158,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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",
|
"verify": "bash scripts/verify.sh",
|
||||||
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tesseract.js-data/eng": "^1.0.0",
|
||||||
|
"tesseract.js": "^7.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^41.7.1",
|
"electron": "^41.7.1",
|
||||||
"electron-builder": "^26.15.2"
|
"electron-builder": "^26.15.2"
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ ${toolRows}
|
|||||||
Fallback policy: when a packaging tool is missing the build still produces
|
Fallback policy: when a packaging tool is missing the build still produces
|
||||||
the runnable app (portable tarball with launcher) plus whatever package
|
the runnable app (portable tarball with launcher) plus whatever package
|
||||||
formats the available tools allow. Windows artifacts are produced by
|
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.
|
AppImage require the tools listed above and are skipped on this host.
|
||||||
|
|
||||||
## Offline guarantee
|
## Offline guarantee
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ const ELECTRON_SKIP_ENV_KEYS = [
|
|||||||
'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD',
|
'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const NPM_IGNORE_SCRIPTS_ENV_KEYS = [
|
||||||
|
'npm_config_ignore_scripts',
|
||||||
|
'NPM_CONFIG_IGNORE_SCRIPTS',
|
||||||
|
];
|
||||||
|
|
||||||
function resolveElectronPackageRoot() {
|
function resolveElectronPackageRoot() {
|
||||||
try {
|
try {
|
||||||
return path.dirname(require.resolve('electron/package.json'));
|
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) {
|
for (const key of ELECTRON_SKIP_ENV_KEYS) {
|
||||||
delete env[key];
|
delete env[key];
|
||||||
}
|
}
|
||||||
|
for (const key of NPM_IGNORE_SCRIPTS_ENV_KEYS) {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
@@ -67,8 +75,10 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
|
|||||||
return candidatePaths;
|
return candidatePaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runNpmRebuild({
|
function runNpmCommand({
|
||||||
packageRoot,
|
packageRoot,
|
||||||
|
npmArgs,
|
||||||
|
errorLabel,
|
||||||
npmExecPath = process.env.npm_execpath || null,
|
npmExecPath = process.env.npm_execpath || null,
|
||||||
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
|
||||||
}) {
|
}) {
|
||||||
@@ -76,31 +86,63 @@ function runNpmRebuild({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = spawnSync(
|
const result = spawnSync(npmNodeExecPath, [npmExecPath, ...npmArgs], {
|
||||||
npmNodeExecPath,
|
cwd: packageRoot,
|
||||||
[npmExecPath, 'rebuild', 'electron', '--force', '--foreground-scripts'],
|
env: sanitizeElectronEnv(),
|
||||||
{
|
stdio: 'inherit',
|
||||||
cwd: packageRoot,
|
});
|
||||||
env: sanitizeElectronEnv(),
|
|
||||||
stdio: 'inherit',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
throw result.error;
|
throw result.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.signal) {
|
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) {
|
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;
|
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({
|
function repairElectronInstall({
|
||||||
packageRoot,
|
packageRoot,
|
||||||
}) {
|
}) {
|
||||||
@@ -153,45 +195,93 @@ function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
|
|||||||
|
|
||||||
function resolveElectronBinary({
|
function resolveElectronBinary({
|
||||||
packageRoot = resolveElectronPackageRoot(),
|
packageRoot = resolveElectronPackageRoot(),
|
||||||
|
projectRoot = process.cwd(),
|
||||||
platform = process.platform,
|
platform = process.platform,
|
||||||
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
|
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(
|
throw new Error(
|
||||||
'Electron could not be started because node_modules/electron is not installed.\n\n' +
|
'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.'
|
'Run `npm install` from the repo root, then try `npm start` again.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const distDir = overrideDistPath || path.join(packageRoot, 'dist');
|
const distDir = overrideDistPath || path.join(currentPackageRoot, 'dist');
|
||||||
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
|
let candidatePaths = electronBinaryCandidates({ packageRoot: currentPackageRoot, distDir, platform });
|
||||||
|
let resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
||||||
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
|
if (resolved) {
|
||||||
if (!resolved) {
|
return 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 }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
module.exports = {
|
||||||
@@ -200,6 +290,7 @@ module.exports = {
|
|||||||
readElectronPathHint,
|
readElectronPathHint,
|
||||||
repairElectronInstall,
|
repairElectronInstall,
|
||||||
runNpmRebuild,
|
runNpmRebuild,
|
||||||
|
runNpmInstall,
|
||||||
sanitizeElectronEnv,
|
sanitizeElectronEnv,
|
||||||
resolveElectronBinary,
|
resolveElectronBinary,
|
||||||
resolveElectronPackageRoot,
|
resolveElectronPackageRoot,
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ const { build, Platform } = require('electron-builder');
|
|||||||
|
|
||||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||||
const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json'));
|
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 APP_ID = 'com.stepforge.app';
|
||||||
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';
|
|
||||||
|
|
||||||
function findPortableExe(dir) {
|
function findInstallerExe(dir) {
|
||||||
if (!fs.existsSync(dir)) return null;
|
if (!fs.existsSync(dir)) return null;
|
||||||
const stack = [dir];
|
const stack = [dir];
|
||||||
while (stack.length) {
|
while (stack.length) {
|
||||||
@@ -31,15 +28,12 @@ function findPortableExe(dir) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
function createWindowsInstallerConfig(outputDir) {
|
||||||
fs.mkdirSync(RELEASE_DIR, { recursive: true });
|
return {
|
||||||
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
|
appId: APP_ID,
|
||||||
|
|
||||||
const config = {
|
|
||||||
appId: 'com.stepforge.app',
|
|
||||||
productName: 'StepForge',
|
productName: 'StepForge',
|
||||||
directories: {
|
directories: {
|
||||||
output: OUTPUT_DIR,
|
output: outputDir,
|
||||||
},
|
},
|
||||||
files: [
|
files: [
|
||||||
'app/**/*',
|
'app/**/*',
|
||||||
@@ -49,33 +43,63 @@ async function main() {
|
|||||||
],
|
],
|
||||||
asar: true,
|
asar: true,
|
||||||
compression: 'normal',
|
compression: 'normal',
|
||||||
artifactName: ARTIFACT_NAME,
|
|
||||||
win: {
|
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) => {
|
function createWindowsInstallerBuildOptions(outputDir) {
|
||||||
console.error(err.message || err);
|
return {
|
||||||
process.exitCode = 1;
|
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ test('queued click captures preserve the original event time and button', async
|
|||||||
assert.deepEqual(seen, [{
|
assert.deepEqual(seen, [{
|
||||||
trigger: 'click',
|
trigger: 'click',
|
||||||
clickPos: { x: 7, y: 8 },
|
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) => {
|
test('reports a helpful error when the runtime is missing', (t) => {
|
||||||
const root = makeTmpDir('electron-missing');
|
const root = makeTmpDir('electron-missing');
|
||||||
t.after(() => rmrf(root));
|
t.after(() => rmrf(root));
|
||||||
|
|||||||
@@ -339,6 +339,46 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (
|
|||||||
assert.ok(entries.size >= 6);
|
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) => {
|
test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => {
|
||||||
const { ast, root } = fixtureAst(t, 'pptx');
|
const { ast, root } = fixtureAst(t, 'pptx');
|
||||||
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));
|
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
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 () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [
|
||||||
|
{ name: 'llama3.2:1b' },
|
||||||
|
{ name: 'qwen3:0.6b' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testAiConnection();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.installed, true);
|
||||||
|
assert.equal(result.model, 'llama3.2:1b');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||