Compare commits

..
Author SHA1 Message Date
Anthony StirlingandGitHub 7f15f559bd Update RequestUriUtils.java 2025-11-06 16:45:14 +00:00
Anthony StirlingandGitHub 4079542369 Merge branch 'V2' into settingsPageEnhanced 2025-11-06 16:44:06 +00:00
1c27687099 Update frontend/src/core/components/shared/AppConfigModal.tsx
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-06 14:53:26 +00:00
Anthony Stirling 09711670ad } fix 2025-11-06 12:45:47 +00:00
Anthony Stirling 933f103dc6 @app 2025-11-06 12:39:55 +00:00
Anthony Stirling e31cb01f81 Merge branch 'settingsPageEnhanced' of
git@github.com:Stirling-Tools/Stirling-PDF.git into settingsPageEnhanced
2025-11-05 18:14:58 +00:00
Anthony Stirling a8a849e848 addressing comments 2025-11-05 18:12:58 +00:00
Anthony StirlingandGitHub 14ed892f51 Merge branch 'V2' into settingsPageEnhanced 2025-11-05 16:35:55 +00:00
18f85b1aa8 Update frontend/src/proprietary/components/shared/config/configNavSections.tsx
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-05 16:06:50 +00:00
d38e5692e7 Update frontend/src/core/components/shared/config/configSections/audit/AuditEventsTable.tsx
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-05 16:00:28 +00:00
Anthony Stirling f1fec4e0d4 merge 2025-11-04 17:12:58 +00:00
Anthony StirlingandGitHub cdeebc010d Merge branch 'V2' into settingsPageEnhanced 2025-11-04 17:08:01 +00:00
Anthony Stirling dd717fc69b translations 2025-10-31 14:34:10 +00:00
Anthony Stirling 19ff2444a8 fix for path 2025-10-31 13:22:52 +00:00
Anthony Stirling 2148bc2b5c agagin 2025-10-31 13:18:11 +00:00
Anthony Stirling 8ec664d61f final 2025-10-31 13:12:55 +00:00
Anthony Stirling a544092c36 cleanups unused 2025-10-31 13:08:15 +00:00
Anthony Stirling a2c2579f38 lint 2025-10-31 12:57:02 +00:00
Anthony Stirling a256fd9593 px stuff 2025-10-31 12:52:25 +00:00
Anthony Stirling 28c70cbae7 Merge branch 'settingsPageEnhanced' of git@github.com:Stirling-Tools/Stirling-PDF.git into settingsPageEnhanced 2025-10-31 11:34:28 +00:00
Anthony Stirling f82b8a85c0 better logging 2025-10-31 10:22:57 +00:00
Anthony Stirling ba80ca1f67 app URL settings 2025-10-31 09:58:05 +00:00
Anthony StirlingandGitHub 0bd4d26638 Merge branch 'V2' into settingsPageEnhanced 2025-10-31 09:44:58 +00:00
Anthony Stirling 6be4c98f7c lint typecheck 2025-10-31 09:42:56 +00:00
Anthony Stirling bb2b321b28 lint 2025-10-31 09:38:46 +00:00
Anthony Stirling 6d98b0b209 restart 2025-10-30 23:40:47 +00:00
Anthony Stirling 7205ba9c9a @app stuff 2025-10-30 14:05:03 +00:00
Anthony Stirling cf2c7517eb team updates 2025-10-30 13:30:17 +00:00
Anthony Stirling e88c69be70 update full UI 2025-10-29 17:36:38 +00:00
Anthony Stirling 02dfafb254 Merge branch 'V2' into settingsPageEnhanced
Resolved conflicts by:
- Kept invite link feature (inviteLinkExpiryHours in ApplicationProperties)
- Kept enhanced email invite validation (both SMTP and invites enabled check)
- Adopted V2's Posthog/Scarf tracking additions
- Adopted V2's password length validation (min 6 chars) on both backend and frontend
- Converted email templates to Java text blocks
- Kept success message functionality in Login flow
- Used @app path aliases consistently across frontend files
- Fixed remaining relative imports in Workbench, RestartConfirmationModal, useRestartServer, and InviteAccept
- Accepted deletion of refactored files (App.tsx, Landing.tsx, etc.)
- Kept InviteAccept.tsx for invite link feature
2025-10-28 20:45:29 +00:00
Anthony Stirling 404e353183 translations 2025-10-27 12:19:28 +00:00
Anthony Stirling 7bc9a40487 lint 2025-10-27 12:03:52 +00:00
Anthony Stirling a4fd02bc58 fixes and remove unused 2025-10-27 11:24:52 +00:00
Anthony Stirling b2a52eca79 lint 2025-10-27 11:07:20 +00:00
Anthony Stirling 31fda096ec send email and invite link 2025-10-27 11:02:00 +00:00
Anthony Stirling 0d6966de92 lint changes 2025-10-26 11:19:11 +00:00
Anthony Stirling 976fb958fd cleanups 2025-10-26 11:13:39 +00:00
Anthony Stirling ac88125bf5 team fuctionality 2025-10-26 11:11:51 +00:00
Anthony Stirling 7f406774e3 translations 2025-10-25 11:10:58 +01:00
Anthony Stirling d9e03ccf2c cleanups 2025-10-25 11:03:14 +01:00
Anthony Stirling cbf48b409b fixes 2025-10-25 10:56:46 +01:00
Anthony Stirling 8f84bb1349 isAdmin interface fixes 2025-10-25 10:51:20 +01:00
Anthony Stirling 81374d9b7c cleanups 2025-10-24 21:13:05 +01:00
Anthony Stirling e4db9e183d fixes 2025-10-24 19:27:37 +01:00
Anthony Stirling 928a591839 conflict fixes 2025-10-24 15:47:38 +01:00
Anthony Stirling 634eb564d6 settings updates 2025-10-21 17:56:07 +01:00
Anthony Stirling 82cf8cfde4 remove unused configs, add others 2025-10-19 22:28:55 +01:00
Anthony Stirling d70ec668f1 remove unused settings and enhance others 2025-10-16 16:25:05 +01:00
Anthony Stirling 535c95b1cb restart func 2025-10-16 12:56:24 +01:00
Anthony Stirling b3c1b4791c settingsPage Init selfhost 2025-10-15 23:37:27 +01:00
349 changed files with 20067 additions and 165203 deletions
-74
View File
@@ -1,74 +0,0 @@
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.iml
*.iws
*.ipr
# Logs
*.log
logs
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
+77 -83
View File
@@ -17,11 +17,18 @@ on:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions:
contents: read
@@ -40,19 +47,21 @@ jobs:
"windows")
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
;;
# "macos")
# echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $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":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
;;
esac
else
# For PR/push 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":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
fi
build:
@@ -87,7 +96,7 @@ jobs:
uses: dtolnay/rust-toolchain@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-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
@@ -174,96 +183,80 @@ jobs:
working-directory: ./frontend
run: npm install
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
# Disabled Mac builds - Import Apple Developer Certificate
# - name: Import Apple Developer Certificate
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# env:
# APPLE_ID: ${{ secrets.APPLE_ID }}
# APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
# APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
# run: |
# echo "Importing Apple Developer Certificate..."
# echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# security default-keychain -s build.keychain
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# security set-keychain-settings -t 3600 -u build.keychain
# security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# security find-identity -v -p codesigning build.keychain
# - name: Verify Certificate
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# run: |
# echo "Verifying Apple Developer Certificate..."
# CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
# echo "Certificate Info: $CERT_INFO"
# CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
# echo "Certificate ID: $CERT_ID"
# echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
# echo "Certificate imported."
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
# - name: Check DMG creation dependencies (macOS only)
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# run: |
# echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
# echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
# echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
# echo "Available disk space: $(df -h /tmp | tail -1)"
# echo "macOS version: $(sw_vers -productVersion)"
# echo "Available tools:"
# ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
echo "Available disk space: $(df -h /tmp | tail -1)"
echo "macOS version: $(sw_vers -productVersion)"
echo "Available tools:"
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Build Tauri app
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
SIGN: 1
CI: true
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/src-tauri/target
DMG_FILE=$(find . -name "*.dmg" | head -1)
if [ -n "$DMG_FILE" ]; then
echo "Found DMG: $DMG_FILE"
echo "Checking notarization ticket..."
spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
else
echo "⚠️ No DMG file found to verify"
fi
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
# Disabled Mac builds
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; 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
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
@@ -280,7 +273,7 @@ jobs:
shell: bash
run: |
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
echo "Checking for Windows artifacts..."
@@ -289,13 +282,14 @@ jobs:
echo "❌ No Windows executable found"
exit 1
fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found"
exit 1
fi
# Disabled Mac builds
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
# echo "Checking for macOS artifacts..."
# find . -name "*.dmg" -o -name "*.app" | head -5
# if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
# echo "❌ No macOS artifacts found"
# exit 1
# fi
else
echo "Checking for Linux artifacts..."
find . -name "*.deb" -o -name "*.AppImage" | head -5
@@ -304,7 +298,7 @@ jobs:
exit 1
fi
fi
echo "✅ Build artifacts found for ${{ matrix.name }}"
- name: Test artifact sizes
@@ -337,4 +331,4 @@ jobs:
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi
fi
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) |
| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) |
| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) |
| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) |
| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) |
| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) |
| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) |
| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) |
| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) |
| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) |
| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) |
| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) |
| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) |
| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) |
| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) |
| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) |
| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) |
| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) |
| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) |
| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) |
| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) |
| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) |
| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) |
| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) |
| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) |
| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) |
| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) |
| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) |
| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) |
| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) |
| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) |
| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) |
| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) |
| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) |
| French (Français) (fr_FR) | ![82%](https://geps.dev/progress/82) |
| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) |
| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) |
| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) |
| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) |
| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) |
| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) |
| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) |
| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) |
| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) |
| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) |
| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) |
| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) |
| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) |
| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) |
| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) |
| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) |
| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) |
| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) |
| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) |
| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) |
| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) |
| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) |
| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) |
| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) |
| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -1,37 +0,0 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -505,19 +505,10 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
}
@Data
@@ -8,6 +8,4 @@ public interface UserServiceInterface {
long getTotalUsersCount();
boolean isCurrentUserAdmin();
boolean isCurrentUserFirstLogin();
}
@@ -0,0 +1,658 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class FormUtils {
private FormUtils() {}
public static boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public static void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Do not mutate the source AcroForm; skip bad widgets during copy
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Refresh appearances to ensure widgets render correctly across viewers
try {
// Use reflection to avoid compile-time dependency on PDFBox version
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
m.invoke(newAcroForm);
} catch (NoSuchMethodException nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
} catch (Throwable t) {
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
}
}
private static void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (sourceField instanceof PDTextField pdtextfield) {
createSimpleTextField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdtextfield,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
createSimpleCheckBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdCheckBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
createSimpleRadioButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdRadioButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDComboBox pdComboBox) {
createSimpleComboBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdComboBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDListBox pdlistbox) {
createSimpleListBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdlistbox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
createSimpleSignatureField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdSignatureField,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDPushButton pdPushButton) {
createSimplePushButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdPushButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private static void createSimpleTextField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDTextField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTextField newTextField = new PDTextField(newAcroForm);
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newTextField,
sourceField.getPartialName(),
"textField",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getValueAsString() != null) {
newTextField.setValue(sourceField.getValueAsString());
}
} catch (Exception e) {
log.warn(
"Failed to create text field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleCheckBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDCheckBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newCheckBox,
sourceField.getPartialName(),
"checkBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.isChecked()) {
newCheckBox.check();
} else {
newCheckBox.unCheck();
}
} catch (Exception e) {
log.warn(
"Failed to create checkbox field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleRadioButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDRadioButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newRadioButton,
sourceField.getPartialName(),
"radioButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getExportValues() != null) {
newRadioButton.setExportValues(sourceField.getExportValues());
}
if (sourceField.getValue() != null) {
newRadioButton.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create radio button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleComboBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDComboBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDComboBox newComboBox = new PDComboBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newComboBox,
sourceField.getPartialName(),
"comboBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newComboBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newComboBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create combo box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleListBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDListBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDListBox newListBox = new PDListBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newListBox,
sourceField.getPartialName(),
"listBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newListBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newListBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create list box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleSignatureField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDSignatureField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newSignatureField,
sourceField.getPartialName(),
"signature",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
} catch (Exception e) {
log.warn(
"Failed to create signature field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimplePushButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDPushButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDPushButton newPushButton = new PDPushButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newPushButton,
sourceField.getPartialName(),
"pushButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} catch (Exception e) {
log.warn(
"Failed to create push button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private static String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget w : widgets) {
if (w != null) {
map.put(w, field);
}
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -1,6 +1,5 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@@ -242,11 +241,6 @@ public final class RegexPatternUtils {
return getPattern("\\s+");
}
/** Pattern for matching punctuation characters */
public Pattern getPunctuationPattern() {
return getPattern("[\\p{Punct}]+");
}
/** Pattern for matching newlines (Windows and Unix style) */
public Pattern getNewlinesPattern() {
return getPattern("\\r?\\n");
@@ -292,24 +286,6 @@ public final class RegexPatternUtils {
return getPattern("[^a-zA-Z0-9 ]");
}
/** Pattern for removing bracketed indices like [0], [Child], etc. in field names */
public Pattern getFormFieldBracketPattern() {
return getPattern("\\[[^\\]]*\\]");
}
/** Pattern that replaces underscores or hyphens with spaces */
public Pattern getUnderscoreHyphenPattern() {
return getPattern("[-_]+");
}
/**
* Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces.
* Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size
*/
public Pattern getCamelCaseBoundaryPattern() {
return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])");
}
/** Pattern for removing angle brackets */
public Pattern getAngleBracketsPattern() {
return getPattern("[<>]");
@@ -359,26 +335,6 @@ public final class RegexPatternUtils {
return getPattern("[1-9][0-9]{0,2}");
}
/**
* Pattern for very simple generic field tokens such as "field", "text", "checkbox" with
* optional numeric suffix (e.g. "field 1"). Case-insensitive.
*/
public Pattern getGenericFieldNamePattern() {
return getPattern(
"^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$",
Pattern.CASE_INSENSITIVE);
}
/** Pattern for short identifiers like t1, f2, a10 etc. */
public Pattern getSimpleFormFieldPattern() {
return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$");
}
/** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */
public Pattern getOptionalTNumericPattern() {
return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for validating mathematical expressions */
public Pattern getMathExpressionPattern() {
return getPattern("[0-9n+\\-*/() ]+");
@@ -511,11 +467,6 @@ public final class RegexPatternUtils {
return getPattern("/");
}
/** Supported logical types when creating new fields programmatically */
public Set<String> getSupportedNewFieldTypes() {
return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature");
}
/**
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
* compilation overhead for frequent patterns.
@@ -75,9 +75,6 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
@@ -5,7 +5,6 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.TempFileManager;
@@ -19,7 +18,6 @@ import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy;
public class ReplaceAndInvertColorFactory {
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
public ReplaceAndInvertColorStrategy replaceAndInvert(
MultipartFile file,
@@ -28,13 +26,6 @@ public class ReplaceAndInvertColorFactory {
String backGroundColor,
String textColor) {
// Check Ghostscript availability for CMYK conversion
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw new IllegalStateException(
"CMYK color space conversion requires Ghostscript, which is not available on this system");
}
return switch (replaceAndInvertOption) {
case CUSTOM_COLOR, HIGH_CONTRAST_COLOR ->
new CustomColorReplaceStrategy(
@@ -294,12 +294,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool");
// Adding form-related endpoints to "Other" group
addEndpointToGroup("Other", "fields");
addEndpointToGroup("Other", "modify-fields");
addEndpointToGroup("Other", "delete-fields");
addEndpointToGroup("Other", "fill");
// Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf");
@@ -409,6 +403,8 @@ public class EndpointConfiguration {
/* Ghostscript */
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "crop");
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
@@ -28,8 +28,6 @@ public class InitialSetup {
private final ApplicationProperties applicationProperties;
private static boolean isNewServer = false;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
@@ -90,13 +88,6 @@ public class InitialSetup {
}
public void initSetAppVersion() throws IOException {
// Check if this is a new server before setting the version
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
@@ -108,8 +99,4 @@ public class InitialSetup {
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
public static boolean isNewServer() {
return isNewServer;
}
}
@@ -22,7 +22,6 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -38,11 +37,6 @@ import stirling.software.common.util.WebResponseUtils;
public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@@ -52,13 +46,9 @@ public class CropController {
"This operation takes an input PDF file and crops it according to the given"
+ " coordinates. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
if (request.isRemoveDataOutsideCrop()) {
return cropWithGhostscript(request);
} else {
if (request.isRemoveDataOutsideCrop()) {
log.warn(
"Ghostscript not available - 'removeDataOutsideCrop' option requires Ghostscript. Falling back to visual crop only.");
}
return cropWithPDFBox(request);
}
}
@@ -26,6 +26,7 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -136,6 +137,26 @@ public class MultiPageLayoutController {
contentStream.close();
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
FormUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -10,10 +10,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -22,7 +19,6 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@Slf4j
public class ConfigController {
private final ApplicationProperties applicationProperties;
@@ -62,15 +58,9 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
@@ -88,22 +78,6 @@ public class ConfigController {
}
configData.put("isAdmin", isAdmin);
// Check if this is a new server (version was 0.0.0 before initialization)
configData.put("isNewServer", InitialSetup.isNewServer());
// Check if the current user is a first-time user
boolean isNewUser =
false; // Default to false when security is disabled or user not found
if (userService != null) {
try {
isNewUser = userService.isCurrentUserFirstLogin();
} catch (Exception e) {
// If there's an error, assume not new user for safety
isNewUser = false;
}
}
configData.put("isNewUser", isNewUser);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -176,7 +176,6 @@ system:
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints:
@@ -7,8 +7,6 @@
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
@@ -27,12 +25,6 @@
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}
@@ -116,10 +116,6 @@ public class ProprietaryUIDataController {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@@ -452,7 +448,6 @@ public class ProprietaryUIDataController {
@Data
public static class LoginData {
private Boolean enableLogin;
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
@@ -1,215 +0,0 @@
package stirling.software.proprietary.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.util.FormUtils;
@RestController
@RequestMapping("/api/v1/form")
@Tag(name = "Forms", description = "PDF form APIs")
@RequiredArgsConstructor
public class FormFillController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private static ResponseEntity<byte[]> saveDocument(PDDocument document, String baseName)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf");
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
original = "document";
}
if (!original.toLowerCase().endsWith(".pdf")) {
return original + "_" + suffix;
}
String withoutExtension = original.substring(0, original.length() - 4);
return withoutExtension + "_" + suffix;
}
private static void requirePdf(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "{0} must be in PDF format", "file");
}
}
private static String decodePart(byte[] payload) {
if (payload == null || payload.length == 0) {
return null;
}
return new String(payload, StandardCharsets.UTF_8);
}
@PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Inspect PDF form fields",
description = "Returns metadata describing each field in the provided PDF form")
public ResponseEntity<FormUtils.FormFieldExtraction> listFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file)
throws IOException {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.FormFieldExtraction extraction =
FormUtils.extractFieldsWithTemplate(document);
return ResponseEntity.ok(extraction);
}
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
description =
"Updates existing fields in the provided PDF and returns the updated file")
public ResponseEntity<byte[]> modifyFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@RequestPart(value = "updates", required = false) byte[] updatesPayload)
throws IOException {
String rawUpdates = decodePart(updatesPayload);
List<FormUtils.ModifyFormFieldDefinition> modifications =
FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates);
if (modifications.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Delete form fields",
description = "Removes the specified fields from the PDF and returns the updated file")
public ResponseEntity<byte[]> deleteFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON array of field names or objects with a name property,"
+ " matching the /fields response format",
example = "[{\"name\":\"Field1\"}]")
@RequestPart(value = "names", required = false)
byte[] namesPayload)
throws IOException {
String rawNames = decodePart(namesPayload);
List<String> names = FormPayloadParser.parseNameList(objectMapper, rawNames);
if (names.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Fill PDF form fields",
description =
"Populates the supplied PDF form using values from the provided JSON payload"
+ " and returns the filled PDF")
public ResponseEntity<byte[]> fillForm(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON object of field-value pairs to apply",
example = "{\"field\":\"value\"}")
@RequestPart(value = "data", required = false)
byte[] valuesPayload,
@RequestParam(value = "flatten", defaultValue = "false") boolean flatten)
throws IOException {
String rawValues = decodePart(valuesPayload);
Map<String, Object> values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
return processSingleFile(
file,
"filled",
document -> FormUtils.applyFieldValues(document, values, flatten, true));
}
private ResponseEntity<byte[]> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
processor.accept(document);
return saveDocument(document, baseName);
}
}
@FunctionalInterface
private interface DocumentProcessor {
void accept(PDDocument document) throws IOException;
}
}
@@ -1,295 +0,0 @@
package stirling.software.proprietary.controller.api.form;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.util.FormUtils;
final class FormPayloadParser {
private static final String KEY_FIELDS = "fields";
private static final String KEY_NAME = "name";
private static final String KEY_TARGET_NAME = "targetName";
private static final String KEY_FIELD_NAME = "fieldName";
private static final String KEY_FIELD = "field";
private static final String KEY_VALUE = "value";
private static final String KEY_DEFAULT_VALUE = "defaultValue";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json)
throws IOException {
if (json == null || json.isBlank()) {
return Map.of();
}
JsonNode root;
try {
root = objectMapper.readTree(json);
} catch (IOException e) {
// Fallback to legacy direct map parse (will throw again if invalid)
return objectMapper.readValue(json, MAP_TYPE);
}
if (root == null || root.isNull()) {
return Map.of();
}
// 1. If payload already a flat object with no special wrapping, keep legacy behavior
if (root.isObject()) {
// a) Prefer explicit 'template' object if present (new combined /fields response)
JsonNode templateNode = root.get("template");
if (templateNode != null && templateNode.isObject()) {
return objectToLinkedMap(templateNode);
}
// b) Accept an inline 'fields' array of field definitions (build map from them)
JsonNode fieldsNode = root.get(KEY_FIELDS);
if (fieldsNode != null && fieldsNode.isArray()) {
Map<String, Object> record = extractFieldInfoArray(fieldsNode);
if (!record.isEmpty()) {
return record;
}
}
// c) Fallback: treat entire object as the value map (legacy behavior)
return objectToLinkedMap(root);
}
// 2. If an array was supplied to /fill (non-standard), treat first element as record
if (root.isArray()) {
if (root.isEmpty()) {
return Map.of();
}
JsonNode first = root.get(0);
if (first != null && first.isObject()) {
if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) {
return extractFieldInfoArray(root);
}
return objectToLinkedMap(first);
}
return Map.of();
}
// 3. Anything else: fallback to strict map parse
return objectMapper.readValue(json, MAP_TYPE);
}
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
final JsonNode root = objectMapper.readTree(json);
if (root == null || root.isNull()) {
return List.of();
}
final Set<String> names = new LinkedHashSet<>();
if (root.isArray()) {
collectNames(root, names);
} else if (root.isObject()) {
if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) {
collectNames(root.get(KEY_FIELDS), names);
} else {
final String single = extractName(root);
if (nonBlank(single)) {
names.add(single);
}
}
} else if (root.isTextual()) {
final String single = trimToNull(root.asText());
if (single != null) {
names.add(single);
}
}
if (!names.isEmpty()) {
return List.copyOf(names);
}
try {
return objectMapper.readValue(json, STRING_LIST_TYPE);
} catch (IOException e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"names payload",
"expected array of strings or objects with 'name'-like properties");
}
}
private static Map<String, Object> extractFieldInfoArray(JsonNode fieldsNode) {
final Map<String, Object> record = new LinkedHashMap<>();
if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) {
return record;
}
for (JsonNode fieldNode : fieldsNode) {
if (fieldNode == null || !fieldNode.isObject()) {
continue;
}
final String name = extractName(fieldNode);
if (!nonBlank(name)) {
continue;
}
JsonNode valueNode = fieldNode.get(KEY_VALUE);
if ((valueNode == null || valueNode.isNull())
&& fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) {
valueNode = fieldNode.get(KEY_DEFAULT_VALUE);
}
final String normalized = normalizeFieldValue(valueNode);
record.put(name, normalized == null ? "" : normalized);
}
return record;
}
private static String normalizeFieldValue(JsonNode valueNode) {
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isArray()) {
final List<String> values = new ArrayList<>();
for (JsonNode element : valueNode) {
final String text = coerceScalarToString(element);
if (text != null) {
values.add(text);
}
}
return String.join(",", values);
}
if (valueNode.isObject()) {
// Preserve object as JSON string
return valueNode.toString();
}
// Scalar (text/number/boolean)
return coerceScalarToString(valueNode);
}
private static String coerceScalarToString(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToEmpty(node.asText());
}
if (node.isNumber()) {
return node.numberValue().toString();
}
if (node.isBoolean()) {
return Boolean.toString(node.booleanValue());
}
// Fallback for other scalar-like nodes
return trimToEmpty(node.asText());
}
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
if (arrayNode == null || !arrayNode.isArray()) {
return;
}
for (JsonNode node : arrayNode) {
final String name = extractName(node);
if (nonBlank(name)) {
sink.add(name);
}
}
}
private static String extractName(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToNull(node.asText());
}
if (node.isObject()) {
final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(direct)) {
return direct;
}
final JsonNode field = node.get(KEY_FIELD);
if (field != null && field.isObject()) {
final String nested =
textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(nested)) {
return nested;
}
}
}
return null;
}
private static String textProperty(JsonNode node, String... keys) {
for (String key : keys) {
final JsonNode valueNode = node.get(key);
final String value = coerceScalarToString(valueNode);
if (nonBlank(value)) {
return value;
}
}
return null;
}
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
final Map<String, Object> result = new LinkedHashMap<>();
objectNode
.fieldNames()
.forEachRemaining(
key -> {
final JsonNode v = objectNode.get(key);
if (v == null || v.isNull()) {
result.put(key, null);
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
result.put(key, coerceScalarToString(v));
} else {
result.put(key, v.toString());
}
});
return result;
}
private static boolean nonBlank(String s) {
return s != null && !s.isBlank();
}
private static String trimToNull(String s) {
if (s == null) return null;
final String t = s.trim();
return t.isEmpty() ? null : t;
}
private static String trimToEmpty(String s) {
return s == null ? "" : s.trim();
}
}
@@ -129,53 +129,61 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
configuredOrigins);
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
} else {
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
log.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,9 +26,11 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@InviteApi
@UserApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
@@ -742,31 +742,4 @@ public class UserController {
return errorMessage;
}
}
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
User user = userOpt.get();
user.setHasCompletedInitialSetup(true);
userRepository.save(user);
log.info("User {} completed initial setup", username);
return ResponseEntity.ok().body(Map.of("success", true));
} catch (Exception e) {
log.error("Error completing initial setup", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to complete initial setup");
}
}
}
@@ -107,8 +107,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
// the request
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
@@ -223,8 +222,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
|| trimmedUri.startsWith("/api/v1/auth/logout");
}
private enum UserLoginType {
@@ -56,9 +56,6 @@ public class User implements UserDetails, Serializable {
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "roleName")
private String roleName;
@@ -106,14 +103,6 @@ public class User implements UserDetails, Serializable {
this.isFirstLogin = isFirstLogin;
}
public boolean hasCompletedInitialSetup() {
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
}
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -663,21 +663,6 @@ public class UserService implements UserServiceInterface {
return false;
}
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
if (username != null) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
return !userOpt.get().hasCompletedInitialSetup();
}
}
} catch (Exception e) {
log.debug("Error checking first login status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {
@@ -1,345 +0,0 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@UtilityClass
public class FormCopyUtils {
public boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Temporarily set NeedAppearances to true during field creation
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (rowIndex >= rows) {
continue;
}
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Generate appearance streams and embed them authoritatively
boolean appearancesGenerated = false;
try {
newAcroForm.refreshAppearances();
appearancesGenerated = true;
} catch (NoSuchMethodError nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; "
+ "leaving NeedAppearances=true for viewer-side rendering.");
} catch (Exception t) {
log.warn(
"Failed to refresh field appearances via AcroForm: {}. "
+ "Leaving NeedAppearances=true as fallback.",
t.getMessage(),
t);
}
// After successful appearance generation, set NeedAppearances to false
// to signal that appearance streams are now embedded authoritatively
if (appearancesGenerated) {
try {
newAcroForm.setNeedAppearances(false);
} catch (Exception e) {
log.debug(
"Failed to set NeedAppearances to false: {}. "
+ "Appearances were generated but flag could not be updated.",
e.getMessage());
}
}
}
private void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (!(sourceField instanceof PDTerminalField terminalField)) {
continue;
}
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
if (handler == null) {
log.debug(
"Skipping unsupported field type '{}' for widget '{}'",
sourceField.getClass().getSimpleName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName));
continue;
}
copyFieldUsingHandler(
handler,
terminalField,
newAcroForm,
destinationPage,
destinationAnnotations,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private void copyFieldUsingHandler(
FormFieldTypeSupport handler,
PDTerminalField sourceField,
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTerminalField newField = handler.createField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newField,
sourceField.getPartialName(),
handler.fallbackWidgetName(),
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
handler.copyFromOriginal(sourceField, newField);
} catch (Exception e) {
log.warn(
"Failed to copy {} field '{}': {}",
handler.typeName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName),
e.getMessage(),
e);
}
}
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget != null) {
map.put(widget, field);
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -1,368 +0,0 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public enum FormFieldTypeSupport {
TEXT("text", "textField", PDTextField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
PDTextField textField = new PDTextField(acroForm);
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
return textField;
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDTextField src = (PDTextField) source;
PDTextField dst = (PDTextField) target;
String value = src.getValueAsString();
if (value != null) {
dst.setValue(value);
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
}
}
},
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDCheckBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDCheckBox src = (PDCheckBox) source;
PDCheckBox dst = (PDCheckBox) target;
if (src.isChecked()) {
dst.check();
} else {
dst.unCheck();
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDCheckBox checkBox = (PDCheckBox) field;
if (!options.isEmpty()) {
checkBox.setExportValues(options);
}
ensureCheckBoxAppearance(checkBox);
if (FormUtils.isChecked(definition.defaultValue())) {
checkBox.check();
} else {
checkBox.unCheck();
}
}
private static void ensureCheckBoxAppearance(PDCheckBox checkBox) {
try {
if (checkBox.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
PDAppearanceCharacteristicsDictionary appearanceChars =
widget.getAppearanceCharacteristics();
if (appearanceChars == null) {
appearanceChars =
new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(appearanceChars);
}
appearanceChars.setBorderColour(
new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE));
appearanceChars.setBackground(
new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE));
appearanceChars.setNormalCaption("4");
widget.setPrinted(true);
widget.setReadOnly(false);
widget.setHidden(false);
} catch (Exception e) {
log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage());
}
}
},
RADIO("radio", "radioButton", PDRadioButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDRadioButton(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDRadioButton src = (PDRadioButton) source;
PDRadioButton dst = (PDRadioButton) target;
if (src.getExportValues() != null) {
dst.setExportValues(src.getExportValues());
}
if (src.getValue() != null) {
dst.setValue(src.getValue());
}
}
},
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDComboBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDComboBox src = (PDComboBox) source;
PDComboBox dst = (PDComboBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDComboBox comboBox = (PDComboBox) field;
if (!options.isEmpty()) {
comboBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.resolveOptions(comboBox);
String comboName =
Optional.ofNullable(comboBox.getFullyQualifiedName())
.orElseGet(comboBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, comboName);
if (filtered != null) {
comboBox.setValue(filtered);
}
}
}
},
LISTBOX("listbox", "listBox", PDListBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDListBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDListBox src = (PDListBox) source;
PDListBox dst = (PDListBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDListBox listBox = (PDListBox) field;
listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect()));
if (!options.isEmpty()) {
listBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.collectChoiceAllowedValues(listBox);
String listBoxName =
Optional.ofNullable(listBox.getFullyQualifiedName())
.orElseGet(listBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
if (Boolean.TRUE.equals(definition.multiSelect())) {
List<String> selections = FormUtils.parseMultiChoiceSelections(defaultValue);
List<String> filtered =
FormUtils.filterChoiceSelections(
selections, allowedOptions, listBoxName);
if (!filtered.isEmpty()) {
listBox.setValue(filtered);
}
} else {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, listBoxName);
if (filtered != null) {
listBox.setValue(filtered);
}
}
}
}
},
SIGNATURE("signature", "signature", PDSignatureField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
};
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
Collectors.toUnmodifiableMap(
FormFieldTypeSupport::typeName, Function.identity()));
private final String typeName;
private final String fallbackWidgetName;
private final Class<? extends PDTerminalField> fieldClass;
FormFieldTypeSupport(
String typeName,
String fallbackWidgetName,
Class<? extends PDTerminalField> fieldClass) {
this.typeName = typeName;
this.fallbackWidgetName = fallbackWidgetName;
this.fieldClass = fieldClass;
}
public static FormFieldTypeSupport forField(PDField field) {
if (field == null) {
return null;
}
for (FormFieldTypeSupport handler : values()) {
if (handler.fieldClass.isInstance(field)) {
return handler;
}
}
return null;
}
public static FormFieldTypeSupport forTypeName(String typeName) {
if (typeName == null) {
return null;
}
return BY_TYPE.get(typeName);
}
private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) {
if (sourceField == null || targetField == null) {
return;
}
try {
int flags = sourceField.getCOSObject().getInt(COSName.FF);
targetField.getCOSObject().setInt(COSName.FF, flags);
} catch (Exception e) {
// ignore and continue
}
if (sourceField instanceof PDListBox sourceList
&& targetField instanceof PDListBox targetList) {
try {
targetList.setMultiSelect(sourceList.isMultiSelect());
} catch (Exception ignored) {
// ignore
}
}
}
String typeName() {
return typeName;
}
String fallbackWidgetName() {
return fallbackWidgetName;
}
abstract PDTerminalField createField(PDAcroForm acroForm);
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
// default no-op
}
boolean doesNotsupportsDefinitionCreation() {
return true;
}
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
// default no-op
}
}
File diff suppressed because it is too large Load Diff
@@ -1,114 +0,0 @@
package stirling.software.proprietary.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior")
class FormUtilsTest {
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
PDPage page = new PDPage();
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle)
throws IOException {
attachWidget(setup, field, rectangle);
}
private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle)
throws IOException {
field.setExportValues(List.of("Yes"));
attachWidget(setup, field, rectangle);
}
private static void attachWidget(
SetupDocument setup,
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
PDRectangle rectangle)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page);
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
widgets = new ArrayList<>();
} else {
widgets = new ArrayList<>(widgets);
}
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm.getFields().add(field);
setup.page.getAnnotations().add(widget);
}
@Test
void extractFormFieldsReturnsFieldMetadata() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("firstName");
attachField(setup, textField, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
assertEquals(1, fields.size());
FormUtils.FormFieldInfo info = fields.get(0);
assertEquals("firstName", info.name());
assertEquals("text", info.type());
assertEquals(0, info.pageIndex());
assertEquals("", info.value());
}
}
@Test
void applyFieldValuesPopulatesTextAndCheckbox() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachField(setup, textField, new PDRectangle(60, 720, 220, 20));
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
checkBox.setPartialName("subscribed");
attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16));
FormUtils.applyFieldValues(
document, Map.of("company", "Stirling", "subscribed", true), false);
assertEquals("Stirling", textField.getValueAsString());
assertTrue(checkBox.isChecked());
FormUtils.applyFieldValues(document, Map.of("subscribed", false), false);
assertFalse(checkBox.isChecked());
assertEquals("Off", checkBox.getValue());
}
}
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
}
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.0'
version = '1.4.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
-3
View File
@@ -1,6 +1,3 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
+58 -84
View File
@@ -10,25 +10,24 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -442,6 +441,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -488,6 +488,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -506,68 +507,12 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@embedpdf/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@@ -651,6 +596,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -667,6 +613,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -684,6 +631,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -720,6 +668,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -754,6 +703,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -790,6 +740,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -865,6 +816,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -1020,6 +972,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1063,6 +1016,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2093,6 +2047,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2143,6 +2098,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2210,6 +2166,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@@ -3893,6 +3850,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4216,6 +4174,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4226,6 +4185,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4286,6 +4246,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -4999,7 +4960,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
@@ -5009,7 +4969,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
@@ -5020,7 +4979,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
@@ -5033,7 +4991,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
@@ -5060,6 +5017,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5744,6 +5702,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6789,7 +6748,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7184,6 +7144,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7354,6 +7315,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -8676,6 +8638,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9483,6 +9446,7 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@@ -11259,6 +11223,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11538,6 +11503,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -11910,6 +11876,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11919,6 +11886,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13353,9 +13321,9 @@
}
},
"node_modules/svelte": {
"version": "5.43.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz",
"integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==",
"version": "5.42.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz",
"integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -13589,6 +13557,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13890,6 +13859,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13972,6 +13942,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14176,6 +14147,7 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14327,6 +14299,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14340,6 +14313,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
+18 -20
View File
@@ -6,25 +6,24 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@emotion/react": "^11.14.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
@@ -67,7 +66,6 @@
"preview": "vite preview",
"tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84 -434
View File
@@ -81,137 +81,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -286,6 +155,12 @@ dependencies = [
"wyz",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -313,19 +188,6 @@ dependencies = [
"objc2 0.6.3",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "borsh"
version = "1.5.7"
@@ -558,6 +420,36 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -568,15 +460,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -619,6 +502,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -627,11 +523,22 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-graphics-types",
"core-graphics-types 0.2.0",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@@ -643,15 +550,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core-services"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a"
dependencies = [
"core-foundation 0.10.1",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -936,33 +834,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "env_filter"
version = "0.1.4"
@@ -1000,27 +871,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -1176,19 +1026,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@@ -1575,12 +1412,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -2161,6 +1992,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2308,19 +2148,6 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -2373,6 +2200,15 @@ dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@@ -2705,16 +2541,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
@@ -2750,12 +2576,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2937,17 +2757,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2980,20 +2789,6 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@@ -3894,7 +3689,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"core-graphics 0.24.0",
"foreign-types 0.5.0",
"js-sys",
"log",
@@ -3940,19 +3735,14 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"core-foundation 0.10.1",
"core-services",
"cocoa",
"log",
"objc",
"once_cell",
"reqwest 0.11.27",
"serde",
"serde_json",
@@ -3961,7 +3751,6 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
]
@@ -4098,7 +3887,7 @@ dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"core-foundation 0.10.1",
"core-graphics",
"core-graphics 0.24.0",
"crossbeam-channel",
"dispatch",
"dlopen2",
@@ -4348,21 +4137,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.9.1"
@@ -4770,21 +4544,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "tracing-core"
version = "0.1.34"
@@ -4834,17 +4596,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
dependencies = [
"memoffset",
"tempfile",
"winapi",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@@ -5860,67 +5611,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"nix",
"ordered-stream",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.13",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.108",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
dependencies = [
"serde",
"static_assertions",
"winnow 0.7.13",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.27"
@@ -5994,43 +5684,3 @@ dependencies = [
"quote",
"syn 2.0.108",
]
[[package]]
name = "zvariant"
version = "5.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 0.7.13",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.108",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.108",
"winnow 0.7.13",
]
+4 -6
View File
@@ -10,9 +10,6 @@ rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
warnings = "deny"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@@ -28,10 +25,11 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
# macOS-specific dependencies for native file opening
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-services = "1.0"
objc = "0.2"
cocoa = "0.24"
once_cell = "1.19"
@@ -234,8 +234,6 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📤 Backend: {}", output_str));
// Look for startup indicators
@@ -252,8 +250,6 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators
@@ -1,217 +0,0 @@
use crate::utils::add_log;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::process::Command;
/// Check if Stirling PDF is the default PDF handler
#[tauri::command]
pub fn is_default_pdf_handler() -> Result<bool, String> {
add_log("🔍 Checking if app is default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
check_default_windows()
}
#[cfg(target_os = "macos")]
{
check_default_macos()
}
#[cfg(target_os = "linux")]
{
check_default_linux()
}
}
/// Attempt to set/prompt for Stirling PDF as default PDF handler
#[tauri::command]
pub fn set_as_default_pdf_handler() -> Result<String, String> {
add_log("⚙️ Attempting to set as default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
set_default_windows()
}
#[cfg(target_os = "macos")]
{
set_default_macos()
}
#[cfg(target_os = "linux")]
{
set_default_linux()
}
}
// ============================================================================
// Windows Implementation
// ============================================================================
#[cfg(target_os = "windows")]
fn check_default_windows() -> Result<bool, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Query the default handler for .pdf extension
let output = Command::new("cmd")
.args(["/C", "assoc .pdf"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let assoc = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows PDF association: {}", assoc.trim()));
// Get the ProgID for .pdf files
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
// Query what application handles this ProgID
let output = Command::new("cmd")
.args(["/C", &format!("ftype {}", prog_id)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to query file type: {}", e))?;
let ftype = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows file type: {}", ftype.trim()));
// Check if it contains "Stirling" or our app name
let is_default = ftype.to_lowercase().contains("stirling");
Ok(is_default)
} else {
Ok(false)
}
}
#[cfg(target_os = "windows")]
fn set_default_windows() -> Result<String, String> {
// On Windows 10+, we need to open the Default Apps settings
// as programmatic setting requires a signed installer
Command::new("cmd")
.args(["/C", "start", "ms-settings:defaultapps"])
.spawn()
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
add_log("Opened Windows Default Apps settings".to_string());
Ok("opened_settings".to_string())
}
// ============================================================================
// macOS Implementation (using LaunchServices framework)
// ============================================================================
#[cfg(target_os = "macos")]
fn check_default_macos() -> Result<bool, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSCopyDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSCopyDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
) -> CFStringRef;
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
let pdf_uti = CFString::new("com.adobe.pdf");
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
if handler_ref.is_null() {
add_log("No default PDF handler found".to_string());
return Ok(false);
}
let handler = CFString::wrap_under_create_rule(handler_ref);
let handler_str = handler.to_string();
add_log(format!("macOS PDF handler: {}", handler_str));
// Check if it's our bundle identifier
let is_default = handler_str == "stirling.pdf.dev";
Ok(is_default)
}
}
#[cfg(target_os = "macos")]
fn set_default_macos() -> Result<String, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSSetDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
handler_bundle_id: CFStringRef,
) -> c_int; // OSStatus
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Set our app as the default handler for PDF files
let pdf_uti = CFString::new("com.adobe.pdf");
let our_bundle_id = CFString::new("stirling.pdf.dev");
let status = LSSetDefaultRoleHandlerForContentType(
pdf_uti.as_concrete_TypeRef(),
K_LS_ROLES_ALL,
our_bundle_id.as_concrete_TypeRef(),
);
if status == 0 {
add_log("Successfully triggered default app dialog".to_string());
Ok("set_successfully".to_string())
} else {
let error_msg = format!("LaunchServices returned status: {}", status);
add_log(error_msg.clone());
Err(error_msg)
}
}
}
// ============================================================================
// Linux Implementation
// ============================================================================
#[cfg(target_os = "linux")]
fn check_default_linux() -> Result<bool, String> {
// Use xdg-mime to check the default application for PDF files
let output = Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let handler = String::from_utf8_lossy(&output.stdout);
add_log(format!("Linux PDF handler: {}", handler.trim()));
// Check if it's our .desktop file
let is_default = handler.trim() == "stirling-pdf.desktop";
Ok(is_default)
}
#[cfg(target_os = "linux")]
fn set_default_linux() -> Result<String, String> {
// Use xdg-mime to set the default application for PDF files
let result = Command::new("xdg-mime")
.args(["default", "stirling-pdf.desktop", "application/pdf"])
.output()
.map_err(|e| format!("Failed to set default app: {}", e))?;
if result.status.success() {
add_log("Set as default PDF handler on Linux".to_string());
Ok("set_successfully".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to set default: {}", error));
Err(format!("Failed to set as default: {}", error))
}
}
+35 -22
View File
@@ -1,35 +1,48 @@
use crate::utils::add_log;
use std::sync::Mutex;
// Store the opened file paths globally (supports multiple files)
static OPENED_FILES: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Store the opened file path globally
static OPENED_FILE: Mutex<Option<String>> = Mutex::new(None);
// Add an opened file path
pub fn add_opened_file(file_path: String) {
let mut opened_files = OPENED_FILES.lock().unwrap();
opened_files.push(file_path.clone());
add_log(format!("📂 File stored for later retrieval: {}", file_path));
// Set the opened file path (called by macOS file open events)
pub fn set_opened_file(file_path: String) {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = Some(file_path.clone());
add_log(format!("📂 File opened via file open event: {}", file_path));
}
// Command to get opened file paths (if app was launched with files)
// Command to get opened file path (if app was launched with a file)
#[tauri::command]
pub async fn get_opened_files() -> Result<Vec<String>, String> {
// Get all files from the OPENED_FILES store
// Command line args are processed in setup() callback and added to this store
// Additional files from second instances or events are also added here
let opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
Ok(all_files)
pub async fn get_opened_file() -> Result<Option<String>, String> {
// First check if we have a file from macOS file open events
{
let opened_file = OPENED_FILE.lock().unwrap();
if let Some(ref file_path) = *opened_file {
add_log(format!("📂 Returning stored opened file: {}", file_path));
return Ok(Some(file_path.clone()));
}
}
// Fallback to command line arguments (Windows/Linux)
let args: Vec<String> = std::env::args().collect();
// Look for a PDF file argument (skip the first arg which is the executable)
for arg in args.iter().skip(1) {
if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 PDF file opened via command line: {}", arg));
return Ok(Some(arg.clone()));
}
}
Ok(None)
}
// Command to clear the opened files (after processing)
// Command to clear the opened file (after processing)
#[tauri::command]
pub async fn clear_opened_files() -> Result<(), String> {
let mut opened_files = OPENED_FILES.lock().unwrap();
opened_files.clear();
add_log("📂 Cleared opened files".to_string());
pub async fn clear_opened_file() -> Result<(), String> {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = None;
add_log("📂 Cleared opened file".to_string());
Ok(())
}
+1 -3
View File
@@ -1,9 +1,7 @@
pub mod backend;
pub mod health;
pub mod files;
pub mod default_app;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
pub use files::{get_opened_file, clear_opened_file, set_opened_file};
+189
View File
@@ -0,0 +1,189 @@
/// Multi-platform file opening handler
///
/// This module provides unified file opening support across platforms:
/// - macOS: Uses native NSApplication delegate (proper Apple Events)
/// - Windows/Linux: Uses command line arguments (fallback approach)
/// - All platforms: Runtime event handling via Tauri events
use crate::utils::add_log;
use crate::commands::set_opened_file;
use tauri::AppHandle;
/// Initialize file handling for the current platform
pub fn initialize_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Initializing file handler...".to_string());
// Platform-specific initialization
#[cfg(target_os = "macos")]
{
add_log("🍎 Using macOS native file handler".to_string());
macos_native::register_open_file_handler(app);
}
#[cfg(not(target_os = "macos"))]
{
add_log("🖥️ Using command line argument file handler".to_string());
let _ = app; // Suppress unused variable warning
}
// Universal: Check command line arguments (works on all platforms)
check_command_line_args();
}
/// Early initialization for macOS delegate registration
pub fn early_init() {
#[cfg(target_os = "macos")]
{
add_log("🔄 Early macOS initialization...".to_string());
macos_native::register_delegate_early();
}
}
/// Check command line arguments for file paths (universal fallback)
fn check_command_line_args() {
let args: Vec<String> = std::env::args().collect();
add_log(format!("🔍 DEBUG: All command line args: {:?}", args));
// Check command line arguments for file opening
for (i, arg) in args.iter().enumerate() {
add_log(format!("🔍 DEBUG: Arg {}: {}", i, arg));
if i > 0 && arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 File argument detected: {}", arg));
set_opened_file(arg.clone());
break; // Only handle the first PDF file
}
}
}
/// Handle runtime file open events (for future single-instance support)
#[allow(dead_code)]
pub fn handle_runtime_file_open(file_path: String) {
if file_path.ends_with(".pdf") && std::path::Path::new(&file_path).exists() {
add_log(format!("📂 Runtime file open: {}", file_path));
set_opened_file(file_path);
}
}
#[cfg(target_os = "macos")]
mod macos_native {
use objc::{class, msg_send, sel, sel_impl};
use objc::runtime::{Class, Object, Sel};
use cocoa::appkit::NSApplication;
use cocoa::base::{id, nil};
use once_cell::sync::Lazy;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
use crate::utils::add_log;
use crate::commands::set_opened_file;
// Static app handle storage
static APP_HANDLE: Lazy<Mutex<Option<AppHandle<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
// Store files opened during launch
static LAUNCH_FILES: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
extern "C" fn open_files(_self: &Object, _cmd: Sel, _sender: id, filenames: id) {
unsafe {
add_log(format!("📂 macOS native openFiles event called"));
// filenames is an NSArray of NSString objects
let count: usize = msg_send![filenames, count];
add_log(format!("📂 Number of files to open: {}", count));
for i in 0..count {
let filename: id = msg_send![filenames, objectAtIndex: i];
let cstr = {
let bytes: *const std::os::raw::c_char = msg_send![filename, UTF8String];
std::ffi::CStr::from_ptr(bytes)
};
if let Ok(path) = cstr.to_str() {
add_log(format!("📂 macOS file open: {}", path));
if path.ends_with(".pdf") {
// Always set the opened file for command-line interface
set_opened_file(path.to_string());
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
// App is running, emit event immediately
add_log(format!("✅ App running, emitting file event: {}", path));
let _ = app.emit("macos://open-file", path.to_string());
} else {
// App not ready yet, store for later processing
add_log(format!("🚀 App not ready, storing file for later: {}", path));
LAUNCH_FILES.lock().unwrap().push(path.to_string());
}
}
}
}
}
}
// Register the delegate immediately when the module loads
pub fn register_delegate_early() {
add_log("🔧 Registering macOS delegate early...".to_string());
unsafe {
let ns_app = NSApplication::sharedApplication(nil);
// Check if there's already a delegate
let existing_delegate: id = msg_send![ns_app, delegate];
if existing_delegate != nil {
add_log("⚠️ Tauri already has an NSApplication delegate, trying to extend it...".to_string());
// Try to add our method to the existing delegate's class
let delegate_class: id = msg_send![existing_delegate, class];
let class_name: *const std::os::raw::c_char = msg_send![delegate_class, name];
let class_name_str = std::ffi::CStr::from_ptr(class_name).to_string_lossy();
add_log(format!("🔍 Existing delegate class: {}", class_name_str));
// This approach won't work with existing classes, so let's try a different method
// We'll use method swizzling or create a new delegate that forwards to the old one
add_log("🔄 Will try alternative approach...".to_string());
}
let delegate_class = Class::get("StirlingAppDelegate").unwrap_or_else(|| {
let superclass = class!(NSObject);
let mut decl = objc::declare::ClassDecl::new("StirlingAppDelegate", superclass).unwrap();
// Add file opening delegate method (modern plural version)
decl.add_method(
sel!(application:openFiles:),
open_files as extern "C" fn(&Object, Sel, id, id)
);
decl.register()
});
let delegate: id = msg_send![delegate_class, new];
let _: () = msg_send![ns_app, setDelegate:delegate];
}
add_log("✅ macOS delegate registered early".to_string());
}
pub fn register_open_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Connecting app handle to file handler...".to_string());
// Store the app handle
*APP_HANDLE.lock().unwrap() = Some(app.clone());
// Process any files that were opened during launch
let launch_files = {
let mut files = LAUNCH_FILES.lock().unwrap();
let result = files.clone();
files.clear();
result
};
for file_path in launch_files {
add_log(format!("📂 Processing stored launch file: {}", file_path));
set_opened_file(file_path.clone());
let _ = app.emit("macos://open-file", file_path);
}
add_log("✅ macOS file handler connected successfully".to_string());
}
}
+15 -62
View File
@@ -1,72 +1,30 @@
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
use tauri::{RunEvent, WindowEvent, Emitter};
mod utils;
mod commands;
mod file_handler;
use commands::{
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
cleanup_backend,
add_opened_file,
is_default_pdf_handler,
set_as_default_pdf_handler,
};
use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend, set_opened_file};
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize file handler early for macOS
file_handler::early_init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
add_log(format!("📂 Second instance detected with args: {:?}", args));
// Scan args for PDF files (skip first arg which is the executable)
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
let _ = app.emit("files-changed", ());
}))
.setup(|_app| {
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Process command line arguments on first launch
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Initial file from command line: {}", arg));
add_opened_file(arg.clone());
}
}
// Initialize platform-specific file handler
file_handler::initialize_file_handler(&app.handle());
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
get_tauri_logs,
is_default_pdf_handler,
set_as_default_pdf_handler,
])
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
@@ -85,22 +43,17 @@ pub fn run() {
#[cfg(target_os = "macos")]
RunEvent::Opened { urls } => {
add_log(format!("📂 Tauri file opened event: {:?}", urls));
let mut added_files = false;
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
let file_path = url_str.strip_prefix("file://").unwrap_or(url_str);
if file_path.ends_with(".pdf") {
add_log(format!("📂 Processing opened PDF: {}", file_path));
add_opened_file(file_path.to_string());
added_files = true;
set_opened_file(file_path.to_string());
let _ = app_handle.emit("macos://open-file", file_path.to_string());
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
if added_files {
let _ = app_handle.emit("files-changed", ());
}
}
_ => {
// Only log unhandled events in debug mode to reduce noise
@@ -109,4 +62,4 @@ pub fn run() {
}
}
});
}
}
+1 -7
View File
@@ -22,7 +22,7 @@
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "dmg", "app", "msi"],
"targets": ["deb", "rpm", "dmg", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
@@ -50,12 +50,6 @@
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null
}
},
"plugins": {
+2 -5
View File
@@ -1,6 +1,5 @@
import { Suspense } from "react";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
@@ -17,10 +16,8 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<HomePage />
<OnboardingTour />
</AppLayout>
<HomePage />
<OnboardingTour />
</AppProviders>
</Suspense>
);
@@ -1,31 +0,0 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen,
.right-rail {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
</div>
</>
);
}
+30 -59
View File
@@ -8,18 +8,14 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -27,68 +23,43 @@ function ScarfTrackingInitializer() {
return null;
}
// Component to run app-level initialization (must be inside AppProviders for context access)
function AppInitializer() {
useAppInitialization();
return null;
}
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
export interface AppProvidersProps {
children: ReactNode;
appConfigRetryOptions?: AppConfigRetryOptions;
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
}
/**
* Core application providers
* Contains all providers needed for the core
*/
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
export function AppProviders({ children }: { children: ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
<OnboardingProvider>
<AppConfigProvider>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
{children}
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
@@ -3,7 +3,6 @@ import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColor
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface DrawingCanvasProps {
selectedColor: string;
@@ -178,21 +177,19 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<PrivateContent>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
</PrivateContent>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
@@ -249,25 +246,23 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
</div>
</div>
<PrivateContent>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
</PrivateContent>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
@@ -1,7 +1,6 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
@@ -41,15 +40,13 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
return (
<Stack gap="sm">
<PrivateContent>
<FileInput
label={label || t('sign.image.label', 'Upload signature image')}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
/>
</PrivateContent>
<FileInput
label={label || t('sign.image.label', 'Upload signature image')}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload an image of your signature')}
</Text>
@@ -14,7 +14,6 @@ import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadBlob } from '@app/utils/downloadUtils';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
interface FileEditorProps {
@@ -66,15 +65,6 @@ const FileEditor = ({
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
// Compute effective max allowed files based on the active tool and mode
const maxAllowed = useMemo<number>(() => {
const rawMax = selectedTool?.maxFiles;
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
useEffect(() => {
if (toolMode) {
@@ -93,10 +83,7 @@ const FileEditor = ({
const localSelectedIds = contextSelectedIds;
const handleSelectAllFiles = useCallback(() => {
// Respect maxAllowed: if limited, select the last N files
const allIds = state.files.ids;
const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds;
setSelectedFiles(idsToSelect);
setSelectedFiles(state.files.ids);
try {
clearAllFileErrors();
} catch (error) {
@@ -104,7 +91,7 @@ const FileEditor = ({
console.warn('Failed to clear file errors on select all:', error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
const handleDeselectAllFiles = useCallback(() => {
setSelectedFiles([]);
@@ -144,13 +131,6 @@ const FileEditor = ({
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
} catch (err) {
@@ -158,7 +138,7 @@ const FileEditor = ({
showError(errorMessage);
console.error('File processing error:', err);
}
}, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]);
}, [addFiles, showStatus, showError]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
@@ -176,33 +156,24 @@ const FileEditor = ({
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
// In tool mode, typically allow multiple files unless specified otherwise
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
// Check if we've hit the selection limit
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
return;
}
newSelection = [...currentSelectedIds, contextFileId];
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
setSelectedFiles(selectedFileIds.slice(-maxAllowed));
}
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
// File reordering handler for drag and drop
@@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { useMediaQuery } from '@mantine/hooks';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@@ -21,7 +21,6 @@ import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -64,7 +63,7 @@ const FileEditorThumbnail = ({
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useIsMobile();
const isMobile = useMediaQuery('(max-width: 1024px)');
const [showCloseModal, setShowCloseModal] = useState(false);
// Resolve the actual File object for pin/unpin operations
@@ -93,13 +92,6 @@ const FileEditorThumbnail = ({
return (m?.[1] || '').toUpperCase();
}, [file.name]);
const extLower = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toLowerCase();
}, [file.name]);
const isCBZ = extLower === 'cbz';
const pageLabel = useMemo(
() =>
pageCount > 0
@@ -213,7 +205,7 @@ const FileEditorThumbnail = ({
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
},
hidden: !isZipFile || !onUnzipFile || isCBZ,
hidden: !isZipFile || !onUnzipFile,
},
{
id: 'close',
@@ -336,8 +328,8 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
<PrivateContent>{file.name}</PrivateContent>
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
{file.name}
</Text>
<Text
size="sm"
@@ -361,20 +353,20 @@ const FileEditorThumbnail = ({
>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
}}
style={{
maxWidth: '80%',
<img
className="ph-no-capture"
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
@@ -386,7 +378,6 @@ const FileEditorThumbnail = ({
alignSelf: 'start'
}}
/>
</PrivateContent>
)}
</div>
@@ -6,7 +6,6 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface CompactFileDetailsProps {
currentFile: StirlingFileStub | null;
@@ -42,19 +41,18 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Small preview */}
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && thumbnail ? (
<PrivateContent>
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
</PrivateContent>
<img
className='ph-no-capture'
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
) : currentFile ? (
<Center style={{
width: '100%',
@@ -69,8 +67,8 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : 'No file selected'}</PrivateContent>
<Text className='ph-no-capture' size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import ToolChain from '@app/components/shared/ToolChain';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileInfoCardProps {
currentFile: StirlingFileStub | null;
@@ -27,9 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
<PrivateContent>{t('fileManager.fileName', 'Name')}</PrivateContent>
</Text>
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>
@@ -13,7 +13,6 @@ import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { zipFileService } from '@app/services/zipFileService';
import ToolChain from '@app/components/shared/ToolChain';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileListItemProps {
file: StirlingFileStub;
@@ -100,9 +99,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>
@@ -1,21 +1,21 @@
.workbenchScrollable {
.workbench-scrollable {
overflow-y: auto !important;
overflow-x: hidden !important;
}
.workbenchScrollable::-webkit-scrollbar {
.workbench-scrollable::-webkit-scrollbar {
width: 0.375rem;
}
.workbenchScrollable::-webkit-scrollbar-track {
.workbench-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.workbenchScrollable::-webkit-scrollbar-thumb {
.workbench-scrollable::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-4);
border-radius: 0.1875rem;
}
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-5);
}
@@ -1,3 +1,4 @@
import React from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
@@ -80,7 +81,6 @@ export default function Workbench() {
switch (currentView) {
case "fileEditor":
return (
<FileEditor
toolMode={!!selectedToolId}
@@ -98,7 +98,6 @@ export default function Workbench() {
);
case "viewer":
return (
<Viewer
sidebarsVisible={sidebarsVisible}
@@ -111,7 +110,6 @@ export default function Workbench() {
);
case "pageEditor":
return (
<>
<PageEditor
@@ -145,8 +143,6 @@ export default function Workbench() {
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@@ -158,7 +154,7 @@ export default function Workbench() {
return (
<Box
className="flex-1 h-full min-w-0 relative flex flex-col"
className="flex-1 h-full min-w-80 relative flex flex-col"
data-tour="workbench"
style={
isRainbowMode
@@ -189,13 +185,14 @@ export default function Workbench() {
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
}}
>
{renderMainContent()}
</Box>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
analyticsEnabled={config?.enableAnalytics ?? undefined}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
@@ -6,28 +6,3 @@
ry: 8px;
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
}
/* Add glowing border to navigation items during admin tour */
.modal-nav-item.tour-nav-glow {
position: relative;
box-shadow:
0 0 0 2px var(--mantine-primary-color-filled),
0 0 15px var(--mantine-primary-color-filled),
inset 0 0 15px rgba(59, 130, 246, 0.1);
border-radius: 8px;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 20px var(--mantine-primary-color-filled),
inset 0 0 20px rgba(59, 130, 246, 0.1);
}
50% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 30px var(--mantine-primary-color-filled),
inset 0 0 30px rgba(59, 130, 246, 0.2);
}
}

Some files were not shown because too many files have changed in this diff Show More