diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index 1afd3bba37..8498b05d2b 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc 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') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 568b86b83f..3a06d652a4 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc 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') diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 2fdf5ba8f2..fdcfe0621e 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -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 diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 0ca7a1e9a2..f8f2ecd2de 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -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: diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 1991b5d035..f57c81cccb 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -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' } }; diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index c18728767e..a9f7b44543 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -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 diff --git a/build.gradle b/build.gradle index f200c5775b..5c7617bfa7 100644 --- a/build.gradle +++ b/build.gradle @@ -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" diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index fcc94f39c1..38b9369015 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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)" diff --git a/frontend/scripts/build-universal-mac-jre.sh b/frontend/scripts/build-universal-mac-jre.sh new file mode 100755 index 0000000000..a8fa6ce4d2 --- /dev/null +++ b/frontend/scripts/build-universal-mac-jre.sh @@ -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 diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index e14fa4520f..528f6d8de5 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -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 { } #[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, 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, 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 = 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] diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 6e643e50d3..65070180e6 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -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", diff --git a/frontend/src/core/components/onboarding/useOnboardingDownload.ts b/frontend/src/core/components/onboarding/useOnboardingDownload.ts index 82abc29b82..5e23d9a0ad 100644 --- a/frontend/src/core/components/onboarding/useOnboardingDownload.ts +++ b/frontend/src/core/components/onboarding/useOnboardingDownload.ts @@ -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), [], diff --git a/frontend/src/core/components/tools/automate/AutomationCreation.tsx b/frontend/src/core/components/tools/automate/AutomationCreation.tsx index f39c30036f..0079367394 100644 --- a/frontend/src/core/components/tools/automate/AutomationCreation.tsx +++ b/frontend/src/core/components/tools/automate/AutomationCreation.tsx @@ -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 (
- + + + + diff --git a/frontend/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/src/core/components/tools/automate/AutomationEntry.tsx index e9116951dc..d7fdc43b69 100644 --- a/frontend/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/src/core/components/tools/automate/AutomationEntry.tsx @@ -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; } @@ -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({ + {onImport && ( + } + onClick={(e) => { + e.stopPropagation(); + onImport(); + }} + > + {t("automate.import", "Import")} + + )} {onCopy && ( } @@ -250,12 +273,23 @@ export default function AutomationEntry({ {t("edit", "Edit")} )} - {onExport && ( + {onExportAutomation && ( } onClick={(e) => { e.stopPropagation(); - onExport(); + onExportAutomation(); + }} + > + {t("automate.export", "Export")} + + )} + {onExportFolderScan && ( + } + onClick={(e) => { + e.stopPropagation(); + onExportFolderScan(); }} > {t( diff --git a/frontend/src/core/components/tools/automate/AutomationImportModal.tsx b/frontend/src/core/components/tools/automate/AutomationImportModal.tsx new file mode 100644 index 0000000000..95e85e8026 --- /dev/null +++ b/frontend/src/core/components/tools/automate/AutomationImportModal.tsx @@ -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; + onCancel: () => void; + onImport: ( + automation: ImportableAutomation, + meta: { format: ParsedAutomationImport["format"]; unresolved: string[] }, + ) => void | Promise; +} + +/** + * 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(null); + const [parseError, setParseError] = useState(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 ( + + + + {t( + "automate.importModal.intro", + "Drop a JSON file or paste its contents below. The format (Automate or Folder Scanning) is detected automatically.", + )} + + + 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", + )} + > + + +
+ + {t( + "automate.importModal.dropHint", + "Drop JSON here or click to choose a file", + )} + + + {t( + "automate.importModal.dropSubhint", + "Both Automate and Folder Scanning configs are accepted", + )} + +
+
+
+ +