merge main

This commit is contained in:
EthanHealy01
2026-05-08 12:36:23 +01:00
28 changed files with 1934 additions and 247 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.10.0
pkgver=2.10.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.10.0
pkgver=2.10.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+31 -7
View File
@@ -93,18 +93,18 @@ jobs:
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For push/release events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build-jars:
@@ -218,7 +218,23 @@ jobs:
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -234,6 +250,14 @@ jobs:
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
run: task desktop:prepare
env:
@@ -321,7 +345,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -342,7 +366,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
if: matrix.platform == 'macos-15' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master')
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -557,7 +581,7 @@ jobs:
# Only ship the MSI installer on Windows. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
+18 -22
View File
@@ -22,8 +22,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.info.outputs.version }}
dmg_arm64_sha256: ${{ steps.hashes.outputs.dmg_arm64_sha256 }}
dmg_x86_64_sha256: ${{ steps.hashes.outputs.dmg_x86_64_sha256 }}
dmg_sha256: ${{ steps.hashes.outputs.dmg_sha256 }}
msi_sha256: ${{ steps.hashes.outputs.msi_sha256 }}
deb_sha256: ${{ steps.hashes.outputs.deb_sha256 }}
jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }}
@@ -63,17 +62,15 @@ jobs:
sha256sum "$file" | awk '{print $1}'
}
DMG_ARM64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-aarch64.dmg")
DMG_X64_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-x86_64.dmg")
DMG_SHA=$(download_sha256 "${BASE}/Stirling-PDF-macos-universal.dmg")
MSI_SHA=$(download_sha256 "${BASE}/Stirling-PDF-windows-x86_64.msi")
DEB_SHA=$(download_sha256 "${BASE}/Stirling-PDF-linux-x86_64.deb")
JAR_SHA=$(download_sha256 "${BASE}/Stirling-PDF-with-login.jar")
echo "dmg_arm64_sha256=$DMG_ARM64_SHA" >> "$GITHUB_OUTPUT"
echo "dmg_x86_64_sha256=$DMG_X64_SHA" >> "$GITHUB_OUTPUT"
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
echo "dmg_sha256=$DMG_SHA" >> "$GITHUB_OUTPUT"
echo "msi_sha256=$MSI_SHA" >> "$GITHUB_OUTPUT"
echo "deb_sha256=$DEB_SHA" >> "$GITHUB_OUTPUT"
echo "jar_sha256=$JAR_SHA" >> "$GITHUB_OUTPUT"
update-homebrew-and-scoop:
needs: get-release-info
@@ -93,22 +90,21 @@ jobs:
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap
# Stirling-PDF now ships a single universal DMG, so the Cask should
# have one sha256 line rather than separate on_arm/on_intel blocks.
# We rewrite every `sha256 "..."` in the Cask to the same value so
# the workflow keeps working through the Cask migration: pre-migration
# both arch blocks get the universal SHA (still correct, since both
# would resolve to the same DMG), post-migration the lone sha256 line
# is updated.
- name: Update Homebrew cask (Casks/stirling-pdf.rb)
env:
VERSION: ${{ needs.get-release-info.outputs.version }}
ARM64_SHA: ${{ needs.get-release-info.outputs.dmg_arm64_sha256 }}
X64_SHA: ${{ needs.get-release-info.outputs.dmg_x86_64_sha256 }}
DMG_SHA: ${{ needs.get-release-info.outputs.dmg_sha256 }}
run: |
CASK="tap/Casks/stirling-pdf.rb"
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$CASK"
awk -v arm="$ARM64_SHA" -v x64="$X64_SHA" '
/on_arm/ { in_arm=1 }
/on_intel/ { in_arm=0; in_intel=1 }
/end/ { in_arm=0; in_intel=0 }
in_arm && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" arm "\"") }
in_intel && /sha256/ { sub(/sha256 ".*"/, "sha256 \"" x64 "\"") }
{ print }
' "$CASK" > tmp && mv tmp "$CASK"
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$CASK"
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${DMG_SHA}\"/g" "$CASK"
- name: Update Homebrew formula (Formula/stirling-pdf-server.rb)
env:
@@ -116,8 +112,8 @@ jobs:
JAR_SHA: ${{ needs.get-release-info.outputs.jar_sha256 }}
run: |
FORMULA="tap/Formula/stirling-pdf-server.rb"
sed -i "s/version \".*\"/version \"${VERSION}\"/" "$FORMULA"
sed -i "s/sha256 \".*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
sed -i "s/version \"[^\"]*\"/version \"${VERSION}\"/" "$FORMULA"
sed -i "s/sha256 \"[^\"]*\"/sha256 \"${JAR_SHA}\"/" "$FORMULA"
- name: Update Scoop stirling-pdf.json
env:
+37 -14
View File
@@ -1,6 +1,6 @@
name: Build Tauri Applications
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS arm+intel /
# Multi-OS Tauri desktop bundle build matrix (Windows / macOS universal /
# Linux). Called from build.yml on PRs that touch desktop sources (gated
# via the `tauri` filter in .github/config/.files.yaml). Also runnable
# on demand via workflow_dispatch with a per-platform selector.
@@ -48,17 +48,16 @@ jobs:
PLATFORM: ${{ inputs.platform }}
run: |
WINDOWS='{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}'
MACOS_ARM='{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"}'
MACOS_INTEL='{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}'
MACOS='{"platform":"macos-15","args":"--target universal-apple-darwin","name":"macos-universal"}'
LINUX='{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}'
# Resolve requested platform — populated by either workflow_dispatch
# or workflow_call inputs; both paths default to "all".
case "$PLATFORM" in
windows) ENTRIES=("$WINDOWS") ;;
macos) ENTRIES=("$MACOS_ARM" "$MACOS_INTEL") ;;
macos) ENTRIES=("$MACOS") ;;
linux) ENTRIES=("$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS_ARM" "$MACOS_INTEL" "$LINUX") ;;
*) ENTRIES=("$WINDOWS" "$MACOS" "$LINUX") ;;
esac
# Drop macOS entries when Apple certificate secret is unavailable
@@ -111,7 +110,23 @@ jobs:
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
# x86_64 JDK is set up first so the aarch64 step below can leave its
# JAVA_HOME as the active one. The macOS universal JRE build needs
# jmods from both arches; the x64 path is captured into the env
# before the second setup-java overwrites JAVA_HOME.
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
architecture: "x64"
- name: Capture x86_64 JAVA_HOME
if: matrix.platform == 'macos-15'
run: echo "X64_JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
@@ -126,6 +141,15 @@ jobs:
- name: Setup Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
# Build the universal JRE before desktop:prepare so the jlink:runtime
# task short-circuits on its `test -d runtime/jre` status check.
- name: Build universal macOS JRE
if: matrix.platform == 'macos-15'
env:
AARCH64_JAVA_HOME: ${{ env.JAVA_HOME }}
run: task desktop:jlink:universal-mac
- name: Prepare desktop build
run: task desktop:prepare
env:
@@ -213,7 +237,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -234,7 +258,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && env.APPLE_CERTIFICATE != ''
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -246,7 +270,7 @@ jobs:
echo "Certificate imported successfully."
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
if: matrix.platform == 'macos-15'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
@@ -373,7 +397,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
if: matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/src-tauri/target
@@ -398,7 +422,7 @@ jobs:
# Only ship the MSI installer. The loose exe and WiX toolset exes
# are not the user-facing installer - the MSI contains the signed inner exe.
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
@@ -491,7 +515,7 @@ jobs:
echo "❌ No Windows executable found"
exit 1
fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
elif [ "${{ matrix.platform }}" = "macos-15" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" | head -5
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
@@ -556,8 +580,7 @@ jobs:
// Map of expected artifact names to display info
const artifactMap = {
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
'Stirling-PDF-macos-universal': { icon: '🍎', platform: 'macOS Universal', files: '.dmg' },
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .rpm, .AppImage' }
};
+16 -1
View File
@@ -96,7 +96,7 @@ tasks:
jlink
--add-modules {{.JLINK_MODULES}}
--strip-debug
--compress=2
--compress=zip-6
--no-header-files
--no-man-pages
--output src-tauri/runtime/jre
@@ -107,3 +107,18 @@ tasks:
desc: "Remove JLink runtime and bundled JARs"
cmds:
- rm -rf src-tauri/libs src-tauri/runtime
# macOS-only. Replaces jlink:runtime's single-arch JRE with a universal
# (arm64 + x86_64) one for the universal Tauri shell. Runs the x86_64
# jlink under Rosetta on Apple Silicon, so it is opt-in and not part of
# the default desktop:build flow. Requires AARCH64_JAVA_HOME and
# X64_JAVA_HOME to point at matching JDK installations with jmods/.
jlink:universal-mac:
desc: "Create universal (arm64+x86_64) JRE for the macOS Tauri build"
deps: [jlink:jar]
platforms: [darwin]
env:
JLINK_MODULES: "{{.JLINK_MODULES}}"
OUTPUT_DIR: src-tauri/runtime/jre
cmds:
- scripts/build-universal-mac-jre.sh
+1 -1
View File
@@ -78,7 +78,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.10.0'
version = '2.10.1'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
+47 -21
View File
@@ -1791,26 +1791,14 @@ bullet3 = "Keeps the original name if no suitable title is found"
text = "Automatically finds the title from your PDF content and uses it as the filename."
title = "Smart Renaming"
[pdfCommentAgent]
submit = "Generate comments"
prompt.label = "What should the AI comment on?"
prompt.placeholder = "e.g. Flag any ambiguous dates and suggest clarifications"
[pdfCommentAgent.settings]
title = "Comment instructions"
[pdfCommentAgent.results]
title = "Commented PDF"
[pdfCommentAgent.error]
failed = "Failed to generate comments"
emptyPrompt = "Please describe what the AI should comment on"
tooLong = "Prompt is too long (maximum {{max}} characters)"
[automate]
copyToSaved = "Copy to Saved"
desc = "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
export = "Export"
exportForFolderScanning = "Export for Folder Scanning"
import = "Import"
importPartialSuccess = "Imported with {{count}} unmapped operation(s): {{ops}}"
importSuccess = "Imported automation: {{name}}"
invalidStep = "Invalid step"
reviewTitle = "Automation Results"
title = "Automate"
@@ -1826,6 +1814,7 @@ title = "Configure {{toolName}}"
[automate.creation]
createTitle = "Create Automation"
editTitle = "Edit Automation"
export = "Export"
exportForFolderScanning = "Export for Folder Scanning"
intro = "Automations run tools sequentially. To get started, add tools in the order you want them to run."
save = "Save Automation"
@@ -1856,9 +1845,28 @@ confirm = "Go Back"
message = "You have unsaved changes. Are you sure you want to go back? All changes will be lost."
title = "Unsaved Changes"
[automate.entryMenu]
label = "Open menu for {{title}}"
[automate.files]
placeholder = "Select files to process with this automation"
[automate.importModal]
cancel = "Cancel"
confirm = "Import"
detectedAutomation = "Automate JSON"
detectedFolderScan = "Folder Scanning JSON"
dropHint = "Drop JSON here or click to choose a file"
dropSubhint = "Both Automate and Folder Scanning configs are accepted"
dropzoneAriaLabel = "Drop an automation JSON file here"
intro = "Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically."
opCount = "{{count}} operation(s)"
parseError = "Could not parse: {{message}}"
pasteLabel = "Or paste JSON"
pastePlaceholder = "Paste your automation JSON here…"
title = "Import automation"
unresolved = "Unmapped: {{ops}}"
[automate.run]
title = "Run Automation"
@@ -4113,11 +4121,6 @@ desc = "Auto renames a PDF file based on its detected header"
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
title = "Auto Rename PDF File"
[home.pdfCommentAgent]
desc = "Ask AI to annotate a PDF with sticky-note comments based on your prompt"
tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes"
title = "Add AI comments"
[home.autoSizeSplitPDF]
desc = "Automatically split PDFs by file size or page count"
tags = "auto,split,size"
@@ -4257,6 +4260,11 @@ desc = "Merge multiple pages of a PDF document into a single page"
tags = "layout,arrange,combine,N-up,2-up,4-up,multiple per page,pages per sheet,layout pages,tile,grid layout,multi-page layout,combine on page,handout"
title = "Multi-Page Layout"
[home.pdfCommentAgent]
desc = "Ask AI to annotate a PDF with sticky-note comments based on your prompt"
tags = "AI,agent,comment,annotate,sticky note,review,feedback,notes"
title = "Add AI comments"
[home.pdfOrganiser]
desc = "Remove/Rearrange pages in any order"
tags = "organize,rearrange,reorder,organise,arrange pages,sort,move pages,delete pages,remove pages,page management,page organizer,page organiser,resequence"
@@ -5332,6 +5340,24 @@ selectYearly = "Select Yearly"
title = "Choose Your Billing Period"
totalForSeats = "Total ({{count}} seats)"
[pdfCommentAgent]
submit = "Generate comments"
[pdfCommentAgent.error]
emptyPrompt = "Please describe what the AI should comment on"
failed = "Failed to generate comments"
tooLong = "Prompt is too long (maximum {{max}} characters)"
[pdfCommentAgent.prompt]
label = "What should the AI comment on?"
placeholder = "e.g. Flag any ambiguous dates and suggest clarifications"
[pdfCommentAgent.results]
title = "Commented PDF"
[pdfCommentAgent.settings]
title = "Comment instructions"
[pdfOrganiser]
header = "PDF Page Organiser"
placeholder = "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Build a universal (arm64 + x86_64) JRE bundle for the macOS Tauri app.
#
# jlink can only emit a single-architecture runtime, but the Tauri shell is
# universal-apple-darwin. Without this merge the bundled JRE would only run
# on whichever arch the runner used. We jlink twice (once per target JDK's
# jmods) and lipo-fatten every Mach-O file in the result.
#
# Inputs (env):
# AARCH64_JAVA_HOME path to an aarch64 JDK with jmods/
# X64_JAVA_HOME path to an x86_64 JDK with jmods/
# JLINK_MODULES comma-separated module list (matches desktop.yml)
# OUTPUT_DIR target directory (will be wiped); defaults to
# frontend/src-tauri/runtime/jre
set -euo pipefail
: "${AARCH64_JAVA_HOME:?AARCH64_JAVA_HOME must be set}"
: "${X64_JAVA_HOME:?X64_JAVA_HOME must be set}"
: "${JLINK_MODULES:?JLINK_MODULES must be set}"
OUTPUT_DIR="${OUTPUT_DIR:-frontend/src-tauri/runtime/jre}"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "build-universal-mac-jre.sh only runs on macOS" >&2
exit 1
fi
# x86_64 jlink runs under Rosetta on Apple Silicon. If Rosetta isn't
# installed the failure mode is a cryptic "Bad CPU type" from exec, so
# fail loudly up front when running on arm64 without it.
if [[ "$(uname -m)" == "arm64" ]] && ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
echo "Rosetta 2 is required to run x86_64 jlink on Apple Silicon. Install with: softwareupdate --install-rosetta --agree-to-license" >&2
exit 1
fi
WORK_DIR="$(mktemp -d -t universal-jre)"
trap 'rm -rf "$WORK_DIR"' EXIT INT TERM
ARM_JRE="$WORK_DIR/jre-aarch64"
X64_JRE="$WORK_DIR/jre-x86_64"
# Each jlink invocation must run on the native arch of its JDK: jlink
# stamps out launcher executables (bin/java, bin/rmiregistry, etc.)
# matching the host binary, regardless of --module-path. So we run the
# arm64 jlink for the arm64 JRE and the x86_64 jlink for the x86_64
# JRE.
run_jlink() {
local java_home="$1"
local out="$2"
"$java_home/bin/jlink" \
--module-path "$java_home/jmods" \
--add-modules "$JLINK_MODULES" \
--strip-debug \
--compress=zip-6 \
--no-header-files \
--no-man-pages \
--output "$out"
}
echo "Building aarch64 JRE from $AARCH64_JAVA_HOME"
run_jlink "$AARCH64_JAVA_HOME" "$ARM_JRE"
echo "Building x86_64 JRE from $X64_JAVA_HOME (runs under Rosetta on Apple Silicon)"
run_jlink "$X64_JAVA_HOME" "$X64_JRE"
rm -rf "$OUTPUT_DIR"
mkdir -p "$(dirname "$OUTPUT_DIR")"
cp -R "$ARM_JRE" "$OUTPUT_DIR"
# Replace every Mach-O file in the copied tree with a fat binary.
# Files that aren't Mach-O (text, modules archive, etc.) are left as-is.
merged=0
skipped=0
while IFS= read -r -d '' arm_file; do
rel="${arm_file#"$ARM_JRE"/}"
x64_file="$X64_JRE/$rel"
out_file="$OUTPUT_DIR/$rel"
if [[ ! -f "$x64_file" ]]; then
skipped=$((skipped + 1))
continue
fi
if ! file "$arm_file" | grep -q "Mach-O"; then
continue
fi
lipo -create "$arm_file" "$x64_file" -output "$out_file"
merged=$((merged + 1))
done < <(find "$ARM_JRE" -type f -print0)
echo "Universal JRE written to $OUTPUT_DIR (merged $merged Mach-O files, skipped $skipped arm-only files)"
if [[ "$merged" -eq 0 ]]; then
echo "Refusing to ship a JRE with zero merged Mach-O binaries" >&2
exit 1
fi
# Sanity-check: the launcher must be a fat binary or the app crashes on the
# arch we didn't lipo for. `lipo -archs` prints just the arch names space-
# separated (avoiding fragile grep alternation across BSD/GNU grep).
java_archs="$(lipo -archs "$OUTPUT_DIR/bin/java" 2>/dev/null || true)"
if [[ "$java_archs" != *arm64* || "$java_archs" != *x86_64* ]]; then
echo "bin/java is not a universal binary (archs: ${java_archs:-unknown})" >&2
lipo -info "$OUTPUT_DIR/bin/java" >&2
exit 1
fi
+102 -40
View File
@@ -13,6 +13,7 @@ const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
const TOKENS_STORE_FILE: &str = "tokens.json";
const REFRESH_TOKEN_STORE_KEY: &str = "refresh_token";
const AUTH_TOKEN_STORE_KEY: &str = "auth_token";
const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token";
const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token";
@@ -40,73 +41,134 @@ fn get_refresh_token_keyring_entry() -> Result<Entry, String> {
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
pub async fn save_auth_token(app_handle: AppHandle, token: String) -> Result<(), String> {
let trimmed = token.trim();
if trimmed.is_empty() {
log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string());
}
let entry = get_keyring_entry()?;
if trimmed.len() != token.len() {
log::debug!("Auth token had surrounding whitespace; storing trimmed token");
}
entry
.set_password(trimmed)
.map_err(|e| {
log::error!("Failed to set password in keyring: {}", e);
format!("Failed to save token to keyring: {}", e)
})?;
// Verify the save worked
match entry.get_password() {
Ok(retrieved_token) => {
if retrieved_token != trimmed {
log::error!("Token verification failed: Retrieved token doesn't match");
return Err("Token verification failed after save".to_string());
// Try keyring first (works in environments where Credential Manager persists writes).
// Any failure - including entry creation - falls through to the Tauri Store fallback
// below, never an early return, so restricted environments still persist the token.
match get_keyring_entry() {
Ok(entry) => match entry.set_password(trimmed) {
Ok(_) => {
// Verify it persists. On managed Win10 Pro environments (GPO restricting
// Credential Manager, AV/EDR rules, roaming-profile DPAPI quirks) the
// read-back can return the wrong value or NoEntry even when set succeeded.
match entry.get_password() {
Ok(saved) if saved == trimmed => {
// Clear any stale fallback copy so the keyring stays authoritative.
if let Ok(store) = app_handle.store(TOKENS_STORE_FILE) {
if store.get(AUTH_TOKEN_STORE_KEY).is_some() {
store.delete(AUTH_TOKEN_STORE_KEY);
let _ = store.save();
}
}
log::info!("Auth token saved to keyring");
return Ok(());
}
_ => {
log::info!("Keyring did not persist auth token - using Tauri Store fallback");
}
}
}
}
Err(e) => {
log::info!("Keyring set failed for auth token: {} - using Tauri Store fallback", e);
}
},
Err(e) => {
log::error!("Token verification failed: {}", e);
return Err(format!("Token verification failed: {}", e));
log::info!("Keyring entry unavailable for auth token: {} - using Tauri Store fallback", e);
}
}
// Fallback to Tauri Store (same pattern as save_refresh_token)
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
store.set(
AUTH_TOKEN_STORE_KEY,
serde_json::to_value(trimmed)
.map_err(|e| format!("Failed to serialize token: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
log::info!("Auth token saved to Tauri Store (fallback)");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
log::error!("Failed to retrieve token from keyring: {}", e);
Err(format!("Failed to retrieve token: {}", e))
pub async fn get_auth_token(app_handle: AppHandle) -> Result<Option<String>, String> {
// Try keyring first (production / unrestricted environments). Any failure -
// including entry creation - falls through to the Tauri Store fallback below.
match get_keyring_entry() {
Ok(entry) => match entry.get_password() {
Ok(token) => return Ok(Some(token)),
Err(keyring::Error::NoEntry) => {
log::debug!("No auth token in keyring, trying Tauri Store");
}
Err(e) => {
log::warn!("Keyring error reading auth token: {} - trying Tauri Store", e);
}
},
Err(e) => {
log::warn!("Keyring entry unavailable for auth token: {} - trying Tauri Store", e);
}
}
// Fallback to Tauri Store
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
let token: Option<String> = store
.get(AUTH_TOKEN_STORE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(token)
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => {
log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e);
// As a fallback, overwrite with an empty token so a stale value cannot be reused
match entry.set_password("") {
Ok(_) => Ok(()),
Err(e2) => Err(format!("Failed to clear token (delete + overwrite failed): {}", e2)),
pub async fn clear_auth_token(app_handle: AppHandle) -> Result<(), String> {
// Clear from keyring (best-effort). Entry creation failure must not block clearing
// the disk fallback, otherwise a stale fallback token could survive sign-out.
match get_keyring_entry() {
Ok(entry) => match entry.delete_credential() {
Ok(_) | Err(keyring::Error::NoEntry) => {}
Err(e) => {
log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e);
// Overwrite with an empty token so a stale value cannot be reused
if let Err(e2) = entry.set_password("") {
log::warn!("Failed to overwrite keyring auth token: {}", e2);
}
}
},
Err(e) => {
log::warn!("Keyring entry unavailable while clearing auth token: {} - clearing Tauri Store fallback only", e);
}
}
// Clear from Tauri Store fallback
let store = app_handle
.store(TOKENS_STORE_FILE)
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
store.delete(AUTH_TOKEN_STORE_KEY);
store
.save()
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
Ok(())
}
#[tauri::command]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.10.0",
"version": "2.10.1",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -35,13 +35,8 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
switch (osType) {
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple":
return {
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
};
case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "mac":
return { label: "Mac", url: DOWNLOAD_URLS.MAC };
case "linux-x64":
case "linux-arm64":
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
@@ -54,16 +49,7 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
() =>
[
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
value: "mac-apple",
},
{
label: "Mac (Intel)",
url: DOWNLOAD_URLS.MAC_INTEL,
value: "mac-intel",
},
{ label: "Mac", url: DOWNLOAD_URLS.MAC, value: "mac" },
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
].filter((opt) => opt.url),
[],
@@ -23,7 +23,10 @@ import {
AutomationTool,
} from "@app/types/automation";
import { useAutomationForm } from "@app/hooks/tools/automate/useAutomationForm";
import { downloadFolderScanningConfig } from "@app/utils/automationConverter";
import {
downloadAutomationConfig,
downloadFolderScanningConfig,
} from "@app/utils/automationConverter";
interface AutomationCreationProps {
mode: AutomationMode;
@@ -154,6 +157,24 @@ export default function AutomationCreation({
const currentConfigTool =
configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
/**
* Build a temporary AutomationConfig from in-progress form state so the
* export buttons can run without requiring the user to save first. The
* `id` and timestamps are placeholders — the export utilities strip them.
*/
const buildExportableAutomation = (): AutomationConfig => ({
id: existingAutomation?.id || "temp",
name: automationName.trim(),
description: automationDescription.trim(),
icon: automationIcon,
operations: selectedTools.map((tool) => ({
operation: tool.operation,
parameters: tool.parameters || {},
})),
createdAt: existingAutomation?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
return (
<div>
<Text
@@ -237,34 +258,34 @@ export default function AutomationCreation({
{t("automate.creation.save", "Save Automation")}
</Button>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
// Create a temporary automation config from current state
const tempAutomation: AutomationConfig = {
id: existingAutomation?.id || "temp",
name: automationName.trim(),
description: automationDescription.trim(),
icon: automationIcon,
operations: selectedTools.map((tool) => ({
operation: tool.operation,
parameters: tool.parameters || {},
})),
createdAt:
existingAutomation?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
downloadFolderScanningConfig(tempAutomation, toolRegistry);
}}
disabled={!canSaveAutomation()}
variant="light"
fullWidth
>
{t(
"automate.creation.exportForFolderScanning",
"Export for Folder Scanning",
)}
</Button>
<Group gap="sm" grow>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
downloadAutomationConfig(buildExportableAutomation());
}}
disabled={!canSaveAutomation()}
variant="light"
>
{t("automate.creation.export", "Export")}
</Button>
<Button
leftSection={<DownloadIcon />}
onClick={() => {
downloadFolderScanningConfig(
buildExportableAutomation(),
toolRegistry,
);
}}
disabled={!canSaveAutomation()}
variant="light"
>
{t(
"automate.creation.exportForFolderScanning",
"Export for Folder Scanning",
)}
</Button>
</Group>
</Stack>
</Stack>
@@ -6,6 +6,7 @@ import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import DownloadIcon from "@mui/icons-material/Download";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { Tooltip } from "@app/components/shared/Tooltip";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
@@ -32,8 +33,12 @@ interface AutomationEntryProps {
onDelete?: () => void;
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Export handler (for folder scanning) */
onExport?: () => void;
/** Export handler — folder scanning JSON */
onExportFolderScan?: () => void;
/** Export handler — native Automate JSON (renders as just "Export") */
onExportAutomation?: () => void;
/** Import handler — opens the import modal (used on "Create New" entry) */
onImport?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Partial<ToolRegistry>;
}
@@ -49,7 +54,9 @@ export default function AutomationEntry({
onEdit,
onDelete,
onCopy,
onExport,
onExportFolderScan,
onExportAutomation,
onImport,
toolRegistry,
}: AutomationEntryProps) {
const { t } = useTranslation();
@@ -211,6 +218,11 @@ export default function AutomationEntry({
variant="subtle"
c="dimmed"
size="md"
aria-label={t(
"automate.entryMenu.label",
"Open menu for {{title}}",
{ title: title || operations.join(" → ") },
)}
onClick={(e) => e.stopPropagation()}
style={{
position: "absolute",
@@ -228,6 +240,17 @@ export default function AutomationEntry({
</Menu.Target>
<Menu.Dropdown>
{onImport && (
<Menu.Item
leftSection={<UploadFileIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onImport();
}}
>
{t("automate.import", "Import")}
</Menu.Item>
)}
{onCopy && (
<Menu.Item
leftSection={<ContentCopyIcon style={{ fontSize: 16 }} />}
@@ -250,12 +273,23 @@ export default function AutomationEntry({
{t("edit", "Edit")}
</Menu.Item>
)}
{onExport && (
{onExportAutomation && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onExport();
onExportAutomation();
}}
>
{t("automate.export", "Export")}
</Menu.Item>
)}
{onExportFolderScan && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onExportFolderScan();
}}
>
{t(
@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Badge,
Button,
Group,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex";
import {
ParsedAutomationImport,
parseAutomationFile,
} from "@app/utils/automationConverter";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import type { ImportableAutomation } from "@app/hooks/tools/automate/useSavedAutomations";
interface AutomationImportModalProps {
opened: boolean;
toolRegistry: Partial<ToolRegistry>;
onCancel: () => void;
onImport: (
automation: ImportableAutomation,
meta: { format: ParsedAutomationImport["format"]; unresolved: string[] },
) => void | Promise<void>;
}
/**
* Single import surface for both supported automation JSON shapes.
*
* Accepts a file drop or pasted text, auto-detects whether the JSON is the
* native Automate config or the backend folder-scanning config, and shows
* the resolved name + format before the user commits the import.
*/
export default function AutomationImportModal({
opened,
toolRegistry,
onCancel,
onImport,
}: AutomationImportModalProps) {
const { t } = useTranslation();
const [pastedText, setPastedText] = useState("");
const [parsed, setParsed] = useState<ParsedAutomationImport | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Reset modal state every time it's reopened so a previous import doesn't
// leak into the next one.
useEffect(() => {
if (opened) {
setPastedText("");
setParsed(null);
setParseError(null);
setSubmitting(false);
}
}, [opened]);
// Re-parse whenever the textarea changes — gives the user immediate feedback
// without requiring a click.
useEffect(() => {
const trimmed = pastedText.trim();
if (!trimmed) {
setParsed(null);
setParseError(null);
return;
}
try {
const result = parseAutomationFile(trimmed, toolRegistry);
setParsed(result);
setParseError(null);
} catch (err) {
setParsed(null);
setParseError(err instanceof Error ? err.message : String(err));
}
}, [pastedText, toolRegistry]);
const handleFileDrop = async (files: File[]) => {
const file = files[0];
if (!file) return;
try {
const text = await file.text();
setPastedText(text);
} catch (err) {
setParseError(err instanceof Error ? err.message : String(err));
}
};
const handleSubmit = async () => {
if (!parsed) return;
setSubmitting(true);
try {
await onImport(parsed.automation, {
format: parsed.format,
unresolved: parsed.unresolvedOperations,
});
} finally {
setSubmitting(false);
}
};
const formatLabel =
parsed?.format === "automate"
? t("automate.importModal.detectedAutomation", "Automate JSON")
: parsed?.format === "folderScanning"
? t("automate.importModal.detectedFolderScan", "Folder Scanning JSON")
: null;
return (
<Modal
opened={opened}
onClose={onCancel}
title={t("automate.importModal.title", "Import automation")}
centered
size="lg"
zIndex={Z_INDEX_AUTOMATE_MODAL}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t(
"automate.importModal.intro",
"Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically.",
)}
</Text>
<Dropzone
onDrop={(files) => void handleFileDrop(files)}
accept={["application/json", "text/plain"]}
multiple={false}
maxSize={10 * 1024 * 1024}
aria-label={t(
"automate.importModal.dropzoneAriaLabel",
"Drop an automation JSON file here",
)}
>
<Group
gap="md"
align="center"
wrap="nowrap"
mih={80}
justify="center"
>
<UploadFileIcon style={{ fontSize: 32, opacity: 0.6 }} />
<div>
<Text size="sm" fw={500}>
{t(
"automate.importModal.dropHint",
"Drop JSON here or click to choose a file",
)}
</Text>
<Text size="xs" c="dimmed">
{t(
"automate.importModal.dropSubhint",
"Both Automate and Folder Scanning configs are accepted",
)}
</Text>
</div>
</Group>
</Dropzone>
<Textarea
label={t("automate.importModal.pasteLabel", "Or paste JSON")}
placeholder={t(
"automate.importModal.pastePlaceholder",
"Paste your automation JSON here…",
)}
value={pastedText}
onChange={(e) => setPastedText(e.currentTarget.value)}
autosize
minRows={6}
maxRows={12}
spellCheck={false}
styles={{ input: { fontFamily: "monospace", fontSize: 12 } }}
/>
{parseError && (
<Alert color="red" variant="light">
{t(
"automate.importModal.parseError",
"Could not parse: {{message}}",
{
message: parseError,
},
)}
</Alert>
)}
{parsed && (
<Alert color="green" variant="light">
<Stack gap="xs">
<Group gap="xs" align="center">
<Badge color="green" variant="light">
{formatLabel}
</Badge>
<Text size="sm" fw={500}>
{parsed.automation.name}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("automate.importModal.opCount", "{{count}} operation(s)", {
count: parsed.automation.operations.length,
})}
</Text>
{parsed.unresolvedOperations.length > 0 && (
<Text size="xs" c="orange">
{t("automate.importModal.unresolved", "Unmapped: {{ops}}", {
ops: parsed.unresolvedOperations.join(", "),
})}
</Text>
)}
</Stack>
</Alert>
)}
<Group gap="sm" justify="flex-end">
<Button variant="subtle" onClick={onCancel} disabled={submitting}>
{t("automate.importModal.cancel", "Cancel")}
</Button>
<Button
onClick={() => void handleSubmit()}
disabled={!parsed || submitting}
loading={submitting}
>
{t("automate.importModal.confirm", "Import")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -1,13 +1,19 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Title, Stack, Divider } from "@mantine/core";
import AddCircleOutline from "@mui/icons-material/AddCircleOutlined";
import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "@app/components/tools/automate/AutomationEntry";
import AutomationImportModal from "@app/components/tools/automate/AutomationImportModal";
import { useSuggestedAutomations } from "@app/hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig, SuggestedAutomation } from "@app/types/automation";
import { iconMap } from "@app/components/tools/automate/iconMap";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { downloadFolderScanningConfig } from "@app/utils/automationConverter";
import {
downloadAutomationConfig,
downloadFolderScanningConfig,
} from "@app/utils/automationConverter";
import type { ImportableAutomation } from "@app/hooks/tools/automate/useSavedAutomations";
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@@ -16,6 +22,11 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
onImportAutomation: (
automation: ImportableAutomation,
) => Promise<AutomationConfig>;
onImportError?: (message: string) => void;
onImportSuccess?: (message: string) => void;
toolRegistry: Partial<ToolRegistry>;
}
@@ -26,11 +37,49 @@ export default function AutomationSelection({
onEdit,
onDelete,
onCopyFromSuggested,
onImportAutomation,
onImportError,
onImportSuccess,
toolRegistry,
}: AutomationSelectionProps) {
const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations();
const [importModalOpen, setImportModalOpen] = useState(false);
const handleImportSubmit = async (
automation: ImportableAutomation,
meta: { format: "automate" | "folderScanning"; unresolved: string[] },
) => {
try {
await onImportAutomation(automation);
setImportModalOpen(false);
if (meta.unresolved.length > 0) {
onImportSuccess?.(
t(
"automate.importPartialSuccess",
"Imported with {{count}} unmapped operation(s): {{ops}}",
{
count: meta.unresolved.length,
ops: meta.unresolved.join(", "),
},
),
);
} else {
onImportSuccess?.(
t("automate.importSuccess", "Imported automation: {{name}}", {
name: automation.name,
}),
);
}
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to import automation";
console.error("Failed to import automation:", err);
onImportError?.(message);
}
};
return (
<div>
<Title
@@ -53,6 +102,8 @@ export default function AutomationSelection({
operations={[]}
onClick={onCreateNew}
keepIconColor={true}
showMenu={true}
onImport={() => setImportModalOpen(true)}
toolRegistry={toolRegistry}
/>
{/* Saved Automations */}
@@ -72,7 +123,8 @@ export default function AutomationSelection({
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onExport={() =>
onExportAutomation={() => downloadAutomationConfig(automation)}
onExportFolderScan={() =>
downloadFolderScanningConfig(automation, toolRegistry)
}
onDelete={() => onDelete(automation)}
@@ -110,6 +162,13 @@ export default function AutomationSelection({
</Stack>
</div>
</Stack>
<AutomationImportModal
opened={importModalOpen}
toolRegistry={toolRegistry}
onCancel={() => setImportModalOpen(false)}
onImport={handleImportSubmit}
/>
</div>
);
}
+1 -2
View File
@@ -1,8 +1,7 @@
// Centralized download URLs for Stirling PDF desktop installers
export const DOWNLOAD_URLS = {
WINDOWS: "https://files.stirlingpdf.com/win-installer.exe",
MAC_APPLE_SILICON: "https://files.stirlingpdf.com/mac-installer.dmg",
MAC_INTEL: "https://files.stirlingpdf.com/mac-x86_64-installer.dmg",
MAC: "https://files.stirlingpdf.com/mac-installer.dmg",
LINUX_DOCS: "https://docs.stirlingpdf.com/Installation/Unix%20Installation/",
} as const;
@@ -24,7 +24,11 @@ export function useAutomationForm({
const [automationName, setAutomationName] = useState("");
const [automationDescription, setAutomationDescription] = useState("");
const [automationIcon, setAutomationIcon] = useState<string>("");
// Default to "SettingsIcon" so the saved entry always has a valid icon key
// matching what the IconSelector renders visually. Saving an empty string
// here would round-trip into a fallback cog, making it look like the
// picker silently lost the user's choice.
const [automationIcon, setAutomationIcon] = useState<string>("SettingsIcon");
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = useCallback(
@@ -54,7 +58,7 @@ export function useAutomationForm({
) {
setAutomationName(existingAutomation.name || "");
setAutomationDescription(existingAutomation.description || "");
setAutomationIcon(existingAutomation.icon || "");
setAutomationIcon(existingAutomation.icon || "SettingsIcon");
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
@@ -4,6 +4,11 @@ import { SuggestedAutomation } from "@app/types/automation";
export interface SavedAutomation extends AutomationConfig {}
export type ImportableAutomation = Omit<
AutomationConfig,
"id" | "createdAt" | "updatedAt"
>;
export function useSavedAutomations() {
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>(
[],
@@ -89,6 +94,22 @@ export function useSavedAutomations() {
[refreshAutomations],
);
const importAutomation = useCallback(
async (automation: ImportableAutomation): Promise<SavedAutomation> => {
try {
const { automationStorage } =
await import("@app/services/automationStorage");
const saved = await automationStorage.saveAutomation(automation);
refreshAutomations();
return saved;
} catch (err) {
console.error("Error importing automation:", err);
throw err;
}
},
[refreshAutomations],
);
// Load automations on mount
useEffect(() => {
loadSavedAutomations();
@@ -101,5 +122,6 @@ export function useSavedAutomations() {
refreshAutomations,
deleteAutomation,
copyFromSuggested,
importAutomation,
};
}
+3 -18
View File
@@ -2,8 +2,7 @@ import { useEffect, useState } from "react";
export type OS =
| "windows"
| "mac-intel"
| "mac-apple"
| "mac"
| "linux-x64"
| "linux-arm64"
| "ios"
@@ -23,15 +22,7 @@ function parseUA(ua: string): OS {
if (/android/.test(uaLower)) return "android";
if (/windows nt/.test(uaLower)) return "windows";
if (/mac os x/.test(uaLower)) {
// Default to Intel; refine via hints below
let detected: OS = "mac-intel";
// Safari on Apple Silicon sometimes exposes both tokens
if (ua.includes("Apple") && ua.includes("ARM")) {
detected = "mac-apple";
}
return detected; // will be further refined via Client Hints if available
}
if (/mac os x/.test(uaLower)) return "mac";
if (/linux|x11/.test(uaLower)) return "linux-x64";
return "unknown";
@@ -64,10 +55,7 @@ export function useOs(): OS {
else if (plat.includes("ios")) detected = "ios";
else if (plat.includes("android")) detected = "android";
else if (plat.includes("mac")) {
// CH “architecture” is often "arm" on Apple Silicon
detected = architecture?.toLowerCase().includes("arm")
? "mac-apple"
: "mac-intel";
detected = "mac";
} else if (plat.includes("linux") || plat.includes("chrome os")) {
const archLower = (architecture || "").toLowerCase();
const isArm =
@@ -78,9 +66,6 @@ export function useOs(): OS {
} catch {
// ignore
}
} else {
// Heuristic Apple Silicon from UA when no Client Hints (Safari): uncertain, prefer not to guess
// Keep detected as-is (often 'mac-intel').
}
if (!cancelled) setOs(detected);
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.10.0",
appVersion: "2.10.1",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
@@ -0,0 +1,382 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
/**
* Stubbed coverage for the Automate tool's import / export flows.
*
* Imports go through a single "Import" entry on the Create New kebab that
* opens a modal. The modal accepts either a dropped JSON file or pasted
* text, auto-detects the format (Automate vs Folder Scanning), and only
* enables the Import button once the input parses cleanly.
*
* Exports come in two flavours:
* - Per-automation kebab: "Export" (native Automate) + "Export for
* Folder Scanning" (backend pipeline shape).
* - Builder buttons: same two options, side by side.
*/
async function clearAutomationStorage(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
indexedDB.deleteDatabase("StirlingPDF_Automations");
} catch {
// First-page-load case where IDB hasn't been opened yet — safe to ignore.
}
});
}
async function gotoAutomate(page: Page): Promise<void> {
await clearAutomationStorage(page);
await page.goto("/automate");
await page.waitForLoadState("domcontentloaded");
}
async function ensureOnSelection(page: Page): Promise<void> {
const savedHeader = page.getByText("Saved").first();
if (!(await savedHeader.isVisible({ timeout: 2_000 }).catch(() => false))) {
await page
.getByText(/Automation Selection/i)
.first()
.click();
await expect(savedHeader).toBeVisible({ timeout: 5_000 });
}
}
/**
* Hover the entry button matching `entryTitle` to reveal its kebab, then
* click the kebab. Returns once the menu is open.
*/
async function openEntryMenu(
page: Page,
entryTitle: RegExp | string,
): Promise<void> {
const entryButton = page
.getByRole("button", { name: entryTitle as RegExp })
.first();
await entryButton.hover();
const titleSource =
typeof entryTitle === "string" ? entryTitle : entryTitle.source;
const escaped = titleSource.replace(/^\^|\$$/g, "");
const kebab = page
.getByRole("button", {
name: new RegExp(`Open menu for .*${escaped}`, "i"),
})
.first();
await kebab.click();
}
function makeAutomateJson(name: string): string {
return JSON.stringify({
name,
description: "Imported via Playwright",
icon: "CompressIcon",
operations: [
{ operation: "merge", parameters: { generateToc: true } },
{ operation: "compress", parameters: { compressionLevel: 3 } },
],
});
}
function makeFolderScanJson(name: string): string {
return JSON.stringify({
name,
pipeline: [
{
operation: "/api/v1/general/merge-pdfs",
parameters: {
generateToc: true,
fileInput: "automated",
},
},
{
operation: "/api/v1/misc/compress-pdf",
parameters: {
compressionLevel: 3,
fileInput: "automated",
},
},
],
_examples: {
outputDir: "{outputFolder}/{folderName}",
outputFileName: "{filename}-{pipelineName}-{date}-{time}",
},
outputDir: "{outputFolder}",
outputFileName: "{filename}",
});
}
/**
* Open the import modal from the "Create New Automation" kebab.
*/
async function openImportModal(page: Page): Promise<void> {
await openEntryMenu(page, /Create New Automation/i);
await page.getByRole("menuitem", { name: /^Import$/ }).click();
await expect(
page.getByRole("dialog", { name: /Import automation/i }),
).toBeVisible({ timeout: 5_000 });
}
test.describe("12. Automation Page — Import / Export", () => {
test.beforeEach(async ({ page }) => {
await gotoAutomate(page);
await ensureOnSelection(page);
});
test.describe("12.1 Create New kebab — single Import option", () => {
test("kebab on Create New entry exposes a single Import option", async ({
page,
}) => {
await openEntryMenu(page, /Create New Automation/i);
await expect(
page.getByRole("menuitem", { name: /^Import$/ }),
).toBeVisible({ timeout: 5_000 });
// Saved-only options should not appear on Create New.
await expect(
page.getByRole("menuitem", { name: /^Edit$/ }),
).not.toBeVisible();
await expect(
page.getByRole("menuitem", { name: /^Delete$/ }),
).not.toBeVisible();
await expect(
page.getByRole("menuitem", { name: /^Export$/ }),
).not.toBeVisible();
});
});
test.describe("12.2 Import modal — paste flow", () => {
test("pasting valid Automate JSON enables Import and saves the entry", async ({
page,
}) => {
await openImportModal(page);
// Import button starts disabled.
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
await expect(importBtn).toBeDisabled();
const textarea = page.getByLabel(/Or paste JSON/i);
await textarea.fill(makeAutomateJson("Pasted Automate"));
// Detected-format badge should appear.
await expect(page.getByText(/Automate JSON/).first()).toBeVisible({
timeout: 5_000,
});
await expect(importBtn).toBeEnabled();
await importBtn.click();
// Modal closes and the new entry appears in Saved.
await expect(
page.getByRole("dialog", { name: /Import automation/i }),
).not.toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /Pasted Automate/i }).first(),
).toBeVisible({ timeout: 10_000 });
// Icon must round-trip into IndexedDB. If it doesn't, the saved-entry
// render falls back to SettingsIcon and looks like a silent picker bug.
const persistedIcon = await page.evaluate(
() =>
new Promise<string | undefined>((resolve) => {
const req = indexedDB.open("StirlingPDF_Automations", 1);
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction("automations", "readonly");
const store = tx.objectStore("automations");
const all = store.getAll();
all.onsuccess = () => {
const match = (
all.result as Array<{
name: string;
icon?: string;
}>
).find((a) => a.name === "Pasted Automate");
resolve(match?.icon);
};
all.onerror = () => resolve(undefined);
};
req.onerror = () => resolve(undefined);
}),
);
expect(persistedIcon).toBe("CompressIcon");
});
test("pasting valid Folder Scanning JSON shows the right format and imports", async ({
page,
}) => {
await openImportModal(page);
const textarea = page.getByLabel(/Or paste JSON/i);
await textarea.fill(makeFolderScanJson("Pasted Folder Scan"));
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
timeout: 5_000,
});
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Pasted Folder Scan/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
test("pasting invalid JSON shows an error and keeps Import disabled", async ({
page,
}) => {
await openImportModal(page);
const textarea = page.getByLabel(/Or paste JSON/i);
await textarea.fill("{ this is not valid json");
await expect(page.getByText(/Could not parse/i).first()).toBeVisible({
timeout: 5_000,
});
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
await expect(importBtn).toBeDisabled();
});
});
test.describe("12.3 Import modal — file drop flow", () => {
test("dropping an Automate JSON file fills the textarea and imports", async ({
page,
}) => {
await openImportModal(page);
// The Mantine Dropzone exposes a hidden file input we can target via
// `setInputFiles`. There's only one file input inside the modal.
const fileInput = page
.getByRole("dialog", { name: /Import automation/i })
.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "dropped.automate.json",
mimeType: "application/json",
buffer: Buffer.from(makeAutomateJson("Dropped Automate")),
});
// The textarea should reflect the dropped content.
await expect(page.getByLabel(/Or paste JSON/i)).toHaveValue(
/Dropped Automate/,
{ timeout: 5_000 },
);
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Dropped Automate/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
test("dropping a Folder Scanning JSON file imports it", async ({
page,
}) => {
await openImportModal(page);
const fileInput = page
.getByRole("dialog", { name: /Import automation/i })
.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "dropped.folder-scan.json",
mimeType: "application/json",
buffer: Buffer.from(makeFolderScanJson("Dropped Folder Scan")),
});
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
timeout: 5_000,
});
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Dropped Folder Scan/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
});
test.describe("12.4 Export — per-automation kebab menu", () => {
test("saved entry kebab exposes Export and Export for Folder Scanning", async ({
page,
}) => {
// Seed a saved automation by importing one first.
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.fill(makeAutomateJson("Export Menu Seed"));
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
const seededEntry = page
.getByRole("button", { name: /Export Menu Seed/i })
.first();
await expect(seededEntry).toBeVisible({ timeout: 10_000 });
await openEntryMenu(page, /Export Menu Seed/i);
await expect(
page.getByRole("menuitem", { name: /^Export$/ }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("menuitem", { name: /Export for Folder Scanning/i }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("menuitem", { name: /^Edit$/ }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: /^Delete$/ }),
).toBeVisible();
});
test("clicking 'Export' triggers a .automate.json download", async ({
page,
}) => {
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.fill(makeAutomateJson("Download Test"));
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Download Test/i }).first(),
).toBeVisible({ timeout: 10_000 });
await openEntryMenu(page, /Download Test/i);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("menuitem", { name: /^Export$/ }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.automate\.json$/);
});
test("clicking 'Export for Folder Scanning' triggers a .folder-scan.json download", async ({
page,
}) => {
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.fill(makeAutomateJson("Folder Download Test"));
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Folder Download Test/i }).first(),
).toBeVisible({ timeout: 10_000 });
await openEntryMenu(page, /Folder Download Test/i);
const downloadPromise = page.waitForEvent("download");
await page
.getByRole("menuitem", { name: /Export for Folder Scanning/i })
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.folder-scan\.json$/);
});
});
});
@@ -1,35 +1,99 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
/**
* Stubbed coverage for the `/automate` super-tool. The Automate UI is a small
* three-step state machine: Selection ↔ Creation ↔ Run. These specs exercise
* the surface area that doesn't require a live backend run — the catalogue,
* the builder save flow, and the per-automation menu.
*
* Live runs (actually executing a pipeline against a real backend) live in
* `src/core/tests/live/automate-chain.spec.ts`.
*/
/**
* IndexedDB persists across tests sharing a worker. Each test wipes the
* automation store before navigating so the "Saved" list starts empty.
*/
async function clearAutomationStorage(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
indexedDB.deleteDatabase("StirlingPDF_Automations");
} catch {
// First-page-load case where IDB hasn't been opened yet — safe to ignore.
}
});
}
async function gotoAutomate(page: Page): Promise<void> {
await clearAutomationStorage(page);
await page.goto("/automate");
await page.waitForLoadState("domcontentloaded");
}
/**
* Land on the Selection step. The auto-goto fixture sometimes parks us on the
* builder if a previous test left state behind; click the section header to
* snap back to a known state.
*/
async function ensureOnSelection(page: Page): Promise<void> {
const savedHeader = page.getByText("Saved").first();
if (!(await savedHeader.isVisible({ timeout: 2_000 }).catch(() => false))) {
await page
.getByText(/Automation Selection/i)
.first()
.click();
await expect(savedHeader).toBeVisible({ timeout: 5_000 });
}
}
/**
* Hover the entry whose title matches `entryTitle` and click its kebab.
* Returns once the kebab menu is open and the dropdown is visible.
*/
async function openEntryMenu(page: Page, entryTitle: RegExp): Promise<void> {
const entry = page.getByRole("button", { name: entryTitle }).first();
await entry.hover();
const kebab = page
.getByRole("button", { name: /^Open menu for / })
.filter({ hasText: "" }) // ActionIcon has no text content
.first();
// Match by accessible-name fragment — the title is interpolated.
const titleHint = (entryTitle.source || "")
.replace(/^\^|\$$/g, "")
.replace(/\\/g, "");
const scopedKebab = page
.getByRole("button", {
name: new RegExp(`Open menu for .*${titleHint}`, "i"),
})
.first();
if (await scopedKebab.isVisible({ timeout: 1_000 }).catch(() => false)) {
await scopedKebab.click();
} else {
await kebab.click();
}
}
test.describe("11. Automation Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/automate");
await page.waitForLoadState("domcontentloaded");
await gotoAutomate(page);
});
test.describe("11.1 Automation - Suggested Workflows", () => {
test("should display saved and suggested workflows", async ({ page }) => {
// Step 1: Verify the Automation Selection header is present
const automateLink = page.locator('a[href="/automate"]').first();
await expect(automateLink).toBeVisible();
await expect(
page.getByText(/Automation Selection/i).first(),
).toBeVisible();
await ensureOnSelection(page);
// If we accidentally landed on the creation step, click back to selection
const selectionHeader = page.getByText(/Automation Selection/i).first();
const savedText = page.getByText(/Saved/i).first();
if (!(await savedText.isVisible().catch(() => false))) {
await selectionHeader.click();
await page.waitForTimeout(500);
}
// Step 3: Verify the Saved section is visible
await expect(page.getByText("Saved").first()).toBeVisible();
// Step 4: Verify the Create New Automation entry is present
await expect(
page.getByText(/Create New Automation/i).first(),
).toBeVisible();
// Step 5: Verify suggested preset workflows are listed
await expect(page.getByText("Suggested").first()).toBeVisible();
const suggestedWorkflows = [
@@ -42,7 +106,7 @@ test.describe("11. Automation Page", () => {
for (const workflow of suggestedWorkflows) {
await expect(page.getByText(workflow).first()).toBeVisible({
timeout: 5000,
timeout: 5_000,
});
}
});
@@ -52,31 +116,85 @@ test.describe("11. Automation Page", () => {
test("should open automation builder when clicking create button", async ({
page,
}) => {
// Ensure we're on the selection step first
const savedText = page.getByText("Saved").first();
if (!(await savedText.isVisible().catch(() => false))) {
const selectionHeader = page.getByText(/Automation Selection/i).first();
await selectionHeader.click();
await page.waitForTimeout(500);
}
await ensureOnSelection(page);
// Step 1: Click the Create New Automation entry
const createEntry = page.getByText(/Create New Automation/i).first();
await createEntry.click();
await page
.getByText(/Create New Automation/i)
.first()
.click();
// Step 2: Verify the automation builder/editor opens with form fields
await expect(page.getByText(/Create Automation/i).first()).toBeVisible({
timeout: 5000,
timeout: 5_000,
});
// Step 3: Verify the user can see automation configuration fields
await expect(page.getByText(/Automation Name/i).first()).toBeVisible({
timeout: 5000,
timeout: 5_000,
});
await expect(page.getByText(/Add Tool/i).first()).toBeVisible();
await expect(
page.getByRole("button", { name: /Save Automation/i }).first(),
).toBeVisible();
});
test("save button stays disabled until a name is filled", async ({
page,
}) => {
await ensureOnSelection(page);
await page
.getByText(/Create New Automation/i)
.first()
.click();
const saveBtn = page
.getByRole("button", { name: /Save Automation/i })
.first();
await expect(saveBtn).toBeVisible({ timeout: 5_000 });
await expect(saveBtn).toBeDisabled();
// Naming alone shouldn't enable save — a tool must be added too.
await page.getByLabel(/Automation Name/i).fill("E2E Builder Smoke");
await expect(saveBtn).toBeDisabled();
});
test("both export buttons render in the builder and are disabled until valid", async ({
page,
}) => {
await ensureOnSelection(page);
await page
.getByText(/Create New Automation/i)
.first()
.click();
const exportAutomate = page
.getByRole("button", { name: /^Export$/ })
.first();
const exportFolderScan = page
.getByRole("button", { name: /Export for Folder Scanning/i })
.first();
await expect(exportAutomate).toBeVisible({ timeout: 5_000 });
await expect(exportFolderScan).toBeVisible({ timeout: 5_000 });
// Same disabled-when-invalid contract as Save — no name + no tool.
await expect(exportAutomate).toBeDisabled();
await expect(exportFolderScan).toBeDisabled();
});
});
test.describe("11.3 Automation - Suggested copy-to-saved", () => {
test("copies a suggested automation into the Saved list", async ({
page,
}) => {
await ensureOnSelection(page);
await openEntryMenu(page, /Secure PDF Ingestion/i);
await page.getByRole("menuitem", { name: /Copy to Saved/i }).click();
// The suggested entry stays in the list and a duplicate appears under
// Saved, so the count of matching texts goes from 1 to 2.
await expect
.poll(async () => page.getByText(/Secure PDF Ingestion/i).count(), {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(2);
});
});
});
+3
View File
@@ -45,6 +45,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
deleteAutomation,
refreshAutomations,
copyFromSuggested,
importAutomation,
} = useSavedAutomations();
// Use ref to store the latest reset function to avoid closure issues
@@ -153,6 +154,8 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
);
}
}}
onImportAutomation={importAutomation}
onImportError={(message) => onError?.(message)}
toolRegistry={toolRegistry}
/>
);
@@ -0,0 +1,273 @@
/**
* Unit tests for automationConverter import/export round-tripping.
*/
import { describe, test, expect } from "vitest";
import {
convertToAutomationConfig,
convertToFolderScanningConfig,
detectAutomationFormat,
parseAutomationConfigJson,
parseAutomationFile,
parseFolderScanningConfig,
} from "@app/utils/automationConverter";
import { AutomationConfig } from "@app/types/automation";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
// Minimal stub registry — only the fields automationConverter actually reads.
const registry = {
merge: {
operationConfig: {
endpoint: "/api/v1/general/merge-pdfs",
},
},
compress: {
operationConfig: {
endpoint: "/api/v1/misc/compress-pdf",
},
},
ocr: {
operationConfig: {
endpoint: "/api/v1/misc/ocr-pdf",
},
},
// Dynamic endpoint — endpoint depends on parameters.
convert: {
operationConfig: {
endpoint: (params: Record<string, any>) =>
`/api/v1/convert/${params.fromExtension}-to-${params.toExtension}`,
},
},
} as unknown as Partial<ToolRegistry>;
const sampleAutomation: AutomationConfig = {
id: "auto-123",
name: "Sample",
description: "Test automation",
icon: "CompressIcon",
operations: [
{ operation: "merge", parameters: { generateToc: true } },
{ operation: "compress", parameters: { compressionLevel: 3 } },
],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
};
describe("automationConverter", () => {
describe("convertToAutomationConfig", () => {
test("strips id/createdAt/updatedAt and clones parameters", () => {
const result = convertToAutomationConfig(sampleAutomation);
expect(result).toEqual({
name: "Sample",
description: "Test automation",
icon: "CompressIcon",
operations: [
{ operation: "merge", parameters: { generateToc: true } },
{ operation: "compress", parameters: { compressionLevel: 3 } },
],
});
expect(result).not.toHaveProperty("id");
expect(result).not.toHaveProperty("createdAt");
expect(result).not.toHaveProperty("updatedAt");
// Parameters should be cloned, not the same reference.
expect(result.operations[0].parameters).not.toBe(
sampleAutomation.operations[0].parameters,
);
});
});
describe("convertToFolderScanningConfig", () => {
test("rewrites operation keys to backend endpoints and adds fileInput", () => {
const config = convertToFolderScanningConfig(sampleAutomation, registry);
expect(config.pipeline).toEqual([
{
operation: "/api/v1/general/merge-pdfs",
parameters: { generateToc: true, fileInput: "automated" },
},
{
operation: "/api/v1/misc/compress-pdf",
parameters: { compressionLevel: 3, fileInput: "automated" },
},
]);
});
test("preserves icon and description so round-tripping keeps UI metadata", () => {
const config = convertToFolderScanningConfig(sampleAutomation, registry);
expect(config.icon).toBe("CompressIcon");
expect(config.description).toBe("Test automation");
});
test("omits icon and description when not set on the source automation", () => {
const minimal: AutomationConfig = {
id: "min",
name: "Minimal",
operations: [{ operation: "merge", parameters: {} }],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
};
const config = convertToFolderScanningConfig(minimal, registry);
expect(config).not.toHaveProperty("icon");
expect(config).not.toHaveProperty("description");
});
test("falls back to operation key when no endpoint is registered", () => {
const automation: AutomationConfig = {
...sampleAutomation,
operations: [{ operation: "unknownTool", parameters: {} }],
};
const config = convertToFolderScanningConfig(automation, registry);
expect(config.pipeline[0].operation).toBe("unknownTool");
});
});
describe("detectAutomationFormat", () => {
test("detects native Automate JSON", () => {
expect(detectAutomationFormat({ operations: [] })).toBe("automate");
});
test("detects folder-scanning JSON", () => {
expect(detectAutomationFormat({ pipeline: [] })).toBe("folderScanning");
});
test("returns unknown for ambiguous or invalid input", () => {
expect(detectAutomationFormat({ pipeline: [], operations: [] })).toBe(
"unknown",
);
expect(detectAutomationFormat(null)).toBe("unknown");
expect(detectAutomationFormat("string")).toBe("unknown");
});
});
describe("parseAutomationConfigJson", () => {
test("accepts a previously exported native Automate JSON", () => {
const exported = convertToAutomationConfig(sampleAutomation);
const parsed = parseAutomationConfigJson(exported, registry);
expect(parsed.unresolvedOperations).toEqual([]);
expect(parsed.automation.name).toBe("Sample");
expect(parsed.automation.operations).toHaveLength(2);
expect(parsed.automation.operations[0].operation).toBe("merge");
// Icon must round-trip — losing it makes saved entries fall back to the
// settings cog, which historically looked like a silent picker bug.
expect(parsed.automation.icon).toBe("CompressIcon");
});
test("flags operations not present in the registry", () => {
const result = parseAutomationConfigJson(
{
name: "x",
operations: [{ operation: "notARealTool", parameters: {} }],
},
registry,
);
expect(result.unresolvedOperations).toEqual(["notARealTool"]);
});
test("throws on missing operations array", () => {
expect(() => parseAutomationConfigJson({ name: "x" }, registry)).toThrow(
/operations/,
);
});
});
describe("parseFolderScanningConfig", () => {
test("round-trips a folder-scan export back to tool IDs", () => {
const exported = convertToFolderScanningConfig(
sampleAutomation,
registry,
);
const parsed = parseFolderScanningConfig(exported, registry);
expect(parsed.unresolvedOperations).toEqual([]);
expect(parsed.automation.operations.map((o) => o.operation)).toEqual([
"merge",
"compress",
]);
// Icon survives the round trip even through the folder-scan format.
expect(parsed.automation.icon).toBe("CompressIcon");
// The export-time `fileInput: "automated"` marker must not survive import.
for (const op of parsed.automation.operations) {
expect(op.parameters).not.toHaveProperty("fileInput");
}
});
test("resolves a dynamic endpoint by replaying it with the imported parameters", () => {
const config = {
name: "Convert",
pipeline: [
{
operation: "/api/v1/convert/pdf-to-docx",
parameters: {
fromExtension: "pdf",
toExtension: "docx",
fileInput: "automated",
},
},
],
};
const parsed = parseFolderScanningConfig(config, registry);
expect(parsed.unresolvedOperations).toEqual([]);
expect(parsed.automation.operations[0].operation).toBe("convert");
expect(parsed.automation.operations[0].parameters).toEqual({
fromExtension: "pdf",
toExtension: "docx",
});
});
test("keeps unmappable endpoints verbatim and reports them", () => {
const config = {
name: "Mystery",
pipeline: [{ operation: "/api/v1/unknown/op", parameters: {} }],
};
const parsed = parseFolderScanningConfig(config, registry);
expect(parsed.unresolvedOperations).toEqual(["/api/v1/unknown/op"]);
expect(parsed.automation.operations[0].operation).toBe(
"/api/v1/unknown/op",
);
});
test("throws on missing pipeline array", () => {
expect(() => parseFolderScanningConfig({ name: "x" }, registry)).toThrow(
/pipeline/,
);
});
});
describe("parseAutomationFile", () => {
test("auto-detects Automate JSON", () => {
const text = JSON.stringify({
name: "x",
operations: [{ operation: "merge", parameters: {} }],
});
const result = parseAutomationFile(text, registry);
expect(result.format).toBe("automate");
});
test("auto-detects Folder Scanning JSON", () => {
const text = JSON.stringify({
name: "x",
pipeline: [{ operation: "/api/v1/general/merge-pdfs", parameters: {} }],
});
const result = parseAutomationFile(text, registry);
expect(result.format).toBe("folderScanning");
});
test("rejects mismatched explicit format", () => {
const text = JSON.stringify({
name: "x",
operations: [{ operation: "merge", parameters: {} }],
});
expect(() =>
parseAutomationFile(text, registry, "folderScanning"),
).toThrow();
});
test("rejects malformed JSON", () => {
expect(() => parseAutomationFile("{ not json", registry)).toThrow(
/not valid JSON/,
);
});
test("rejects unrecognized shape", () => {
expect(() =>
parseAutomationFile(JSON.stringify({ foo: "bar" }), registry),
).toThrow(/Unrecognized JSON/);
});
});
});
+339 -13
View File
@@ -1,17 +1,33 @@
/**
* Utility functions for converting between automation formats
* Utility functions for converting between automation formats.
*
* Two on-disk formats are supported:
*
* 1. **Automate JSON** (native) — mirrors {@link AutomationConfig}; the format
* used internally by the Automate tool and persisted in IndexedDB. Operation
* names are frontend tool IDs (e.g. "merge", "compress").
*
* 2. **Folder Scanning JSON** — the format consumed by the backend
* PipelineDirectoryProcessor. Operation names are full backend endpoint
* paths (e.g. "/api/v1/general/merge-pdfs").
*/
import { AutomationConfig } from "@app/types/automation";
import { AutomationConfig, AutomationOperation } from "@app/types/automation";
import { ToolRegistry } from "@app/data/toolsTaxonomy";
import { downloadFile } from "@app/services/downloadService";
import { ToolId } from "@app/types/toolId";
/**
* Pipeline configuration format used by folder scanning
* Pipeline configuration format used by folder scanning.
*
* `description` and `icon` are unused by the backend pipeline runner but are
* preserved so a folder-scan export can round-trip cleanly back into the
* Automate UI without losing display metadata.
*/
interface FolderScanningPipeline {
name: string;
description?: string;
icon?: string;
pipeline: Array<{
operation: string;
parameters: Record<string, any>;
@@ -25,10 +41,28 @@ interface FolderScanningPipeline {
}
/**
* Converts an AutomationConfig to a folder scanning pipeline configuration
* @param automation The automation configuration to convert
* @param toolRegistry The tool registry to map operation types to endpoints
* @returns Folder scanning pipeline configuration
* Discriminated result returned by {@link parseAutomationFile}.
*/
export type ParsedAutomationImport =
| {
format: "automate";
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
}
| {
format: "folderScanning";
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
};
/**
* Sanitize a filename so it works on Windows / macOS / Linux.
*/
const sanitizeFilename = (name: string): string =>
(name || "automation").replace(/[\\/:*?"<>|]+/g, "_").trim() || "automation";
/**
* Converts an AutomationConfig to a folder scanning pipeline configuration.
*/
export function convertToFolderScanningConfig(
automation: AutomationConfig,
@@ -36,8 +70,9 @@ export function convertToFolderScanningConfig(
): FolderScanningPipeline {
return {
name: automation.name,
...(automation.description ? { description: automation.description } : {}),
...(automation.icon ? { icon: automation.icon } : {}),
pipeline: automation.operations.map((op) => {
// Map operationType to full API endpoint path
const toolId = op.operation as ToolId;
const toolEntry = toolRegistry[toolId];
const endpointConfig = toolEntry?.operationConfig?.endpoint;
@@ -47,7 +82,6 @@ export function convertToFolderScanningConfig(
if (typeof endpointConfig === "string") {
endpoint = endpointConfig;
} else if (typeof endpointConfig === "function") {
// For dynamic endpoints, call with the saved parameters
try {
endpoint = endpointConfig(op.parameters);
} catch (error) {
@@ -85,9 +119,7 @@ export function convertToFolderScanningConfig(
}
/**
* Downloads a folder scanning configuration as a JSON file
* @param automation The automation configuration to export
* @param toolRegistry The tool registry to map operation types to endpoints
* Downloads a folder scanning configuration as a JSON file.
*/
export function downloadFolderScanningConfig(
automation: AutomationConfig,
@@ -96,5 +128,299 @@ export function downloadFolderScanningConfig(
const config = convertToFolderScanningConfig(automation, toolRegistry);
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: "application/json" });
void downloadFile({ data: blob, filename: `${automation.name}.json` });
void downloadFile({
data: blob,
filename: `${sanitizeFilename(automation.name)}.folder-scan.json`,
});
}
/**
* Builds an exportable native Automate JSON object. Strips the IndexedDB
* primary key and timestamps so an imported copy looks fresh.
*/
export function convertToAutomationConfig(
automation: AutomationConfig,
): Omit<AutomationConfig, "id" | "createdAt" | "updatedAt"> {
return {
name: automation.name,
description: automation.description,
icon: automation.icon,
operations: automation.operations.map((op) => ({
operation: op.operation,
parameters: { ...(op.parameters || {}) },
})),
};
}
/**
* Downloads an automation in the native Automate JSON format. The file can be
* re-imported via {@link parseAutomationFile} to restore the automation on a
* different machine or browser profile.
*/
export function downloadAutomationConfig(automation: AutomationConfig): void {
const config = convertToAutomationConfig(automation);
const json = JSON.stringify(config, null, 2);
const blob = new Blob([json], { type: "application/json" });
void downloadFile({
data: blob,
filename: `${sanitizeFilename(automation.name)}.automate.json`,
});
}
/**
* Build an inverse map of `endpoint string` → `frontend tool ID` from the
* tool registry. Only static string endpoints are added — dynamic
* (function) endpoints are matched at parse time by replaying them.
*/
function buildEndpointToToolIdMap(
toolRegistry: Partial<ToolRegistry>,
): Map<string, ToolId> {
const map = new Map<string, ToolId>();
for (const [toolId, entry] of Object.entries(toolRegistry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "string" && !map.has(endpoint)) {
map.set(endpoint, toolId as ToolId);
}
}
return map;
}
/**
* Try every dynamic endpoint in the registry with the supplied parameters,
* returning the first tool ID whose endpoint function produces `targetEndpoint`.
*/
function findDynamicEndpointMatch(
targetEndpoint: string,
parameters: Record<string, any>,
toolRegistry: Partial<ToolRegistry>,
): ToolId | undefined {
for (const [toolId, entry] of Object.entries(toolRegistry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "function") {
try {
if (endpoint(parameters) === targetEndpoint) {
return toolId as ToolId;
}
} catch {
// Endpoint function expected different parameters — ignore.
}
}
}
return undefined;
}
const isToolIdInRegistry = (
candidate: string,
toolRegistry: Partial<ToolRegistry>,
): boolean => Object.prototype.hasOwnProperty.call(toolRegistry, candidate);
/**
* Parse a folder-scanning pipeline JSON into the native AutomationConfig
* shape. Endpoint paths are reverse-mapped to frontend tool IDs via the
* supplied registry; unmappable operations are kept verbatim and reported in
* `unresolvedOperations`.
*/
export function parseFolderScanningConfig(
raw: unknown,
toolRegistry: Partial<ToolRegistry>,
): {
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
} {
if (!raw || typeof raw !== "object") {
throw new Error("Invalid folder scanning config: expected JSON object");
}
const obj = raw as Record<string, unknown>;
const pipeline = obj.pipeline;
if (!Array.isArray(pipeline)) {
throw new Error("Invalid folder scanning config: missing 'pipeline' array");
}
const endpointMap = buildEndpointToToolIdMap(toolRegistry);
const unresolved: string[] = [];
const operations: AutomationOperation[] = pipeline.map(
(step: unknown, index: number) => {
if (!step || typeof step !== "object") {
throw new Error(
`Invalid folder scanning config: pipeline[${index}] is not an object`,
);
}
const stepObj = step as Record<string, unknown>;
const rawOperation = stepObj.operation;
if (typeof rawOperation !== "string" || rawOperation.length === 0) {
throw new Error(
`Invalid folder scanning config: pipeline[${index}].operation must be a non-empty string`,
);
}
const rawParameters = (stepObj.parameters as Record<string, any>) || {};
// Strip the export-time marker so the imported automation runs cleanly.
const { fileInput: _fileInput, ...parameters } = rawParameters;
// 1) Direct frontend-tool-id match (handles the converter's fallback when
// no endpoint could be resolved at export time).
if (isToolIdInRegistry(rawOperation, toolRegistry)) {
return { operation: rawOperation, parameters };
}
// 2) Static endpoint string match.
const staticMatch = endpointMap.get(rawOperation);
if (staticMatch) {
return { operation: staticMatch, parameters };
}
// 3) Dynamic endpoint match — replay each function endpoint with the
// parameters we have and look for a match.
const dynamicMatch = findDynamicEndpointMatch(
rawOperation,
parameters,
toolRegistry,
);
if (dynamicMatch) {
return { operation: dynamicMatch, parameters };
}
unresolved.push(rawOperation);
return { operation: rawOperation, parameters };
},
);
const name =
typeof obj.name === "string" && obj.name.length > 0
? obj.name
: "Imported Automation";
return {
automation: {
name,
description: typeof obj.description === "string" ? obj.description : "",
icon: typeof obj.icon === "string" ? obj.icon : undefined,
operations,
},
unresolvedOperations: unresolved,
};
}
/**
* Parse a native Automate JSON file (a previously-exported AutomationConfig).
* The id / createdAt / updatedAt fields are dropped — the storage layer
* regenerates them on save.
*/
export function parseAutomationConfigJson(
raw: unknown,
toolRegistry: Partial<ToolRegistry>,
): {
automation: Omit<AutomationConfig, "id" | "createdAt" | "updatedAt">;
unresolvedOperations: string[];
} {
if (!raw || typeof raw !== "object") {
throw new Error("Invalid automation config: expected JSON object");
}
const obj = raw as Record<string, unknown>;
const operations = obj.operations;
if (!Array.isArray(operations)) {
throw new Error("Invalid automation config: missing 'operations' array");
}
const unresolved: string[] = [];
const parsedOperations: AutomationOperation[] = operations.map(
(op: unknown, index: number) => {
if (!op || typeof op !== "object") {
throw new Error(
`Invalid automation config: operations[${index}] is not an object`,
);
}
const opObj = op as Record<string, unknown>;
const operation = opObj.operation;
if (typeof operation !== "string" || operation.length === 0) {
throw new Error(
`Invalid automation config: operations[${index}].operation must be a non-empty string`,
);
}
const parameters = (opObj.parameters as Record<string, any>) || {};
if (!isToolIdInRegistry(operation, toolRegistry)) {
unresolved.push(operation);
}
return { operation, parameters };
},
);
const name =
typeof obj.name === "string" && obj.name.length > 0
? obj.name
: "Imported Automation";
return {
automation: {
name,
description: typeof obj.description === "string" ? obj.description : "",
icon: typeof obj.icon === "string" ? obj.icon : undefined,
operations: parsedOperations,
},
unresolvedOperations: unresolved,
};
}
/**
* Heuristic format detector. Folder-scanning JSON uses a `pipeline` array;
* native Automate JSON uses an `operations` array. Both is invalid.
*/
export function detectAutomationFormat(
raw: unknown,
): "automate" | "folderScanning" | "unknown" {
if (!raw || typeof raw !== "object") return "unknown";
const obj = raw as Record<string, unknown>;
const hasPipeline = Array.isArray(obj.pipeline);
const hasOperations = Array.isArray(obj.operations);
if (hasPipeline && !hasOperations) return "folderScanning";
if (hasOperations && !hasPipeline) return "automate";
return "unknown";
}
/**
* Parse a JSON file's text content into a normalized AutomationConfig.
* Auto-detects the format unless `expectedFormat` is supplied; throws with a
* user-readable message on any structural problem.
*/
export function parseAutomationFile(
fileText: string,
toolRegistry: Partial<ToolRegistry>,
expectedFormat?: "automate" | "folderScanning",
): ParsedAutomationImport {
let raw: unknown;
try {
raw = JSON.parse(fileText);
} catch (err) {
throw new Error(`File is not valid JSON: ${(err as Error).message}`, {
cause: err,
});
}
const detected = detectAutomationFormat(raw);
const format = expectedFormat ?? detected;
if (expectedFormat && detected !== "unknown" && detected !== expectedFormat) {
throw new Error(
`Expected ${
expectedFormat === "automate"
? "Automate JSON (operations array)"
: "Folder Scanning JSON (pipeline array)"
} but file looks like ${
detected === "automate" ? "Automate JSON" : "Folder Scanning JSON"
}.`,
);
}
if (format === "automate") {
const result = parseAutomationConfigJson(raw, toolRegistry);
return { format: "automate", ...result };
}
if (format === "folderScanning") {
const result = parseFolderScanningConfig(raw, toolRegistry);
return { format: "folderScanning", ...result };
}
throw new Error(
"Unrecognized JSON shape. Expected an Automate config (operations[]) or a Folder Scanning config (pipeline[]).",
);
}
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: "2.10.0",
appVersion: "2.10.1",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
@@ -46,13 +46,8 @@ export function useSaasOnboardingState({
switch (osType) {
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple":
return {
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
};
case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "mac":
return { label: "Mac", url: DOWNLOAD_URLS.MAC };
case "linux-x64":
case "linux-arm64":
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
@@ -64,16 +59,7 @@ export function useSaasOnboardingState({
const osOptions = useMemo(() => {
const options = [
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
value: "mac-apple",
},
{
label: "Mac (Intel)",
url: DOWNLOAD_URLS.MAC_INTEL,
value: "mac-intel",
},
{ label: "Mac", url: DOWNLOAD_URLS.MAC, value: "mac" },
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
];
return options.filter((opt) => opt.url);