merge main
This commit is contained in:
@@ -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,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Executable
+108
@@ -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
|
||||
@@ -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,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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user