From 6f7b8ce4336921d67b631f3ad8fb87ebe5de20b7 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Sat, 13 Dec 2025 12:57:10 +0000 Subject: [PATCH 01/62] Bug/v2/static wasm (#5238) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../software/common/util/RequestUriUtils.java | 8 ++++++-- .../software/common/util/RequestUriUtilsTest.java | 12 ++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index f1763e4313..acad6f4a9d 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -26,6 +26,7 @@ public class RequestUriUtils { || normalizedUri.startsWith("/public/") || normalizedUri.startsWith("/pdfjs/") || normalizedUri.startsWith("/pdfjs-legacy/") + || normalizedUri.startsWith("/pdfium/") || normalizedUri.startsWith("/assets/") || normalizedUri.startsWith("/locales/") || normalizedUri.startsWith("/Login/") @@ -61,7 +62,8 @@ public class RequestUriUtils { || normalizedUri.endsWith(".css") || normalizedUri.endsWith(".mjs") || normalizedUri.endsWith(".html") - || normalizedUri.endsWith(".toml"); + || normalizedUri.endsWith(".toml") + || normalizedUri.endsWith(".wasm"); } public static boolean isFrontendRoute(String contextPath, String requestURI) { @@ -125,11 +127,13 @@ public class RequestUriUtils { || requestURI.endsWith("popularity.txt") || requestURI.endsWith(".js") || requestURI.endsWith(".toml") + || requestURI.endsWith(".wasm") || requestURI.contains("swagger") || requestURI.startsWith("/api/v1/info") || requestURI.startsWith("/site.webmanifest") || requestURI.startsWith("/fonts") - || requestURI.startsWith("/pdfjs")); + || requestURI.startsWith("/pdfjs") + || requestURI.startsWith("/pdfium")); } /** diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index b7c121ab30..409615e68b 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -24,6 +24,9 @@ public class RequestUriUtilsTest { assertTrue( RequestUriUtils.isStaticResource("/pdfjs/pdf.worker.js"), "PDF.js files should be static"); + assertTrue( + RequestUriUtils.isStaticResource("/pdfium/pdfium.wasm"), + "PDFium wasm should be static"); assertTrue( RequestUriUtils.isStaticResource("/api/v1/info/status"), "API status should be static"); @@ -110,7 +113,8 @@ public class RequestUriUtilsTest { "/downloads/document.png", "/assets/brand.ico", "/any/path/with/image.svg", - "/deep/nested/folder/icon.png" + "/deep/nested/folder/icon.png", + "/pdfium/pdfium.wasm" }) void testIsStaticResourceWithFileExtensions(String path) { assertTrue( @@ -148,6 +152,9 @@ public class RequestUriUtilsTest { assertFalse( RequestUriUtils.isTrackableResource("/script.js"), "JS files should not be trackable"); + assertFalse( + RequestUriUtils.isTrackableResource("/pdfium/pdfium.wasm"), + "PDFium wasm should not be trackable"); assertFalse( RequestUriUtils.isTrackableResource("/swagger/index.html"), "Swagger files should not be trackable"); @@ -224,7 +231,8 @@ public class RequestUriUtilsTest { "/api/v1/info/health", "/site.webmanifest", "/fonts/roboto.woff", - "/pdfjs/viewer.js" + "/pdfjs/viewer.js", + "/pdfium/pdfium.wasm" }) void testNonTrackableResources(String path) { assertFalse( From 5f54308d2bd8d0ac137f61055d4bdc38ac25f670 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 13 Dec 2025 16:51:16 +0000 Subject: [PATCH 02/62] Simplify V2 PR auto-deploy triggers (#5236) ## Summary - deploy V2 environments for authorized PR authors without requiring title or branch keywords - update deployment messaging to reflect the new trigger policy ## Testing - not run ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_693c9ac446d88328b8ae04609caec787) --- .github/workflows/PR-Auto-Deploy-V2.yml | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index ad6d6f3dc4..c96d7d5b20 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -52,7 +52,6 @@ jobs: core.setOutput('repository', pr.head.repo.full_name); core.setOutput('ref', pr.head.ref); core.setOutput('is_fork', String(pr.head.repo.fork)); - core.setOutput('base_ref', pr.base.ref); core.setOutput('author', pr.user.login); core.setOutput('state', pr.state); @@ -65,10 +64,6 @@ jobs: IS_FORK: ${{ steps.resolve.outputs.is_fork }} # nur bei workflow_dispatch gesetzt: ALLOW_FORK_INPUT: ${{ inputs.allow_fork }} - # für Auto-PR-Logik: - PR_TITLE: ${{ github.event.pull_request.title }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_BASE: ${{ steps.resolve.outputs.base_ref }} PR_AUTHOR: ${{ steps.resolve.outputs.author }} run: | set -e @@ -89,14 +84,8 @@ jobs: else auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done - if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then + if [ "$is_auth" = true ]; then should=true - else - title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true - branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true - if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then - should=true - fi fi fi @@ -174,7 +163,7 @@ jobs: owner, repo, issue_number: prNumber, - body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` + body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment for approved V2 contributors._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.` }); return newComment.id; @@ -394,7 +383,7 @@ jobs: `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + - `🔄 **Auto-deployed** because PR title or branch name contains V2/version2/React keywords.`; + `🔄 **Auto-deployed** for approved V2 contributors.`; await github.rest.issues.createComment({ owner, From 371d816ce77d05e2203a9f2cb9e620bd258ecd03 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sat, 13 Dec 2025 17:42:12 +0000 Subject: [PATCH 03/62] Validate certificate inputs for cert signing (#5191) ## Summary - validate required certificate inputs before loading keystores to prevent null dereferences - surface clear errors for missing PEM, PKCS12/PFX, and JKS uploads during PDF signing - add a unit test covering the missing PKCS12/PFX keystore scenario ## Testing - ./gradlew :stirling-pdf:test --tests stirling.software.SPDF.controller.api.security.CertSignControllerTest ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6934c8803d648328bf76b72a4f689c60) --- .../api/security/CertSignController.java | 23 ++++++++++++++ .../api/security/CertSignControllerTest.java | 31 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 3b27492a6e..893c9c54c8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -191,6 +191,12 @@ public class CertSignController { switch (certType) { case "PEM": + privateKeyFile = + validateFilePresent( + privateKeyFile, "PEM private key", "private key file is required"); + certFile = + validateFilePresent( + certFile, "PEM certificate", "certificate file is required"); ks = KeyStore.getInstance("JKS"); ks.load(null); PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password); @@ -200,10 +206,16 @@ public class CertSignController { break; case "PKCS12": case "PFX": + p12File = + validateFilePresent( + p12File, "PKCS12 keystore", "PKCS12/PFX keystore file is required"); ks = KeyStore.getInstance("PKCS12"); ks.load(p12File.getInputStream(), password.toCharArray()); break; case "JKS": + jksfile = + validateFilePresent( + jksfile, "JKS keystore", "JKS keystore file is required"); ks = KeyStore.getInstance("JKS"); ks.load(jksfile.getInputStream(), password.toCharArray()); break; @@ -251,6 +263,17 @@ public class CertSignController { GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf")); } + private MultipartFile validateFilePresent( + MultipartFile file, String argumentName, String errorDescription) { + if (file == null || file.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", + "Invalid argument: {0}", + argumentName + " - " + errorDescription); + } + return file; + } + private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password) throws IOException, OperatorCreationException, PKCSException { try (PEMParser pemParser = diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java index c9f02cd28d..5a5eff1f12 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/CertSignControllerTest.java @@ -1,9 +1,10 @@ package stirling.software.SPDF.controller.api.security; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; import java.io.ByteArrayOutputStream; import java.io.InputStream; @@ -107,7 +108,8 @@ class CertSignControllerTest { derCertBytes = baos.toByteArray(); } - when(pdfDocumentFactory.load(any(MultipartFile.class))) + lenient() + .when(pdfDocumentFactory.load(any(MultipartFile.class))) .thenAnswer( invocation -> { MultipartFile file = invocation.getArgument(0); @@ -167,6 +169,31 @@ class CertSignControllerTest { assertTrue(response.getBody().length > 0); } + @Test + void testSignPdfWithMissingPkcs12FileThrowsError() { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "test.pdf", MediaType.APPLICATION_PDF_VALUE, pdfBytes); + + SignPDFWithCertRequest request = new SignPDFWithCertRequest(); + request.setFileInput(pdfFile); + request.setCertType("PFX"); + request.setPassword("password"); + request.setShowSignature(false); + request.setReason("test"); + request.setLocation("test"); + request.setName("tester"); + request.setPageNumber(1); + request.setShowLogo(false); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> certSignController.signPDFWithCert(request)); + + assertTrue(exception.getMessage().contains("PKCS12 keystore")); + } + @Test void testSignPdfWithJks() throws Exception { MockMultipartFile pdfFile = From 0064c1866e5d03a32831718ef317f25c947f6ebe Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:31:41 +0000 Subject: [PATCH 04/62] Shorten onbaording (#5198) Also added `enableDesktopInstallSlide` flag in `settings.yml` to hide the download for desktop page in the onboarding. --------- Co-authored-by: James Brunton --- .../common/model/ApplicationProperties.java | 1 + .../controller/api/misc/ConfigController.java | 3 + .../src/main/resources/settings.yml.template | 1 + .../public/locales/en-GB/translation.toml | 34 ++- .../InitialOnboardingModal/renderButtons.tsx | 6 +- .../core/components/onboarding/Onboarding.tsx | 209 +++++++++++++----- .../onboarding/OnboardingModalSlide.tsx | 38 +++- .../components/onboarding/OnboardingTour.tsx | 2 +- .../onboarding/onboardingFlowConfig.ts | 81 ++++++- .../orchestrator/onboardingConfig.ts | 56 +++-- .../orchestrator/onboardingStorage.ts | 83 +++---- .../orchestrator/useOnboardingOrchestrator.ts | 113 +++++----- .../slides/AnalyticsChoiceSlide.tsx | 55 +++++ .../onboarding/slides/TourOverviewSlide.tsx | 27 +++ .../onboarding/useBypassOnboarding.ts | 17 +- .../onboarding/useOnboardingEffects.ts | 4 +- .../components/onboarding/userStepsConfig.ts | 38 ---- .../onboarding/whatsNewStepsConfig.ts | 197 +++++++++++++++++ .../shared/AdminAnalyticsChoiceModal.tsx | 139 ------------ .../src/core/components/shared/Footer.tsx | 16 ++ .../core/components/shared/QuickAccessBar.tsx | 122 ++++++---- .../src/core/components/shared/Tooltip.tsx | 31 ++- .../shared/quickAccessBar/useToursTooltip.ts | 81 +++++++ .../shared/tooltip/Tooltip.module.css | 11 +- .../shared/tooltip/TooltipContent.tsx | 4 +- frontend/src/core/constants/events.ts | 2 +- .../src/core/contexts/AppConfigContext.tsx | 1 + .../contexts/TourOrchestrationContext.tsx | 2 + .../src/core/hooks/useServerExperience.ts | 5 +- frontend/src/core/services/auditService.ts | 4 +- .../components/shared/UpgradeBanner.tsx | 19 +- .../configSections/AdminAuditSection.tsx | 20 +- .../testing/serverExperienceSimulations.ts | 1 + 33 files changed, 948 insertions(+), 475 deletions(-) create mode 100644 frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx create mode 100644 frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx create mode 100644 frontend/src/core/components/onboarding/whatsNewStepsConfig.ts delete mode 100644 frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx create mode 100644 frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 35ddddeaac..7e64ea9a7f 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -357,6 +357,7 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Boolean enablePosthog; private Boolean enableScarf; + private Boolean enableDesktopInstallSlide; private Datasource datasource; private Boolean disableSanitize; private int maxDPI; diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 43aaecd9d7..91aa9924da 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -124,6 +124,9 @@ public class ConfigController { "enableAnalytics", applicationProperties.getSystem().getEnableAnalytics()); configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog()); configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf()); + configData.put( + "enableDesktopInstallSlide", + applicationProperties.getSystem().getEnableDesktopInstallSlide()); // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 5a50ef9035..64b4bd50e9 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -126,6 +126,7 @@ system: customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored. enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch + enableDesktopInstallSlide: true # Set to 'false' to hide the desktop app installation slide in the onboarding flow enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 83963987d8..3f269a52c1 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -312,10 +312,10 @@ yamlAdvert = "Stirling PDF Pro supports YAML configuration files and other SSO f ssoAdvert = "Looking for more user management features? Check out Stirling PDF Pro" [analytics] -title = "Do you want make Stirling PDF better?" -paragraph1 = "Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents." +title = "Do you want to help make Stirling PDF better?" +paragraph1 = "Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents." paragraph2 = "Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better." -learnMore = "Learn more" +learnMore = "Learn more about our analytics" enable = "Enable analytics" disable = "Disable analytics" settings = "You can change the settings for analytics in the config/settings.yml file" @@ -340,6 +340,10 @@ advance = "Advanced" edit = "View & Edit" popular = "Popular" +[footer] +discord = "Discord" +issues = "GitHub" + [settings.preferences] title = "Preferences" @@ -4060,12 +4064,20 @@ settings = "Settings" adminSettings = "Admin Settings" allTools = "Tools" reader = "Reader" +tours = "Tours" +showMeAround = "Show me around" + +[quickAccess.toursTooltip] +admin = "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour." +user = "Watch walkthroughs here: Tools tour and the New V2 layout tour." [quickAccess.helpMenu] toolsTour = "Tools Tour" toolsTourDesc = "Learn what the tools can do" adminTour = "Admin Tour" adminTourDesc = "Explore admin settings & features" +whatsNewTour = "See what's new in V2" +whatsNewTourDesc = "Tour the updated layout" [admin] error = "Error" @@ -5259,6 +5271,16 @@ finish = "Finish" startTour = "Start Tour" startTourDescription = "Take a guided tour of Stirling PDF's key features" +[onboarding.whatsNew] +quickAccess = "Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours." +leftPanel = "The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly." +fileUpload = "Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace." +rightRail = "The Right Rail holds quick actions to select files, change theme or language, and download results." +topBar = "The top bar lets you swap between Viewer, Page Editor, and Active Files." +pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages." +activeFilesView = "Use Active Files to see everything you have open and pick what to work on." +wrapUp = "That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour." + [onboarding.welcomeModal] title = "Welcome to Stirling PDF!" description = "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" @@ -5279,6 +5301,10 @@ download = "Download →" showMeAround = "Show me around" skipTheTour = "Skip the tour" +[onboarding.tourOverview] +title = "Tour Overview" +body = "Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need." + [onboarding.serverLicense] skip = "Skip for now" seePlans = "See Plans →" @@ -5817,6 +5843,8 @@ notAvailable = "Audit system not available" notAvailableMessage = "The audit system is not configured or not available." disabled = "Audit logging is disabled" disabledMessage = "Enable audit logging in your application configuration to track system events." +enterpriseRequired = "Enterprise License Required" +enterpriseRequiredMessage = "The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics." [audit.error] title = "Error loading audit system" diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index ae90c27da0..c50ae7b43e 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -47,11 +47,11 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi ) { return t('onboarding.serverLicense.upgrade', 'Upgrade now →'); } - + // Translate the label (it's a translation key) const label = button.label ?? ''; if (!label) return ''; - + // Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next') const fallback = label.split('.').pop() || label; return t(label, fallback); @@ -105,4 +105,4 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi {rightButtons.map(renderButton)} ); -} \ No newline at end of file +} diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index e851ee60bf..bcfa449bf7 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -5,7 +5,6 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { isAuthRoute } from '@app/constants/routes'; import { dispatchTourState } from '@app/constants/events'; import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator'; -import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour'; import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide'; @@ -20,10 +19,12 @@ import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; +import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig'; import { removeAllGlows } from '@app/components/onboarding/tourGlow'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useServerExperience } from '@app/hooks/useServerExperience'; -import AdminAnalyticsChoiceModal from '@app/components/shared/AdminAnalyticsChoiceModal'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import apiClient from '@app/services/apiClient'; import '@app/components/onboarding/OnboardingTour.css'; export default function Onboarding() { @@ -39,6 +40,11 @@ export default function Onboarding() { const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload(); const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest(); const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest(); + const { config, refetch: refetchConfig } = useAppConfig(); + const [analyticsError, setAnalyticsError] = useState(null); + const [analyticsLoading, setAnalyticsLoading] = useState(false); + const [showAnalyticsModal, setShowAnalyticsModal] = useState(false); + const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false); const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => { actions.updateRuntimeState({ selectedRole: role }); @@ -50,7 +56,34 @@ export default function Onboarding() { window.location.href = '/login'; }, [actions]); - const handleButtonAction = useCallback((action: ButtonAction) => { + // Check if we should show analytics modal before onboarding + useEffect(() => { + if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) { + setShowAnalyticsModal(true); + } + }, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]); + + const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => { + if (analyticsLoading) return; + setAnalyticsLoading(true); + setAnalyticsError(null); + + try { + const formData = new FormData(); + formData.append('enabled', enableAnalytics.toString()); + + await apiClient.post('/api/v1/settings/update-enable-analytics', formData); + await refetchConfig(); + setShowAnalyticsModal(false); + setAnalyticsModalDismissed(true); + setAnalyticsLoading(false); + } catch (error) { + setAnalyticsError(error instanceof Error ? error.message : 'Unknown error'); + setAnalyticsLoading(false); + } + }, [analyticsLoading, refetchConfig]); + + const handleButtonAction = useCallback(async (action: ButtonAction) => { switch (action) { case 'next': case 'complete-close': @@ -69,35 +102,43 @@ export default function Onboarding() { case 'security-next': if (!runtimeState.selectedRole) return; if (runtimeState.selectedRole !== 'admin') { - actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' }); + actions.updateRuntimeState({ tourType: 'whatsnew' }); + setIsTourOpen(true); } actions.complete(); break; case 'launch-admin': - actions.updateRuntimeState({ tourRequested: true, tourType: 'admin' }); - actions.complete(); + actions.updateRuntimeState({ tourType: 'admin' }); + setIsTourOpen(true); break; case 'launch-tools': - actions.updateRuntimeState({ tourRequested: true, tourType: 'tools' }); - actions.complete(); + actions.updateRuntimeState({ tourType: 'whatsnew' }); + setIsTourOpen(true); break; case 'launch-auto': { - const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'tools'; - actions.updateRuntimeState({ tourRequested: true, tourType }); - actions.complete(); + const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew'; + actions.updateRuntimeState({ tourType }); + setIsTourOpen(true); break; } case 'skip-to-license': - markStepSeen('tour'); - actions.updateRuntimeState({ tourRequested: false }); + actions.complete(); + break; + case 'skip-tour': actions.complete(); break; case 'see-plans': actions.complete(); navigate('/settings/adminPlan'); break; + case 'enable-analytics': + await handleAnalyticsChoice(true); + break; + case 'disable-analytics': + await handleAnalyticsChoice(false); + break; } - }, [actions, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); + }, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; const [isTourOpen, setIsTourOpen] = useState(false); @@ -117,10 +158,7 @@ export default function Onboarding() { backToAllTools: tourOrch.backToAllTools, selectCropTool: tourOrch.selectCropTool, loadSampleFile: tourOrch.loadSampleFile, - switchToViewer: tourOrch.switchToViewer, - switchToPageEditor: tourOrch.switchToPageEditor, switchToActiveFiles: tourOrch.switchToActiveFiles, - selectFirstFile: tourOrch.selectFirstFile, pinFile: tourOrch.pinFile, modifyCropSettings: tourOrch.modifyCropSettings, executeTool: tourOrch.executeTool, @@ -130,6 +168,24 @@ export default function Onboarding() { [t, tourOrch, closeFilesModal, openFilesModal] ); + const whatsNewStepsConfig = useMemo( + () => createWhatsNewStepsConfig({ + t, + actions: { + saveWorkbenchState: tourOrch.saveWorkbenchState, + closeFilesModal, + backToAllTools: tourOrch.backToAllTools, + openFilesModal, + loadSampleFile: tourOrch.loadSampleFile, + switchToViewer: tourOrch.switchToViewer, + switchToPageEditor: tourOrch.switchToPageEditor, + switchToActiveFiles: tourOrch.switchToActiveFiles, + selectFirstFile: tourOrch.selectFirstFile, + }, + }), + [t, tourOrch, closeFilesModal, openFilesModal] + ); + const adminStepsConfig = useMemo( () => createAdminStepsConfig({ t, @@ -144,21 +200,19 @@ export default function Onboarding() { ); const tourSteps = useMemo(() => { - const config = runtimeState.tourType === 'admin' ? adminStepsConfig : userStepsConfig; - return Object.values(config); - }, [adminStepsConfig, runtimeState.tourType, userStepsConfig]); - - useEffect(() => { - if (currentStep?.id === 'tour' && !isTourOpen) { - markStepSeen('tour'); - setIsTourOpen(true); + switch (runtimeState.tourType) { + case 'admin': + return Object.values(adminStepsConfig); + case 'whatsnew': + return Object.values(whatsNewStepsConfig); + default: + return Object.values(userStepsConfig); } - }, [currentStep, isTourOpen, activeFlow]); + }, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]); useEffect(() => { if (externalTourRequested) { - actions.updateRuntimeState({ tourRequested: true, tourType: requestedTourType }); - markStepSeen('tour'); + actions.updateRuntimeState({ tourType: requestedTourType }); setIsTourOpen(true); clearTourRequest(); } @@ -176,9 +230,9 @@ export default function Onboarding() { } else { tourOrch.restoreWorkbenchState(); } - markStepSeen('tour'); - if (currentStep?.id === 'tour') actions.complete(); - }, [actions, adminTourOrch, currentStep?.id, runtimeState.tourType, tourOrch]); + // Advance to next onboarding step after tour completes + actions.complete(); + }, [actions, adminTourOrch, runtimeState.tourType, tourOrch]); const handleAdvanceTour = useCallback((args: AdvanceArgs) => { const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; @@ -216,8 +270,10 @@ export default function Onboarding() { firstLoginUsername: runtimeState.firstLoginUsername, onPasswordChanged: handlePasswordChanged, usingDefaultCredentials: runtimeState.usingDefaultCredentials, + analyticsError, + analyticsLoading, }); - }, [currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); + }, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged]); const modalSlideCount = useMemo(() => { return activeFlow.filter((step) => step.type === 'modal-slide').length; @@ -237,8 +293,45 @@ export default function Onboarding() { return null; } + // Show analytics modal before onboarding if needed + if (showAnalyticsModal) { + const slideDefinition = SLIDE_DEFINITIONS['analytics-choice']; + const slideContent = slideDefinition.createSlide({ + osLabel: '', + osUrl: '', + selectedRole: null, + onRoleSelect: () => {}, + analyticsError, + analyticsLoading, + }); + + return ( + {}} // No skip allowed + onAction={async (action) => { + if (action === 'enable-analytics') { + await handleAnalyticsChoice(true); + } else if (action === 'disable-analytics') { + await handleAnalyticsChoice(false); + } + }} + allowDismiss={false} + /> + ); + } + if (showLicenseSlide) { - const slideDefinition = SLIDE_DEFINITIONS['server-license']; + const baseSlideDefinition = SLIDE_DEFINITIONS['server-license']; + // Remove back button for external license notice + const slideDefinition = { + ...baseSlideDefinition, + buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back') + }; const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; const slideContent = slideDefinition.createSlide({ osLabel: '', @@ -250,7 +343,7 @@ export default function Onboarding() { licenseNotice: effectiveLicenseNotice, loginEnabled: serverExperience.loginEnabled, }); - + return ( + ); + + // If no active onboarding, just show the tour (which may or may not be open) if (isLoading || !isActive || !currentStep) { - return ( - - ); + return tourComponent; } + // If tour is open, hide the onboarding modal and just show the tour + if (isTourOpen) { + return tourComponent; + } + + // Render the current onboarding step switch (currentStep.type) { case 'tool-prompt': return ; - case 'tour': - return ( - - ); - - case 'analytics-modal': - return ; - case 'modal-slide': if (!currentSlideDefinition || !currentSlideContent) return null; return ( diff --git a/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx index 381a608d8a..60a9309199 100644 --- a/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -6,8 +6,9 @@ */ import React from 'react'; -import { Modal, Stack } from '@mantine/core'; +import { Modal, Stack, ActionIcon } from '@mantine/core'; import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined'; +import CloseIcon from '@mui/icons-material/Close'; import type { SlideDefinition, ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; import type { OnboardingRuntimeState } from '@app/components/onboarding/orchestrator/onboardingConfig'; @@ -28,6 +29,7 @@ interface OnboardingModalSlideProps { currentModalSlideIndex: number; onSkip: () => void; onAction: (action: ButtonAction) => void; + allowDismiss?: boolean; } export default function OnboardingModalSlide({ @@ -38,6 +40,7 @@ export default function OnboardingModalSlide({ currentModalSlideIndex, onSkip, onAction, + allowDismiss = true, }: OnboardingModalSlideProps) { const renderHero = () => { @@ -62,6 +65,9 @@ export default function OnboardingModalSlide({ {slideDefinition.hero.type === 'lock' && ( )} + {slideDefinition.hero.type === 'analytics' && ( + + )} {slideDefinition.hero.type === 'diamond' && } {slideDefinition.hero.type === 'logo' && ( Stirling logo @@ -75,6 +81,7 @@ export default function OnboardingModalSlide({ opened={true} onClose={onSkip} closeOnClickOutside={false} + closeOnEscape={allowDismiss} centered size="lg" radius="lg" @@ -93,6 +100,31 @@ export default function OnboardingModalSlide({ isActive slideKey={slideContent.key} /> + {allowDismiss && ( + + + + )}
{renderHero()}
@@ -114,7 +146,9 @@ export default function OnboardingModalSlide({ - + {modalSlideCount > 1 && ( + + )}
void; usingDefaultCredentials?: boolean; + analyticsError?: string | null; + analyticsLoading?: boolean; } export interface HeroDefinition { @@ -79,9 +88,9 @@ export interface SlideDefinition { export const SLIDE_DEFINITIONS: Record = { 'first-login': { id: 'first-login', - createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => - FirstLoginSlide({ - username: firstLoginUsername || '', + createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => + FirstLoginSlide({ + username: firstLoginUsername || '', onPasswordChanged: onPasswordChanged || (() => {}), usingDefaultCredentials: usingDefaultCredentials || false, }), @@ -194,6 +203,13 @@ export const SLIDE_DEFINITIONS: Record = { createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), hero: { type: 'dual-icon' }, buttons: [ + { + key: 'license-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, { key: 'license-close', type: 'button', @@ -212,5 +228,58 @@ export const SLIDE_DEFINITIONS: Record = { }, ], }, + 'tour-overview': { + id: 'tour-overview', + createSlide: () => TourOverviewSlide(), + hero: { type: 'rocket' }, + buttons: [ + { + key: 'tour-overview-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'tour-overview-skip', + type: 'button', + label: 'onboarding.buttons.skipForNow', + variant: 'secondary', + group: 'left', + action: 'skip-tour', + }, + { + key: 'tour-overview-show', + type: 'button', + label: 'onboarding.buttons.showMeAround', + variant: 'primary', + group: 'right', + action: 'launch-tools', + }, + ], + }, + 'analytics-choice': { + id: 'analytics-choice', + createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }), + hero: { type: 'analytics' }, + buttons: [ + { + key: 'analytics-disable', + type: 'button', + label: 'no', + variant: 'secondary', + group: 'left', + action: 'disable-analytics', + }, + { + key: 'analytics-enable', + type: 'button', + label: 'yes', + variant: 'primary', + group: 'right', + action: 'enable-analytics', + }, + ], + }, }; diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 60843dea1f..7c9431dc47 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -5,21 +5,20 @@ export type OnboardingStepId = | 'security-check' | 'admin-overview' | 'tool-layout' - | 'tour' + | 'tour-overview' | 'server-license' | 'analytics-choice'; export type OnboardingStepType = | 'modal-slide' - | 'tool-prompt' - | 'tour' - | 'analytics-modal'; + | 'tool-prompt'; export interface OnboardingRuntimeState { selectedRole: 'admin' | 'user' | null; tourRequested: boolean; - tourType: 'admin' | 'tools'; + tourType: 'admin' | 'tools' | 'whatsnew'; isDesktopApp: boolean; + desktopSlideEnabled: boolean; analyticsNotConfigured: boolean; analyticsEnabled: boolean; licenseNotice: { @@ -42,13 +41,13 @@ export interface OnboardingStep { id: OnboardingStepId; type: OnboardingStepType; condition: (ctx: OnboardingConditionContext) => boolean; - slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license'; + slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice'; } export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { selectedRole: null, tourRequested: false, - tourType: 'tools', + tourType: 'whatsnew', isDesktopApp: false, analyticsNotConfigured: false, analyticsEnabled: false, @@ -61,6 +60,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { requiresPasswordChange: false, firstLoginUsername: '', usingDefaultCredentials: false, + desktopSlideEnabled: true, }; export const ONBOARDING_STEPS: OnboardingStep[] = [ @@ -76,18 +76,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: 'welcome', condition: () => true, }, - { - id: 'desktop-install', - type: 'modal-slide', - slideId: 'desktop-install', - condition: (ctx) => !ctx.isDesktopApp, - }, - { - id: 'security-check', - type: 'modal-slide', - slideId: 'security-check', - condition: (ctx) => !ctx.loginEnabled && !ctx.isDesktopApp, - }, { id: 'admin-overview', type: 'modal-slide', @@ -95,14 +83,27 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ condition: (ctx) => ctx.effectiveIsAdmin, }, { - id: 'tool-layout', - type: 'tool-prompt', - condition: () => true, + id: 'desktop-install', + type: 'modal-slide', + slideId: 'desktop-install', + condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled, }, { - id: 'tour', - type: 'tour', - condition: (ctx) => ctx.tourRequested || !ctx.effectiveIsAdmin, + id: 'security-check', + type: 'modal-slide', + slideId: 'security-check', + condition: () => false, + }, + { + id: 'tool-layout', + type: 'tool-prompt', + condition: () => false, + }, + { + id: 'tour-overview', + type: 'modal-slide', + slideId: 'tour-overview', + condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin', }, { id: 'server-license', @@ -110,11 +111,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: 'server-license', condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, }, - { - id: 'analytics-choice', - type: 'analytics-modal', - condition: (ctx) => ctx.effectiveIsAdmin && ctx.analyticsNotConfigured, - }, ]; export function getStepById(id: OnboardingStepId): OnboardingStep | undefined { diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts index ef9f51be17..9e3065614a 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -1,94 +1,71 @@ -import { type OnboardingStepId, ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig'; - const STORAGE_PREFIX = 'onboarding'; +const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`; +const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`; -export function getStorageKey(stepId: OnboardingStepId): string { - return `${STORAGE_PREFIX}::${stepId}`; -} - -export function hasSeenStep(stepId: OnboardingStepId): boolean { +export function isOnboardingCompleted(): boolean { if (typeof window === 'undefined') return false; try { - return localStorage.getItem(getStorageKey(stepId)) === 'true'; + return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true'; } catch { return false; } } -export function markStepSeen(stepId: OnboardingStepId): void { +export function markOnboardingCompleted(): void { if (typeof window === 'undefined') return; try { - localStorage.setItem(getStorageKey(stepId), 'true'); + localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true'); } catch (error) { - console.error('[onboardingStorage] Error marking step as seen:', error); + console.error('[onboardingStorage] Error marking onboarding as completed:', error); } } -export function resetStepSeen(stepId: OnboardingStepId): void { +export function resetOnboardingProgress(): void { if (typeof window === 'undefined') return; try { - localStorage.removeItem(getStorageKey(stepId)); + localStorage.removeItem(ONBOARDING_COMPLETED_KEY); } catch (error) { - console.error('[onboardingStorage] Error resetting step seen:', error); + console.error('[onboardingStorage] Error resetting onboarding progress:', error); } } -export function resetAllOnboardingProgress(): void { - if (typeof window === 'undefined') return; +export function hasShownToursTooltip(): boolean { + if (typeof window === 'undefined') return false; try { - const prefix = `${STORAGE_PREFIX}::`; - const keysToRemove: string[] = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key?.startsWith(prefix)) keysToRemove.push(key); - } - keysToRemove.forEach((key) => localStorage.removeItem(key)); - } catch (error) { - console.error('[onboardingStorage] Error resetting all onboarding progress:', error); + return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true'; + } catch { + return false; } } -export function getOnboardingStorageState(): Record { - const state: Record = {}; - ONBOARDING_STEPS.forEach((step) => { - state[step.id] = hasSeenStep(step.id); - }); - return state; +export function markToursTooltipShown(): void { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(TOURS_TOOLTIP_KEY, 'true'); + } catch (error) { + console.error('[onboardingStorage] Error marking tours tooltip as shown:', error); + } } export function migrateFromLegacyPreferences(): void { if (typeof window === 'undefined') return; - + const migrationKey = `${STORAGE_PREFIX}::migrated`; - + try { // Skip if already migrated if (localStorage.getItem(migrationKey) === 'true') return; - + const prefsRaw = localStorage.getItem('stirlingpdf_preferences'); if (prefsRaw) { const prefs = JSON.parse(prefsRaw) as Record; - - // Migrate based on legacy flags - if (prefs.hasSeenIntroOnboarding === true) { - markStepSeen('welcome'); - markStepSeen('desktop-install'); - markStepSeen('security-check'); - markStepSeen('admin-overview'); + + // If user had completed onboarding in old system, mark new system as complete + if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) { + markOnboardingCompleted(); } - - if (prefs.toolPanelModePromptSeen === true || prefs.hasSelectedToolPanelMode === true) { - markStepSeen('tool-layout'); - } - - if (prefs.hasCompletedOnboarding === true) { - markStepSeen('tour'); - markStepSeen('analytics-choice'); - markStepSeen('server-license'); - } - } - + // Mark migration complete localStorage.setItem(migrationKey, 'true'); } catch { diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 514b98e1c7..eb3c5367ec 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -12,8 +12,8 @@ import { DEFAULT_RUNTIME_STATE, } from '@app/components/onboarding/orchestrator/onboardingConfig'; import { - hasSeenStep, - markStepSeen, + isOnboardingCompleted, + markOnboardingCompleted, migrateFromLegacyPreferences, } from '@app/components/onboarding/orchestrator/onboardingStorage'; import { accountService } from '@app/services/accountService'; @@ -35,12 +35,15 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu if (typeof window === 'undefined') { return baseState; } - + try { const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true'; - const tourType = (sessionStorage.getItem(SESSION_TOUR_TYPE) as 'admin' | 'tools') || 'tools'; + const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); + const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew') + ? sessionTourType + : 'whatsnew'; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null; - + return { ...baseState, tourRequested, @@ -54,7 +57,7 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu function persistRuntimeState(state: Partial): void { if (typeof window === 'undefined') return; - + try { if (state.tourRequested !== undefined) { sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false'); @@ -76,7 +79,7 @@ function persistRuntimeState(state: Partial): void { function clearRuntimeStateSession(): void { if (typeof window === 'undefined') return; - + try { sessionStorage.removeItem(SESSION_TOUR_REQUESTED); sessionStorage.removeItem(SESSION_TOUR_TYPE); @@ -145,7 +148,7 @@ export function useOnboardingOrchestrator( const location = useLocation(); const bypassOnboarding = useBypassOnboarding(); - const [runtimeState, setRuntimeState] = useState(() => + const [runtimeState, setRuntimeState] = useState(() => getInitialRuntimeState(defaultState) ); const [isPaused, setIsPaused] = useState(false); @@ -166,6 +169,7 @@ export function useOnboardingOrchestrator( ...prev, analyticsEnabled: config?.enableAnalytics === true, analyticsNotConfigured: config?.enableAnalytics == null, + desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true, licenseNotice: { totalUsers: serverExperience.totalUsers, freeTierLimit: serverExperience.freeTierLimit, @@ -221,7 +225,7 @@ export function useOnboardingOrchestrator( const conditionContext = useMemo(() => ({ ...serverExperience, ...runtimeState, - effectiveIsAdmin: serverExperience.effectiveIsAdmin || + effectiveIsAdmin: serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'), }), [serverExperience, runtimeState]); @@ -235,53 +239,44 @@ export function useOnboardingOrchestrator( // Wait for config AND admin status before calculating initial step const adminStatusResolved = !configLoading && ( - config?.enableLogin === false || - config?.enableLogin === undefined || + config?.enableLogin === false || + config?.enableLogin === undefined || config?.isAdmin !== undefined ); - + useEffect(() => { - if (configLoading || !adminStatusResolved || activeFlow.length === 0) return; + if (configLoading || !adminStatusResolved) return; - let firstUnseenIndex = -1; - for (let i = 0; i < activeFlow.length; i++) { - // Special case: first-login step should always be considered "unseen" if requiresPasswordChange is true - const isFirstLoginStep = activeFlow[i].id === 'first-login'; - const shouldTreatAsUnseen = isFirstLoginStep ? runtimeState.requiresPasswordChange : !hasSeenStep(activeFlow[i].id); - - if (shouldTreatAsUnseen) { - firstUnseenIndex = i; - break; - } - } - - // Force reset index when password change is required (overrides initialIndexSet) - if (runtimeState.requiresPasswordChange && firstUnseenIndex === 0) { + // If there are no steps to show, mark initialized/completed baseline + if (activeFlow.length === 0) { setCurrentStepIndex(0); initialIndexSet.current = true; - } else if (firstUnseenIndex === -1) { + return; + } + + // If onboarding has been completed, don't show it + if (isOnboardingCompleted() && !runtimeState.requiresPasswordChange) { setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; - } else if (!initialIndexSet.current) { - setCurrentStepIndex(firstUnseenIndex); + return; + } + + // Start from the beginning + if (!initialIndexSet.current) { + setCurrentStepIndex(0); initialIndexSet.current = true; } }, [activeFlow, configLoading, adminStatusResolved, runtimeState.requiresPasswordChange]); const totalSteps = activeFlow.length; - - const allStepsAlreadySeen = useMemo(() => { - if (activeFlow.length === 0) return false; - return activeFlow.every(step => hasSeenStep(step.id)); - }, [activeFlow]); - - const isComplete = isInitialized && initialIndexSet.current && - (currentStepIndex >= totalSteps || allStepsAlreadySeen); - const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps && !allStepsAlreadySeen) - ? activeFlow[currentStepIndex] + + const isComplete = isInitialized && + (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); + const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps) + ? activeFlow[currentStepIndex] : null; const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null; - const isLoading = configLoading || !adminStatusResolved || !isInitialized || + const isLoading = configLoading || !adminStatusResolved || !isInitialized || !initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0); useEffect(() => { @@ -293,35 +288,33 @@ export function useOnboardingOrchestrator( }, [isComplete]); const next = useCallback(() => { - if (currentStep) markStepSeen(currentStep.id); - setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); - }, [currentStep, totalSteps]); + const nextIndex = currentStepIndex + 1; + if (nextIndex >= totalSteps) { + // Reached the end, mark onboarding as completed + markOnboardingCompleted(); + } + setCurrentStepIndex(nextIndex); + }, [currentStepIndex, totalSteps]); const prev = useCallback(() => { setCurrentStepIndex((prev) => Math.max(prev - 1, 0)); }, []); const skip = useCallback(() => { - if (currentStep) markStepSeen(currentStep.id); - setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); - }, [currentStep, totalSteps]); + // Skip marks the entire onboarding as completed + markOnboardingCompleted(); + setCurrentStepIndex(totalSteps); + }, [totalSteps]); const complete = useCallback(() => { - if (currentStep) markStepSeen(currentStep.id); - setCurrentStepIndex((prev) => Math.min(prev + 1, totalSteps)); - }, [currentStep, totalSteps]); - - useEffect(() => { - if (!currentStep || isLoading) { - return; + const nextIndex = currentStepIndex + 1; + if (nextIndex >= totalSteps) { + // Reached the end, mark onboarding as completed + markOnboardingCompleted(); } - // Special case: never auto-complete first-login step if requiresPasswordChange is true - const isFirstLoginStep = currentStep.id === 'first-login'; + setCurrentStepIndex(nextIndex); + }, [currentStepIndex, totalSteps]); - if (!isFirstLoginStep && hasSeenStep(currentStep.id)) { - complete(); - } - }, [currentStep, isLoading, complete, runtimeState.requiresPasswordChange]); const updateRuntimeState = useCallback((updates: Partial) => { persistRuntimeState(updates); diff --git a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx new file mode 100644 index 0000000000..c04009ca4c --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Trans } from 'react-i18next'; +import { Button } from '@mantine/core'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import i18n from '@app/i18n'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +interface AnalyticsChoiceSlideProps { + analyticsError?: string | null; +} + +export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig { + return { + key: 'analytics-choice', + title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'), + body: ( +
+ }} + /> +
+ }} + /> +
+
+ +
+ {analyticsError && ( +
+ {analyticsError} +
+ )} +
+ ), + background: { + gradientStops: ['#0EA5E9', '#6366F1'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx new file mode 100644 index 0000000000..b76a10b7f5 --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/TourOverviewSlide.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Trans } from 'react-i18next'; +import i18n from '@app/i18n'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +export default function TourOverviewSlide(): SlideConfig { + return { + key: 'tour-overview', + title: i18n.t('onboarding.tourOverview.title', 'Tour Overview'), + body: ( + + }} + /> + + ), + background: { + gradientStops: ['#2563EB', '#7C3AED'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/useBypassOnboarding.ts b/frontend/src/core/components/onboarding/useBypassOnboarding.ts index 8e7d9b14f0..166c893527 100644 --- a/frontend/src/core/components/onboarding/useBypassOnboarding.ts +++ b/frontend/src/core/components/onboarding/useBypassOnboarding.ts @@ -1,7 +1,6 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; -import { ONBOARDING_STEPS } from '@app/components/onboarding/orchestrator/onboardingConfig'; -import { markStepSeen } from '@app/components/onboarding/orchestrator/onboardingStorage'; +import { markOnboardingCompleted } from '@app/components/onboarding/orchestrator/onboardingStorage'; const SESSION_KEY = 'onboarding::bypass-all'; const PARAM_KEY = 'bypassOnboarding'; @@ -34,13 +33,12 @@ function setStoredBypass(enabled: boolean): void { /** * Detects the `bypassOnboarding` query parameter and stores it in session storage - * so that onboarding remains disabled while the app is open. Also marks all steps - * as seen to ensure any dependent UI elements remain hidden. + * so that onboarding remains disabled while the app is open. Also marks onboarding + * as completed to ensure any dependent UI elements remain hidden. */ export function useBypassOnboarding(): boolean { const location = useLocation(); const [bypassOnboarding, setBypassOnboarding] = useState(() => readStoredBypass()); - const stepsMarkedRef = useRef(false); const shouldBypassFromSearch = useMemo(() => { try { @@ -57,14 +55,9 @@ export function useBypassOnboarding(): boolean { setBypassOnboarding(nextBypass); if (nextBypass) { setStoredBypass(true); + markOnboardingCompleted(); } }, [shouldBypassFromSearch]); - useEffect(() => { - if (!bypassOnboarding || stepsMarkedRef.current) return; - stepsMarkedRef.current = true; - ONBOARDING_STEPS.forEach((step) => markStepSeen(step.id)); - }, [bypassOnboarding]); - return bypassOnboarding; } diff --git a/frontend/src/core/components/onboarding/useOnboardingEffects.ts b/frontend/src/core/components/onboarding/useOnboardingEffects.ts index 54c58eceee..f7eacb1f9c 100644 --- a/frontend/src/core/components/onboarding/useOnboardingEffects.ts +++ b/frontend/src/core/components/onboarding/useOnboardingEffects.ts @@ -51,14 +51,14 @@ export function useTourRequest(): { clearTourRequest: () => void; } { const [tourRequested, setTourRequested] = useState(false); - const [requestedTourType, setRequestedTourType] = useState('tools'); + const [requestedTourType, setRequestedTourType] = useState('whatsnew'); useEffect(() => { if (typeof window === 'undefined') return; const handleTourRequest = (event: Event) => { const { detail } = event as CustomEvent; - setRequestedTourType(detail?.tourType ?? 'tools'); + setRequestedTourType(detail?.tourType ?? 'whatsnew'); setTourRequested(true); }; diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts index 07e3da7607..5a936b3d65 100644 --- a/frontend/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/src/core/components/onboarding/userStepsConfig.ts @@ -8,12 +8,8 @@ export enum TourStep { FILES_BUTTON, FILE_SOURCES, WORKBENCH, - VIEW_SWITCHER, - VIEWER, - PAGE_EDITOR, ACTIVE_FILES, FILE_CHECKBOX, - SELECT_CONTROLS, CROP_SETTINGS, RUN_BUTTON, RESULTS, @@ -28,10 +24,7 @@ interface UserStepActions { backToAllTools: () => void; selectCropTool: () => void; loadSampleFile: () => void; - switchToViewer: () => void; - switchToPageEditor: () => void; switchToActiveFiles: () => void; - selectFirstFile: () => void; pinFile: () => void; modifyCropSettings: () => void; executeTool: () => void; @@ -50,10 +43,7 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs) backToAllTools, selectCropTool, loadSampleFile, - switchToViewer, - switchToPageEditor, switchToActiveFiles, - selectFirstFile, pinFile, modifyCropSettings, executeTool, @@ -108,26 +98,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs) position: 'center', padding: 0, }, - [TourStep.VIEW_SWITCHER]: { - selector: '[data-tour="view-switcher"]', - content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'), - position: 'bottom', - padding: 0, - }, - [TourStep.VIEWER]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."), - position: 'center', - padding: 0, - action: () => switchToViewer(), - }, - [TourStep.PAGE_EDITOR]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."), - position: 'center', - padding: 0, - action: () => switchToPageEditor(), - }, [TourStep.ACTIVE_FILES]: { selector: '[data-tour="workbench"]', content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."), @@ -141,14 +111,6 @@ export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs) position: 'top', padding: 10, }, - [TourStep.SELECT_CONTROLS]: { - selector: '[data-tour="right-rail-controls"]', - highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], - content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."), - position: 'left', - padding: 5, - action: () => selectFirstFile(), - }, [TourStep.CROP_SETTINGS]: { selector: '[data-tour="crop-settings"]', content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."), diff --git a/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts new file mode 100644 index 0000000000..cb86644c4a --- /dev/null +++ b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts @@ -0,0 +1,197 @@ +import type { StepType } from '@reactour/tour'; +import type { TFunction } from 'i18next'; + +async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise { + if (typeof document === 'undefined') return; + const start = Date.now(); + // Immediate hit + if (document.querySelector(selector)) return; + + return new Promise((resolve) => { + const check = () => { + if (document.querySelector(selector) || Date.now() - start >= timeoutMs) { + resolve(); + return; + } + setTimeout(check, intervalMs); + }; + check(); + }); +} + +async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise { + if (typeof document === 'undefined') return; + const start = Date.now(); + + return new Promise((resolve) => { + const check = () => { + const el = document.querySelector(selector); + const isVisible = !!el && el.getClientRects().length > 0; + if (isVisible || Date.now() - start >= timeoutMs) { + // Nudge Reactour to recalc positions in case layout shifted + window.dispatchEvent(new Event('resize')); + requestAnimationFrame(() => window.dispatchEvent(new Event('resize'))); + resolve(); + return; + } + setTimeout(check, intervalMs); + }; + check(); + }); +} + +export enum WhatsNewTourStep { + QUICK_ACCESS, + LEFT_PANEL, + FILE_UPLOAD, + RIGHT_RAIL, + TOP_BAR, + PAGE_EDITOR_VIEW, + ACTIVE_FILES_VIEW, + WRAP_UP, +} + +interface WhatsNewStepActions { + saveWorkbenchState: () => void; + closeFilesModal: () => void; + backToAllTools: () => void; + openFilesModal: () => void; + loadSampleFile: () => Promise | void; + switchToViewer: () => void; + switchToPageEditor: () => void; + switchToActiveFiles: () => void; + selectFirstFile: () => void; +} + +interface CreateWhatsNewStepsConfigArgs { + t: TFunction; + actions: WhatsNewStepActions; +} + +export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record { + const { + saveWorkbenchState, + closeFilesModal, + backToAllTools, + openFilesModal, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + } = actions; + + return { + [WhatsNewTourStep.QUICK_ACCESS]: { + selector: '[data-tour="quick-access-bar"]', + content: t( + 'onboarding.whatsNew.quickAccess', + 'Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours.' + ), + position: 'right', + padding: 10, + action: () => { + saveWorkbenchState(); + closeFilesModal(); + backToAllTools(); + }, + }, + [WhatsNewTourStep.LEFT_PANEL]: { + selector: '[data-tour="tool-panel"]', + content: t( + 'onboarding.whatsNew.leftPanel', + 'The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly.' + ), + position: 'center', + padding: 0, + }, + [WhatsNewTourStep.FILE_UPLOAD]: { + selector: '[data-tour="files-button"]', + content: t( + 'onboarding.whatsNew.fileUpload', + 'Use the Files button to upload or pick a recent PDF. We will load a sample so you can see the workspace.' + ), + position: 'right', + padding: 10, + action: async () => { + openFilesModal(); + await waitForElement('[data-tour="file-sources"]', 5000, 100); + }, + actionAfter: async () => { + await Promise.resolve(loadSampleFile()); + closeFilesModal(); + switchToViewer(); + // wait for file render and top controls to mount + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.RIGHT_RAIL]: { + selector: '[data-tour="right-rail-controls"]', + highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], + content: t( + 'onboarding.whatsNew.rightRail', + 'The Right Rail holds quick actions to select files, change theme or language, and download results.' + ), + position: 'left', + padding: 10, + action: async () => { + await waitForElement('[data-tour="right-rail-controls"]', 7000, 100); + selectFirstFile(); + }, + }, + [WhatsNewTourStep.TOP_BAR]: { + selector: '[data-tour="view-switcher"]', + content: t( + 'onboarding.whatsNew.topBar', + 'The top bar lets you swap between Viewer, Page Editor, and Active Files.' + ), + position: 'bottom', + padding: 8, + // Ensure the switcher has mounted before this step renders + action: async () => { + switchToViewer(); + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.PAGE_EDITOR_VIEW]: { + selector: '[data-tour="view-switcher"]', + content: t( + 'onboarding.whatsNew.pageEditorView', + 'Switch to the Page Editor to reorder, rotate, or delete pages.' + ), + position: 'bottom', + padding: 8, + action: async () => { + switchToPageEditor(); + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.ACTIVE_FILES_VIEW]: { + selector: '[data-tour="view-switcher"]', + content: t( + 'onboarding.whatsNew.activeFilesView', + 'Use Active Files to see everything you have open and pick what to work on.' + ), + position: 'bottom', + padding: 8, + action: async () => { + switchToActiveFiles(); + await waitForElement('[data-tour="view-switcher"]', 7000, 100); + await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500); + }, + }, + [WhatsNewTourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t( + 'onboarding.whatsNew.wrapUp', + 'That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour.' + ), + position: 'right', + padding: 10, + }, + }; +} + diff --git a/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx b/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx deleted file mode 100644 index f72d28452a..0000000000 --- a/frontend/src/core/components/shared/AdminAnalyticsChoiceModal.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { - Modal, - Stack, - Button, - Text, - Title, - Anchor, - useMantineTheme, - useComputedColorScheme, -} from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; -import { Z_ANALYTICS_MODAL } from '@app/styles/zIndex'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import apiClient from '@app/services/apiClient'; - -interface AdminAnalyticsChoiceModalProps { - opened: boolean; - onClose: () => void; -} - -export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) { - const { t } = useTranslation(); - const { refetch } = useAppConfig(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const theme = useMantineTheme(); - const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true }); - const isDark = computedColorScheme === 'dark'; - const privacyHighlightStyles = { - color: isDark ? '#FFFFFF' : theme.colors.blue[7], - padding: `${theme.spacing.xs} ${theme.spacing.sm}`, - borderRadius: theme.radius.md, - fontWeight: 700, - textAlign: 'center' as const, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: theme.spacing.xs, - letterSpacing: 0.3, - }; - - const handleChoice = async (enableAnalytics: boolean) => { - setLoading(true); - setError(null); - - try { - const formData = new FormData(); - formData.append('enabled', enableAnalytics.toString()); - - await apiClient.post('/api/v1/settings/update-enable-analytics', formData); - - // Refetch config to apply new settings without page reload - await refetch(); - - // Close the modal after successful save - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error occurred'); - setLoading(false); - } - }; - - const handleEnable = () => { - handleChoice(true); - }; - - const handleDisable = () => { - handleChoice(false); - }; - - return ( - {}} // Prevent closing - closeOnClickOutside={false} - closeOnEscape={false} - withCloseButton={false} - size="lg" - centered - zIndex={Z_ANALYTICS_MODAL} - > - - {t('analytics.title', 'Do you want make Stirling PDF better?')} - - - {t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product.')} - - - • {t('analytics.privacyAssurance', 'We do not track any personal information or the contents of your files.')} • - - - - {t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '} - - {t('analytics.learnMore', 'Learn more')} - - - - {error && ( - - {error} - - )} - - - - - - - - - {t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')} - - - - ); -} diff --git a/frontend/src/core/components/shared/Footer.tsx b/frontend/src/core/components/shared/Footer.tsx index 172861d1bb..6a03a2f542 100644 --- a/frontend/src/core/components/shared/Footer.tsx +++ b/frontend/src/core/components/shared/Footer.tsx @@ -95,6 +95,22 @@ export default function Footer({ > {t('legal.terms', 'Terms and Conditions')} + + {t('footer.discord', 'Discord')} + + + {t('footer.issues', 'GitHub')} + ((_, ref) => { const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); + const { + tooltipOpen, + manualCloseOnly, + showCloseButton, + toursMenuOpen, + setToursMenuOpen, + handleTooltipOpenChange, + } = useToursTooltip(); const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; @@ -166,8 +176,8 @@ const QuickAccessBar = forwardRef((_, ref) => { const bottomButtons: ButtonConfig[] = [ { id: 'help', - name: t("quickAccess.help", "Help"), - icon: , + name: t("quickAccess.tours", "Tours"), + icon: , isRound: true, size: 'md', type: 'action', @@ -192,6 +202,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
{/* Fixed header outside scrollable area */} @@ -247,58 +258,83 @@ const QuickAccessBar = forwardRef((_, ref) => { // Handle help button with menu or direct action if (buttonConfig.id === 'help') { const isAdmin = config?.isAdmin === true; + const toursTooltipContent = isAdmin + ? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.') + : t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.'); + const tourItems = [ + { + key: 'whatsnew', + icon: , + title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"), + description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"), + onClick: () => requestStartTour('whatsnew'), + }, + { + key: 'tools', + icon: , + title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"), + description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"), + onClick: () => requestStartTour('tools'), + }, + ...(isAdmin ? [{ + key: 'admin', + icon: , + title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), + description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), + onClick: () => requestStartTour('admin'), + }] : []), + ]; - // If not admin, just show button that starts tools tour directly - if (!isAdmin) { - return ( -
requestStartTour('tools')} + const helpButtonNode = ( +
+ - {renderNavButton(buttonConfig, index)} -
- ); - } - - // If admin, show menu with both options - return ( -
-
{renderNavButton(buttonConfig, index)}
- } - onClick={() => requestStartTour('tools')} - > -
-
- {t("quickAccess.helpMenu.toolsTour", "Tools Tour")} + {tourItems.map((item) => ( + +
+
+ {item.title} +
+
+ {item.description} +
-
- {t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")} -
-
- - } - onClick={() => requestStartTour('admin')} - > -
-
- {t("quickAccess.helpMenu.adminTour", "Admin Tour")} -
-
- {t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")} -
-
-
+ + ))}
); + + return ( + + {helpButtonNode} + + ); } const buttonNode = renderNavButton(buttonConfig, index); diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 980d2cfe76..10fefec023 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -33,6 +33,10 @@ export interface TooltipProps { disabled?: boolean; /** If false, tooltip will not open on focus (hover only) */ openOnFocus?: boolean; + /** If true, tooltip stays open until explicitly closed (ignores hover/blur/esc/outside) */ + manualCloseOnly?: boolean; + /** Show a close button even when not pinned */ + showCloseButton?: boolean; } export const Tooltip: React.FC = ({ @@ -55,6 +59,8 @@ export const Tooltip: React.FC = ({ closeOnOutside = true, disabled = false, openOnFocus = true, + manualCloseOnly = false, + showCloseButton = false, }) => { const [internalOpen, setInternalOpen] = useState(false); const [isPinned, setIsPinned] = useState(false); @@ -81,6 +87,7 @@ export const Tooltip: React.FC = ({ const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; + const allowAutoClose = !manualCloseOnly; const resolvedPosition: NonNullable = useMemo(() => { const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr'; @@ -132,11 +139,11 @@ export const Tooltip: React.FC = ({ } // Not pinned and configured to close on outside - if (closeOnOutside && !insideTooltip && !insideTrigger) { + if (allowAutoClose && closeOnOutside && !insideTooltip && !insideTrigger) { setOpen(false); } }, - [isPinned, closeOnOutside, setOpen] + [isPinned, closeOnOutside, setOpen, allowAutoClose] ); useEffect(() => { @@ -200,10 +207,10 @@ export const Tooltip: React.FC = ({ } clearTimers(); - if (!isPinned) setOpen(false); + if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onPointerLeave?.(e); }, - [clearTimers, isPinned, setOpen, children.props] + [clearTimers, isPinned, setOpen, children.props, allowAutoClose] ); const handleMouseDown = useCallback( @@ -257,15 +264,16 @@ export const Tooltip: React.FC = ({ return; } clearTimers(); - if (!isPinned) setOpen(false); + if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onBlur?.(e); }, - [isPinned, setOpen, children.props, clearTimers] + [isPinned, setOpen, children.props, allowAutoClose, clearTimers] ); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (manualCloseOnly) return; if (e.key === 'Escape') setOpen(false); - }, [setOpen]); + }, [setOpen, manualCloseOnly]); // Keep open while pointer is over the tooltip; close when leaving it (if not pinned) const handleTooltipPointerEnter = useCallback(() => { @@ -276,9 +284,9 @@ export const Tooltip: React.FC = ({ (e: React.PointerEvent) => { const related = e.relatedTarget as Node | null; if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; - if (!isPinned) setOpen(false); + if (allowAutoClose && !isPinned) setOpen(false); }, - [isPinned, setOpen] + [isPinned, setOpen, allowAutoClose] ); // Enhance child with handlers and ref @@ -301,6 +309,7 @@ export const Tooltip: React.FC = ({ }); const shouldShowTooltip = open; + const shouldShowCloseButton = showCloseButton || isPinned; const tooltipElement = shouldShowTooltip ? (
= ({ className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined} > - {isPinned && ( + {shouldShowCloseButton && (
)} - +
) : null; diff --git a/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts new file mode 100644 index 0000000000..bed983fdd7 --- /dev/null +++ b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { TOUR_STATE_EVENT, type TourStatePayload } from '@app/constants/events'; +import { isOnboardingCompleted, hasShownToursTooltip, markToursTooltipShown } from '@app/components/onboarding/orchestrator/onboardingStorage'; + +export interface ToursTooltipState { + tooltipOpen: boolean | undefined; + manualCloseOnly: boolean; + showCloseButton: boolean; + toursMenuOpen: boolean; + setToursMenuOpen: (open: boolean) => void; + handleTooltipOpenChange: (next: boolean) => void; +} + +/** + * Encapsulates all the logic for the tours tooltip: + * - Shows automatically after onboarding/tour completes (once per user) + * - Hides while the tours menu is open + * - After dismissal, reverts to hover-only tooltip + */ +export function useToursTooltip(): ToursTooltipState { + const [showToursTooltip, setShowToursTooltip] = useState(false); + const [toursMenuOpen, setToursMenuOpen] = useState(false); + const tourWasOpenRef = useRef(false); + + // Auto-show when a tour ends (fires once per user) + useEffect(() => { + if (typeof window === 'undefined') return; + + const handleTourStateChange = (event: Event) => { + const { detail } = event as CustomEvent; + const wasOpen = tourWasOpenRef.current; + tourWasOpenRef.current = detail.isOpen; + + if (wasOpen && !detail.isOpen && !hasShownToursTooltip()) { + setShowToursTooltip(true); + } + }; + + window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange); + return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange); + }, []); + + // Show once after onboarding is complete + useEffect(() => { + if (isOnboardingCompleted() && !hasShownToursTooltip()) { + setShowToursTooltip(true); + } + }, []); + + const handleDismissToursTooltip = useCallback(() => { + markToursTooltipShown(); + setShowToursTooltip(false); + }, []); + + const hasBeenDismissed = hasShownToursTooltip(); + + const handleTooltipOpenChange = useCallback( + (next: boolean) => { + if (!next) { + if (!hasBeenDismissed) { + handleDismissToursTooltip(); + } + } else if (!hasBeenDismissed && !toursMenuOpen) { + setShowToursTooltip(true); + } + }, + [hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip] + ); + + const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip; + + return { + tooltipOpen, + manualCloseOnly: !hasBeenDismissed, + showCloseButton: !hasBeenDismissed, + toursMenuOpen, + setToursMenuOpen, + handleTooltipOpenChange, + }; +} + diff --git a/frontend/src/core/components/shared/tooltip/Tooltip.module.css b/frontend/src/core/components/shared/tooltip/Tooltip.module.css index 62c4bf6963..9d3bdb80a6 100644 --- a/frontend/src/core/components/shared/tooltip/Tooltip.module.css +++ b/frontend/src/core/components/shared/tooltip/Tooltip.module.css @@ -39,7 +39,7 @@ background: var(--bg-raised); padding: 0.25rem; border-radius: 0.25rem; - border: 0.0625rem solid var(--primary-color, #3b82f6); + border: 0.0625rem solid var(--border-default); cursor: pointer; transition: background-color 0.2s ease, border-color 0.2s ease; z-index: 1; @@ -60,6 +60,13 @@ border-color: #ef4444 !important; } +.tooltip-pin-button:focus, +.tooltip-pin-button:focus-visible { + outline: none; + border-color: var(--border-default) !important; + background-color: var(--bg-raised) !important; +} + /* Tooltip Header */ .tooltip-header { display: flex; @@ -91,7 +98,7 @@ /* Tooltip Body */ .tooltip-body { - padding: 1rem !important; + padding: 1rem; color: var(--text-primary) !important; font-size: 0.875rem !important; line-height: 1.6 !important; diff --git a/frontend/src/core/components/shared/tooltip/TooltipContent.tsx b/frontend/src/core/components/shared/tooltip/TooltipContent.tsx index 8bd85966a3..42ee19af36 100644 --- a/frontend/src/core/components/shared/tooltip/TooltipContent.tsx +++ b/frontend/src/core/components/shared/tooltip/TooltipContent.tsx @@ -5,18 +5,20 @@ import { TooltipTip } from '@app/types/tips'; interface TooltipContentProps { content?: React.ReactNode; tips?: TooltipTip[]; + extraRightPadding?: number; } export const TooltipContent: React.FC = ({ content, tips, + extraRightPadding = 0, }) => { return (
const loadSampleFile = useCallback(async () => { try { + // Hide the modal immediately so the tour targets are visible while we load + closeFilesModal(); const response = await fetch(`${BASE_PATH}/samples/Sample.pdf`); const blob = await response.blob(); const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' }); diff --git a/frontend/src/core/hooks/useServerExperience.ts b/frontend/src/core/hooks/useServerExperience.ts index 28f62c1c57..7b78a58d49 100644 --- a/frontend/src/core/hooks/useServerExperience.ts +++ b/frontend/src/core/hooks/useServerExperience.ts @@ -64,7 +64,10 @@ export function useServerExperience(): ServerExperienceValue { const loginEnabled = config?.enableLogin !== false; const configIsAdmin = Boolean(config?.isAdmin); - const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin); + // For no-login servers, treat everyone as a regular user (no effective admin) + // Commented out the previous self-reported admin path to avoid elevating users. + // const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin); + const effectiveIsAdmin = loginEnabled ? configIsAdmin : false; const hasPaidLicense = config?.license === 'SERVER' || config?.license === 'PRO' || config?.license === 'ENTERPRISE'; const setSelfReportedAdmin = useCallback((value: boolean) => { diff --git a/frontend/src/core/services/auditService.ts b/frontend/src/core/services/auditService.ts index ac2da176b3..30951b67b2 100644 --- a/frontend/src/core/services/auditService.ts +++ b/frontend/src/core/services/auditService.ts @@ -49,7 +49,9 @@ const auditService = { * Get audit system status */ async getSystemStatus(): Promise { - const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard'); + const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-dashboard', { + suppressErrorToast: true, + }); const data = response.data; // Map V1 response to expected format diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx index 966a82f2ef..51441f2283 100644 --- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -13,7 +13,7 @@ import { UPGRADE_BANNER_ALERT_EVENT, } from '@core/constants/events'; import { useServerExperience } from '@app/hooks/useServerExperience'; -import { hasSeenStep } from '@core/components/onboarding/orchestrator/onboardingStorage'; +import { isOnboardingCompleted } from '@core/components/onboarding/orchestrator/onboardingStorage'; const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt'; const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; @@ -26,6 +26,7 @@ const UpgradeBanner: React.FC = () => { const onAuthRoute = isAuthRoute(location.pathname); const { openCheckout } = useCheckout(); const { + loginEnabled, totalUsers, userCountResolved, userCountLoading, @@ -34,9 +35,11 @@ const UpgradeBanner: React.FC = () => { licenseLoading, freeTierLimit, overFreeTierLimit, + weeklyActiveUsers, scenarioKey, } = useServerExperience(); - const onboardingComplete = hasSeenStep('welcome'); + const onboardingComplete = isOnboardingCompleted(); + console.log('onboardingComplete', onboardingComplete); const [friendlyVisible, setFriendlyVisible] = useState(() => { if (typeof window === 'undefined') return false; const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY); @@ -296,8 +299,18 @@ const UpgradeBanner: React.FC = () => { ); }; + const suppressForNoLogin = + !loginEnabled || + (!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5); + // Don't show on auth routes or if neither banner type should show - if (onAuthRoute || (!friendlyVisible && !shouldEvaluateUrgent)) { + // Also suppress entirely for no-login servers (treat them as regular users only) + // and, per request, never surface upgrade messaging there when WAU > 5. + if ( + onAuthRoute || + suppressForNoLogin || + (!friendlyVisible && !shouldEvaluateUrgent) + ) { return null; } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx index c7142b3d27..1f71b090d0 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminAuditSection.tsx @@ -23,8 +23,14 @@ const AdminAuditSection: React.FC = () => { setError(null); const status = await auditService.getSystemStatus(); setSystemStatus(status); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load audit system status'); + } catch (err: any) { + // Check if this is a permission/license error (403/404) + const status = err?.response?.status; + if (status === 403 || status === 404) { + setError('enterprise-license-required'); + } else { + setError(err instanceof Error ? err.message : 'Failed to load audit system status'); + } } finally { setLoading(false); } @@ -56,6 +62,16 @@ const AdminAuditSection: React.FC = () => { } if (error) { + if (error === 'enterprise-license-required') { + return ( + + {t( + 'audit.enterpriseRequiredMessage', + 'The audit logging system is an enterprise feature. Please upgrade to an enterprise license to access audit logs and analytics.' + )} + + ); + } return ( {error} diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index 0aa745d54f..ce6b60ab0a 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -51,6 +51,7 @@ const BASE_NO_LOGIN_CONFIG: AppConfig = { appVersion: '2.0.0', serverCertificateEnabled: false, enableAlphaFunctionality: false, + enableDesktopInstallSlide: true, serverPort: 8080, premiumEnabled: false, runningProOrHigher: false, From 33188815da3e179c089ecfffdfaf919db46fe0c4 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:43:36 +0000 Subject: [PATCH 05/62] Remove UserApi mapping from proprietary signature controller (#5239) ## Summary - remove the UserApi composite annotation from the proprietary signature controller to avoid duplicate request mappings ## Testing - Not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_693af8cc8210832890f4787bae07d11f) --- .../proprietary/controller/api/SignatureController.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java index a073c21371..53b3e1f388 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -22,7 +22,6 @@ import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.annotations.api.UserApi; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; @@ -34,7 +33,6 @@ import stirling.software.proprietary.service.SignatureService; * authentication and enforces per-user storage limits. All endpoints require authentication * via @PreAuthorize("isAuthenticated()"). */ -@UserApi @Slf4j @RestController @RequestMapping("/api/v1/proprietary/signatures") From 5f72c056230adb54a8ca878ece1185c1e9a5637a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:14:10 +0000 Subject: [PATCH 06/62] line art (#5052) ## Summary - introduce a shared line art conversion interface and proprietary ImageMagick-backed implementation - have the compress controller optionally autowire the enterprise service before running per-image line art processing - remove ImageMagick command details from core by delegating conversions through the proprietary service ## Testing - not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6928aecceaf083289a9269b1ca99307e) --------- Co-authored-by: James Brunton --- .../SPDF/config/EndpointConfiguration.java | 4 + .../common/model/ApplicationProperties.java | 11 ++ .../service/LineArtConversionService.java | 12 ++ .../software/common/util/ProcessExecutor.java | 11 ++ .../SPDF/config/ExternalAppDepConfig.java | 2 + .../api/misc/CompressController.java | 103 ++++++++++++ .../controller/api/misc/ConfigController.java | 6 + .../web/ReactRoutingController.java | 4 +- .../model/api/misc/OptimizePdfRequest.java | 22 +++ .../src/main/resources/settings.yml.template | 2 + .../ImageMagickLineArtConversionService.java | 81 ++++++++++ docker/Dockerfile.unified | 1 + docker/backend/Dockerfile | 1 + docker/backend/Dockerfile.fat | 1 + docker/embedded/Dockerfile | 1 + docker/embedded/Dockerfile.fat | 1 + .../public/locales/en-GB/translation.toml | 14 ++ frontend/src-tauri/tauri.conf.json | 146 +++++++++--------- .../tools/compress/CompressSettings.tsx | 75 ++++++++- .../components/tooltips/useCompressTips.ts | 7 + .../tools/compress/useCompressOperation.ts | 5 + .../tools/compress/useCompressParameters.ts | 6 + .../testing/serverExperienceSimulations.ts | 2 +- .../testing/serverExperienceSimulations.ts | 2 +- 24 files changed, 445 insertions(+), 75 deletions(-) create mode 100644 app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 35f68939c3..ff48b5b2e7 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -491,6 +491,9 @@ public class EndpointConfiguration { addEndpointToGroup("Ghostscript", "repair"); addEndpointToGroup("Ghostscript", "compress-pdf"); + /* ImageMagick */ + addEndpointToGroup("ImageMagick", "compress-pdf"); + /* tesseract */ addEndpointToGroup("tesseract", "ocr-pdf"); @@ -574,6 +577,7 @@ public class EndpointConfiguration { || "Javascript".equals(group) || "Weasyprint".equals(group) || "Pdftohtml".equals(group) + || "ImageMagick".equals(group) || "rar".equals(group); } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 7e64ea9a7f..e94a5f3951 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -653,6 +653,7 @@ public class ApplicationProperties { private int weasyPrintSessionLimit; private int installAppSessionLimit; private int calibreSessionLimit; + private int imageMagickSessionLimit; private int qpdfSessionLimit; private int tesseractSessionLimit; private int ghostscriptSessionLimit; @@ -690,6 +691,10 @@ public class ApplicationProperties { return calibreSessionLimit > 0 ? calibreSessionLimit : 1; } + public int getImageMagickSessionLimit() { + return imageMagickSessionLimit > 0 ? imageMagickSessionLimit : 4; + } + public int getGhostscriptSessionLimit() { return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8; } @@ -719,6 +724,8 @@ public class ApplicationProperties { @JsonProperty("calibretimeoutMinutes") private long calibreTimeoutMinutes; + private long imageMagickTimeoutMinutes; + private long tesseractTimeoutMinutes; private long qpdfTimeoutMinutes; private long ghostscriptTimeoutMinutes; @@ -756,6 +763,10 @@ public class ApplicationProperties { return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30; } + public long getImageMagickTimeoutMinutes() { + return imageMagickTimeoutMinutes > 0 ? imageMagickTimeoutMinutes : 30; + } + public long getGhostscriptTimeoutMinutes() { return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30; } diff --git a/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java new file mode 100644 index 0000000000..ab4f55d2e6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/LineArtConversionService.java @@ -0,0 +1,12 @@ +package stirling.software.common.service; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; + +public interface LineArtConversionService { + PDImageXObject convertImageToLineArt( + PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel) + throws IOException; +} diff --git a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index 3b94fbfbce..2694418135 100644 --- a/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/app/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -86,6 +86,11 @@ public class ProcessExecutor { .getProcessExecutor() .getSessionLimit() .getCalibreSessionLimit(); + case IMAGEMAGICK -> + applicationProperties + .getProcessExecutor() + .getSessionLimit() + .getImageMagickSessionLimit(); case GHOSTSCRIPT -> applicationProperties .getProcessExecutor() @@ -141,6 +146,11 @@ public class ProcessExecutor { .getProcessExecutor() .getTimeoutMinutes() .getCalibreTimeoutMinutes(); + case IMAGEMAGICK -> + applicationProperties + .getProcessExecutor() + .getTimeoutMinutes() + .getImageMagickTimeoutMinutes(); case GHOSTSCRIPT -> applicationProperties .getProcessExecutor() @@ -301,6 +311,7 @@ public class ProcessExecutor { WEASYPRINT, INSTALL_APP, CALIBRE, + IMAGEMAGICK, TESSERACT, QPDF, GHOSTSCRIPT, diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 59c8825fc4..8606cc2a92 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -46,6 +46,7 @@ public class ExternalAppDepConfig { put("qpdf", List.of("qpdf")); put("tesseract", List.of("tesseract")); put("rar", List.of("rar")); // Required for real CBR output + put("magick", List.of("ImageMagick")); } }; } @@ -128,6 +129,7 @@ public class ExternalAppDepConfig { checkDependencyAndDisableGroup("pdftohtml"); checkDependencyAndDisableGroup(unoconvPath); checkDependencyAndDisableGroup("rar"); + checkDependencyAndDisableGroup("magick"); // Special handling for Python/OpenCV dependencies boolean pythonAvailable = isCommandAvailable("python3") || isCommandAvailable("python"); if (!pythonAvailable) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index 865a95c5cb..9b0483da94 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -28,10 +28,13 @@ import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.graphics.PDXObject; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import io.swagger.v3.oas.annotations.Operation; @@ -44,6 +47,7 @@ import stirling.software.SPDF.model.api.misc.OptimizePdfRequest; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.MiscApi; import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.LineArtConversionService; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; import stirling.software.common.util.ProcessExecutor; @@ -58,6 +62,9 @@ public class CompressController { private final CustomPDFDocumentFactory pdfDocumentFactory; private final EndpointConfiguration endpointConfiguration; + @Autowired(required = false) + private LineArtConversionService lineArtConversionService; + private boolean isQpdfEnabled() { return endpointConfiguration.isGroupEnabled("qpdf"); } @@ -66,6 +73,10 @@ public class CompressController { return endpointConfiguration.isGroupEnabled("Ghostscript"); } + private boolean isImageMagickEnabled() { + return endpointConfiguration.isGroupEnabled("ImageMagick"); + } + @Data @AllArgsConstructor @NoArgsConstructor @@ -660,6 +671,9 @@ public class CompressController { Integer optimizeLevel = request.getOptimizeLevel(); String expectedOutputSizeString = request.getExpectedOutputSize(); Boolean convertToGrayscale = request.getGrayscale(); + Boolean convertToLineArt = request.getLineArt(); + Double lineArtThreshold = request.getLineArtThreshold(); + Integer lineArtEdgeLevel = request.getLineArtEdgeLevel(); if (expectedOutputSizeString == null && optimizeLevel == null) { throw new Exception("Both expected output size and optimize level are not specified"); } @@ -689,6 +703,26 @@ public class CompressController { optimizeLevel = determineOptimizeLevel(sizeReductionRatio); } + if (Boolean.TRUE.equals(convertToLineArt)) { + if (lineArtConversionService == null) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Line art conversion is unavailable - ImageMagick service not found"); + } + if (!isImageMagickEnabled()) { + throw new IOException( + "ImageMagick is not enabled but line art conversion was requested"); + } + double thresholdValue = + lineArtThreshold == null + ? 55d + : Math.min(100d, Math.max(0d, lineArtThreshold)); + int edgeLevel = + lineArtEdgeLevel == null ? 1 : Math.min(3, Math.max(1, lineArtEdgeLevel)); + currentFile = + applyLineArtConversion(currentFile, tempFiles, thresholdValue, edgeLevel); + } + boolean sizeMet = false; boolean imageCompressionApplied = false; boolean externalCompressionApplied = false; @@ -810,6 +844,75 @@ public class CompressController { } } + private Path applyLineArtConversion( + Path currentFile, List tempFiles, double threshold, int edgeLevel) + throws IOException { + + Path lineArtFile = Files.createTempFile("lineart_output_", ".pdf"); + tempFiles.add(lineArtFile); + + try (PDDocument doc = pdfDocumentFactory.load(currentFile.toFile())) { + Map> uniqueImages = findImages(doc); + CompressionStats stats = new CompressionStats(); + stats.uniqueImagesCount = uniqueImages.size(); + calculateImageStats(uniqueImages, stats); + + Map convertedImages = + createLineArtImages(doc, uniqueImages, stats, threshold, edgeLevel); + + replaceImages(doc, uniqueImages, convertedImages, stats); + + log.info( + "Applied line art conversion to {} unique images ({} total references)", + stats.uniqueImagesCount, + stats.totalImages); + + doc.save(lineArtFile.toString()); + return lineArtFile; + } + } + + private Map createLineArtImages( + PDDocument doc, + Map> uniqueImages, + CompressionStats stats, + double threshold, + int edgeLevel) + throws IOException { + + Map convertedImages = new HashMap<>(); + + for (Entry> entry : uniqueImages.entrySet()) { + String imageHash = entry.getKey(); + List references = entry.getValue(); + if (references.isEmpty()) continue; + + PDImageXObject originalImage = getOriginalImage(doc, references.get(0)); + + int originalSize = (int) originalImage.getCOSObject().getLength(); + stats.totalOriginalBytes += originalSize; + + PDImageXObject converted = + lineArtConversionService.convertImageToLineArt( + doc, originalImage, threshold, edgeLevel); + convertedImages.put(imageHash, converted); + stats.compressedImages++; + + int convertedSize = (int) converted.getCOSObject().getLength(); + stats.totalCompressedBytes += convertedSize * references.size(); + + double reductionPercentage = 100.0 - ((convertedSize * 100.0) / originalSize); + log.info( + "Image hash {}: Line art conversion {} → {} (reduced by {}%)", + imageHash, + GeneralUtils.formatBytes(originalSize), + GeneralUtils.formatBytes(convertedSize), + String.format("%.1f", reductionPercentage)); + } + + return convertedImages; + } + // Run Ghostscript compression private void applyGhostscriptCompression( OptimizePdfRequest request, int optimizeLevel, Path currentFile, List tempFiles) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 91aa9924da..95486ff9b8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -230,4 +230,10 @@ public class ConfigController { } return ResponseEntity.ok(result); } + + @GetMapping("/group-enabled") + public ResponseEntity isGroupEnabled(@RequestParam(name = "group") String group) { + boolean enabled = endpointConfiguration.isGroupEnabled(group); + return ResponseEntity.ok(enabled); + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 6373e07520..ab8f3b75b6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -74,13 +74,13 @@ public class ReactRoutingController { } @GetMapping( - "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") + "/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}") public ResponseEntity forwardRootPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); } @GetMapping( - "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") + "/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}") public ResponseEntity forwardNestedPaths(HttpServletRequest request) throws IOException { return serveIndexHtml(request); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java index bf96dd2171..d6e5c7021d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequest.java @@ -45,4 +45,26 @@ public class OptimizePdfRequest extends PDFFile { requiredMode = Schema.RequiredMode.REQUIRED, defaultValue = "false") private Boolean grayscale = false; + + @Schema( + description = + "Whether to convert images to high-contrast line art using ImageMagick. Default is false.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "false") + private Boolean lineArt = false; + + @Schema( + description = "Threshold to use for line art conversion (0-100).", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "55") + private Double lineArtThreshold = 55d; + + @Schema( + description = + "Edge detection strength to use for line art conversion (1-3). This maps to" + + " ImageMagick's -edge radius.", + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + defaultValue = "1", + allowableValues = {"1", "2", "3"}) + private Integer lineArtEdgeLevel = 1; } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 64b4bd50e9..a272c54cca 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -224,6 +224,7 @@ processExecutor: weasyPrintSessionLimit: 16 installAppSessionLimit: 1 calibreSessionLimit: 1 + imageMagickSessionLimit: 4 ghostscriptSessionLimit: 8 ocrMyPdfSessionLimit: 2 timeoutMinutes: # Process executor timeout in minutes @@ -233,6 +234,7 @@ processExecutor: weasyPrinttimeoutMinutes: 30 installApptimeoutMinutes: 60 calibretimeoutMinutes: 30 + imageMagickTimeoutMinutes: 30 tesseractTimeoutMinutes: 30 qpdfTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java new file mode 100644 index 0000000000..8ca6a83e7c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ImageMagickLineArtConversionService.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.LineArtConversionService; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + +@Slf4j +@Service +public class ImageMagickLineArtConversionService implements LineArtConversionService { + + @Override + public PDImageXObject convertImageToLineArt( + PDDocument doc, PDImageXObject originalImage, double threshold, int edgeLevel) + throws IOException { + + Path inputImage = Files.createTempFile("lineart_image_input_", ".png"); + Path outputImage = Files.createTempFile("lineart_image_output_", ".tiff"); + + try { + ImageIO.write(originalImage.getImage(), "png", inputImage.toFile()); + + List command = new ArrayList<>(); + command.add("magick"); + command.add(inputImage.toString()); + command.add("-colorspace"); + command.add("Gray"); + + // Edge-aware line art conversion using ImageMagick's built-in operators. + // -edge/-negate/-normalize are standard convert options (IM v6+/v7) that + // accentuate outlines before thresholding to a bilevel image. + command.add("-edge"); + command.add(String.valueOf(edgeLevel)); + command.add("-negate"); + command.add("-normalize"); + + command.add("-type"); + command.add("Bilevel"); + command.add("-threshold"); + command.add(String.format(Locale.ROOT, "%.1f%%", threshold)); + command.add("-compress"); + command.add("Group4"); + command.add(outputImage.toString()); + + ProcessExecutorResult result = + ProcessExecutor.getInstance(ProcessExecutor.Processes.IMAGEMAGICK) + .runCommandWithOutputHandling(command); + + if (result.getRc() != 0) { + log.warn( + "ImageMagick line art conversion failed with return code: {}", + result.getRc()); + throw new IOException("ImageMagick line art conversion failed"); + } + + byte[] convertedBytes = Files.readAllBytes(outputImage); + return PDImageXObject.createFromByteArray( + doc, convertedBytes, originalImage.getCOSObject().toString()); + } catch (Exception e) { + log.warn("ImageMagick line art conversion failed", e); + throw new IOException("ImageMagick line art conversion failed", e); + } finally { + Files.deleteIfExists(inputImage); + Files.deleteIfExists(outputImage); + } + } +} diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index 0ba7cfb3c2..2968f569ce 100644 --- a/docker/Dockerfile.unified +++ b/docker/Dockerfile.unified @@ -105,6 +105,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a gcompat \ libc6-compat \ libreoffice \ + imagemagick \ # pdftohtml poppler-utils \ # OCR MY PDF diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index f2421fa943..b946e1e619 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -81,6 +81,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index c54a162dae..78d395d9de 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -74,6 +74,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index a38aee9b4a..6b189f3108 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -99,6 +99,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 67e648aee0..462daa901b 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -101,6 +101,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a libc6-compat \ libreoffice \ ghostscript \ + imagemagick \ fontforge \ # pdftohtml poppler-utils \ diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 3f269a52c1..7743ca9b1e 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -3729,6 +3729,16 @@ filesize = "File Size" [compress.grayscale] label = "Apply Grayscale for Compression" +[compress.lineArt] +label = "Convert images to line art" +description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction." +unavailable = "ImageMagick is not installed or enabled on this server" +detailLevel = "Detail level" +edgeEmphasis = "Edge emphasis" +edgeLow = "Gentle" +edgeMedium = "Balanced" +edgeHigh = "Strong" + [compress.tooltip.header] title = "Compress Settings Overview" @@ -3746,6 +3756,10 @@ bullet2 = "Higher values reduce file size" title = "Grayscale" text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents." +[compress.tooltip.lineArt] +title = "Line Art" +text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected." + [compress.error] failed = "An error occurred while compressing the PDF." diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a7c3355d89..b10fec0e54 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,74 +1,82 @@ { - "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "Stirling-PDF", - "version": "2.0.0", - "identifier": "stirling.pdf.dev", - "build": { - "frontendDist": "../dist", - "devUrl": "http://localhost:5173", - "beforeDevCommand": "npm run dev -- --mode desktop", - "beforeBuildCommand": "npm run build -- --mode desktop" - }, - "app": { - "windows": [ - { - "title": "Stirling-PDF", - "width": 1280, - "height": 800, - "resizable": true, - "fullscreen": false - } - ] - }, - "bundle": { - "active": true, - "targets": ["deb", "rpm", "dmg", "app", "msi"], - "icon": [ - "icons/icon.png", - "icons/icon.icns", - "icons/icon.ico", - "icons/16x16.png", - "icons/32x32.png", - "icons/64x64.png", - "icons/128x128.png", - "icons/192x192.png" - ], - "resources": [ - "libs/*.jar", - "runtime/jre/**/*" - ], - "fileAssociations": [ - { - "ext": ["pdf"], - "name": "PDF Document", - "description": "Open PDF files with Stirling-PDF", - "role": "Editor", - "mimeType": "application/pdf" - } - ], - "linux": { - "deb": { - "desktopTemplate": "stirling-pdf.desktop" - } + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Stirling-PDF", + "version": "2.1.3", + "identifier": "stirling.pdf.dev", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm run dev -- --mode desktop", + "beforeBuildCommand": "npm run build -- --mode desktop" }, - "windows": { - "certificateThumbprint": null, - "digestAlgorithm": "sha256", - "timestampUrl": "http://timestamp.digicert.com" + "app": { + "windows": [ + { + "title": "Stirling-PDF", + "width": 1280, + "height": 800, + "resizable": true, + "fullscreen": false + } + ] }, - "macOS": { - "minimumSystemVersion": "10.15", - "signingIdentity": null, - "entitlements": null, - "providerShortName": null + "bundle": { + "active": true, + "targets": [ + "deb", + "rpm", + "dmg", + "app", + "msi" + ], + "icon": [ + "icons/icon.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/16x16.png", + "icons/32x32.png", + "icons/64x64.png", + "icons/128x128.png", + "icons/192x192.png" + ], + "resources": [ + "libs/*.jar", + "runtime/jre/**/*" + ], + "fileAssociations": [ + { + "ext": [ + "pdf" + ], + "name": "PDF Document", + "description": "Open PDF files with Stirling-PDF", + "role": "Editor", + "mimeType": "application/pdf" + } + ], + "linux": { + "deb": { + "desktopTemplate": "stirling-pdf.desktop" + } + }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com" + }, + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": null, + "entitlements": null, + "providerShortName": null + } + }, + "plugins": { + "shell": { + "open": true + }, + "fs": { + "requireLiteralLeadingDot": false + } } - }, - "plugins": { - "shell": { - "open": true - }, - "fs": { - "requireLiteralLeadingDot": false - } - } } diff --git a/frontend/src/core/components/tools/compress/CompressSettings.tsx b/frontend/src/core/components/tools/compress/CompressSettings.tsx index 398e0b7b47..f444da2631 100644 --- a/frontend/src/core/components/tools/compress/CompressSettings.tsx +++ b/frontend/src/core/components/tools/compress/CompressSettings.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; -import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core"; +import { useState, useEffect } from "react"; +import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; +import apiClient from "@app/services/apiClient"; interface CompressSettingsProps { parameters: CompressParameters; @@ -13,6 +14,20 @@ interface CompressSettingsProps { const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => { const { t } = useTranslation(); const [isSliding, setIsSliding] = useState(false); + const [imageMagickAvailable, setImageMagickAvailable] = useState(null); + + useEffect(() => { + const checkImageMagick = async () => { + try { + const response = await apiClient.get('/api/v1/config/group-enabled?group=ImageMagick'); + setImageMagickAvailable(response.data); + } catch (error) { + console.error('Failed to check ImageMagick availability:', error); + setImageMagickAvailable(true); // Optimistic fallback + } + }; + checkImageMagick(); + }, []); return ( @@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C disabled={disabled} label={t("compress.grayscale.label", "Apply Grayscale for compression")} /> + onParameterChange('lineArt', event.currentTarget.checked)} + disabled={disabled || imageMagickAvailable === false} + label={t("compress.lineArt.label", "Convert images to line art (bilevel)")} + description={ + imageMagickAvailable === false + ? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server") + : t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.") + } + /> + {parameters.lineArt && ( + + {t('compress.lineArt.detailLevel', 'Detail level')} + { + // Map threshold to slider position + const thresholdMap = [20, 35, 50, 65, 80]; + const closest = thresholdMap.reduce((prev, curr, idx) => + Math.abs(curr - parameters.lineArtThreshold) < Math.abs(thresholdMap[prev] - parameters.lineArtThreshold) + ? idx : prev, 0); + return closest + 1; + })()} + onChange={(value) => { + // Map slider position to threshold: 1=20%, 2=35%, 3=50%, 4=65%, 5=80% + const thresholdMap = [20, 35, 50, 65, 80]; + onParameterChange('lineArtThreshold', thresholdMap[value - 1]); + }} + disabled={disabled || imageMagickAvailable === false} + label={null} + marks={[ + { value: 1 }, + { value: 2 }, + { value: 3 }, + { value: 4 }, + { value: 5 }, + ]} + /> + + {t('compress.lineArt.edgeEmphasis', 'Edge emphasis')} + onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)} + /> + + )} ); diff --git a/frontend/src/core/components/tooltips/useCompressTips.ts b/frontend/src/core/components/tooltips/useCompressTips.ts index c49ceaca21..3b5299e08e 100644 --- a/frontend/src/core/components/tooltips/useCompressTips.ts +++ b/frontend/src/core/components/tooltips/useCompressTips.ts @@ -24,6 +24,13 @@ export const useCompressTips = (): TooltipContent => { { title: t("compress.tooltip.grayscale.title", "Grayscale"), description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.") + }, + { + title: t("compress.tooltip.lineArt.title", "Line Art"), + description: t( + "compress.tooltip.lineArt.text", + "Convert pages to high-contrast black and white using ImageMagick. Use line thickness to control the threshold percentage and detection strength to choose how aggressively edges are outlined." + ) } ] }; diff --git a/frontend/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/src/core/hooks/tools/compress/useCompressOperation.ts index 5b1417b21f..8e8f27b331 100644 --- a/frontend/src/core/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/core/hooks/tools/compress/useCompressOperation.ts @@ -19,6 +19,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File } formData.append("grayscale", parameters.grayscale.toString()); + formData.append("lineArt", parameters.lineArt.toString()); + if (parameters.lineArt) { + formData.append("lineArtThreshold", parameters.lineArtThreshold.toString()); + formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString()); + } return formData; }; diff --git a/frontend/src/core/hooks/tools/compress/useCompressParameters.ts b/frontend/src/core/hooks/tools/compress/useCompressParameters.ts index 1ae9298f5e..16f80f1d1e 100644 --- a/frontend/src/core/hooks/tools/compress/useCompressParameters.ts +++ b/frontend/src/core/hooks/tools/compress/useCompressParameters.ts @@ -4,6 +4,9 @@ import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/u export interface CompressParameters extends BaseParameters { compressionLevel: number; grayscale: boolean; + lineArt: boolean; + lineArtThreshold: number; + lineArtEdgeLevel: 1 | 2 | 3; expectedSize: string; compressionMethod: 'quality' | 'filesize'; fileSizeValue: string; @@ -13,6 +16,9 @@ export interface CompressParameters extends BaseParameters { export const defaultParameters: CompressParameters = { compressionLevel: 5, grayscale: false, + lineArt: false, + lineArtThreshold: 50, + lineArtEdgeLevel: 3, expectedSize: '', compressionMethod: 'quality', fileSizeValue: '', diff --git a/frontend/src/core/testing/serverExperienceSimulations.ts b/frontend/src/core/testing/serverExperienceSimulations.ts index f136ce4890..4bb3725459 100644 --- a/frontend/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: '2.0.0', + appVersion: '2.1.3', serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/src/proprietary/testing/serverExperienceSimulations.ts index ce6b60ab0a..022910d0aa 100644 --- a/frontend/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: '2.0.0', + appVersion: '2.1.3', serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true, From 336ec34125523b9f5e83e4da457f64f233f95633 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:55:58 +0000 Subject: [PATCH 07/62] V2 Handle SSO account restrictions in account settings (#5225) ## Summary - hide password and username update controls for SSO accounts and show a managed-account notice - prevent account update handlers from calling APIs when the user authenticates via SSO - expose authenticationType on the user session model and add translations for new SSO messaging ## Testing - not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_693ae8144148832888ecf128e66cd3ca) --- .../public/locales/en-GB/translation.toml | 2 + .../src/proprietary/auth/springAuthClient.ts | 1 + .../config/configSections/AccountSection.tsx | 63 ++++++++++++++----- 3 files changed, 51 insertions(+), 15 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 7743ca9b1e..4f81d64a34 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -449,6 +449,7 @@ required = "All fields are required." mismatch = "New passwords do not match." error = "Unable to update password. Please verify your current password and try again." success = "Password updated successfully. Please sign in again." +ssoDisabled = "Password changes are managed by your identity provider." current = "Current password" currentPlaceholder = "Enter your current password" new = "New password" @@ -510,6 +511,7 @@ low = "Low" title = "Change Credentials" header = "Update Your Account Details" changePassword = "You are using default login credentials. Please enter a new password" +ssoManaged = "Your account is managed by your identity provider." newUsername = "New Username" oldPassword = "Current Password" newPassword = "New Password" diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 646b711823..be404ffb4b 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -60,6 +60,7 @@ export interface User { enabled?: boolean; is_anonymous?: boolean; isFirstLogin?: boolean; + authenticationType?: string; app_metadata?: Record; } diff --git a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx index f627302b4a..95a9ee57fa 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -24,6 +24,17 @@ const AccountSection: React.FC = () => { const [usernameError, setUsernameError] = useState(''); const [usernameSubmitting, setUsernameSubmitting] = useState(false); + const authTypeFromMetadata = useMemo(() => { + const metadata = user?.app_metadata as { authType?: string; authenticationType?: string } | undefined; + return metadata?.authenticationType ?? metadata?.authType; + }, [user?.app_metadata]); + + const normalizedAuthType = useMemo( + () => (user?.authenticationType ?? authTypeFromMetadata ?? '').toLowerCase(), + [authTypeFromMetadata, user?.authenticationType] + ); + const isSsoUser = useMemo(() => ['sso', 'oauth2', 'saml2'].includes(normalizedAuthType), [normalizedAuthType]); + const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]); const redirectToLogin = useCallback(() => { @@ -41,6 +52,11 @@ const AccountSection: React.FC = () => { const handlePasswordSubmit = async (event: React.FormEvent) => { event.preventDefault(); + if (isSsoUser) { + setPasswordError(t('settings.security.password.ssoDisabled', 'Password changes are managed by your identity provider.')); + return; + } + if (!currentPassword || !newPassword || !confirmPassword) { setPasswordError(t('settings.security.password.required', 'All fields are required.')); return; @@ -81,6 +97,11 @@ const AccountSection: React.FC = () => { const handleUsernameSubmit = async (event: React.FormEvent) => { event.preventDefault(); + if (isSsoUser) { + setUsernameError(t('changeCreds.ssoManaged', 'Your account is managed by your identity provider.')); + return; + } + if (!currentPasswordForUsername || !newUsername) { setUsernameError(t('settings.security.password.required', 'All fields are required.')); return; @@ -132,23 +153,35 @@ const AccountSection: React.FC = () => { : t('account.accountSettings', 'Account Settings')} - - + + {isSsoUser && ( + } color="blue" variant="light"> + {t('changeCreds.ssoManaged', 'Your account is managed by your identity provider.')} + + )} - + + {!isSsoUser && ( + + )} - - + {!isSsoUser && ( + + )} + + + + From d80e627899daf804f1390a0b75a1da3fd093aa84 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:54:25 +0000 Subject: [PATCH 08/62] Cache fix issues V2 (#5237) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../common/model/ApplicationProperties.java | 50 ++- .../software/SPDF/config/InitialSetup.java | 1 + .../ConvertPdfJsonExceptionHandler.java | 60 ++++ .../exception/CacheUnavailableException.java | 8 + .../service/PdfJsonConversionService.java | 310 ++++++++++++++++-- .../service/PdfJsonFallbackFontService.java | 17 + .../service/pdfjson/PdfJsonFontService.java | 30 +- .../pdfjson/type3/Type3LibraryStrategy.java | 16 +- .../type3/library/Type3FontLibrary.java | 14 +- .../src/main/resources/settings.yml.template | 42 +-- .../api/ProprietaryUIDataController.java | 27 +- .../security/config/AccountWebController.java | 4 +- .../configuration/SecurityConfiguration.java | 3 +- .../controller/api/AuthController.java | 5 +- ...stomSaml2AuthenticationSuccessHandler.java | 57 +++- .../security/saml2/Saml2Configuration.java | 73 ++++- .../service/UserLicenseSettingsService.java | 66 ++-- .../UserLicenseSettingsServiceTest.java | 3 + build.gradle | 2 +- .../tools/pdfTextEditor/PdfTextEditor.tsx | 171 ++++++---- .../proprietary/auth/springAuthClient.test.ts | 2 +- .../src/proprietary/auth/springAuthClient.ts | 17 +- .../src/proprietary/routes/Login.test.tsx | 12 +- frontend/src/proprietary/routes/Login.tsx | 11 +- .../proprietary/routes/login/OAuthButtons.tsx | 21 +- frontend/vite.config.ts | 12 + 26 files changed, 805 insertions(+), 229 deletions(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index e94a5f3951..72cfef1a0c 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -68,6 +68,7 @@ public class ApplicationProperties { private AutoPipeline autoPipeline = new AutoPipeline(); private ProcessExecutor processExecutor = new ProcessExecutor(); + private PdfEditor pdfEditor = new PdfEditor(); @Bean public PropertySource dynamicYamlPropertySource(ConfigurableEnvironment environment) @@ -100,6 +101,46 @@ public class ApplicationProperties { private String outputFolder; } + @Data + public static class PdfEditor { + private Cache cache = new Cache(); + private FontNormalization fontNormalization = new FontNormalization(); + private CffConverter cffConverter = new CffConverter(); + private Type3 type3 = new Type3(); + private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf"; + + @Data + public static class Cache { + private long maxBytes = -1; + private int maxPercent = 20; + } + + @Data + public static class FontNormalization { + private boolean enabled = false; + } + + @Data + public static class CffConverter { + private boolean enabled = true; + private String method = "python"; + private String pythonCommand = "/opt/venv/bin/python3"; + private String pythonScript = "/scripts/convert_cff_to_ttf.py"; + private String fontforgeCommand = "fontforge"; + } + + @Data + public static class Type3 { + private Library library = new Library(); + + @Data + public static class Library { + private boolean enabled = true; + private String index = "classpath:/type3/library/index.json"; + } + } + } + @Data public static class Legal { private String termsAndConditions; @@ -368,10 +409,12 @@ public class ApplicationProperties { private TempFileManagement tempFileManagement = new TempFileManagement(); private DatabaseBackup databaseBackup = new DatabaseBackup(); private List corsAllowedOrigins = new ArrayList<>(); - private String - frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, + private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g. + // 'http://localhost:8080', 'https://api.example.com'). Required for + // SSO. + private String frontendUrl; // Frontend URL for invite email links (e.g. - // falls back to backend URL. + // 'https://app.example.com'). If not set, falls back to backendUrl. public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); @@ -536,6 +579,7 @@ public class ApplicationProperties { @ToString.Exclude private String key; private String UUID; private String appVersion; + private Boolean isNewServer; } // TODO: Remove post migration diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 88755f9504..2cded405fa 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -94,6 +94,7 @@ public class InitialSetup { } GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); + applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer); } public static boolean isNewServer() { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java new file mode 100644 index 0000000000..c82fe19e11 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java @@ -0,0 +1,60 @@ +package stirling.software.SPDF.controller.api.converters; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.exception.CacheUnavailableException; + +@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class) +@Slf4j +@RequiredArgsConstructor +public class ConvertPdfJsonExceptionHandler { + + private final ObjectMapper objectMapper; + + @ExceptionHandler(CacheUnavailableException.class) + @ResponseBody + public ResponseEntity handleCacheUnavailable(CacheUnavailableException ex) { + try { + byte[] body = + objectMapper.writeValueAsBytes( + java.util.Map.of( + "error", "cache_unavailable", + "action", "reupload", + "message", ex.getMessage())); + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body(body); + } catch (Exception e) { + log.warn("Failed to serialize cache_unavailable response", e); + var fallbackBody = + java.util.Map.of( + "error", "cache_unavailable", + "action", "reupload", + "message", String.valueOf(ex.getMessage())); + try { + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body(objectMapper.writeValueAsBytes(fallbackBody)); + } catch (Exception ignored) { + // Truly last-ditch fallback + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body( + "{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}" + .getBytes(StandardCharsets.UTF_8)); + } + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java new file mode 100644 index 0000000000..fd5d776773 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java @@ -0,0 +1,8 @@ +package stirling.software.SPDF.exception; + +public class CacheUnavailableException extends RuntimeException { + + public CacheUnavailableException(String message) { + super(message); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java index 623b99260e..604e9ba38e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java @@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; import org.apache.pdfbox.util.DateConverter; import org.apache.pdfbox.util.Matrix; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -144,15 +143,23 @@ public class PdfJsonConversionService { private final PdfJsonFontService fontService; private final Type3FontConversionService type3FontConversionService; private final Type3GlyphExtractor type3GlyphExtractor; + private final stirling.software.common.model.ApplicationProperties applicationProperties; private final Map type3NormalizedFontCache = new ConcurrentHashMap<>(); private final Map> type3GlyphCoverageCache = new ConcurrentHashMap<>(); - @Value("${stirling.pdf.json.font-normalization.enabled:true}") private boolean fontNormalizationEnabled; + private long cacheMaxBytes; + private int cacheMaxPercent; /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ private final Map documentCache = new ConcurrentHashMap<>(); + private final java.util.LinkedHashMap lruCache = + new java.util.LinkedHashMap<>(16, 0.75f, true); + private final Object cacheLock = new Object(); + private volatile long currentCacheBytes = 0L; + private volatile long cacheBudgetBytes = -1L; + private volatile boolean ghostscriptAvailable; private static final float FLOAT_EPSILON = 0.0001f; @@ -161,7 +168,23 @@ public class PdfJsonConversionService { @PostConstruct private void initializeToolAvailability() { + loadConfigurationFromProperties(); initializeGhostscriptAvailability(); + initializeCacheBudget(); + } + + private void loadConfigurationFromProperties() { + stirling.software.common.model.ApplicationProperties.PdfEditor cfg = + applicationProperties.getPdfEditor(); + if (cfg != null) { + fontNormalizationEnabled = cfg.getFontNormalization().isEnabled(); + cacheMaxBytes = cfg.getCache().getMaxBytes(); + cacheMaxPercent = cfg.getCache().getMaxPercent(); + } else { + fontNormalizationEnabled = false; + cacheMaxBytes = -1; + cacheMaxPercent = 20; + } } private void initializeGhostscriptAvailability() { @@ -202,6 +225,25 @@ public class PdfJsonConversionService { } } + private void initializeCacheBudget() { + long effective = -1L; + if (cacheMaxBytes > 0) { + effective = cacheMaxBytes; + } else if (cacheMaxPercent > 0) { + long maxMem = Runtime.getRuntime().maxMemory(); + effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100); + } + cacheBudgetBytes = effective; + if (cacheBudgetBytes > 0) { + log.info( + "PDF JSON cache budget configured: {} bytes (source: {})", + cacheBudgetBytes, + cacheMaxBytes > 0 ? "max-bytes" : "max-percent"); + } else { + log.info("PDF JSON cache budget: unlimited"); + } + } + public byte[] convertPdfToJson(MultipartFile file) throws IOException { return convertPdfToJson(file, null, false); } @@ -236,7 +278,10 @@ public class PdfJsonConversionService { log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId); } else { jobId = contextJobId; - log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId); + log.info( + "Starting PDF to JSON conversion, jobId from context: {} (lightweight={})", + jobId, + lightweight); } Consumer progress = @@ -318,9 +363,9 @@ public class PdfJsonConversionService { try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) { int totalPages = document.getNumberOfPages(); - // Only use lazy images for real async jobs where client can access the cache - // Synchronous calls with synthetic jobId should do full extraction - boolean useLazyImages = totalPages > 5 && isRealJobId; + // Always enable lazy mode for real async jobs so cache is available regardless of + // page count. Synchronous calls with synthetic jobId still do full extraction. + boolean useLazyImages = isRealJobId; Map fontCache = new IdentityHashMap<>(); Map imageCache = new IdentityHashMap<>(); log.debug( @@ -403,6 +448,11 @@ public class PdfJsonConversionService { // Only cache for real async jobIds, not synthetic synchronous ones if (useLazyImages && isRealJobId) { + log.info( + "Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})", + jobId, + useLazyImages, + isRealJobId); PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); docMetadata.setMetadata(pdfJson.getMetadata()); docMetadata.setXmpMetadata(pdfJson.getXmpMetadata()); @@ -435,16 +485,23 @@ public class PdfJsonConversionService { cachedPdfBytes = Files.readAllBytes(workingPath); } CachedPdfDocument cached = - new CachedPdfDocument( - cachedPdfBytes, docMetadata, fonts, pageFontResources); - documentCache.put(jobId, cached); - log.debug( - "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}", - cachedPdfBytes.length, + buildCachedDocument( + jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources); + putCachedDocument(jobId, cached); + log.info( + "Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})", + cached.getPdfSize(), totalPages, fonts.size(), - jobId); + jobId, + cached.isDiskBacked()); scheduleDocumentCleanup(jobId); + } else { + log.warn( + "Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}", + useLazyImages, + isRealJobId, + jobId); } if (lightweight) { @@ -2973,6 +3030,139 @@ public class PdfJsonConversionService { } } + // Cache helpers + private CachedPdfDocument buildCachedDocument( + String jobId, + byte[] pdfBytes, + PdfJsonDocumentMetadata metadata, + Map fonts, + Map> pageFontResources) + throws IOException { + if (pdfBytes == null) { + throw new IllegalArgumentException("pdfBytes must not be null"); + } + long budget = cacheBudgetBytes; + // If single document is larger than budget, spill straight to disk + if (budget > 0 && pdfBytes.length > budget) { + TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache"); + Files.write(tempFile.getPath(), pdfBytes); + log.debug( + "Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}", + pdfBytes.length, + budget, + jobId); + return new CachedPdfDocument( + null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources); + } + return new CachedPdfDocument( + pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources); + } + + private void putCachedDocument(String jobId, CachedPdfDocument cached) { + synchronized (cacheLock) { + CachedPdfDocument existing = documentCache.put(jobId, cached); + if (existing != null) { + lruCache.remove(jobId); + currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize()); + existing.close(); + } + lruCache.put(jobId, cached); + currentCacheBytes += cached.getInMemorySize(); + enforceCacheBudget(); + } + } + + private CachedPdfDocument getCachedDocument(String jobId) { + synchronized (cacheLock) { + CachedPdfDocument cached = documentCache.get(jobId); + if (cached != null) { + lruCache.remove(jobId); + lruCache.put(jobId, cached); + } + return cached; + } + } + + private void enforceCacheBudget() { + if (cacheBudgetBytes <= 0) { + return; + } + // Must be called under cacheLock + java.util.Iterator> it = + lruCache.entrySet().iterator(); + while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) { + java.util.Map.Entry entry = it.next(); + it.remove(); + CachedPdfDocument removed = entry.getValue(); + documentCache.remove(entry.getKey(), removed); + currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize()); + removed.close(); + log.warn( + "Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)", + entry.getKey(), + cacheBudgetBytes, + currentCacheBytes); + } + if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) { + // Spill the most recently used large entry to disk + String key = + lruCache.entrySet().stream() + .reduce((first, second) -> second) + .map(java.util.Map.Entry::getKey) + .orElse(null); + if (key != null) { + CachedPdfDocument doc = lruCache.get(key); + if (doc != null && doc.getInMemorySize() > 0) { + try { + CachedPdfDocument diskDoc = + buildCachedDocument( + key, + doc.getPdfBytes(), + doc.getMetadata(), + doc.getFonts(), + doc.getPageFontResources()); + lruCache.put(key, diskDoc); + documentCache.put(key, diskDoc); + currentCacheBytes = + Math.max(0L, currentCacheBytes - doc.getInMemorySize()) + + diskDoc.getInMemorySize(); + doc.close(); + log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key); + } catch (IOException ex) { + log.warn( + "Failed to spill cached PDF for jobId {} to disk: {}", + key, + ex.getMessage()); + } + } + } + } + } + + private void removeCachedDocument(String jobId) { + log.warn( + "removeCachedDocument called for jobId: {} [CALLER: {}]", + jobId, + Thread.currentThread().getStackTrace()[2].toString()); + CachedPdfDocument removed = null; + synchronized (cacheLock) { + removed = documentCache.remove(jobId); + if (removed != null) { + lruCache.remove(jobId); + currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize()); + log.warn( + "Removed cached document for jobId: {} (size={} bytes)", + jobId, + removed.getInMemorySize()); + } else { + log.warn("Attempted to remove jobId: {} but it was not in cache", jobId); + } + } + if (removed != null) { + removed.close(); + } + } + private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element) throws IOException { if (element.getCharacterSpacing() != null) { @@ -5311,6 +5501,8 @@ public class PdfJsonConversionService { */ private static class CachedPdfDocument { private final byte[] pdfBytes; + private final TempFile pdfTempFile; + private final long pdfSize; private final PdfJsonDocumentMetadata metadata; private final Map fonts; // Font map with UIDs for consistency private final Map> pageFontResources; // Page font resources @@ -5318,10 +5510,14 @@ public class PdfJsonConversionService { public CachedPdfDocument( byte[] pdfBytes, + TempFile pdfTempFile, + long pdfSize, PdfJsonDocumentMetadata metadata, Map fonts, Map> pageFontResources) { this.pdfBytes = pdfBytes; + this.pdfTempFile = pdfTempFile; + this.pdfSize = pdfSize; this.metadata = metadata; // Create defensive copies to prevent mutation of shared maps this.fonts = @@ -5336,8 +5532,14 @@ public class PdfJsonConversionService { } // Getters return defensive copies to prevent external mutation - public byte[] getPdfBytes() { - return pdfBytes; + public byte[] getPdfBytes() throws IOException { + if (pdfBytes != null) { + return pdfBytes; + } + if (pdfTempFile != null) { + return Files.readAllBytes(pdfTempFile.getPath()); + } + throw new IOException("Cached PDF backing missing"); } public PdfJsonDocumentMetadata getMetadata() { @@ -5352,6 +5554,18 @@ public class PdfJsonConversionService { return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources); } + public long getPdfSize() { + return pdfSize; + } + + public long getInMemorySize() { + return pdfBytes != null ? pdfBytes.length : 0L; + } + + public boolean isDiskBacked() { + return pdfBytes == null && pdfTempFile != null; + } + public long getTimestamp() { return timestamp; } @@ -5363,7 +5577,19 @@ public class PdfJsonConversionService { public CachedPdfDocument withUpdatedFonts( byte[] nextBytes, Map nextFonts) { Map fontsToUse = nextFonts != null ? nextFonts : this.fonts; - return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources); + return new CachedPdfDocument( + nextBytes, + null, + nextBytes != null ? nextBytes.length : 0, + metadata, + fontsToUse, + pageFontResources); + } + + public void close() { + if (pdfTempFile != null) { + pdfTempFile.close(); + } } } @@ -5444,14 +5670,15 @@ public class PdfJsonConversionService { // Cache PDF bytes, metadata, and fonts for lazy page loading if (jobId != null) { CachedPdfDocument cached = - new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources); - documentCache.put(jobId, cached); + buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources); + putCachedDocument(jobId, cached); log.debug( - "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}", - pdfBytes.length, + "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})", + cached.getPdfSize(), totalPages, fonts.size(), - jobId); + jobId, + cached.isDiskBacked()); // Schedule cleanup after 30 minutes scheduleDocumentCleanup(jobId); @@ -5466,9 +5693,10 @@ public class PdfJsonConversionService { /** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */ public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException { - CachedPdfDocument cached = documentCache.get(jobId); + CachedPdfDocument cached = getCachedDocument(jobId); if (cached == null) { - throw new IllegalArgumentException("No cached document found for jobId: " + jobId); + throw new stirling.software.SPDF.exception.CacheUnavailableException( + "No cached document found for jobId: " + jobId); } int pageIndex = pageNumber - 1; @@ -5480,8 +5708,8 @@ public class PdfJsonConversionService { } log.debug( - "Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})", - cached.getPdfBytes().length, + "Loading PDF from {} to extract page {} (jobId: {})", + cached.isDiskBacked() ? "disk cache" : "memory cache", pageNumber, jobId); @@ -5627,10 +5855,21 @@ public class PdfJsonConversionService { if (jobId == null || jobId.isBlank()) { throw new IllegalArgumentException("jobId is required for incremental export"); } - CachedPdfDocument cached = documentCache.get(jobId); + log.info("Looking up cache for jobId: {}", jobId); + CachedPdfDocument cached = getCachedDocument(jobId); if (cached == null) { - throw new IllegalArgumentException("No cached document available for jobId: " + jobId); + log.error( + "Cache not found for jobId: {}. Available cache keys: {}", + jobId, + documentCache.keySet()); + throw new stirling.software.SPDF.exception.CacheUnavailableException( + "No cached document available for jobId: " + jobId); } + log.info( + "Found cached document for jobId: {} (size={}, diskBacked={})", + jobId, + cached.getPdfSize(), + cached.isDiskBacked()); if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) { log.debug( "Incremental export requested with no page updates; returning cached PDF for jobId {}", @@ -5709,7 +5948,14 @@ public class PdfJsonConversionService { document.save(baos); byte[] updatedBytes = baos.toByteArray(); - documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts)); + CachedPdfDocument updated = + buildCachedDocument( + jobId, + updatedBytes, + cached.getMetadata(), + mergedFonts, + cached.getPageFontResources()); + putCachedDocument(jobId, updated); // Clear Type3 cache entries for this incremental update clearType3CacheEntriesForJob(updateJobId); @@ -5724,11 +5970,13 @@ public class PdfJsonConversionService { /** Clears a cached document. */ public void clearCachedDocument(String jobId) { - CachedPdfDocument cached = documentCache.remove(jobId); + CachedPdfDocument cached = getCachedDocument(jobId); + removeCachedDocument(jobId); if (cached != null) { log.debug( - "Removed cached PDF bytes ({} bytes) for jobId: {}", - cached.getPdfBytes().length, + "Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}", + cached.getPdfSize(), + cached.isDiskBacked(), jobId); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java index 107abbe2b2..e4baee0559 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService { "ttf"))); private final ResourceLoader resourceLoader; + private final stirling.software.common.model.ApplicationProperties applicationProperties; @Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}") + private String legacyFallbackFontLocation; + private String fallbackFontLocation; private final Map fallbackFontCache = new ConcurrentHashMap<>(); + @jakarta.annotation.PostConstruct + private void loadConfig() { + String configured = null; + if (applicationProperties.getPdfEditor() != null) { + configured = applicationProperties.getPdfEditor().getFallbackFont(); + } + if (configured != null && !configured.isBlank()) { + fallbackFontLocation = configured; + } else { + fallbackFontLocation = legacyFallbackFontLocation; + } + log.info("Using fallback font location: {}", fallbackFontLocation); + } + public PdfJsonFont buildFallbackFontModel() throws IOException { return buildFallbackFontModel(FALLBACK_FONT_ID); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java index 1a9f7f698f..6a56bad09f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java @@ -5,7 +5,6 @@ import java.nio.file.Files; import java.util.Base64; import java.util.Locale; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import jakarta.annotation.PostConstruct; @@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager; public class PdfJsonFontService { private final TempFileManager tempFileManager; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Getter - @Value("${stirling.pdf.json.cff-converter.enabled:true}") - private boolean cffConversionEnabled; + @Getter private boolean cffConversionEnabled; - @Getter - @Value("${stirling.pdf.json.cff-converter.method:python}") - private String cffConverterMethod; + @Getter private String cffConverterMethod; - @Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}") private String pythonCommand; - @Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}") private String pythonScript; - @Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}") private String fontforgeCommand; private volatile boolean pythonCffConverterAvailable; @@ -48,6 +41,7 @@ public class PdfJsonFontService { @PostConstruct private void initialiseCffConverterAvailability() { + loadConfiguration(); if (!cffConversionEnabled) { log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration"); pythonCffConverterAvailable = false; @@ -77,6 +71,22 @@ public class PdfJsonFontService { log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod); } + private void loadConfiguration() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getCffConverter() != null) { + var cfg = applicationProperties.getPdfEditor().getCffConverter(); + this.cffConversionEnabled = cfg.isEnabled(); + this.cffConverterMethod = cfg.getMethod(); + this.pythonCommand = cfg.getPythonCommand(); + this.pythonScript = cfg.getPythonScript(); + this.fontforgeCommand = cfg.getFontforgeCommand(); + } else { + // Use defaults when config is not available + this.cffConversionEnabled = false; + log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled"); + } + } + public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) { if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) { log.warn( diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java index 4385e57251..b4e8f9d952 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java @@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3; import java.io.IOException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl public class Type3LibraryStrategy implements Type3ConversionStrategy { private final Type3FontLibrary fontLibrary; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Value("${stirling.pdf.json.type3.library.enabled:true}") private boolean enabled; @Override @@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy { return enabled && fontLibrary != null && fontLibrary.isLoaded(); } + @jakarta.annotation.PostConstruct + private void loadConfiguration() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getType3() != null + && applicationProperties.getPdfEditor().getType3().getLibrary() != null) { + var cfg = applicationProperties.getPdfEditor().getType3().getLibrary(); + this.enabled = cfg.isEnabled(); + } else { + this.enabled = false; + log.warn("PdfEditor Type3 library configuration not available, disabled"); + } + } + @Override public PdfJsonFontConversionCandidate convert( Type3ConversionRequest request, Type3GlyphContext context) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java index 32a6abec21..f00c729a24 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java @@ -14,7 +14,6 @@ import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.font.PDType3Font; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; @@ -34,8 +33,8 @@ public class Type3FontLibrary { private final ObjectMapper objectMapper; private final ResourceLoader resourceLoader; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}") private String indexLocation; private final Map signatureIndex = new ConcurrentHashMap<>(); @@ -44,6 +43,17 @@ public class Type3FontLibrary { @jakarta.annotation.PostConstruct void initialise() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getType3() != null + && applicationProperties.getPdfEditor().getType3().getLibrary() != null) { + this.indexLocation = + applicationProperties.getPdfEditor().getType3().getLibrary().getIndex(); + } else { + log.warn( + "[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled"); + entries = List.of(); + return; + } Resource resource = resourceLoader.getResource(indexLocation); if (!resource.exists()) { log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a272c54cca..dffebe1bb3 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -58,6 +58,8 @@ security: idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair + # IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId} + # Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration. jwt: # This feature is currently under development and not yet fully supported. Do not use in production. persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation @@ -132,8 +134,9 @@ system: enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion - corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. - frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL. + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173' + backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. + frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates @@ -179,23 +182,6 @@ system: databaseBackup: cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight -stirling: - pdf: - fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font - json: - font-normalization: - enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts. - cff-converter: - enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility - method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts) - python-command: /opt/venv/bin/python3 # Python interpreter path - python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script - fontforge-command: fontforge # Override if FontForge is installed under a different name/path - type3: - library: - enabled: true # Match common Type3 fonts against the built-in library of converted programs - index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:) - ui: appNameNavbar: '' # name displayed on the navigation bar logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) @@ -239,3 +225,21 @@ processExecutor: qpdfTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30 ocrMyPdfTimeoutMinutes: 30 + +pdfEditor: + fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font + cache: + max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap + max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0 + font-normalization: + enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts. + cff-converter: + enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility + method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts) + python-command: /opt/venv/bin/python3 # Python interpreter path + python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script + fontforge-command: fontforge # Override if FontForge is installed under a different name/path + type3: + library: + enabled: true # Match common Type3 fonts against the built-in library of converted programs + index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 88fddb7eaf..e13d807da0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -94,6 +94,22 @@ public class ProprietaryUIDataController { this.auditRepository = auditRepository; } + /** + * Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set, + * otherwise defaults to http://localhost:8080 + */ + private String getBackendBaseUrl() { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + + // If backendUrl is configured, use it + if (backendUrl != null && !backendUrl.trim().isEmpty()) { + return backendUrl.trim(); + } + + // For development, default to localhost:8080 (backend port) + return "http://localhost:8080"; + } + @GetMapping("/audit-dashboard") @PreAuthorize("hasRole('ADMIN')") @EnterpriseEndpoint @@ -185,14 +201,17 @@ public class ProprietaryUIDataController { } SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + // For SAML, we need to use the backend URL directly, not a relative path + // This ensures Spring Security generates the correct ACS URL + String backendUrl = getBackendBaseUrl(); + String fullSamlPath = backendUrl + saml2AuthenticationPath; + if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { - providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index d035dbc587..17857fc85b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -120,9 +120,7 @@ public class AccountWebController { SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 257d243ea4..1226237c87 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -334,7 +334,8 @@ public class SecurityConfiguration { securityProperties.getSaml2(), userService, jwtService, - licenseSettingsService)) + licenseSettingsService, + applicationProperties)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index de6428554c..c3e11c3ab3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -244,10 +244,13 @@ public class AuthController { userMap.put("username", user.getUsername()); userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); + userMap.put( + "authenticationType", + user.getAuthenticationType()); // Expose authentication type for SSO detection // Add metadata for OAuth compatibility Map appMetadata = new HashMap<>(); - appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + appMetadata.put("provider", user.getAuthenticationType()); userMap.put("app_metadata", appMetadata); return userMap; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index e8bce579a0..3c63f1bf46 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; + private final ApplicationProperties applicationProperties; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isSamlEligible(null)) { @@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } @@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); - response.sendRedirect( - contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (!userExists || saml2Properties.getBlockRegistration()) { - log.debug("Registration blocked for new user: {}", username); - response.sendRedirect( - contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); + // Block new users only if: blockRegistration is true OR autoCreateUser is false + if (!userExists + && (saml2Properties.getBlockRegistration() + || !saml2Properties.getAutoCreateUser())) { + log.debug( + "Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})", + username, + saml2Properties.getBlockRegistration(), + saml2Properties.getAutoCreateUser()); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { - response.sendRedirect(contextPath + "/logout?maxUsersReached=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?maxUsersReached=true"); return; } @@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler String contextPath, String jwt) { String redirectPath = resolveRedirectPath(request, contextPath); - String origin = - resolveForwardedOrigin(request) - .orElseGet( - () -> - resolveOriginFromReferer(request) - .orElseGet(() -> buildOriginFromRequest(request))); + String origin = resolveOrigin(request); clearRedirectCookie(response); return origin + redirectPath + "#access_token=" + jwt; } + /** + * Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config, + * then falls back to detecting from request headers. + */ + private String resolveOrigin(HttpServletRequest request) { + // First check if frontendUrl is configured + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + return configuredFrontendUrl.trim(); + } + + // Fall back to auto-detection from request headers + return resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + } + private String resolveRedirectPath(HttpServletRequest request, String contextPath) { return extractRedirectPathFromCookie(request) .filter(path -> path.startsWith("/")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 9d21f88a37..99be4b5b0f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -41,22 +41,74 @@ public class Saml2Configuration { @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert()); + + log.info( + "Initializing SAML2 configuration with registration ID: {}", + samlConf.getRegistrationId()); + + // Load IdP certificate + X509Certificate idpCert; + try { + Resource idpCertResource = samlConf.getIdpCert(); + log.info("Loading IdP certificate from: {}", idpCertResource.getDescription()); + if (!idpCertResource.exists()) { + log.error( + "SAML2 IdP certificate not found at: {}", idpCertResource.getDescription()); + throw new IllegalStateException( + "SAML2 IdP certificate file does not exist: " + + idpCertResource.getDescription()); + } + idpCert = CertificateUtils.readCertificate(idpCertResource); + log.info( + "Successfully loaded IdP certificate. Subject: {}", + idpCert.getSubjectX500Principal().getName()); + } catch (Exception e) { + log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 IdP certificate", e); + } + Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); + + // Load SP private key and certificate Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); - Saml2X509Credential signingCredential = - new Saml2X509Credential( - CertificateUtils.readPrivateKey(privateKeyResource), - CertificateUtils.readCertificate(certificateResource), - Saml2X509CredentialType.SIGNING); + + log.info("Loading SP private key from: {}", privateKeyResource.getDescription()); + if (!privateKeyResource.exists()) { + log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP private key file does not exist: " + + privateKeyResource.getDescription()); + } + + log.info("Loading SP certificate from: {}", certificateResource.getDescription()); + if (!certificateResource.exists()) { + log.error( + "SAML2 SP certificate not found at: {}", certificateResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP certificate file does not exist: " + + certificateResource.getDescription()); + } + + Saml2X509Credential signingCredential; + try { + signingCredential = + new Saml2X509Credential( + CertificateUtils.readPrivateKey(privateKeyResource), + CertificateUtils.readCertificate(certificateResource), + Saml2X509CredentialType.SIGNING); + log.info("Successfully loaded SP credentials"); + } catch (Exception e) { + log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 SP credentials", e); + } RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) .signingX509Credentials(c -> c.add(signingCredential)) .entityId(samlConf.getIdpIssuer()) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .singleLogoutServiceResponseLocation("{baseUrl}/login") .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") @@ -75,9 +127,14 @@ public class Saml2Configuration { .singleLogoutServiceLocation( samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceResponseLocation( - "http://localhost:8080/login") + "{baseUrl}/login") .wantAuthnRequestsSigned(true)) .build(); + + log.info( + "SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}", + samlConf.getRegistrationId(), + samlConf.getIdpIssuer()); return new InMemoryRelyingPartyRegistrationRepository(rp); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index aa794e6997..54660a1ccb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -177,6 +177,13 @@ public class UserLicenseSettingsService { */ @Transactional public void grandfatherExistingOAuthUsers() { + // Only grandfather users if this is a V1→V2 upgrade, not a fresh V2 install + Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer(); + if (Boolean.TRUE.equals(isNewServer)) { + log.info("Fresh V2 installation detected - skipping OAuth user grandfathering"); + return; + } + UserLicenseSettings settings = getOrCreateSettings(); // Check if we've already run this migration @@ -348,30 +355,22 @@ public class UserLicenseSettingsService { String username = (user != null) ? user.getUsername() : ""; log.info("OAuth eligibility check for user: {}", username); - // Grandfathered users always have OAuth access - if (user != null && user.isOauthGrandfathered()) { - log.debug("User {} is grandfathered for OAuth", user.getUsername()); + // Check license first - if paying, they're eligible (no need to check grandfathering) + boolean hasPaid = hasPaidLicense(); + if (hasPaid) { + log.debug("User {} eligible for OAuth via paid license", username); return true; } - // todo: remove - if (user != null) { - log.info( - "User {} is NOT grandfathered (isOauthGrandfathered={})", - username, - user.isOauthGrandfathered()); - } else { - log.info("New user attempting OAuth login - checking license requirement"); + // No license - check if grandfathered (fallback for V1 users) + if (user != null && user.isOauthGrandfathered()) { + log.info("User {} eligible for OAuth via grandfathering (no paid license)", username); + return true; } - // Users can use OAuth with SERVER or ENTERPRISE license - boolean hasPaid = hasPaidLicense(); - log.info( - "OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}", - hasPaid, - username, - hasPaid); - return hasPaid; + // Not grandfathered and no license + log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username); + return false; } /** @@ -391,29 +390,26 @@ public class UserLicenseSettingsService { String username = (user != null) ? user.getUsername() : ""; log.info("SAML2 eligibility check for user: {}", username); - // Grandfathered users always have SAML access - if (user != null && user.isOauthGrandfathered()) { - log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username); + // Check license first - if paying, they're eligible (no need to check grandfathering) + boolean hasEnterprise = hasEnterpriseLicense(); + if (hasEnterprise) { + log.debug("User {} eligible for SAML2 via ENTERPRISE license", username); return true; } - if (user != null) { + // No license - check if grandfathered (fallback for V1 users) + if (user != null && user.isOauthGrandfathered()) { log.info( - "User {} is NOT grandfathered (isOauthGrandfathered={})", - username, - user.isOauthGrandfathered()); - } else { - log.info("New user attempting SAML2 login - checking license requirement"); + "User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)", + username); + return true; } - // Users can use SAML only with ENTERPRISE license - boolean hasEnterprise = hasEnterpriseLicense(); + // Not grandfathered and no license log.info( - "SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}", - hasEnterprise, - username, - hasEnterprise); - return hasEnterprise; + "User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered", + username); + return false; } /** diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java index 7f9445ad7c..a7f8042f6f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java @@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest { @Mock private UserService userService; @Mock private ApplicationProperties applicationProperties; @Mock private ApplicationProperties.Premium premium; + @Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated; @Mock private LicenseKeyChecker licenseKeyChecker; @Mock private ObjectProvider licenseKeyCheckerProvider; @@ -49,6 +50,8 @@ class UserLicenseSettingsServiceTest { mockSettings.setGrandfatheredUserSignature("80:test-signature"); when(applicationProperties.getPremium()).thenReturn(premium); + when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated); + when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings)); when(userService.getTotalUsersCount()).thenReturn(80L); when(settingsRepository.save(any(UserLicenseSettings.class))) diff --git a/build.gradle b/build.gradle index 90fde88e59..17bcdb8782 100644 --- a/build.gradle +++ b/build.gradle @@ -59,7 +59,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.3' + version = '2.1.4' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index 533dc644b3..422b066038 100644 --- a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -238,6 +238,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const originalImagesRef = useRef([]); const originalGroupsRef = useRef([]); const imagesByPageRef = useRef([]); + const lastLoadedFileRef = useRef(null); const autoLoadKeyRef = useRef(null); const sourceFileIdRef = useRef(null); const loadRequestIdRef = useRef(0); @@ -251,6 +252,10 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const pagePreviewsRef = useRef>(pagePreviews); const previewScaleRef = useRef>(new Map()); const cachedJobIdRef = useRef(null); + const previousCachedJobIdRef = useRef(null); + const cacheRecoveryInProgressRef = useRef(false); + const cacheRecoveryAttemptsRef = useRef(0); + const recoverCacheAndReloadRef = useRef<() => Promise>(async () => false); // Keep ref in sync with state for access in async callbacks useEffect(() => { @@ -279,6 +284,13 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }; }, []); + const isCacheUnavailableError = useCallback((error: any): boolean => { + const status = error?.response?.status; + // Treat any 410 as cache unavailable, since responseType: 'blob' makes + // it impossible to reliably check the JSON body + return status === 410; + }, []); + const dirtyPages = useMemo( () => getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current), [groupsByPage, imagesByPage], @@ -316,6 +328,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { loadedImagePagesRef.current = new Set(); loadingImagePagesRef.current = new Set(); setSelectedPage(0); + setIsLazyMode(false); + setCachedJobId(null); + cachedJobIdRef.current = null; return; } const cloned = deepCloneDocument(document); @@ -365,11 +380,14 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }, []); useEffect(() => { - const previousJobId = cachedJobIdRef.current; + // Clear old cached job when job ID changes + const previousJobId = previousCachedJobIdRef.current; if (previousJobId && previousJobId !== cachedJobId) { + console.log(`[PdfTextEditor] Clearing old cache for jobId: ${previousJobId}, new jobId: ${cachedJobId}`); clearCachedJob(previousJobId); } - cachedJobIdRef.current = cachedJobId; + // Update the previous jobId ref for next time + previousCachedJobIdRef.current = cachedJobId; }, [cachedJobId, clearCachedJob]); const initializePdfPreview = useCallback( @@ -489,6 +507,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { ); } catch (error) { console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, error); + if (isCacheUnavailableError(error)) { + console.log('[loadImagesForPage] Cache expired, triggering automatic recovery...'); + // Automatically recover by reloading the file + void recoverCacheAndReloadRef.current(); + } } finally { loadingImagePagesRef.current.delete(pageIndex); setLoadingImagePages((prev) => { @@ -498,7 +521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }); } }, - [isLazyMode, cachedJobId], + [isLazyMode, cachedJobId, isCacheUnavailableError], ); const handleLoadFile = useCallback( @@ -507,6 +530,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { return; } + lastLoadedFileRef.current = file; const requestId = loadRequestIdRef.current + 1; loadRequestIdRef.current = requestId; @@ -555,59 +579,35 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { message: 'Starting conversion...', }); - let jobComplete = false; - let attempts = 0; - const maxAttempts = 600; + let jobComplete = false; + let attempts = 0; + const maxAttempts = 600; + let pollDelay = 500; - while (!jobComplete && attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - attempts += 1; + while (!jobComplete && attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, pollDelay)); + attempts += 1; + if (pollDelay < 10000) { + pollDelay = Math.min(10000, Math.floor(pollDelay * 1.5)); + } try { const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`); const jobStatus = statusResponse.data; console.log(`Job status (attempt ${attempts}):`, jobStatus); - if (jobStatus.notes && jobStatus.notes.length > 0) { - const lastNote = jobStatus.notes[jobStatus.notes.length - 1]; - console.log('Latest note:', lastNote); - const matchWithCount = lastNote.match( - /\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/, - ); - if (matchWithCount) { - const percent = parseInt(matchWithCount[1], 10); - const stage = matchWithCount[2]; - const message = matchWithCount[3]; - const current = parseInt(matchWithCount[4], 10); - const total = parseInt(matchWithCount[5], 10); - setConversionProgress({ - percent, - stage, - message, - current, - total, - }); - } else { - const match = lastNote.match(/\[(\d+)%\]\s+(\w+):\s+(.+)/); - if (match) { - const percent = parseInt(match[1], 10); - const stage = match[2]; - const message = match[3]; - setConversionProgress({ - percent, - stage, - message, - }); - } - } - } else if (jobStatus.progress !== undefined) { - const percent = Math.min(Math.max(jobStatus.progress, 0), 100); - setConversionProgress({ - percent, - stage: jobStatus.stage || 'processing', - message: jobStatus.note || 'Converting PDF to JSON...', - }); - } + const percent = Math.min(Math.max(jobStatus.progress ?? 0, 0), 100); + const stage = jobStatus.stage || 'processing'; + const message = jobStatus.note || 'Converting PDF to JSON...'; + const current = jobStatus.current ?? undefined; + const total = jobStatus.total ?? undefined; + setConversionProgress({ + percent, + stage, + message, + current, + total, + }); if (jobStatus.complete) { if (jobStatus.error) { @@ -701,7 +701,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { setLoadedDocument(parsed); resetToDocument(parsed, groupingMode); setIsLazyMode(shouldUseLazyMode); - setCachedJobId(shouldUseLazyMode ? pendingJobId : null); + const newJobId = shouldUseLazyMode ? pendingJobId : null; + setCachedJobId(newJobId); + cachedJobIdRef.current = newJobId; setFileName(file.name); setErrorMessage(null); } catch (error: any) { @@ -719,6 +721,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { setLoadedDocument(null); resetToDocument(null, groupingMode); clearPdfPreview(); + setIsLazyMode(false); + setCachedJobId(null); + cachedJobIdRef.current = null; if (isPdf) { const errorMsg = @@ -743,6 +748,38 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { [groupingMode, resetToDocument, t], ); + const recoverCacheAndReload = useCallback(async () => { + if (cacheRecoveryInProgressRef.current) { + return false; + } + if (cacheRecoveryAttemptsRef.current >= 2) { + console.warn('[PdfTextEditor] Cache recovery limit reached'); + return false; + } + cacheRecoveryAttemptsRef.current += 1; + const file = lastLoadedFileRef.current; + if (!file) { + console.warn('[PdfTextEditor] No file available for cache recovery'); + return false; + } + cacheRecoveryInProgressRef.current = true; + try { + console.log('[PdfTextEditor] Automatically reloading file due to cache expiration...'); + await handleLoadFile(file); + console.log('[PdfTextEditor] Cache recovery successful'); + return true; + } catch (error) { + console.error('[PdfTextEditor] Cache recovery failed', error); + return false; + } finally { + cacheRecoveryInProgressRef.current = false; + } + }, [handleLoadFile]); + + useEffect(() => { + recoverCacheAndReloadRef.current = recoverCacheAndReload; + }, [recoverCacheAndReload]); + // Wrapper for loading files from the dropzone - adds to workbench first const handleLoadFileFromDropzone = useCallback( async (file: File) => { @@ -1057,7 +1094,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { try { const payload = buildPayload(); if (!payload) { - return; + throw new Error('Failed to build payload'); } const { document, filename } = payload; @@ -1076,7 +1113,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); const expectedName = `${baseName || 'document'}.pdf`; const response = await apiClient.post( - `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`, + `/api/v1/convert/pdf/text-editor/partial/${cachedJobIdRef.current}?filename=${encodeURIComponent(expectedName)}`, partialDocument, { responseType: 'blob', @@ -1100,6 +1137,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { '[handleGeneratePdf] Incremental export failed, falling back to full export', incrementalError, ); + // Fall through to full export below } } @@ -1636,6 +1674,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }, 0); }, [navigationActions, navigationState.selectedTool]); + // Register workbench view (re-runs when dependencies change) useEffect(() => { registerCustomWorkbenchView({ id: WORKBENCH_VIEW_ID, @@ -1646,24 +1685,30 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }); setLeftPanelView('hidden'); setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current); - - return () => { - // Clear backend cache if we were using lazy loading - clearCachedJob(cachedJobIdRef.current); - clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); - unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); - setLeftPanelView('toolPicker'); - }; }, [ - clearCachedJob, - clearCustomWorkbenchViewData, registerCustomWorkbenchView, setCustomWorkbenchViewData, setLeftPanelView, viewLabel, - unregisterCustomWorkbenchView, ]); + // Cleanup ONLY on component unmount (not on re-renders) + useEffect(() => { + return () => { + // Clear backend cache when leaving the tool + const jobId = cachedJobIdRef.current; + if (jobId) { + console.log(`[PdfTextEditor] Cleaning up cached document on unmount: ${jobId}`); + apiClient.post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`).catch((error) => { + console.warn('[PdfTextEditor] Failed to clear cache on unmount:', error); + }); + } + clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); + unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); + setLeftPanelView('toolPicker'); + }; + }, []); // Empty deps = cleanup only on unmount + // Note: Compare tool doesn't auto-force workbench, and neither should we // The workbench should be set when the tool is selected via proper channels // (tool registry, tool picker, etc.) - not forced here diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts index 5c6af7a75e..e3db603388 100644 --- a/frontend/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/src/proprietary/auth/springAuthClient.test.ts @@ -343,7 +343,7 @@ describe('SpringAuthClient', () => { }); const result = await springAuth.signInWithOAuth({ - provider: 'github', + provider: '/oauth2/authorization/github', options: { redirectTo: '/auth/callback' }, }); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index be404ffb4b..f77e34cb64 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -250,11 +250,11 @@ class SpringAuthClient { } /** - * Sign in with OAuth provider (GitHub, Google, Authentik, etc.) - * This redirects to the Spring OAuth2 authorization endpoint + * Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.) + * This redirects to the Spring OAuth2/SAML2 authorization endpoint * - * @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany') - * Can be any known provider or custom string - the backend determines available providers + * @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') + * The backend provides the complete path including the auth type and provider ID */ async signInWithOAuth(params: { provider: OAuthProvider; @@ -264,15 +264,16 @@ class SpringAuthClient { const redirectPath = normalizeRedirectPath(params.options?.redirectTo); persistRedirectPath(redirectPath); - // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) - const redirectUrl = `/oauth2/authorization/${params.provider}`; - // console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); + // Use the full path provided by the backend + // This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...) + const redirectUrl = params.provider; + // console.log('[SpringAuth] Redirecting to SSO:', redirectUrl); // Use window.location.assign for full page navigation window.location.assign(redirectUrl); return { error: null }; } catch (error) { return { - error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' }, + error: { message: error instanceof Error ? error.message : 'SSO redirect failed' }, }; } } diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index 218eb7efb4..dc4e76419a 100644 --- a/frontend/src/proprietary/routes/Login.test.tsx +++ b/frontend/src/proprietary/routes/Login.test.tsx @@ -295,9 +295,9 @@ describe('Login', () => { await user.click(oauthButton); await waitFor(() => { - // Should use 'authentik' directly, NOT map to 'oidc' + // Should use full path directly, NOT map to 'oidc' expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ - provider: 'authentik', + provider: '/oauth2/authorization/authentik', options: { redirectTo: '/auth/callback' } }); }); @@ -338,10 +338,10 @@ describe('Login', () => { await user.click(oauthButton); await waitFor(() => { - // Should use 'mycompany' directly - this is the critical fix + // Should use full path directly - this is the critical fix // Previously it would map unknown providers to 'oidc' expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ - provider: 'mycompany', + provider: '/oauth2/authorization/mycompany', options: { redirectTo: '/auth/callback' } }); }); @@ -382,9 +382,9 @@ describe('Login', () => { await user.click(oauthButton); await waitFor(() => { - // Should use 'oidc' when explicitly configured + // Should use full path when explicitly configured expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ - provider: 'oidc', + provider: '/oauth2/authorization/oidc', options: { redirectTo: '/auth/callback' } }); }); diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 4bca34d435..d25b70c58d 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -109,13 +109,12 @@ export default function Login() { updateSupportedLanguages(data.languages, data.defaultLocale); } - // Extract provider IDs from the providerList map - // The keys are like "/oauth2/authorization/google" - extract the last part - const providerIds = Object.keys(data.providerList || {}) - .map(key => key.split('/').pop()) - .filter((id): id is string => id !== undefined); + // Use the full paths from providerList as provider identifiers + // The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling" + // We'll use these full paths so the auth client knows where to redirect + const providerPaths = Object.keys(data.providerList || {}); - setEnabledProviders(providerIds); + setEnabledProviders(providerPaths); } catch (err) { console.error('[Login] Failed to fetch enabled providers:', err); } diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.tsx index d62edfdc15..4a9cc3cc30 100644 --- a/frontend/src/proprietary/routes/login/OAuthButtons.tsx +++ b/frontend/src/proprietary/routes/login/OAuthButtons.tsx @@ -26,7 +26,7 @@ interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void isSubmitting: boolean layout?: 'vertical' | 'grid' | 'icons' - enabledProviders?: OAuthProvider[] // List of enabled provider IDs from backend + enabledProviders?: OAuthProvider[] // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') } export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) { @@ -37,19 +37,24 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' ? Object.keys(oauthProviderConfig) : enabledProviders; - // Build provider list - use provider ID to determine icon and label - const providers = providersToShow.map(id => { - if (id in oauthProviderConfig) { + // Build provider list - extract provider ID from full path for display + const providers = providersToShow.map(pathOrId => { + // Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling') + const providerId = pathOrId.split('/').pop() || pathOrId; + + if (providerId in oauthProviderConfig) { // Known provider - use predefined icon and label return { - id, - ...oauthProviderConfig[id] + id: pathOrId, // Keep full path for redirect + providerId, // Store extracted ID for display lookup + ...oauthProviderConfig[providerId] }; } // Unknown provider - use generic icon and capitalize ID for label return { - id, - label: id.charAt(0).toUpperCase() + id.slice(1), + id: pathOrId, // Keep full path for redirect + providerId, // Store extracted ID for display lookup + label: providerId.charAt(0).toUpperCase() + providerId.slice(1), file: GENERIC_PROVIDER_ICON }; }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f8d52908d9..56de53e371 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -55,12 +55,24 @@ export default defineConfig(({ mode }) => { secure: false, xfwd: true, }, + '/saml2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, '/login/oauth2': { target: 'http://localhost:8080', changeOrigin: true, secure: false, xfwd: true, }, + '/login/saml2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, '/swagger-ui': { target: 'http://localhost:8080', changeOrigin: true, From 340006ceea649a0130171c5392916524192952dc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 16 Dec 2025 14:55:53 +0000 Subject: [PATCH 09/62] Add Sign Up functionality to desktop app (#5244) # Description of Changes Adds Sign Up with email to desktop app. SSO sign up will come in a future PR. --- frontend/src-tauri/Cargo.lock | 93 ++++++++++++++++ frontend/src-tauri/Cargo.toml | 3 +- frontend/src-tauri/capabilities/default.json | 4 +- frontend/src-tauri/src/lib.rs | 37 ++++++- frontend/src-tauri/tauri.conf.json | 7 ++ .../SetupWizard/SaaSLoginScreen.tsx | 16 +++ .../SetupWizard/SaaSSignupScreen.tsx | 104 ++++++++++++++++++ .../desktop/components/SetupWizard/index.tsx | 68 ++++++++++++ frontend/src/desktop/constants/connection.ts | 6 + frontend/src/desktop/services/authService.ts | 63 ++++++++++- 10 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 9719752dc8..2689b6b98a 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -589,6 +589,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -716,6 +736,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -908,6 +934,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1626,6 +1661,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -2820,6 +2861,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3651,6 +3702,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4256,6 +4317,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", @@ -4615,6 +4677,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.4" @@ -4735,6 +4818,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", @@ -4954,6 +5038,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index dc84ad8a23..455436cdfe 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" tauri-plugin-http = "2.4.4" -tauri-plugin-single-instance = "2.0.1" +tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-store = "2.1.0" tauri-plugin-opener = "2.0.0" +tauri-plugin-deep-link = "2.4.5" keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index b992b32211..445e1d30a5 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -19,6 +19,8 @@ { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] - } + }, + "opener:default", + "shell:allow-open" ] } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a08587cc17..61cbd6d435 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use tauri::{Manager, RunEvent, WindowEvent, Emitter}; +use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; mod commands; @@ -28,6 +28,17 @@ use commands::{ }; use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; +use tauri_plugin_deep_link::DeepLinkExt; + +fn dispatch_deep_link(app: &AppHandle, url: &str) { + add_log(format!("🔗 Dispatching deep link: {}", url)); + let _ = app.emit("deep-link", url.to_string()); + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -42,6 +53,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { // This callback runs when a second instance tries to start @@ -78,6 +90,29 @@ pub fn run() { } } + { + let app_handle = app.handle(); + // On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux + #[cfg(any(target_os = "linux", target_os = "windows"))] + if let Err(err) = app_handle.deep_link().register_all() { + add_log(format!("⚠️ Failed to register deep link handler: {}", err)); + } + + if let Ok(Some(urls)) = app_handle.deep_link().get_current() { + let initial_handle = app_handle.clone(); + for url in urls { + dispatch_deep_link(&initial_handle, url.as_str()); + } + } + + let event_app_handle = app_handle.clone(); + app_handle.deep_link().on_open_url(move |event| { + for url in event.urls() { + dispatch_deep_link(&event_app_handle, url.as_str()); + } + }); + } + // Start backend immediately, non-blocking let app_handle = app.handle().clone(); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index b10fec0e54..43546413c4 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -77,6 +77,13 @@ }, "fs": { "requireLiteralLeadingDot": false + }, + "deep-link": { + "desktop": { + "schemes": [ + "stirlingpdf" + ] + } } } } diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index ab82bde1ed..6acd288589 100644 --- a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -14,6 +14,7 @@ interface SaaSLoginScreenProps { onLogin: (username: string, password: string) => Promise; onOAuthSuccess: (userInfo: UserInfo) => Promise; onSelfHostedClick: () => void; + onSwitchToSignup: () => void; loading: boolean; error: string | null; } @@ -23,6 +24,7 @@ export const SaaSLoginScreen: React.FC = ({ onLogin, onOAuthSuccess, onSelfHostedClick, + onSwitchToSignup, loading, error, }) => { @@ -89,6 +91,20 @@ export const SaaSLoginScreen: React.FC = ({ submitButtonText={t('setup.login.submit', 'Login')} /> +
+ +
+ ); diff --git a/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx new file mode 100644 index 0000000000..6ad3309261 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LoginHeader from '@app/routes/login/LoginHeader'; +import ErrorMessage from '@app/routes/login/ErrorMessage'; +import SignupForm from '@app/routes/signup/SignupForm'; +import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'; +import { authService } from '@app/services/authService'; +import '@app/routes/authShared/auth.css'; + +interface SaaSSignupScreenProps { + loading: boolean; + error: string | null; + onLogin: (username: string, password: string) => Promise; + onSwitchToLogin: () => void; +} + +export const SaaSSignupScreen: React.FC = ({ + loading, + error, + onLogin: _onLogin, + onSwitchToLogin: _onSwitchToLogin, +}) => { + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + const [signupFieldErrors, setSignupFieldErrors] = useState({}); + const [signupSuccessMessage, setSignupSuccessMessage] = useState(null); + const [isSignupSubmitting, setIsSignupSubmitting] = useState(false); + const { validateSignupForm } = useSignupFormValidation(); + + const displayError = error || validationError; + + const handleSignupSubmit = async () => { + setValidationError(null); + setSignupSuccessMessage(null); + setSignupFieldErrors({}); + + const validation = validateSignupForm(email, password, confirmPassword); + if (!validation.isValid) { + setValidationError(validation.error); + setSignupFieldErrors(validation.fieldErrors || {}); + return; + } + + try { + setIsSignupSubmitting(true); + await authService.signUpSaas(email.trim(), password); + setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.')); + setSignupFieldErrors({}); + setValidationError(null); + } catch (err) { + setSignupSuccessMessage(null); + const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }); + setValidationError(message); + } finally { + setIsSignupSubmitting(false); + } + }; + + return ( + <> + + + + {signupSuccessMessage && ( +
+

{signupSuccessMessage}

+
+ )} + + { + setEmail(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + setPassword={(value) => { + setPassword(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + setConfirmPassword={(value) => { + setConfirmPassword(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + onSubmit={handleSignupSubmit} + isSubmitting={loading || isSignupSubmitting} + fieldErrors={signupFieldErrors} + showName={false} + showTerms={false} + /> + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index ac4fa23691..9fbd0e6ccd 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -2,16 +2,20 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout'; import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen'; +import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen'; import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen'; import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen'; import { ServerConfig, connectionModeService } from '@app/services/connectionModeService'; import { authService, UserInfo } from '@app/services/authService'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; +import { listen } from '@tauri-apps/api/event'; +import { useEffect } from 'react'; import '@app/routes/authShared/auth.css'; enum SetupStep { SaaSLogin, + SaaSSignup, ServerSelection, SelfHostedLogin, } @@ -80,6 +84,16 @@ export const SetupWizard: React.FC = ({ onComplete }) => { setActiveStep(SetupStep.ServerSelection); }; + const handleSwitchToSignup = () => { + setError(null); + setActiveStep(SetupStep.SaaSSignup); + }; + + const handleSwitchToLogin = () => { + setError(null); + setActiveStep(SetupStep.SaaSLogin); + }; + const handleServerSelection = (config: ServerConfig) => { setServerConfig(config); setError(null); @@ -128,6 +142,48 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } }; + useEffect(() => { + const unsubscribePromise = listen('deep-link', async (event) => { + const url = event.payload; + if (!url) return; + + try { + const parsed = new URL(url); + + // Supabase sends tokens in the URL hash + const hash = parsed.hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const accessToken = params.get('access_token'); + const type = params.get('type') || parsed.searchParams.get('type'); + + if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) { + return; + } + + if (!accessToken) { + console.error('[SetupWizard] Deep link missing access_token'); + return; + } + + setLoading(true); + setError(null); + + await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL); + await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL); + tauriBackendService.startBackend().catch(console.error); + onComplete(); + } catch (err) { + console.error('[SetupWizard] Failed to handle deep link', err); + setError(err instanceof Error ? err.message : 'Failed to complete signup'); + setLoading(false); + } + }); + + return () => { + void unsubscribePromise.then((unsub) => unsub()); + }; + }, [onComplete, serverConfig?.url]); + const handleBack = () => { setError(null); if (activeStep === SetupStep.SelfHostedLogin) { @@ -135,6 +191,8 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } else if (activeStep === SetupStep.ServerSelection) { setActiveStep(SetupStep.SaaSLogin); setServerConfig({ url: STIRLING_SAAS_URL }); + } else if (activeStep === SetupStep.SaaSSignup) { + setActiveStep(SetupStep.SaaSLogin); } }; @@ -147,11 +205,21 @@ export const SetupWizard: React.FC = ({ onComplete }) => { onLogin={handleSaaSLogin} onOAuthSuccess={handleSaaSLoginOAuth} onSelfHostedClick={handleSelfHostedClick} + onSwitchToSignup={handleSwitchToSignup} loading={loading} error={error} /> )} + {activeStep === SetupStep.SaaSSignup && ( + + )} + {activeStep === SetupStep.ServerSelection && ( { + if (!accessToken || !accessToken.trim()) { + throw new Error('Invalid access token'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + await this.saveTokenEverywhere(accessToken); + + const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken); + + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + return userInfo; + } + + async signUpSaas(email: string, password: string): Promise { + if (!STIRLING_SAAS_URL) { + throw new Error('VITE_SAAS_SERVER_URL is not configured'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK); + const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`; + + try { + const response = await axios.post( + signupUrl, + { email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK }, + { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + }, + } + ); + + if (response.status >= 400) { + throw new Error('Sign up failed'); + } + } catch (error) { + if (axios.isAxiosError(error)) { + const message = + error.response?.data?.error_description || + error.response?.data?.msg || + error.response?.data?.message || + error.message; + throw new Error(message || 'Sign up failed'); + } + throw error instanceof Error ? error : new Error('Sign up failed'); + } + } + async login(serverUrl: string, username: string, password: string): Promise { try { console.log('Logging in to:', serverUrl); From 195b1472e41a736d6be17071662d17aa7eae8afb Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:52:47 +0000 Subject: [PATCH 10/62] Bug/v2/viewer annotations (#5245) Show uneditable annotations on viewer show editable annotations layer when in annotation tools (sign, add image, add text) Remove draw tool from viewer (this is replaced wholesale in an upcoming PR so it wasn't worth doing the work to ensure it worked with the new annotation layer set up_) refactoring work, mostly renaming variables we can use for all annotation based tools that had sign specific names. remove "tools" tooltip --------- Co-authored-by: Claude Sonnet 4.5 --- .../components/shared/AllToolsNavButton.tsx | 35 ++-- .../rightRail/ViewerAnnotationControls.tsx | 188 +----------------- .../core/components/viewer/EmbedPdfViewer.tsx | 19 +- .../core/components/viewer/LocalEmbedPDF.tsx | 7 +- frontend/src/core/contexts/ViewerContext.tsx | 6 - 5 files changed, 31 insertions(+), 224 deletions(-) diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index 1608d5bd3d..3ba3bb70b4 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Tooltip } from '@app/components/shared/Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; @@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess interface AllToolsNavButtonProps { activeButton: string; setActiveButton: (id: string) => void; - tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'; } const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton, - tooltipPosition = 'right' }) => { const { t } = useTranslation(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); @@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC = ({ }; return ( - -
- } - label={t("quickAccess.allTools", "Tools")} - isActive={isActive} - onClick={handleNavClick} - href={navProps.href} - ariaLabel={t("quickAccess.allTools", "Tools")} - textClassName="all-tools-text" - component="a" - /> -
-
+
+ } + label={t("quickAccess.allTools", "Tools")} + isActive={isActive} + onClick={handleNavClick} + href={navProps.href} + ariaLabel={t("quickAccess.allTools", "Tools")} + textClassName="all-tools-text" + component="a" + /> +
); }; diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index cd95b90b96..0c9cfc3dd4 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -1,15 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { ActionIcon, Popover } from '@mantine/core'; +import React from 'react'; +import { ActionIcon } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; import { Tooltip } from '@app/components/shared/Tooltip'; import { ViewerContext } from '@app/contexts/ViewerContext'; -import { useSignature } from '@app/contexts/SignatureContext'; -import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker'; -import { useFileState, useFileContext } from '@app/contexts/FileContext'; -import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils'; -import { createProcessedFile } from '@app/contexts/file/fileActions'; -import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext'; import { useNavigationState } from '@app/contexts/NavigationContext'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; @@ -23,32 +17,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const { t } = useTranslation(); const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); - const [selectedColor, setSelectedColor] = useState('#000000'); - const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false); // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); - // Signature context for accessing drawing API - const { signatureApiRef, isPlacementMode } = useSignature(); - - // File state for save functionality - const { state, selectors } = useFileState(); - const { actions: fileActions } = useFileContext(); - const activeFiles = selectors.getFiles(); - // Check if we're in sign mode const { selectedTool } = useNavigationState(); const isSignMode = selectedTool === 'sign'; - // Turn off annotation mode when switching away from viewer - useEffect(() => { - if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) { - viewerContext.setAnnotationMode(false); - } - }, [currentView, viewerContext]); - // Don't show any annotation controls in sign mode if (isSignMode) { return null; @@ -65,7 +41,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false onClick={() => { viewerContext?.toggleAnnotationsVisibility(); }} - disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode} + disabled={disabled || currentView !== 'viewer'} > - - {/* Annotation Mode Toggle with Drawing Controls */} - {viewerContext?.isAnnotationMode ? ( - // When active: Show color picker on hover -
setIsHoverColorPickerOpen(true)} - onMouseLeave={() => setIsHoverColorPickerOpen(false)} - style={{ display: 'inline-flex' }} - > - setIsHoverColorPickerOpen(false)} - position="left" - withArrow - shadow="md" - offset={8} - > - - { - viewerContext?.toggleAnnotationMode(); - setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off - // Deactivate drawing tool when exiting annotation mode - if (signatureApiRef?.current) { - try { - signatureApiRef.current.deactivateTools(); - } catch (error) { - console.log('Signature API not ready:', error); - } - } - }} - disabled={disabled} - aria-label="Drawing mode active" - > - - - - -
-
-
Drawing Color
- { - setIsHoverColorPickerOpen(false); // Close hover picker - setIsColorPickerOpen(true); // Open main color picker modal - }} - /> -
-
-
-
-
- ) : ( - // When inactive: Show "Draw" tooltip - - { - viewerContext?.toggleAnnotationMode(); - // Activate ink drawing tool when entering annotation mode - if (signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.activateDrawMode(); - signatureApiRef.current.updateDrawSettings(selectedColor, 2); - } catch (error) { - console.log('Signature API not ready:', error); - } - } - }} - disabled={disabled} - aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'} - > - - - - )} - - {/* Save PDF with Annotations */} - - { - if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') { - try { - const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy(); - if (pdfArrayBuffer) { - // Create new File object with flattened annotations - const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' }); - - // Get the original file name or use a default - const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf'; - const newFile = new File([blob], originalFileName, { type: 'application/pdf' }); - - // Replace the current file in context with the saved version (exact same logic as Sign tool) - if (activeFiles.length > 0) { - // Generate thumbnail and metadata for the saved file - const thumbnailResult = await generateThumbnailWithMetadata(newFile); - const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail); - - // Get current file info - const currentFileIds = state.files.ids; - if (currentFileIds.length > 0) { - const currentFileId = currentFileIds[0]; - const currentRecord = selectors.getStirlingFileStub(currentFileId); - - if (!currentRecord) { - console.error('No file record found for:', currentFileId); - return; - } - - // Create output stub and file (exact same as Sign tool) - const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata); - const outputStirlingFile = createStirlingFile(newFile, outputStub.id); - - // Replace the original file with the saved version - await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]); - } - } - } - } catch (error) { - console.error('Error saving PDF:', error); - } - } - }} - disabled={disabled} - > - - - - - {/* Color Picker Modal */} - setIsColorPickerOpen(false)} - selectedColor={selectedColor} - onColorChange={(color) => { - setSelectedColor(color); - // Update drawing tool color if annotation mode is active - if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.updateDrawSettings(color, 2); - } catch (error) { - console.log('Unable to update drawing settings:', error); - } - } - }} - title="Choose Drawing Color" - /> ); } diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 94bff5a352..8e66f0b5c5 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -51,6 +51,7 @@ const EmbedPdfViewerContent = ({ getScrollState, getRotationState, isAnnotationMode, + setAnnotationMode, isAnnotationsVisible, exportActions, } = useViewer(); @@ -82,15 +83,18 @@ const EmbedPdfViewerContent = ({ // Navigation guard for unsaved changes const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); - // Check if we're in signature mode OR viewer annotation mode + // Check if we're in an annotation tool const { selectedTool } = useNavigationState(); - // Tools that use the stamp/signature placement system with hover preview - const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + // Tools that require the annotation layer (Sign, Add Text, Add Image) + const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + + // Sync isAnnotationMode in ViewerContext with current tool + useEffect(() => { + setAnnotationMode(isInAnnotationTool); + }, [isInAnnotationTool, setAnnotationMode]); - // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations - const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; const isPlacementOverlayActive = Boolean( - isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig + isInAnnotationTool && isPlacementMode && signatureConfig ); // Track which file tab is active @@ -333,7 +337,8 @@ const EmbedPdfViewerContent = ({ key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)} file={effectiveFile.file} url={effectiveFile.url} - enableAnnotations={shouldEnableAnnotations} + enableAnnotations={isAnnotationMode} + showBakedAnnotations={isAnnotationsVisible} signatureApiRef={signatureApiRef as React.RefObject} historyApiRef={historyApiRef as React.RefObject} onSignatureAdded={() => { diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 0b79604fd6..8f20cd96f0 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -52,12 +52,13 @@ interface LocalEmbedPDFProps { file?: File | Blob; url?: string | null; enableAnnotations?: boolean; + showBakedAnnotations?: boolean; onSignatureAdded?: (annotation: any) => void; signatureApiRef?: React.RefObject; historyApiRef?: React.RefObject; } -export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { +export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { const { t } = useTranslation(); const [pdfUrl, setPdfUrl] = useState(null); const [, setAnnotations] = useState>([]); @@ -100,7 +101,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur }), createPluginRegistration(RenderPluginPackage, { withForms: true, - withAnnotations: true, + withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF }), // Register interaction manager (required for zoom and selection features) @@ -166,7 +167,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register print plugin for printing PDFs createPluginRegistration(PrintPluginPackage), ]; - }, [pdfUrl]); + }, [pdfUrl, enableAnnotations, showBakedAnnotations]); // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 9217511efe..aa2dc497bf 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -95,7 +95,6 @@ interface ViewerContextType { // Annotation/drawing mode for viewer isAnnotationMode: boolean; setAnnotationMode: (enabled: boolean) => void; - toggleAnnotationMode: () => void; // Active file index for multi-file viewing activeFileIndex: number; @@ -230,10 +229,6 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsAnnotationModeState(enabled); }; - const toggleAnnotationMode = () => { - setIsAnnotationModeState(prev => !prev); - }; - // State getters - read from bridge refs const getScrollState = (): ScrollState => { return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 }; @@ -318,7 +313,6 @@ export const ViewerProvider: React.FC = ({ children }) => { toggleAnnotationsVisibility, isAnnotationMode, setAnnotationMode, - toggleAnnotationMode, // Active file index activeFileIndex, From 93ed05b0549af444712f06f7a29d2df372eded47 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:02:22 +0000 Subject: [PATCH 11/62] =?UTF-8?q?Only=20allow=20dual=20page=20view=20when?= =?UTF-8?q?=20there=20is=20more=20than=20one=20page,=20update=20to?= =?UTF-8?q?=E2=80=A6=20(#5246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/viewer/PdfViewerToolbar.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx index a2e36d4e23..258b8ba430 100644 --- a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Button, Paper, Group, NumberInput } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useViewer } from '@app/contexts/ViewerContext'; +import { Tooltip } from '@app/components/shared/Tooltip'; import FirstPageIcon from '@mui/icons-material/FirstPage'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; @@ -209,21 +210,27 @@ export function PdfViewerToolbar({ {/* Dual Page Toggle */} - + + {/* Zoom Controls */} From 4ec75d4d8c3010a2993dfe28e2ae2a61c71afd66 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:07:51 +0000 Subject: [PATCH 12/62] allow static overrides (#5258) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../software/SPDF/config/WebMvcConfig.java | 25 ++++++++++- .../web/ReactRoutingController.java | 45 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 8eac8fa80d..71c402aa87 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -33,14 +33,35 @@ public class WebMvcConfig implements WebMvcConfigurer { public void addResourceHandlers(ResourceHandlerRegistry registry) { // Cache hashed assets (JS/CSS with content hashes) for 1 year // These files have names like index-ChAS4tCC.js that change when content changes + // Check customFiles/static first, then fall back to classpath registry.addResourceHandler("/assets/**") - .addResourceLocations("classpath:/static/assets/") + .addResourceLocations( + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath() + + "assets/", + "classpath:/static/assets/") .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()); // Don't cache index.html - it needs to be fresh to reference latest hashed assets + // Note: index.html is handled by ReactRoutingController for dynamic processing registry.addResourceHandler("/index.html") - .addResourceLocations("classpath:/static/") + .addResourceLocations( + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath(), + "classpath:/static/") .setCacheControl(CacheControl.noCache().mustRevalidate()); + + // Handle all other static resources (js, css, images, fonts, etc.) + // Check customFiles/static first for user overrides + registry.addResourceHandler("/**") + .addResourceLocations( + "file:" + + stirling.software.common.configuration.InstallationPathConfig + .getStaticPath(), + "classpath:/static/") + .setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS)); } @Override diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index ab8f3b75b6..eddff0e1e4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -3,9 +3,14 @@ package stirling.software.SPDF.controller.web; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -14,6 +19,11 @@ import org.springframework.web.bind.annotation.GetMapping; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; + +@Slf4j @Controller public class ReactRoutingController { @@ -22,24 +32,44 @@ public class ReactRoutingController { private String cachedIndexHtml; private boolean indexHtmlExists = false; + private boolean useExternalIndexHtml = false; @PostConstruct public void init() { - // Only cache if index.html exists (production builds) + log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath()); + + // Check for external index.html first (customFiles/static/) + Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); + log.debug("Checking for custom index.html at: {}", externalIndexPath); + if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { + log.info("Using custom index.html from: {}", externalIndexPath); + try { + this.cachedIndexHtml = processIndexHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = true; + return; + } catch (IOException e) { + log.warn("Failed to load custom index.html, falling back to classpath", e); + } + } + + // Fall back to classpath index.html ClassPathResource resource = new ClassPathResource("static/index.html"); if (resource.exists()) { try { this.cachedIndexHtml = processIndexHtml(); this.indexHtmlExists = true; + this.useExternalIndexHtml = false; } catch (IOException e) { // Failed to cache, will process on each request + log.warn("Failed to cache index.html", e); this.indexHtmlExists = false; } } } private String processIndexHtml() throws IOException { - ClassPathResource resource = new ClassPathResource("static/index.html"); + Resource resource = getIndexHtmlResource(); try (InputStream inputStream = resource.getInputStream()) { String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); @@ -62,6 +92,17 @@ public class ReactRoutingController { } } + private Resource getIndexHtmlResource() throws IOException { + // Check external location first + Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); + if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { + return new FileSystemResource(externalIndexPath.toFile()); + } + + // Fall back to classpath + return new ClassPathResource("static/index.html"); + } + @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) From f9a44c4da45dd440f4f4ae3e42b85b6b0d81facd Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:52:48 +0000 Subject: [PATCH 13/62] Saml fixes (#5256) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> --- .../common/configuration/AppConfig.java | 15 ++++++++--- .../software/SPDF/SPDFApplication.java | 6 ++--- .../controller/api/misc/ConfigController.java | 3 ++- .../security/CustomLogoutSuccessHandler.java | 2 +- .../configuration/SecurityConfiguration.java | 3 ++- .../security/saml2/Saml2Configuration.java | 25 +++++++++++++++---- .../UserLicenseSettingsServiceTest.java | 3 ++- 7 files changed, 41 insertions(+), 16 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 272c0b35ce..1e9d67269d 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -37,10 +37,6 @@ public class AppConfig { private final ApplicationProperties applicationProperties; - @Getter - @Value("${baseUrl:http://localhost}") - private String baseUrl; - @Getter @Value("${server.servlet.context-path:/}") private String contextPath; @@ -49,6 +45,17 @@ public class AppConfig { @Value("${server.port:8080}") private String serverPort; + /** + * Get the backend URL from system configuration. Falls back to http://localhost if not + * configured. + * + * @return The backend base URL for SAML/OAuth/API callbacks + */ + public String getBackendUrl() { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost"; + } + @Value("${v2}") public boolean v2Enabled; diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index d3a4ce776a..9cb4a786a1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -138,13 +138,13 @@ public class SPDFApplication { @PostConstruct public void init() { - String baseUrl = appConfig.getBaseUrl(); + String backendUrl = appConfig.getBackendUrl(); String contextPath = appConfig.getContextPath(); String serverPort = appConfig.getServerPort(); - baseUrlStatic = baseUrl; + baseUrlStatic = backendUrl; contextPathStatic = contextPath; serverPortStatic = serverPort; - String url = baseUrl + ":" + getStaticPort() + contextPath; + String url = backendUrl + ":" + getStaticPort() + contextPath; // Log Tauri mode information if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 95486ff9b8..2f8d4b62bc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -66,7 +66,8 @@ public class ConfigController { AppConfig appConfig = applicationContext.getBean(AppConfig.class); // Extract key configuration values from AppConfig - configData.put("baseUrl", appConfig.getBaseUrl()); + // Note: Frontend expects "baseUrl" field name for compatibility + configData.put("baseUrl", appConfig.getBackendUrl()); configData.put("contextPath", appConfig.getContextPath()); configData.put("serverPort", appConfig.getServerPort()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 16272b37f0..1f55dd25fe 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { private SamlClient getSamlClient( String registrationId, SAML2 samlConf, List certificates) throws SamlException { - String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort(); + String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort(); String relyingPartyIdentifier = serverUrl + "/saml2/service-provider-metadata/" + registrationId; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 1226237c87..fa936211da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -344,7 +344,8 @@ public class SecurityConfiguration { log.error("Error configuring SAML 2 login", e); throw new RuntimeException(e); } - }); + }) + .saml2Metadata(metadata -> {}); } } else { log.debug("Login is not enabled."); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 99be4b5b0f..6ccffa1dac 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -102,16 +102,31 @@ public class Saml2Configuration { log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); throw new IllegalStateException("Failed to load SAML2 SP credentials", e); } + + // Get backend URL from configuration (for SAML endpoints) + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + if (backendUrl == null || backendUrl.isBlank()) { + backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution + log.warn( + "system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use."); + } else { + log.info("Using configured backend URL for SAML: {}", backendUrl); + } + + String entityId = + backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId(); + String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}"; + String sloResponseLocation = backendUrl + "/login"; + RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) .signingX509Credentials(c -> c.add(signingCredential)) - .entityId(samlConf.getIdpIssuer()) + .entityId(entityId) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("{baseUrl}/login") + .singleLogoutServiceResponseLocation(sloResponseLocation) .assertionConsumerServiceBinding(Saml2MessageBinding.POST) - .assertionConsumerServiceLocation( - "{baseUrl}/login/saml2/sso/{registrationId}") + .assertionConsumerServiceLocation(acsLocation) .authnRequestsSigned(true) .assertingPartyMetadata( metadata -> @@ -127,7 +142,7 @@ public class Saml2Configuration { .singleLogoutServiceLocation( samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceResponseLocation( - "{baseUrl}/login") + sloResponseLocation) .wantAuthnRequestsSigned(true)) .build(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java index a7f8042f6f..3f0214573f 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java @@ -51,7 +51,8 @@ class UserLicenseSettingsServiceTest { when(applicationProperties.getPremium()).thenReturn(premium); when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated); - when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server + when(automaticallyGenerated.getIsNewServer()) + .thenReturn(false); // Default: not a new server when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings)); when(userService.getTotalUsersCount()).thenReturn(80L); when(settingsRepository.save(any(UserLicenseSettings.class))) From 49bea34576daecce68a20d72faf64883bb0ec7d6 Mon Sep 17 00:00:00 2001 From: Stephan Paternotte Date: Wed, 17 Dec 2025 14:37:47 +0100 Subject: [PATCH 14/62] Update translation.toml (#5254) Updated translations of latest additions, Saintized lots of accented letters, Brought consistency in formal addressing of the user --- .../public/locales/nl-NL/translation.toml | 776 ++++++++++-------- 1 file changed, 433 insertions(+), 343 deletions(-) diff --git a/frontend/public/locales/nl-NL/translation.toml b/frontend/public/locales/nl-NL/translation.toml index b9599e1278..a1192b2a71 100644 --- a/frontend/public/locales/nl-NL/translation.toml +++ b/frontend/public/locales/nl-NL/translation.toml @@ -1,5 +1,5 @@ -unsavedChanges = "Je hebt niet-opgeslagen wijzigingen in je PDF." -areYouSure = "Weet je zeker dat je wilt vertrekken?" +unsavedChanges = "U heeft niet-opgeslagen wijzigingen in uw PDF." +areYouSure = "Weet u zeker dat u wilt vertrekken?" unsavedChangesTitle = "Niet-opgeslagen wijzigingen" keepWorking = "Doorgaan met werken" discardChanges = "Verwerpen en verlaten" @@ -10,11 +10,11 @@ pageSelectionPrompt = "Aangepaste pagina selectie (Voer een komma-gescheiden lij startingNumberTooltip = "Het eerste getal dat wordt weergegeven. Volgende pagina's tellen door vanaf dit nummer." marginTooltip = "Afstand tussen het paginanummer en de rand van de pagina." fontSizeTooltip = "Grootte van de paginanummertekst in punten. Grotere getallen geven grotere tekst." -fontTypeTooltip = "Lettertypefamilie voor de paginanummers. Kies passend bij de stijl van je document." -customTextTooltip = "Optioneel aangepast formaat voor paginanummers. Gebruik {n} als placeholder voor het nummer. Voorbeeld: \"Pagina {n}\" toont \"Pagina 1\", \"Pagina 2\", enz." -pdfPrompt = "Selecteer PDF('s)" -multiPdfPrompt = "Selecteer PDF's (2+)" -multiPdfDropPrompt = "Selecteer (of sleep & zet neer) alle PDF's die je nodig hebt" +fontTypeTooltip = "Lettertypefamilie voor de paginanummers. Kies passend bij de stijl van uw document." +customTextTooltip = "Optioneel aangepast formaat voor paginanummers. Gebruik {n} als plaatsvervanger voor het nummer. Voorbeeld: \"Pagina {n}\" toont \"Pagina 1\", \"Pagina 2\", enz." +pdfPrompt = "PDF(-en) selecteren" +multiPdfPrompt = "PDF-en (2+) selecteren" +multiPdfDropPrompt = "Selecteer (of sleep & zet neer) alle PDF-en die u nodig hebt" imgPrompt = "Selecteer afbeelding(en)" genericSubmit = "Indienen" uploadLimit = "Maximale bestandsgrootte:" @@ -22,7 +22,7 @@ uploadLimitExceededSingular = "is te groot. Maximale toegestane grootte is" uploadLimitExceededPlural = "zijn te groot. Maximale toegestane grootte is" processTimeWarning = "Waarschuwing: Dit proces kan tot een minuut duren afhankelijk van de bestandsgrootte" pageOrderPrompt = "Aangepaste pagina volgorde (Voer een komma-gescheiden lijst van paginanummers of functies in, zoals 2n+1) :" -goToPage = "Ga" +goToPage = "Gaan" true = "Waar" false = "Onwaar" unknown = "Onbekend" @@ -38,11 +38,11 @@ undo = "Ongedaan maken" back = "Terug" nothingToUndo = "Niets om ongedaan te maken" moreOptions = "Meer opties" -editYourNewFiles = "Je nieuwe bestand(en) bewerken" +editYourNewFiles = "Uw nieuwe bestand(en) bewerken" close = "Sluiten" openInViewer = "Openen in Viewer" confirmClose = "Sluiten bevestigen" -confirmCloseMessage = "Weet je zeker dat je dit bestand wilt sluiten?" +confirmCloseMessage = "Weet u zeker dat u dit bestand wilt sluiten?" confirmCloseCancel = "Annuleren" confirmCloseConfirm = "Bestand sluiten" fileSelected = "Geselecteerd: {{filename}}" @@ -55,7 +55,7 @@ alphabet = "Alfabet" downloadPdf = "PDF downloaden" text = "Tekst" font = "Lettertype" -selectFillter = "-- Selecteer --" +selectFillter = "-- Selecteren --" pageNum = "Paginanummer" edit = "Bewerken" delete = "Verwijderen" @@ -88,15 +88,15 @@ deleteUsernameExistsMessage = "De gebruikersnaam bestaat niet en kan niet verwij downgradeCurrentUserMessage = "Kan de rol van de huidige gebruiker niet downgraden" disabledCurrentUserMessage = "De huidige gebruiker kan niet worden uitgeschakeld" downgradeCurrentUserLongMessage = "Kan de rol van de huidige gebruiker niet downgraden. Huidige gebruiker wordt dus niet weergegeven." -userAlreadyExistsOAuthMessage = "De gebruiker bestaat al als een OAuth2 gebruiker." -userAlreadyExistsWebMessage = "De gebruiker bestaat al als een web gebruiker." +userAlreadyExistsOAuthMessage = "De gebruiker bestaat al als een OAuth2-gebruiker." +userAlreadyExistsWebMessage = "De gebruiker bestaat al als een webgebruiker." oops = "Oeps!" help = "Hulp" goHomepage = "Ga naar de startpagina" -joinDiscord = "Word lid van onze Discord server" +joinDiscord = "Word lid van onze Discord-server" seeDockerHub = "Zie Docker Hub" visitGithub = "Ga naar de Github Repository" -donate = "Doneer" +donate = "Doneren" color = "Kleur" sponsor = "Sponsor" info = "Informatie" @@ -108,7 +108,7 @@ review = "Beoordelen" addToDoc = "Toevoegen aan document" reset = "Reset" apply = "Toepassen" -noFileSelected = "Geen bestand geselecteerd. Upload er één." +noFileSelected = "Geen bestand geselecteerd. Upload er een." termsAndConditions = "Algemene voorwaarden" logOut = "Uitloggen" customPosition = "Aangepaste positie" @@ -136,14 +136,14 @@ premiumFeature = "Premiumfunctie:" comingSoon = "Binnenkort beschikbaar:" [toolPanel.modePrompt] -title = "Kies hoe je tools bekijkt" -description = "Bekijk beide lay-outs en beslis hoe je de Stirling PDF-tools wilt verkennen." +title = "Kies hoe u door de tools bladert" +description = "Bekijk beide lay-outs en beslis hoe u de Stirling PDF-tools wilt verkennen." sidebarTitle = "Zijbalkmodus" -sidebarDescription = "Houd tools naast je werkruimte voor snel wisselen." +sidebarDescription = "Houd tools naast uw werkruimte voor snel wisselen." recommended = "Aanbevolen" chooseSidebar = "Zijbalkmodus gebruiken" fullscreenTitle = "Volledig scherm-modus - (verouderd)" -fullscreenDescription = "Blader door alle tools in een catalogus die de werkruimte bedekt totdat je er één kiest." +fullscreenDescription = "Blader door alle tools in een catalogus die de werkruimte bedekt totdat u er een kiest." chooseFullscreen = "Volledig scherm-modus gebruiken" dismiss = "Misschien later" @@ -155,7 +155,7 @@ favorites = "Favorieten" unavailable = "Uitgeschakeld door serverbeheerder:" unavailableDependency = "Niet beschikbaar - vereiste tool ontbreekt op server:" heading = "Alle tools (volledig scherm)" -noResults = "Pas je zoekopdracht aan of schakel beschrijvingen om te vinden wat je nodig hebt." +noResults = "Pas uw zoekopdracht aan of schakel beschrijvingen om te vinden wat u nodig hebt." recommended = "Aanbevolen" unfavorite = "Uit favorieten verwijderen" @@ -164,9 +164,9 @@ fullscreen = "Overschakelen naar volledig scherm" sidebar = "Overschakelen naar zijbalkmodus" [backendStartup] -notFoundTitle = "Backend niet gevonden" +notFoundTitle = "Back-end niet gevonden" retry = "Opnieuw proberen" -unreachable = "De applicatie kan momenteel geen verbinding maken met de backend. Controleer de status van de backend en de netwerkverbinding en probeer het vervolgens opnieuw." +unreachable = "De applicatie kan momenteel geen verbinding maken met de back-end. Controleer de status van de back-end en de netwerkverbinding en probeer het vervolgens opnieuw." [zipWarning] title = "Groot ZIP-bestand" @@ -176,23 +176,23 @@ confirm = "Uitpakken" [defaultApp] title = "Instellen als standaard PDF-app" -message = "Wil je Stirling PDF instellen als je standaard PDF-editor?" -description = "Je kunt dit later wijzigen in je systeeminstellingen." +message = "Wilt u Stirling PDF instellen als standaard PDF-editor?" +description = "U kunt dit later wijzigen in de systeeminstellingen." notNow = "Niet nu" setDefault = "Als standaard instellen" dismiss = "Sluiten" [defaultApp.prompt] title = "Instellen als standaard PDF-editor" -message = "Maak Stirling PDF je standaardapp voor het openen van PDF-bestanden." +message = "Maak Stirling PDF de standaardapp voor het openen van PDF-bestanden." [defaultApp.success] title = "Standaardapp ingesteld" -message = "Stirling PDF is nu je standaard PDF-editor" +message = "Stirling PDF is nu de standaard PDF-editor" [defaultApp.settingsOpened] title = "Instellingen geopend" -message = "Selecteer Stirling PDF in je systeeminstellingen" +message = "Selecteer Stirling PDF in de systeeminstellingen" [defaultApp.error] title = "Fout" @@ -252,23 +252,23 @@ x-large = "Extra groot" [error] pdfPassword = "Het PDF document is beveiligd met een wachtwoord en het wachtwoord is niet ingevoerd of is onjuist" -encryptedPdfMustRemovePassword = "Deze PDF is versleuteld of met een wachtwoord beveiligd. Ontgrendel hem voordat je naar PDF/A converteert." +encryptedPdfMustRemovePassword = "Deze PDF is versleuteld of met een wachtwoord beveiligd. Ontgrendel hem voordat u naar PDF/A converteert." incorrectPasswordProvided = "Het PDF-wachtwoord is onjuist of niet opgegeven." _value = "Fout" dismissAllErrors = "Alle fouten sluiten" sorry = "Excuses voor het probleem!" needHelp = "Hulp nodig / probleem gevonden?" -contactTip = "Als je nog steeds problemen hebt, schroom niet om contact met ons op te nemen voor hulp. Je kan een ticket op onze Github pagina indienen of ons via Discord bereiken:" +contactTip = "Als u nog steeds problemen hebt, schroom niet om contact met ons op te nemen voor hulp. U kunt een ticket op onze Github pagina indienen of ons via Discord bereiken:" github = "Dien een ticket op Github in." -showStack = "Geeft tracering weer" -copyStack = "Kopieer tracering" +showStack = "Tracering weergeven" +copyStack = "Tracering kopiëren" githubSubmit = "GitHub - Dien een ticket in" discordSubmit = "Discord - Maak een support post" [error.404] head = "404 - Pagina niet gevonden | Oeps, we struikelden over de code!" -1 = "We kunnen de pagina die je zoek niet vinden." -2 = "Er ging iets mis." +1 = "We kunnen de pagina die u zoekt niet vinden." +2 = "Er ging iets niet goed." [warning] tooltipTitle = "Waarschuwing" @@ -283,14 +283,14 @@ impressum = "Imprint" showCookieBanner = "Cookievoorkeuren" [pipeline] -header = "Pijplijn menu (Beta)" +header = "Pijplijn-menu (Beta)" uploadButton = "Aangepast uploaden" configureButton = "Configureren" defaultOption = "Aangepast" submitButton = "Opslaan" help = "Pijplijn help" scanHelp = "Map scannen help" -deletePrompt = "Weet je zeker dat je deze pijplijn wil verwijderen?" +deletePrompt = "Weet u zeker dat u deze pijplijn wil verwijderen?" tags = "automatiseren,volgorde,gescrript,batch-verwerking" title = "Pijplijn" @@ -312,14 +312,14 @@ yamlAdvert = "Stirling PDF Pro ondersteunt YAML-configuratie­bestanden en ander ssoAdvert = "Op zoek naar meer gebruikersbeheerfuncties? Bekijk Stirling PDF Pro" [analytics] -title = "Wil je Stirling PDF beter maken?" +title = "Wilt u Stirling PDF beter maken?" paragraph1 = "Stirling PDF heeft opt-in analyses om ons te helpen het product te verbeteren. We volgen geen persoonlijke informatie of bestandsinhoud." paragraph2 = "Overweeg analyses in te schakelen om Stirling PDF te helpen groeien en ons onze gebruikers beter te laten begrijpen." learnMore = "Meer informatie" enable = "Analyses inschakelen" disable = "Analyses uitschakelen" -settings = "Je kunt de instellingen voor analyses wijzigen in het bestand config/settings.yml" -privacyAssurance = "We volgen geen persoonlijke informatie of de inhoud van je bestanden." +settings = "U kunt de instellingen voor analyses wijzigen in het bestand config/settings.yml" +privacyAssurance = "We volgen geen persoonlijke informatie of de inhoud van uw bestanden." [navbar] favorite = "Favorieten" @@ -335,11 +335,15 @@ search = "Zoeken" organize = "Organizeren" convertTo = "Converteren naar PDF" convertFrom = "Converteren van PDF" -security = "Ondertekenen & beveiliging" +security = "Ondertekening & beveiliging" advance = "Geavanceerd" edit = "Bekijken & wijzigen" popular = "Populair" +[footer] +discord = "Discord" +issues = "GitHub" + [settings.preferences] title = "Voorkeuren" @@ -362,7 +366,7 @@ security = "Beveiliging" connections = "Verbindingen" [settings.licensingAnalytics] -title = "Licenties & Analytics" +title = "Licenties & Analyse" plan = "Abonnement" audit = "Audit" usageAnalytics = "Gebruiksstatistieken" @@ -378,7 +382,7 @@ apiKeys = "API-sleutels" [settings.tooltips] enableLoginFirst = "Schakel eerst de loginmodus in" -requiresEnterprise = "Vereist Enterprise-licentie" +requiresEnterprise = "Enterprise-licentie vereist" [settings.connection] title = "Verbindingsmodus" @@ -404,20 +408,20 @@ autoUnzipFileLimitTooltip = "Alleen uitpakken als de ZIP dit aantal bestanden of autoUnzipFileLimit = "Limiet automatisch uitpakken" autoUnzipFileLimitDescription = "Maximaal aantal bestanden om uit ZIP te halen" defaultPdfEditor = "Standaard PDF-editor" -defaultPdfEditorActive = "Stirling PDF is je standaard PDF-editor" +defaultPdfEditorActive = "Stirling PDF is de standaard PDF-editor" defaultPdfEditorInactive = "Een andere app is als standaard ingesteld" defaultPdfEditorChecking = "Controleren..." defaultPdfEditorSet = "Al standaard" setAsDefault = "Als standaard instellen" hideUnavailableTools = "Niet-beschikbare tools verbergen" -hideUnavailableToolsDescription = "Verwijder tools die door je server zijn uitgeschakeld in plaats van ze grijs te tonen." +hideUnavailableToolsDescription = "Verwijder tools die door de server zijn uitgeschakeld in plaats van ze grijs te tonen." hideUnavailableConversions = "Niet-beschikbare conversies verbergen" hideUnavailableConversionsDescription = "Verwijder uitgeschakelde conversie-opties in de tool Converteren in plaats van ze grijs te tonen." [settings.general.enableFeatures] dismiss = "Sluiten" title = "Voor systeembeheerders" -intro = "Schakel gebruikersauthenticatie, teambeheer en werkruimtefuncties in voor je organisatie." +intro = "Schakel gebruikersauthenticatie, teambeheer en werkruimtefuncties in voor uw organisatie." action = "Configureren" and = "en" benefit = "Schakelt gebruikersrollen, teamsamenwerking, beheerdersfuncties en enterprise-functies in." @@ -435,6 +439,25 @@ latestVersion = "Laatste versie" checkForUpdates = "Op updates controleren" viewDetails = "Details bekijken" +[settings.security] +title = "Beveiliging" +description = "Werk uw wachtwoord bij om uw account veilig te houden." + +[settings.security.password] +subtitle = "Wijzig uw wachtwoord. U wordt uitgelogd na het bijwerken." +required = "Alle velden zijn vereist." +mismatch = "Nieuwe wachtwoorden komen niet overeen." +error = "Kan wachtwoord niet bijwerken. Controleer uw huidige wachtwoord en probeer het opnieuw." +success = "Wachtwoord succesvol bijgewerkt. Log alstublieft opnieuw in." +ssoDisabled = "Wachtwoordwijzigingen worden beheerd door uw identiteitsprovider." +current = "Huidig wachtwoord" +currentPlaceholder = "Voer het huidige wachtwoord in" +new = "Nieuw wachtwoord" +newPlaceholder = "Voer een nieuw wachtwoord in" +confirm = "Nieuw wachtwoord bevestigen" +confirmPlaceholder = "Voer uw nieuwe wachtwoord nogmaals in" +update = "Wachtwoord bijwerken" + [settings.hotkeys] title = "Sneltoetsen" description = "Pas sneltoetsen aan voor snelle toegang tot tools. Klik op \"Sneltoets wijzigen\" en druk een nieuwe toetsencombinatie in. Druk op Esc om te annuleren." @@ -443,15 +466,15 @@ searchPlaceholder = "Tools zoeken..." none = "Niet toegewezen" customBadge = "Aangepast" defaultLabel = "Standaard: {{shortcut}}" -capturing = "Toetsen indrukken… (Esc om te annuleren)" +capturing = "Toetsen indrukken... (Esc om te annuleren)" change = "Sneltoets wijzigen" reset = "Reset" shortcut = "Sneltoets" noShortcut = "Geen sneltoets ingesteld" [settings.hotkeys.errorModifier] -mac = "Neem ⌘ (Command), ⌥ (Option) of een andere modificatietoets op in je sneltoets." -windows = "Neem Ctrl, Alt of een andere modificatietoets op in je sneltoets." +mac = "Neem ⌘ (Command), ⌥ (Option) of een andere modificatietoets op in de sneltoets." +windows = "Neem Ctrl, Alt of een andere modificatietoets op in de sneltoets." [update] modalTitle = "Update beschikbaar" @@ -461,7 +484,7 @@ latestStable = "Laatste stabiele" priorityLabel = "Prioriteit" recommendedAction = "Aanbevolen actie" breakingChangesDetected = "Incompatibele wijzigingen gedetecteerd" -breakingChangesMessage = "Sommige versies bevatten incompatibele wijzigingen. Bekijk onderstaande migratiehandleidingen voordat je update." +breakingChangesMessage = "Sommige versies bevatten incompatibele wijzigingen. Bekijk onderstaande migratiehandleidingen voordat u update." migrationGuides = "Migratiehandleidingen" viewGuide = "Handleiding bekijken" loadingDetailedInfo = "Gedetailleerde informatie laden..." @@ -486,13 +509,18 @@ low = "Laag" [changeCreds] title = "Inloggegevens wijzigen" -header = "Werk je accountgegevens bij" -changePassword = "Je gebruikt de standaard inloggegevens. Voer alstublieft een nieuw wachtwoord in" +header = "Werk uw accountgegevens bij" +changePassword = "U gebruikt de standaard inloggegevens. Voer alstublieft een nieuw wachtwoord in" +ssoManaged = "Uw account wordt beheerd door uw identity-provider." newUsername = "Nieuwe gebruikersnaam" oldPassword = "Huidige wachtwoord" newPassword = "Nieuw wachtwoord" confirmNewPassword = "Bevestig nieuw wachtwoord" submit = "Wijzigingen opslaan" +credsUpdated = "Account bijgewerkt" +description = "Wijzigingen opgeslagen. Log alstublieft opnieuw in." +error = "Kan gebruikersnaam niet bijwerken. Controleer het wachtwoord en probeer het opnieuw." +changeUsername = "Werk uw gebruikersnaam bij. U wordt uitgelogd na het bijwerken." [account] title = "Account instellingen" @@ -507,7 +535,7 @@ newPassword = "Nieuw wachtwoord" changePassword = "Wijzig wachtwoord" confirmNewPassword = "Bevestig nieuw wachtwoord" signOut = "Uitloggen" -yourApiKey = "Jouw API sleutel" +yourApiKey = "Uw API-sleutel" syncTitle = "Synchroniseer browserinstellingen met account" settingsCompare = "Instellingen vergelijking:" property = "Eigenschap" @@ -517,11 +545,11 @@ syncToAccount = "Synchroniseer account <- browser" [adminUserSettings] title = "Gebruikersbeheer" -header = "Beheer gebruikers" +header = "Gebruikers beheren" admin = "Beheerder" user = "Gebruiker" -addUser = "Voeg nieuwe gebruiker toe" -deleteUser = "Verwijder gebruiker" +addUser = "Nieuwe gebruiker toevoegen" +deleteUser = "Gebruiker verwijderen" confirmDeleteUser = "Moet deze gebruiker verwijderd worden?" confirmChangeUserStatus = "Moet de gebruiker worden uitgeschakeld/ingeschakeld?" usernameInfo = "Gebruikersnaam kan alleen letters, nummers en de volgende speciale tekens @._+- bevatten of moet een geldig emailadres zijn." @@ -565,7 +593,7 @@ endpoint = "Eindpunt" visits = "Bezoeken" percentage = "Percentage" loading = "Laden..." -failedToLoad = "Het is niet gelukt de endpointgegevens te laden. Probeer te vernieuwen." +failedToLoad = "Het is niet gelukt de eindpuntgegevens te laden. Probeer te vernieuwen." home = "Home" login = "Inloggen" top = "Top" @@ -577,29 +605,29 @@ retry = "Opnieuw proberen" title = "Database Importeer/Exporteer" header = "Database Importeer/Exporteer" fileName = "Bestandsnaam" -creationDate = "Creatiedatum" +creationDate = "Aanmaakdatum" fileSize = "Bestandsgrootte" deleteBackupFile = "Backupbestand verwijderen" importBackupFile = "Backupbestand importeren" createBackupFile = "Back-upbestand maken" downloadBackupFile = "Backupbestand downloaden" -info_1 = "Bij het importeren van gegevens is het cruciaal om de juiste structuur te zorgen voor. Als je niet zeker bent van wat je doet, raadpleeg dan advies en ondersteuning bij een professionele. Een fout in de structuur kan leiden tot toepassingsfouten, waarmee wellicht zelfs de volledige uitvoerbaarheid van de toepassing belemmerd wordt." +info_1 = "Bij het importeren van gegevens is het cruciaal om de juiste structuur te zorgen voor. Als u niet zeker bent van wat u doet, raadpleeg dan advies en ondersteuning bij een professionele. Een fout in de structuur kan leiden tot toepassingsfouten, waarmee wellicht zelfs de volledige uitvoerbaarheid van de toepassing belemmerd wordt." info_2 = "De bestandsnaam maakt geen verschil bij het uploaden. Hij zal later worden herbewoond om de indeling backup_user_yyyyMMddHHmm.sql te volgen, waardoor een consistente bestandsnaamconventie waarborgd wordt." submit = "Backup importeren" -importIntoDatabaseSuccessed = "Importeer naar database succesvol" +importIntoDatabaseSuccessed = "Importeren naar database succesvol" backupCreated = "Databaseback-up geslaagd" -fileNotFound = "File not Found" +fileNotFound = "Bestand niet gevonden" fileNullOrEmpty = "Bestand mag niet null of leeg zijn" -failedImportFile = "Failed Import File" -notSupported = "Deze functie is niet beschikbaar voor je databaseverbinding." +failedImportFile = "Bestand importeren is mislukt" +notSupported = "Deze functie is niet beschikbaar voor de databaseverbinding." [session] -expired = "Je sessie is verlopen. Voer de pagina opnieuw in en probeer het opnieuw." +expired = "Uw sessie is verlopen. Voer de pagina opnieuw in en probeer het opnieuw." refreshPage = "Pagina vernieuwen" [home] -desc = "Jouw lokaal gehoste one-stop-shop voor al je PDF-behoeften." -searchBar = "Zoek naar functies..." +desc = "Uw lokaal gehoste one-stop-shop voor al uw PDF-behoeften." +searchBar = "Functies zoeken..." setFavorites = "Favorieten instellen" hideFavorites = "Favorieten verbergen" showFavorites = "Favorieten tonen" @@ -611,7 +639,7 @@ sortBy = "Sorteren op:" [home.viewPdf] title = "PDF bekijken/bewerken" -desc = "Bekijk, annoteer, voeg tekst of afbeeldingen toe" +desc = "Bekijken, annoteren, tekst of afbeeldingen toevoegen" [home.mobile] brandAlt = "Stirling PDF-logo" @@ -631,17 +659,17 @@ desc = "Pagina's samenvoegen, draaien, herschikken en verwijderen" [home.merge] tags = "combineren,samenvoegen,verenigen" title = "Samenvoegen" -desc = "Voeg eenvoudig meerdere PDF's samen tot één." +desc = "Meerdere PDF-en eenvoudig samenvoegen tot één." [home.split] tags = "splitsen,scheiden,opdelen" title = "Splitsen" -desc = "Splits PDF's in meerdere documenten" +desc = "Splits PDF-en in meerdere documenten" [home.rotate] tags = "draaien,omklappen,oriënteren" title = "Roteren" -desc = "Roteer eenvoudig je PDF's." +desc = "Roteer eenvoudig uw PDF-en." [home.convert] tags = "converteren,wijzigen" @@ -666,17 +694,17 @@ desc = "Ingesloten bestanden (bijlagen) toevoegen aan of verwijderen uit een PDF [home.watermark] tags = "stempel,markeren,overlay" title = "Watermerk toevoegen" -desc = "Voeg een aangepast watermerk toe aan je PDF-document." +desc = "Voeg een aangepast watermerk toe aan uw PDF-document." [home.removePassword] tags = "ontgrendelen" title = "Wachtwoord verwijderen" -desc = "Verwijder wachtwoordbeveiliging van je PDF-document." +desc = "Verwijder wachtwoordbeveiliging van uw PDF-document." [home.compress] tags = "verkleinen,verminderen,optimaliseren" title = "Comprimeren" -desc = "Comprimeer PDF's om hun bestandsgrootte te verkleinen." +desc = "Comprimeer PDF-en om hun bestandsgrootte te verkleinen." [home.unlockPDFForms] tags = "ontgrendelen,inschakelen,bewerken" @@ -710,7 +738,7 @@ desc = "Voegt handtekening toe aan PDF via tekenen, tekst of afbeelding" [home.flatten] tags = "vereenvoudigen,verwijderen,interactief" -title = "Platdrukken" +title = "Afvlakken" desc = "Verwijder alle interactieve elementen en formulieren uit een PDF" [home.certSign] @@ -725,7 +753,7 @@ desc = "Probeert een corrupt/beschadigd PDF te herstellen" [home.removeBlanks] tags = "verwijderen,opschonen,leeg" -title = "Verwijder lege pagina's" +title = "Lege pagina's verwijderen" desc = "Detecteert en verwijdert lege pagina's uit een document" [home.removeAnnotations] @@ -755,7 +783,7 @@ desc = "Maak boekjes met de juiste paginavolgorde en meerpagina-indeling voor af [home.scalePages] tags = "formaat wijzigen,aanpassen,schalen" -title = "Aanpassen paginaformaat/schaal" +title = "Paginaformaat/schaal aanpassen" desc = "Wijzig de grootte/schaal van een pagina en/of de inhoud ervan." [home.addPageNumbers] @@ -780,37 +808,37 @@ desc = "Snijd een PDF bij om de grootte te verkleinen (behoudt tekst!)" [home.autoSplitPDF] tags = "auto,splitsen,QR" -title = "Automatisch splitsen pagina's" +title = "Automatisch pagina's splitsen" desc = "Automatisch splitsen van gescande PDF met fysieke gescande paginasplitter QR-code" [home.sanitize] tags = "opschonen,schonen,verwijderen" -title = "Sanitiseren" +title = "Opschonen" desc = "Potentieel schadelijke elementen uit PDF-bestanden verwijderen" [home.getPdfInfo] tags = "info,metadata,details" -title = "Haal ALLE informatie op over PDF" -desc = "Haalt alle mogelijke informatie op van PDF's" +title = "ALLE informatie over PDF ophalen" +desc = "Haalt alle mogelijke informatie op van PDF-en" [home.pdfToSinglePage] -tags = "combineren,samenvoegen,één" +tags = "combineren,samenvoegen,een,enkel" title = "PDF naar één grote pagina" desc = "Voegt alle PDF-pagina's samen tot één grote pagina" [home.showJS] tags = "javascript,code,script" -title = "Toon Javascript" +title = "Javascript weergeven" desc = "Zoekt en toont ieder script dat in een PDF is geïnjecteerd" [home.redact] tags = "censureren,zwartlakken,verbergen" -title = "Manual Redaction" -desc = "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)" +title = "Redigeren" +desc = "Redigeert een PDF op basis van geselecteerde tekst, getekende vormen en/of geselecteerde pagina('s)" [home.splitBySections] tags = "splitsen,secties,verdelen" -title = "PDF splitsen op secties" +title = "PDF splitsen in secties" desc = "Elke pagina van een PDF opdelen in kleinere horizontale en verticale secties" [home.addStamp] @@ -825,7 +853,7 @@ desc = "Afbeeldingen uit PDF verwijderen om het bestandsgrootte te verminderen" [home.splitByChapters] tags = "splitsen,hoofdstukken,structuur" -title = "PDF op hoofdstukken splitsen" +title = "PDF splitsen in hoofdstukken" desc = "Splits een PDF op basis van zijn hoofdstukstructuur in meerdere bestanden." [home.validateSignature] @@ -851,12 +879,12 @@ desc = "Bladwijzers en inhoudsopgave toevoegen of bewerken in PDF-documenten" [home.manageCertificates] tags = "certificaten,importeren,exporteren" title = "Certificaten beheren" -desc = "Digitale certificaatbestanden importeren, exporteren of verwijderen die worden gebruikt voor het ondertekenen van PDF's." +desc = "Digitale certificaatbestanden importeren, exporteren of verwijderen die worden gebruikt voor het ondertekenen van PDF-en." [home.read] tags = "bekijken,openen,weergeven" title = "Lezen" -desc = "PDF's bekijken en annoteren. Markeer tekst, teken of voeg opmerkingen in voor beoordeling en samenwerking." +desc = "PDF-en bekijken en annoteren. Markeer tekst, teken of voeg opmerkingen in voor beoordeling en samenwerking." [home.reorganizePages] tags = "herordenen,herindelen,organiseren" @@ -871,7 +899,7 @@ desc = "Specifieke pagina's uit een PDF-document extraheren" [home.removePages] tags = "verwijderen,extraheren,uitsluiten" title = "Verwijderen" -desc = "Verwijder ongewenste pagina's uit je PDF-document." +desc = "Verwijder ongewenste pagina's uit uw PDF-document." [home.autoSizeSplitPDF] tags = "auto,splitsen,grootte" @@ -902,7 +930,7 @@ desc = "Link naar handleiding voor air-gapped-installatie" [home.addPassword] title = "Wachtwoord toevoegen" -desc = "Versleutel je PDF-document met een wachtwoord." +desc = "Versleutel uw PDF-document met een wachtwoord." [home.changePermissions] title = "Rechten wijzigen" @@ -914,17 +942,17 @@ title = "Automatiseren" desc = "Bouw workflows met meerdere stappen door PDF-acties te koppelen. Ideaal voor terugkerende taken." [home.overlay-pdfs] -desc = "Plaatst PDF's over een andere PDF heen" -title = "PDF's overlappen" +desc = "Plaatst PDF-en over een andere PDF heen" +title = "PDF-en overlappen" [home.pdfTextEditor] title = "PDF-teksteditor" -desc = "Bewerk bestaande tekst en afbeeldingen in PDF's" +desc = "Bewerk bestaande tekst en afbeeldingen in PDF-en" [home.addText] tags = "tekst,annotatie,label" title = "Tekst toevoegen" -desc = "Voeg overal in je PDF aangepaste tekst toe" +desc = "Voeg overal in uw PDF aangepaste tekst toe" [landing] addFiles = "Bestanden toevoegen" @@ -958,8 +986,8 @@ moveLeft = "Naar links verplaatsen" moveRight = "Naar rechts verplaatsen" delete = "Verwijderen" dragDropMessage = "Pagina('s) geselecteerd" -undo = "Undo" -redo = "Redo" +undo = "Undo (CTRL + Z)" +redo = "Redo (CTRL + Y)" [merge] tags = "samenvoegen,pagina bewerkingen,serverzijde" @@ -990,7 +1018,7 @@ descending = "Aflopend" sort = "Sorteren" [merge.error] -failed = "Er is een fout opgetreden bij het samenvoegen van de PDF's." +failed = "Er is een fout opgetreden bij het samenvoegen van de PDF-en." [merge.tooltip.header] title = "Overzicht instellingen samenvoegen" @@ -1004,8 +1032,8 @@ selectMethod = "Selecteer een splitsmethode" resultsTitle = "Splitsresultaten" [split.desc] -1 = "De nummers die je kiest zijn de paginanummers waarop je een splitsing wilt uitvoeren" -2 = "Als zodanig selecteren van 1,3,7-9 zou een 10 pagina's tellend document splitsen in 6 aparte PDF's met:" +1 = "De nummers die u kiest zijn de paginanummers waarop u een splitsing wilt uitvoeren" +2 = "Als zodanig selecteren van 1,3,7-9 zou een 10 pagina's tellend document splitsen in 6 aparte PDF-en met:" 3 = "Document #1: Pagina 1" 4 = "Document #2: Pagina 2 en 3" 5 = "Document #3: Pagina 4, 5, 6 en 7" @@ -1025,7 +1053,7 @@ failed = "Er is een fout opgetreden bij het splitsen van de PDF." [split.method] label = "Kies een splitsmethode" -placeholder = "Selecteer hoe je de PDF wilt splitsen" +placeholder = "Selecteer hoe u de PDF wilt splitsen" [split.methods.prefix] splitAt = "Splitsen op" @@ -1054,7 +1082,7 @@ tooltip = "Voer het aantal pagina's in voor elk gesplitst bestand" [split.methods.byDocCount] name = "Aantal documenten" desc = "Specifiek aantal bestanden maken" -tooltip = "Voer in hoeveel bestanden je wilt maken" +tooltip = "Voer in hoeveel bestanden u wilt maken" [split.methods.byChapters] name = "Hoofdstukken" @@ -1083,7 +1111,7 @@ title = "Overzicht splitsmethoden" [split.tooltip.byPages] title = "Splitsen op paginanummers" -text = "Splits je PDF op specifieke paginanummers. Met 'n' wordt gesplitst na pagina n. Met 'n-m' wordt gesplitst vóór pagina n en na pagina m." +text = "Splits de PDF op specifieke paginanummers. Met 'n' wordt gesplitst na pagina n. Met 'n-m' wordt gesplitst vóór pagina n en na pagina m." bullet1 = "Enkele splitspunten: 3,7 (splitst na pagina's 3 en 7)" bullet2 = "Reeks-splitspunten: 3-8 (splitst vóór pagina 3 en na pagina 8)" bullet3 = "Gemengd: 2,5-10,15 (splitst na pagina 2, vóór pagina 5, na pagina 10 en na pagina 15)" @@ -1093,25 +1121,25 @@ title = "Splitsen op rastersecties" text = "Deel elke pagina op in een raster van secties. Handig voor documenten met meerdere kolommen of het extraheren van specifieke gebieden." bullet1 = "Horizontaal: aantal rijen om te maken" bullet2 = "Verticaal: aantal kolommen om te maken" -bullet3 = "Samenvoegen: alle secties combineren in één PDF" +bullet3 = "Samenvoegen: alle secties combineren in een PDF" [split.tooltip.bySize] title = "Splitsen op bestandsgrootte" -text = "Maak meerdere PDF's die een opgegeven bestandsgrootte niet overschrijden. Ideaal bij limieten of e-mailbijlagen." +text = "Maak meerdere PDF-en die een opgegeven bestandsgrootte niet overschrijden. Ideaal bij limieten of e-mailbijlagen." bullet1 = "Gebruik MB voor grotere bestanden (bijv. 10MB)" bullet2 = "Gebruik KB voor kleinere bestanden (bijv. 500KB)" bullet3 = "Het systeem splitst op paginagrens" [split.tooltip.byCount] title = "Splitsen op aantal" -text = "Maak meerdere PDF's met een specifiek aantal pagina's of documenten elk." +text = "Maak meerdere PDF-en met een specifiek aantal pagina's of documenten elk." bullet1 = "Aantal pagina's: vast aantal pagina's per bestand" bullet2 = "Aantal documenten: vast aantal uitvoerbestanden" bullet3 = "Handig voor batchverwerkingsworkflows" [split.tooltip.byChapters] title = "Splitsen op hoofdstukken" -text = "Gebruik PDF-bladwijzers om automatisch te splitsen op hoofdstukgrenzen. Vereist PDF's met bladwijzerstructuur." +text = "Gebruik PDF-bladwijzers om automatisch te splitsen op hoofdstukgrenzen. Vereist PDF-en met bladwijzerstructuur." bullet1 = "Bladwijzerniveau: welk niveau om op te splitsen (1=bovenste niveau)" bullet2 = "Metadata opnemen: documenteigenschappen behouden" bullet3 = "Duplicaten toestaan: herhaalde bladwijzernamen afhandelen" @@ -1119,7 +1147,7 @@ bullet3 = "Duplicaten toestaan: herhaalde bladwijzernamen afhandelen" [split.tooltip.byDocCount] bullet1 = "Voer het aantal gewenste uitvoerbestanden in" bullet2 = "Pagina's worden zo gelijk mogelijk verdeeld" -bullet3 = "Handig wanneer je een specifiek aantal bestanden nodig hebt" +bullet3 = "Handig wanneer u een specifiek aantal bestanden nodig hebt" text = "Maak een specifiek aantal uitvoerbestanden door pagina's gelijkmatig te verdelen." title = "Splitsen op aantal documenten" @@ -1127,27 +1155,27 @@ title = "Splitsen op aantal documenten" bullet1 = "Voer het aantal pagina's per uitvoerbestand in" bullet2 = "Het laatste bestand kan minder pagina's hebben als het niet gelijkmatig deelbaar is" bullet3 = "Handig voor batchverwerkingsworkflows" -text = "Maak meerdere PDF's met een specifiek aantal pagina's elk. Perfect voor uniforme documentdelen." +text = "Maak meerdere PDF-en met een specifiek aantal pagina's elk. Perfect voor uniforme documentdelen." title = "Splitsen op aantal pagina's" [split.tooltip.byPageDivider] bullet1 = "Print scheidingsvellen via de downloadlink" -bullet2 = "Plaats scheidingsvellen tussen je documenten" +bullet2 = "Plaats scheidingsvellen tussen uw documenten" bullet3 = "Scan alle documenten samen als één PDF" bullet4 = "Uploaden - scheidingspagina's worden automatisch gedetecteerd en verwijderd" -bullet5 = "Schakel Duplex-modus in als je beide zijden van scheidingsvellen scant" +bullet5 = "Schakel Duplex-modus in als u beide zijden van scheidingsvellen scant" text = "Scans automatisch splitsen met fysieke scheidingsvellen met QR-codes. Perfect voor meerdere documenten die samen zijn gescand." title = "Splitsen met paginascheider" [split.methodSelection.tooltip] bullet1 = "Klik op een methodekaart om te selecteren" bullet2 = "Beweeg over elke kaart om een korte beschrijving te zien" -bullet3 = "De instellingenstap verschijnt nadat je een methode hebt geselecteerd" -bullet4 = "Je kunt de methode op elk moment wijzigen vóór verwerking" -title = "Kies je splitsmethode" +bullet3 = "De instellingenstap verschijnt nadat u een methode hebt geselecteerd" +bullet4 = "U kunt de methode op elk moment wijzigen vóór verwerking" +title = "Kies de splitsmethode" [split.methodSelection.tooltip.header] -text = "Kies hoe je je PDF-document wilt splitsen. Elke methode is geoptimaliseerd voor verschillende use-cases en documenttypen." +text = "Kies hoe u het PDF-document wilt splitsen. Elke methode is geoptimaliseerd voor verschillende use-cases en documenttypen." title = "Selectie splitsmethode" [rotate] @@ -1167,7 +1195,7 @@ title = "Rotatievoorbeeld" title = "Overzicht rotatie-instellingen" [rotate.tooltip.description] -text = "Draai je PDF-pagina's met de klok mee of tegen de klok in in stappen van 90 graden. Alle pagina's in de PDF worden gedraaid. Het voorbeeld toont hoe je document eruitziet na het draaien." +text = "Draai uw PDF-pagina's met de klok mee of tegen de klok in in stappen van 90 graden. Alle pagina's in de PDF worden gedraaid. Het voorbeeld toont hoe u document eruitziet na het draaien." [rotate.tooltip.controls] title = "Bediening" @@ -1206,7 +1234,7 @@ fillPage = "Pagina vullen" autoRotate = "Automatisch roteren" autoRotateDescription = "Afbeeldingen automatisch draaien zodat ze beter op de PDF-pagina passen" combineImages = "Afbeeldingen combineren" -combineImagesDescription = "Combineer alle afbeeldingen in één PDF, of maak afzonderlijke PDF's voor elke afbeelding" +combineImagesDescription = "Combineer alle afbeeldingen in één PDF, of maak afzonderlijke PDF-en voor elke afbeelding" webOptions = "Web-naar-PDF-opties" zoomLevel = "Zoomniveau" emailOptions = "E-mail-naar-PDF-opties" @@ -1260,7 +1288,7 @@ multi = "Meerdere afbeeldingen, één afbeelding per pagina" colorType = "Kleurtype" color = "Kleur" grey = "Grijstinten" -blackwhite = "Zwart en wit (kan data verliezen!)" +blackwhite = "Zwart en wit (kans op gegevensverlies!)" submit = "Omzetten" info = "Python is niet geïnstalleerd. Vereist voor WebP-conversie." placeholder = "(bijv. 1,2,8 of 4,7,12-16 of 2n-1)" @@ -1287,11 +1315,11 @@ _value = "Modus" 11 = "Alle pagina's dupliceren" [pdfOrganiser.mode.desc] -BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, …)." +BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, ...)." CUSTOM = "Gebruik een aangepaste reeks paginanummers of expressies om een nieuwe volgorde te definiëren." -DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, …)." +DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, ...)." DUPLICATE = "Dupliceer elke pagina volgens het aantal in de aangepaste volgorde (bijv. 4 duplicaten betekent elke pagina 4×)." -ODD_EVEN_MERGE = "Twee PDF's samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." +ODD_EVEN_MERGE = "Twee PDF-en samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." ODD_EVEN_SPLIT = "Het document splitst in twee uitvoerbestanden: alle oneven pagina's en alle even pagina's." REMOVE_FIRST = "De eerste pagina uit het document verwijderen." REMOVE_FIRST_AND_LAST = "Zowel de eerste als de laatste pagina uit het document verwijderen." @@ -1302,11 +1330,11 @@ SIDE_STITCH_BOOKLET_SORT = "Pagina's rangschikken voor zijkant-geniete boekjes ( [pdfOrganiser.desc] CUSTOM = "Gebruik een aangepaste reeks paginanummers of expressies om een nieuwe volgorde te definiëren." REVERSE_ORDER = "Document omdraaien zodat de laatste pagina de eerste wordt, enzovoort." -DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, …)." -BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, …)." +DUPLEX_SORT = "Voorkanten en daarna achterkanten verweven alsof een duplexscanner eerst alle voorkanten en daarna alle achterkanten heeft gescand (1, n, 2, n-1, ...)." +BOOKLET_SORT = "Pagina's rangschikken voor boekjes afdrukken (laatste, eerste, tweede, een-na-laatste, ...)." SIDE_STITCH_BOOKLET_SORT = "Pagina's rangschikken voor zijkant-geniete boekjes (geoptimaliseerd voor binden aan de zijkant)." ODD_EVEN_SPLIT = "Het document splitst in twee uitvoerbestanden: alle oneven pagina's en alle even pagina's." -ODD_EVEN_MERGE = "Twee PDF's samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." +ODD_EVEN_MERGE = "Twee PDF-en samenvoegen door pagina's af te wisselen: oneven uit de eerste, even uit de tweede." DUPLICATE = "Dupliceer elke pagina volgens het aantal in de aangepaste volgorde (bijv. 4 duplicaten betekent elke pagina 4×)." REMOVE_FIRST = "Verwijder de eerste pagina uit het document." REMOVE_LAST = "Verwijder de laatste pagina uit het document." @@ -1327,15 +1355,15 @@ label = "Afbeeldingsbestand" configure = "Afbeelding configureren" [addImage.step] -createDesc = "Upload de afbeelding die je wilt toevoegen" +createDesc = "Upload de afbeelding die u wilt toevoegen" place = "Afbeelding plaatsen" -placeDesc = "Klik op de PDF om je afbeelding toe te voegen" +placeDesc = "Klik op de PDF om uw afbeelding toe te voegen" [addImage.instructions] title = "Afbeeldingen toevoegen" -text = "Klik na het uploaden van je afbeelding ergens op de PDF om deze te plaatsen." +text = "Klik na het uploaden van de afbeelding ergens op de PDF om deze te plaatsen." paused = "Plaatsing gepauzeerd" -resumeHint = "Hervat de plaatsing om te klikken en je afbeelding toe te voegen." +resumeHint = "Hervat de plaatsing om te klikken en uw afbeelding toe te voegen." noSignature = "Upload hierboven een afbeelding om te kunnen plaatsen." [addImage.mode] @@ -1583,7 +1611,7 @@ tabTitle = "Werkruimte inhoud" subtitle = "Importeer bladwijzers, bouw hiërarchieën en pas de inhoud toe zonder krappe zijpanelen." noFile = "Geen PDF geselecteerd" fileLabel = "Wijzigingen worden toegepast op de momenteel geselecteerde PDF." -filePrompt = "Selecteer een PDF uit je bibliotheek of upload een nieuwe om te beginnen." +filePrompt = "Selecteer een PDF uit uw bibliotheek of upload een nieuwe om te beginnen." changeFile = "PDF wijzigen" selectFile = "PDF selecteren" @@ -1593,7 +1621,7 @@ description = "Selecteer de tool Inhoudsopgave bewerken om de werkruimte te lade [editTableOfContents.editor] heading = "Bladwijzer-editor" -description = "Voeg bladwijzers toe, nestel ze en wijzig de volgorde om je PDF-inhoud te maken." +description = "Voeg bladwijzers toe, nestel ze en wijzig de volgorde om uw PDF-inhoud te maken." addTopLevel = "Bladwijzer op hoogste niveau toevoegen" defaultTitle = "Nieuwe bladwijzer" defaultChildTitle = "Onderliggende bladwijzer" @@ -1605,7 +1633,7 @@ confirmRemove = "Deze bladwijzer en alle onderliggende verwijderen?" [editTableOfContents.editor.empty] title = "Nog geen bladwijzers" -description = "Importeer bestaande bladwijzers of begin met je eerste item toe te voegen." +description = "Importeer bestaande bladwijzers of begin met uw eerste item toe te voegen." action = "Eerste bladwijzer toevoegen" [editTableOfContents.editor.field] @@ -1624,7 +1652,7 @@ loadedBody = "Bestaande bladwijzers uit de PDF zijn in de editor geladen." noBookmarks = "Geen bladwijzers gevonden in de geselecteerde PDF." loadFailed = "Kan geen bladwijzers uit de geselecteerde PDF extraheren." imported = "Bladwijzers geïmporteerd" -importedBody = "Je JSON-inhoud heeft de huidige editorinhoud vervangen." +importedBody = "De JSON-inhoud heeft de huidige editorinhoud vervangen." importedClipboard = "Klembordgegevens hebben de huidige bladwijzerlijst vervangen." invalidJson = "Ongeldige JSON-structuur" invalidJsonBody = "Geef een geldig bladwijzer-JSON-bestand op en probeer het opnieuw." @@ -1765,10 +1793,10 @@ description = "Voer cijfers gescheiden door komma's in." title = "Afzonderlijke pagina's" [pageSelection.tooltip.mathematical] -bullet1 = "2n → alle even pagina's (2, 4, 6…)" -bullet2 = "2n-1 → alle oneven pagina's (1, 3, 5…)" -bullet3 = "3n → elke 3e pagina (3, 6, 9…)" -bullet4 = "4n-1 → pagina's 3, 7, 11, 15…" +bullet1 = "2n → alle even pagina's (2, 4, 6...)" +bullet2 = "2n-1 → alle oneven pagina's (1, 3, 5...)" +bullet3 = "3n → elke 3e pagina (3, 6, 9...)" +bullet4 = "4n-1 → pagina's 3, 7, 11, 15..." description = "Gebruik n in formules voor patronen." title = "Wiskundige functies" @@ -1926,7 +1954,7 @@ valuePlaceholder = "Aangepaste waarde" remove = "Verwijderen" [changeMetadata.results] -title = "Bijgewerkte PDF's" +title = "Bijgewerkte PDF-en" [changeMetadata.error] failed = "Er is een fout opgetreden bij het wijzigen van de PDF-metadata." @@ -2068,7 +2096,7 @@ title = "Geavanceerde OCR-verwerking" [ocr.tooltip.advanced.compatibility] title = "Compatibiliteitsmodus" -text = "Gebruikt OCR-'sandwich PDF'-modus: resulteert in grotere bestanden, maar betrouwbaarder met bepaalde talen en oudere PDF-software. Standaard gebruiken we hOCR voor kleinere, moderne PDF's." +text = "Gebruikt OCR-'sandwich PDF'-modus: resulteert in grotere bestanden, maar betrouwbaarder met bepaalde talen en oudere PDF-software. Standaard gebruiken we hOCR voor kleinere, moderne PDF-en." [ocr.tooltip.advanced.sidecar] title = "Tekstbestand maken" @@ -2095,7 +2123,7 @@ title = "Afbeeldingen extraheren" header = "Afbeeldingen extraheren" selectText = "Selecteer het beeldformaat voor geëxtraheerde afbeeldingen" allowDuplicates = "Dubbele afbeeldingen opslaan" -submit = "Extraheer" +submit = "Extraheren" [extractImages.settings] title = "Instellingen" @@ -2205,7 +2233,7 @@ headsUpDesc = "Overlappende foto's of achtergronden die qua kleur erg op de foto [sign] title = "Ondertekenen" -header = "PDF's ondertekenen" +header = "PDF-en ondertekenen" upload = "Upload afbeelding" clear = "Wissen" add = "Toevoegen" @@ -2233,9 +2261,9 @@ title = "Teken uw handtekening" clear = "Wissen" [sign.canvas] -heading = "Teken je handtekening" +heading = "Teken uw handtekening" clickToOpen = "Klik om het tekenvenster te openen" -modalTitle = "Teken je handtekening" +modalTitle = "Teken uw handtekening" colorLabel = "Kleur" penSizeLabel = "Pendikte" penSizePlaceholder = "Grootte" @@ -2256,7 +2284,7 @@ description = "Gebruik opgeslagen handtekeningen op elk moment opnieuw." emptyTitle = "Nog geen opgeslagen handtekeningen" emptyDescription = "Teken, upload of typ hierboven een handtekening en kies vervolgens 'Opslaan in bibliotheek' om tot {{max}} favorieten paraat te hebben." limitTitle = "Limiet bereikt" -limitDescription = "Verwijder een opgeslagen handtekening voordat je nieuwe toevoegt (max. {{max}})." +limitDescription = "Verwijder een opgeslagen handtekening voordat u nieuwe toevoegt (max. {{max}})." carouselPosition = "{{current}} van {{total}}" prev = "Vorige" next = "Volgende" @@ -2272,7 +2300,7 @@ saveShared = "Als gedeeld opslaan" saveUnavailable = "Maak eerst een handtekening om deze op te slaan." noChanges = "De huidige handtekening is al opgeslagen." tempStorageTitle = "Tijdelijke browseropslag" -tempStorageDescription = "Handtekeningen worden alleen in je browser opgeslagen. Ze gaan verloren als je je browsergegevens wist of van browser wisselt." +tempStorageDescription = "Handtekeningen worden alleen in de browser opgeslagen. Ze gaan verloren als u de browsergegevens wist of van browser wisselt." personalHeading = "Persoonlijke handtekeningen" sharedHeading = "Gedeelde handtekeningen" personalDescription = "Alleen jij kunt deze handtekeningen zien." @@ -2290,9 +2318,9 @@ saved = "Opgeslagen" configure = "Handtekening configureren" [sign.step] -createDesc = "Kies hoe je de handtekening wilt maken" +createDesc = "Kies hoe u de handtekening wilt maken" place = "Plaatsen en opslaan" -placeDesc = "Plaats de handtekening op je PDF" +placeDesc = "Plaats de handtekening op uw PDF" [sign.type] title = "Type handtekening" @@ -2314,7 +2342,7 @@ image = "Na het uploaden van uw handtekeningafbeelding hierboven, klik ergens op saved = "Selecteer hierboven een opgeslagen handtekening en klik vervolgens ergens op de PDF om deze te plaatsen." text = "Na het invoeren van uw naam hierboven, klik ergens op de PDF om uw handtekening te plaatsen." paused = "Plaatsing gepauzeerd" -resumeHint = "Hervat de plaatsing om te klikken en je handtekening toe te voegen." +resumeHint = "Hervat de plaatsing om te klikken en uw handtekening toe te voegen." noSignature = "Maak hierboven een handtekening om de plaatsingstools in te schakelen." [sign.mode] @@ -2331,7 +2359,7 @@ failed = "Er is een fout opgetreden bij het ondertekenen van de PDF." [flatten] title = "Afvlakken" -header = "PDF's afvlakken" +header = "PDF-en afvlakken" flattenOnlyForms = "Alleen formulieren afvlakken" submit = "Afvlakken" filenamePrefix = "afgevlakt" @@ -2358,7 +2386,7 @@ title = "Resultaten afvlakken" failed = "Er is een fout opgetreden bij het afvlakken van de PDF." [flatten.tooltip.header] -title = "Over PDF's afvlakken" +title = "Over PDF-en afvlakken" [flatten.tooltip.description] title = "Wat doet afvlakken?" @@ -2379,7 +2407,7 @@ bullet4 = "Bladwijzers helpen u nog steeds te navigeren" [repair] tags = "repareren,herstellen,correctie,terughalen" title = "Repareren" -header = "PDF's repareren" +header = "PDF-en repareren" submit = "Repareren" description = "Deze tool probeert corrupte of beschadigde PDF-bestanden te repareren. Er zijn geen extra instellingen nodig." filenamePrefix = "gerepareerd" @@ -2466,17 +2494,17 @@ failed = "Er is een fout opgetreden bij het verwijderen van aantekeningen uit de [compare] tags = "onderscheiden,contrasteren,veranderingen,analyse" title = "Vergelijken" -header = "PDF's vergelijken" +header = "PDF-en vergelijken" clearSelected = "Selectie wissen" -addFilesHint = "Voeg PDF's toe in de stap Bestanden om selectie mogelijk te maken." -noFiles = "Nog geen PDF's beschikbaar" +addFilesHint = "Voeg PDF-en toe in de stap Bestanden om selectie mogelijk te maken." +noFiles = "Nog geen PDF-en beschikbaar" pages = "Pagina's" cta = "Vergelijken" loading = "Bezig met vergelijken..." newLine = "nieuwe-regel" [compare.clear] -confirmTitle = "Geselecteerde PDF's wissen?" +confirmTitle = "Geselecteerde PDF-en wissen?" confirmBody = "Dit sluit de huidige vergelijking en brengt u terug naar Actieve bestanden." confirm = "Wissen en terugkeren" @@ -2495,7 +2523,7 @@ label = "Bewerkt document" placeholder = "Selecteer de bewerkte PDF" [compare.selection] -originalEditedTitle = "Originele en bewerkte PDF's selecteren" +originalEditedTitle = "Originele en bewerkte PDF-en selecteren" [compare.original] label = "Originele PDF" @@ -2517,7 +2545,7 @@ pageLabel = "Pagina" pageNotReadyTitle = "Pagina nog niet gerenderd" pageNotReadyBody = "Sommige pagina's worden nog gerenderd. De navigatie klikt vast zodra ze gereed zijn." rendering = "renderen" -inProgress = "Ten minste een van deze PDF's is zeer groot, scrollen verloopt niet soepel totdat het renderen is voltooid" +inProgress = "Ten minste een van deze PDF-en is zeer groot, scrollen verloopt niet soepel totdat het renderen is voltooid" pagesRendered = "pagina's gerenderd" complete = "Renderen voltooid" @@ -2556,10 +2584,10 @@ complete = "Vergelijking gereed" [compare.longJob] title = "Grote vergelijking bezig" -body = "Deze PDF's overschrijden samen 2,000 pagina's. De verwerking kan enkele minuten duren." +body = "Deze PDF-en overschrijden samen 2,000 pagina's. De verwerking kan enkele minuten duren." [compare.slowOperation] -title = "Nog bezig…" +title = "Nog bezig..." body = "Deze vergelijking duurt langer dan normaal. U kunt het laten doorgaan of annuleren." cancel = "Vergelijking annuleren" @@ -2576,7 +2604,7 @@ message = "Een of beide geselecteerde PDF-bestanden bevatten geen tekstinhoud. K message = "Deze documenten lijken sterk van elkaar te verschillen. De vergelijking is gestopt om tijd te besparen." [compare.earlyDissimilarity] -title = "Deze PDF's verschillen sterk" +title = "Deze PDF-en verschillen sterk" body = "We zien tot nu toe zeer weinig overeenkomsten. U kunt de vergelijking stoppen als dit geen verwante documenten zijn." stopButton = "Vergelijking stoppen" @@ -2594,7 +2622,7 @@ logoTitle = "Logo" name = "Naam" noLogo = "Geen logo" pageNumber = "Paginanummer" -password = "Voer je sleutelopslag of privésleutel wachtwoord in (indien van toepassing):" +password = "Voer de sleutelopslag of privésleutel wachtwoord in (indien van toepassing):" passwordOptional = "Leeg laten als er geen wachtwoord is" reason = "Reden" serverCertMessage = "Servercertificaat gebruiken - geen bestanden of wachtwoord nodig" @@ -2668,7 +2696,7 @@ title = "Over het beheren van handtekeningen" [certSign.tooltip.overview] title = "Wat kan deze tool doen?" -text = "Met deze tool kunt u controleren of uw PDF's digitaal zijn ondertekend en nieuwe digitale handtekeningen toevoegen. Digitale handtekeningen bewijzen wie een document heeft gemaakt of goedgekeurd en tonen of het sinds de ondertekening is gewijzigd." +text = "Met deze tool kunt u controleren of uw PDF-en digitaal zijn ondertekend en nieuwe digitale handtekeningen toevoegen. Digitale handtekeningen bewijzen wie een document heeft gemaakt of goedgekeurd en tonen of het sinds de ondertekening is gewijzigd." bullet1 = "Controleer bestaande handtekeningen en hun geldigheid" bullet2 = "Bekijk gedetailleerde informatie over ondertekenaars en certificaten" bullet3 = "Voeg nieuwe digitale handtekeningen toe om uw documenten te beveiligen" @@ -2701,7 +2729,7 @@ text = "Het is een veilige ID voor uw handtekening die bewijst dat u hebt ondert [certSign.certType.tooltip.which] title = "Welke optie moet ik gebruiken?" text = "Kies de indeling die overeenkomt met uw certificaatbestand:" -bullet1 = "PKCS#12 (.p12 / .pfx) – één gecombineerd bestand (meest voorkomend)" +bullet1 = "PKCS#12 (.p12 / .pfx) – een gecombineerd bestand (meest voorkomend)" bullet2 = "PFX (.pfx) – Microsofts versie van PKCS12" bullet3 = "PEM – aparte .pem-bestanden voor privésleutel en certificaat" bullet4 = "JKS – Java .jks-keystore voor dev-/CI-CD-workflows" @@ -2956,7 +2984,7 @@ failed = "PDF bijsnijden mislukt" selectArea = "Selecteer bijsnijgebied" [crop.tooltip] -title = "Hoe PDF's bijsnijden" +title = "PDF-en bijsnijden" description = "Selecteer het gebied om bij te snijden in uw PDF door de blauwe overlay op de miniatuur te slepen en te schalen." drag = "Sleep de overlay om het bijsnijgebied te verplaatsen" resize = "Sleep aan de hoek- en randgrepen om te schalen" @@ -2966,14 +2994,14 @@ precision = "Gebruik coördinatenvelden voor nauwkeurige positionering" title = "Resultaten bijsnijden" [crop.automation] -info = "Voer bijsnijcoördinaten in PDF-punten in. Oorsprong (0,0) bevindt zich linksonder. Deze waarden worden toegepast op alle PDF's die in deze automatisering worden verwerkt." +info = "Voer bijsnijcoördinaten in PDF-punten in. Oorsprong (0,0) bevindt zich linksonder. Deze waarden worden toegepast op alle PDF-en die in deze automatisering worden verwerkt." reference = "Referentie: A4-pagina is 595,28 × 841,89 punten (210mm × 297mm). 1 inch = 72 punten." [autoSplitPDF] tags = "QR-gebaseerd,scheiden,scan-segment,organiseren" title = "PDF automatisch splitsen" header = "PDF automatisch splitsen" -description = "Print, Voeg in, Scan, upload, en laat ons je documenten automatisch scheiden. Geen handmatig sorteerwerk nodig." +description = "Print, Voeg in, Scan, upload, en laat ons uw documenten automatisch scheiden. Geen handmatig sorteerwerk nodig." formPrompt = "Dien PDF in met Stirling-PDF Pagina-scheiders:" duplexMode = "Duplex Modus (voor- en achterkant scannen)" dividerDownload2 = "Download 'Auto Splitter Divider (with instructions).pdf'" @@ -2981,7 +3009,7 @@ submit = "Indienen" [autoSplitPDF.selectText] 1 = "Print enkele scheidingsbladen van hieronder (Zwart-wit is prima)." -2 = "Scan al je documenten tegelijk door het scheidingsblad ertussen te plaatsen." +2 = "Scan al uw documenten tegelijk door het scheidingsblad ertussen te plaatsen." 3 = "Upload het enkele grote gescande PDF-bestand en laat Stirling PDF de rest afhandelen." 4 = "Scheidingspagina's worden automatisch gedetecteerd en verwijderd, wat een net einddocument garandeert." @@ -3263,8 +3291,8 @@ tags = "pdf,splitsen,document,organiseren" [overlay-pdfs] tags = "Overlappen" header = "PDF bestanden overlappen" -title = "PDF's over elkaar leggen" -desc = "Leg één PDF over een andere heen" +title = "PDF-en over elkaar leggen" +desc = "Leg een PDF over een andere heen" submit = "Indienen" [overlay-pdfs.baseFile] @@ -3273,7 +3301,7 @@ label = "Selecteer basis PDF-bestand" [overlay-pdfs.overlayFiles] label = "Selecteer overlappende PDF-bestanden" placeholder = "Kies PDF('s)..." -addMore = "Meer PDF's toevoegen..." +addMore = "Meer PDF-en toevoegen..." [overlay-pdfs.mode] label = "Selecteer overlappingsmodus" @@ -3299,11 +3327,11 @@ title = "Instellingen" title = "Resultaten overlay" [overlay-pdfs.tooltip.header] -title = "Overzicht PDF's over elkaar leggen" +title = "Overzicht PDF-en over elkaar leggen" [overlay-pdfs.tooltip.description] title = "Beschrijving" -text = "Combineer een basis-PDF met een of meer overlay-PDF's. Overlays kunnen pagina-voor-pagina in verschillende modi worden toegepast en in de voorgrond of achtergrond worden geplaatst." +text = "Combineer een basis-PDF met een of meer overlay-PDF-en. Overlays kunnen pagina-voor-pagina in verschillende modi worden toegepast en in de voorgrond of achtergrond worden geplaatst." [overlay-pdfs.tooltip.mode] title = "Overlaymodus" @@ -3318,21 +3346,21 @@ text = "Voorgrond plaatst de overlay boven op de pagina. Achtergrond plaatst dez [overlay-pdfs.tooltip.overlayFiles] title = "Overlay-bestanden" -text = "Selecteer een of meer PDF's om op de basis te leggen. De volgorde van deze bestanden beïnvloedt hoe pagina's worden toegepast in Sequentiële en Vaste herhalingsmodus." +text = "Selecteer een of meer PDF-en om op de basis te leggen. De volgorde van deze bestanden beïnvloedt hoe pagina's worden toegepast in Sequentiële en Vaste herhalingsmodus." [overlay-pdfs.tooltip.counts] title = "Aantallen (alleen Vaste herhaling)" text = "Geef een positief getal op voor elk overlay-bestand dat aangeeft hoeveel pagina's moeten worden genomen voordat wordt doorgeschakeld. Vereist wanneer de modus Vaste herhaling is." [overlay-pdfs.error] -failed = "Er is een fout opgetreden bij het over elkaar leggen van PDF's." +failed = "Er is een fout opgetreden bij het over elkaar leggen van PDF-en." [split-by-sections] tags = "Sectie splitsen, Verdelen, Aanpassen" title = "PDF in secties splitsen" header = "PDF in secties splitsen" submit = "PDF splitsen" -merge = "Samenvoegen in één PDF" +merge = "Samenvoegen in een PDF" [split-by-sections.horizontal] label = "Horizontale secties" @@ -3533,7 +3561,7 @@ signInWith = "Inloggen met" signInAnonymously = "Als gast aanmelden" rememberme = "Onthoud mij" invalid = "Ongeldige gebruikersnaam of wachtwoord." -locked = "Je account is geblokkeerd." +locked = "Uw account is geblokkeerd." sessionExpired = "Uw sessie is verlopen. Log alstublieft opnieuw in." signinTitle = "Gelieve in te loggen" ssoSignIn = "Inloggen via Single Sign-on" @@ -3570,7 +3598,7 @@ login = "Inloggen" or = "Of" useMagicLink = "Gebruik in plaats daarvan een magic link" enterEmailForMagicLink = "Voer uw e-mailadres in voor een magic link" -sending = "Verzenden…" +sending = "Verzenden..." sendMagicLink = "Magic link verzenden" cancel = "Annuleren" dontHaveAccount = "Nog geen account? Registreer" @@ -3591,17 +3619,17 @@ changePasswordWarning = "Wijzig uw wachtwoord nadat u voor het eerst bent ingelo [login.slides.overview] alt = "Stirling PDF-overzicht" -title = "Uw alles-in-één oplossing voor al uw PDF-behoeften." -subtitle = "Een privacy-first cloud suite voor PDF's waarmee u documenten kunt converteren, ondertekenen, anonimiseren en beheren, plus 50+ andere krachtige tools." +title = "Uw alles-in-een oplossing voor al uw PDF-behoeften." +subtitle = "Een privacy-first cloud suite voor PDF-en waarmee u documenten kunt converteren, ondertekenen, anonimiseren en beheren, plus 50+ andere krachtige tools." [login.slides.edit] -alt = "PDF's bewerken" -title = "Bewerk PDF's om de gewenste informatie te tonen/beveiligen" -subtitle = "Met meer dan een dozijn tools om PDF's te anonimiseren, ondertekenen, lezen en bewerken vindt u zeker wat u zoekt." +alt = "PDF-en bewerken" +title = "Bewerk PDF-en om de gewenste informatie te tonen/beveiligen" +subtitle = "Met meer dan een dozijn tools om PDF-en te anonimiseren, ondertekenen, lezen en bewerken vindt u zeker wat u zoekt." [login.slides.secure] -alt = "PDF's beveiligen" -title = "Bescherm gevoelige informatie in uw PDF's" +alt = "PDF-en beveiligen" +title = "Bescherm gevoelige informatie in uw PDF-en" subtitle = "Voeg wachtwoorden toe, anonimiseer inhoud en beheer certificaten met gemak." [signup] @@ -3636,7 +3664,7 @@ confirmPasswordRequired = "Bevestig uw wachtwoord" title = "PDF naar enkele pagina" header = "PDF naar enkele pagina" submit = "Converteren naar enkele pagina" -description = "Deze tool voegt alle pagina's van uw PDF samen tot één grote enkele pagina. De breedte blijft hetzelfde als de oorspronkelijke pagina's, maar de hoogte wordt de som van alle paginahoogten." +description = "Deze tool voegt alle pagina's van uw PDF samen tot een grote enkele pagina. De breedte blijft hetzelfde als de oorspronkelijke pagina's, maar de hoogte wordt de som van alle paginahoogten." filenamePrefix = "enkele_pagina" [pdfToSinglePage.files] @@ -3671,7 +3699,7 @@ submit = "PDF opschonen" title = "Contrast aanpassen" header = "Contrast aanpassen" basic = "Basisaanpassingen" -contrast = "Kehrbrechting:" +contrast = "Contrast:" brightness = "Helderheid:" saturation = "Verzadiging:" download = "Downloaden" @@ -3690,7 +3718,7 @@ title = "Aangepaste PDF" [compress] title = "Comprimeren" -desc = "Comprimeer PDF's om de bestandsgrootte te verkleinen." +desc = "Comprimeer PDF-en om de bestandsgrootte te verkleinen." header = "PDF comprimeren" credit = "Deze functie gebruikt qpdf voor PDF Compressie/Optimalisatie." submit = "Comprimeren" @@ -3701,7 +3729,17 @@ quality = "Kwaliteit" filesize = "Bestandsgrootte" [compress.grayscale] -label = "Grijsschaal toepassen voor compressie" +label = "Grijstinten toepassen voor compressie" + +[compress.lineArt] +label = "Afbeeldingen omzetten in lijntekening" +description = "Gebruikt ImageMagick om pagina's te reduceren tot hoogcontrast zwart-wit voor maximale verkleining van de bestandsgrootte." +unavailable = "ImageMagick is niet geïnstalleerd of ingeschakeld op deze server" +detailLevel = "Detailniveau" +edgeEmphasis = "Randaccent" +edgeLow = "Zacht" +edgeMedium = "Gebalanceerd" +edgeHigh = "Sterk" [compress.tooltip.header] title = "Overzicht compressie-instellingen" @@ -3718,7 +3756,11 @@ bullet2 = "Hogere waarden verminderen de bestandsgrootte" [compress.tooltip.grayscale] title = "Grijstinten" -text = "Selecteer deze optie om alle afbeeldingen naar zwart-wit te converteren, wat de bestandsgrootte aanzienlijk kan verminderen, vooral voor gescande PDF's of documenten met veel afbeeldingen." +text = "Selecteer deze optie om alle afbeeldingen naar zwart-wit te converteren, wat de bestandsgrootte aanzienlijk kan verminderen, vooral voor gescande PDF-en of documenten met veel afbeeldingen." + +[compress.tooltip.lineArt] +title = "Lijntekening" +text = "Zet pagina's om naar hoogcontrast zwart-wit met ImageMagick. Gebruik het detailniveau om te bepalen hoeveel inhoud zwart wordt, en randaccentuering om te controleren hoe agressief randen worden gedetecteerd." [compress.error] failed = "Er is een fout opgetreden bij het comprimeren van de PDF." @@ -3765,7 +3807,7 @@ maintainAspectRatio = "Beeldverhoudingen behouden" 2 = "PDF automatisch draaien" 3 = "Meervoudige bestandslogica (Alleen ingeschakeld bij werken met meerdere afbeeldingen)" 4 = "Voeg samen in één PDF" -5 = "Zet om naar afzonderlijke PDF's" +5 = "Zet om naar afzonderlijke PDF-en" [PDFToCSV] title = "PDF naar CSV" @@ -3817,13 +3859,13 @@ button = "Vul enquête in." dontShowAgain = "Niet weer tonen" [survey.meeting] -1 = "Als je Stirling PDF op het werk gebruikt, spreken we je graag. We bieden technische supportsessies aan in ruil voor een gebruikersgesprek van 15 minuten." +1 = "Als u Stirling PDF op het werk gebruikt, spreken we u graag. We bieden technische supportsessies aan in ruil voor een gebruikersgesprek van 15 minuten." 2 = "Dit is een kans om:" 3 = "Hulp te krijgen bij deployment, integraties of troubleshooting" 4 = "Direct feedback te geven over performance, edge-cases en ontbrekende functies" 5 = "Ons te helpen Stirling PDF te verfijnen voor gebruik in echte enterprise-omgevingen" -6 = "Als je geïnteresseerd bent, kun je direct tijd met ons team boeken. (Alleen Engelstalig)" -7 = "We kijken ernaar uit om jouw gebruiksscenario's te bespreken en Stirling PDF nog beter te maken!" +6 = "Als u geïnteresseerd bent, kunt u direct tijd met ons team boeken. (Alleen Engelstalig)" +7 = "We kijken ernaar uit om uw gebruiksscenario's te bespreken en Stirling PDF nog beter te maken!" notInterested = "Geen bedrijf en/of geen interesse in een afspraak?" button = "Afspraak boeken" @@ -3848,9 +3890,9 @@ allowDuplicates = "Dubbele items toestaan" submit = "PDF splitsen" [splitByChapters.desc] -1 = "Dit hulpmiddel splits een PDF-bestand op in meerdere PDF's gebaseerd op zijn hoofdstukstructuur." +1 = "Dit hulpmiddel splits een PDF-bestand op in meerdere PDF-en gebaseerd op zijn hoofdstukstructuur." 2 = "Boekmarkeer niveau: Kies het boekmarkeer niveau om te gebruiken voor delen (0 voor topniveau, 1 voor tweedelvou, etc.)." -3 = "Metadata inclusief: Als gecijfeld, de originele PDF's metadata wordt ingevoegd in elk gesplitst PDF-bestand." +3 = "Metadata inclusief: Als gecijfeld, de originele PDF-en metadata wordt ingevoegd in elk gesplitst PDF-bestand." 4 = "Dubbele items toestaan: Als gecijfeld, zorgen multiple boekmarkeersymboolen op dezelfde pagina voor het maken van aparte PDF-bestanden." [fileChooser] @@ -3884,7 +3926,7 @@ acceptNecessaryBtn = "Nee, bedankt" showPreferencesBtn = "Voorkeuren beheren" [cookieBanner.popUp.description] -1 = "We gebruiken cookies en andere technologieën om Stirling PDF beter voor je te laten werken—zo verbeteren we onze tools en blijven we functies bouwen die je waardeert." +1 = "We gebruiken cookies en andere technologieën om Stirling PDF beter voor u te laten werken—zo verbeteren we onze tools en blijven we functies bouwen die u waardeert." 2 = "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly." [cookieBanner.preferencesModal] @@ -3897,9 +3939,9 @@ serviceCounterLabel = "Dienst|Diensten" subtitle = "Cookiegebruik" [cookieBanner.preferencesModal.description] -1 = "Stirling PDF gebruikt cookies en vergelijkbare technologieën om je ervaring te verbeteren en te begrijpen hoe onze tools worden gebruikt. Dit helpt ons de prestaties te verbeteren, de functies te ontwikkelen die jij belangrijk vindt en doorlopende ondersteuning te bieden." -2 = "Stirling PDF kan niet—en zal nooit—de inhoud van de documenten die je gebruikt volgen of openen." -3 = "Jouw privacy en vertrouwen staan centraal in wat we doen." +1 = "Stirling PDF gebruikt cookies en vergelijkbare technologieën om uw ervaring te verbeteren en te begrijpen hoe onze tools worden gebruikt. Dit helpt ons de prestaties te verbeteren, de functies te ontwikkelen die jij belangrijk vindt en doorlopende ondersteuning te bieden." +2 = "Stirling PDF kan niet—en zal nooit—de inhoud van de documenten die u gebruikt volgen of openen." +3 = "Uw privacy en vertrouwen staan centraal in wat we doen." [cookieBanner.preferencesModal.necessary] description = "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off." @@ -3910,7 +3952,7 @@ description = "These cookies are essential for the website to function properly. [cookieBanner.preferencesModal.analytics] title = "Analyse" -description = "Deze cookies helpen ons te begrijpen hoe onze tools worden gebruikt, zodat we ons kunnen richten op het bouwen van de functies die onze community het meest waardeert. Wees gerust—Stirling PDF kan niet en zal nooit de inhoud van de documenten waarmee je werkt volgen." +description = "Deze cookies helpen ons te begrijpen hoe onze tools worden gebruikt, zodat we ons kunnen richten op het bouwen van de functies die onze community het meest waardeert. Wees gerust—Stirling PDF kan niet en zal nooit de inhoud van de documenten waarmee u werkt volgen." [cookieBanner.services] posthog = "PostHog Analytics" @@ -3996,8 +4038,8 @@ noResults = "Geen resultaten gevonden" searching = "Zoeken..." [guestBanner] -title = "Je gebruikt Stirling PDF als gast!" -message = "Maak een gratis account aan om je werk op te slaan, meer functies te gebruiken en het project te steunen." +title = "U gebruikt Stirling PDF als gast!" +message = "Maak een gratis account aan om uw werk op te slaan, meer functies te gebruiken en het project te steunen." dismiss = "Banner sluiten" signUp = "Gratis aanmelden" @@ -4038,12 +4080,20 @@ settings = "Opties" adminSettings = "Beheer" allTools = "All Tools" reader = "Reader" +tours = "Tours" +showMeAround = "Leid me rond" + +[quickAccess.toursTooltip] +admin = "Bekijk hier rondleidingen: Tools-tour, nieuwe V2-indeling tour en de Admin-tour." +user = "Bekijk hier rondleidingen: Ronde langs de tools en de nieuwe V2-indeling." [quickAccess.helpMenu] toolsTour = "Rondleiding tools" toolsTourDesc = "Leer wat de tools kunnen" adminTour = "Rondleiding voor beheer" adminTourDesc = "Ontdek beheerinstellingen en functies" +whatsNewTour = "Bekijk wat er nieuw is in V2" +whatsNewTourDesc = "Verken de vernieuwde layout" [admin] error = "Fout" @@ -4079,13 +4129,13 @@ hint = "U hebt niet-opgeslagen wijzigingen" [admin.settings.loginDisabled] title = "Inlogmodus vereist" -message = "Inlogmodus moet zijn ingeschakeld om beheerinstellingen te wijzigen. Stel SECURITY_ENABLELOGIN=true in je omgeving of security.enableLogin: true in settings.yml in, start daarna de server opnieuw." +message = "Inlogmodus moet zijn ingeschakeld om beheerinstellingen te wijzigen. Stel SECURITY_ENABLELOGIN=true in uw omgeving of security.enableLogin: true in settings.yml in, start daarna de server opnieuw." readOnly = "De onderstaande instellingen tonen voorbeeldwaarden ter referentie. Schakel de inlogmodus in om de werkelijke configuratie te bekijken en te bewerken." [admin.settings.restart] title = "Herstart vereist" message = "Instellingen zijn succesvol opgeslagen. Een herstart van de server is vereist om de wijzigingen door te voeren." -question = "Wil je de server nu of later herstarten?" +question = "Wilt u de server nu of later herstarten?" now = "Nu herstarten" later = "Later herstarten" @@ -4168,11 +4218,11 @@ label = "Pipelinemappen" [admin.settings.general.customPaths.pipeline.watchedFoldersDir] label = "Map met bewaakte mappen" -description = "Map waarin de pipeline inkomende PDF's bewaakt (laat leeg voor standaard: /pipeline/watchedFolders)" +description = "Map waarin de pipeline inkomende PDF-en bewaakt (laat leeg voor standaard: /pipeline/watchedFolders)" [admin.settings.general.customPaths.pipeline.finishedFoldersDir] label = "Map met voltooide mappen" -description = "Map waar verwerkte PDF's worden weggeschreven (laat leeg voor standaard: /pipeline/finishedFolders)" +description = "Map waar verwerkte PDF-en worden weggeschreven (laat leeg voor standaard: /pipeline/finishedFolders)" [admin.settings.general.customPaths.operations] label = "Paden naar externe tools" @@ -4344,11 +4394,11 @@ description = "De issuer-URL van de OAuth2-provider" [admin.settings.connections.oauth2.clientId] label = "Client-ID" -description = "De OAuth2 client-ID van je provider" +description = "De OAuth2 client-ID van uw provider" [admin.settings.connections.oauth2.clientSecret] label = "Clientgeheim" -description = "Het OAuth2 clientgeheim van je provider" +description = "Het OAuth2 clientgeheim van uw provider" [admin.settings.connections.oauth2.useAsUsername] label = "Gebruik als gebruikersnaam" @@ -4396,7 +4446,7 @@ configuration = "Databaseconfiguratie" [admin.settings.database.enableCustom] label = "Aangepaste database inschakelen" -description = "Gebruik je eigen aangepaste databaseconfiguratie in plaats van de standaard ingesloten database" +description = "Gebruik uw eigen aangepaste databaseconfiguratie in plaats van de standaard ingesloten database" [admin.settings.database.customUrl] label = "Aangepaste database-URL" @@ -4544,7 +4594,7 @@ description = "E-mailmeldingen en SMTP-functionaliteit inschakelen" [admin.settings.mail.host] label = "SMTP-host" -description = "De hostnaam of het IP-adres van je SMTP-server" +description = "De hostnaam of het IP-adres van uw SMTP-server" [admin.settings.mail.port] label = "SMTP-poort" @@ -4568,7 +4618,7 @@ description = "Sta beheerders toe om gebruikers via e-mail uit te nodigen met au [admin.settings.mail.frontendUrl] label = "Frontend-URL" -description = "Basis-URL voor de frontend (bijv. https://pdf.example.com). Wordt gebruikt voor het genereren van uitnodigingslinks in e-mails. Laat leeg om de backend-URL te gebruiken." +description = "Basis-URL voor de frontend (bijv. https://pdf.example.com). Wordt gebruikt voor het genereren van uitnodigingslinks in e-mails. Laat leeg om de back-end-URL te gebruiken." [admin.settings.legal] title = "Juridische documenten" @@ -4576,7 +4626,7 @@ description = "Links naar juridische documenten en beleidsregels configureren." [admin.settings.legal.disclaimer] title = "Waarschuwing juridische verantwoordelijkheid" -message = "Door deze juridische documenten aan te passen, neem je de volledige verantwoordelijkheid voor conformiteit met alle toepasselijke wetten en voorschriften, inclusief maar niet beperkt tot de GDPR en andere EU-vereisten voor gegevensbescherming. Wijzig deze instellingen alleen als: (1) je een persoonlijke/private instantie beheert, (2) je buiten de EU-jurisdictie valt en je lokale wettelijke verplichtingen begrijpt, of (3) je passend juridisch advies hebt ingewonnen en volledige verantwoordelijkheid aanvaardt voor alle gebruikersgegevens en juridische naleving. Stirling-PDF en zijn ontwikkelaars aanvaarden geen aansprakelijkheid voor jouw wettelijke verplichtingen." +message = "Door deze juridische documenten aan te passen, neemt u de volledige verantwoordelijkheid voor conformiteit met alle toepasselijke wetten en voorschriften, inclusief maar niet beperkt tot de GDPR en andere EU-vereisten voor gegevensbescherming. Wijzig deze instellingen alleen als: (1) u een persoonlijke/private instantie beheert, (2) u buiten de EU-jurisdictie valt en u lokale wettelijke verplichtingen begrijpt, of (3) u passend juridisch advies hebt ingewonnen en volledige verantwoordelijkheid aanvaardt voor alle gebruikersgegevens en juridische naleving. Stirling-PDF en zijn ontwikkelaars aanvaarden geen aansprakelijkheid voor uw wettelijke verplichtingen." [admin.settings.legal.termsAndConditions] label = "Algemene voorwaarden" @@ -4600,24 +4650,24 @@ description = "URL of bestandsnaam van het impressum (in sommige jurisdicties ve [admin.settings.premium] title = "Premium & Enterprise" -description = "Configureer je premium- of enterprise-licentiesleutel." +description = "Configureer uw premium- of enterprise-licentiesleutel." license = "Licentieconfiguratie" noInput = "Geef een licentiesleutel of bestand op" [admin.settings.premium.licenseKey] -toggle = "Heb je een licentiesleutel of certificaatbestand?" -info = "Als je een licentiesleutel of certificaatbestand hebt van een directe aankoop, kun je die hier invoeren om premium- of enterprisefuncties te activeren." +toggle = "Heeft u een licentiesleutel of certificaatbestand?" +info = "Als u een licentiesleutel of certificaatbestand hebt van een directe aankoop, kunt u die hier invoeren om premium- of enterprisefuncties te activeren." [admin.settings.premium.key] label = "Licentiesleutel" -description = "Voer je premium- of enterprise-licentiesleutel in" +description = "Voer uw premium- of enterprise-licentiesleutel in" success = "Licentiesleutel opgeslagen" -successMessage = "Je licentiesleutel is succesvol geactiveerd. Herstart is niet nodig." +successMessage = "Uw licentiesleutel is succesvol geactiveerd. Herstart is niet nodig." [admin.settings.premium.key.overwriteWarning] title = "⚠️ Waarschuwing: Bestaande licentie gedetecteerd" -line1 = "Het overschrijven van je huidige licentiesleutel kan niet ongedaan worden gemaakt." -line2 = "Je vorige licentie gaat permanent verloren, tenzij je er elders een back-up van hebt." +line1 = "Het overschrijven van uw huidige licentiesleutel kan niet ongedaan worden gemaakt." +line2 = "Uw vorige licentie gaat permanent verloren, tenzij u er elders een back-up van hebt." line3 = "Belangrijk: houd licentiesleutels privé en veilig. Deel ze nooit openbaar." [admin.settings.premium.inputMethod] @@ -4626,7 +4676,7 @@ file = "Certificaatbestand" [admin.settings.premium.file] label = "Licentiecertificaatbestand" -description = "Upload je .lic- of .cert-licentiebestand van offline aankopen" +description = "Upload uw .lic- of .cert-licentiebestand van offline aankopen" choose = "Kies licentiebestand" selected = "Geselecteerd: {{filename}} ({{size}})" successMessage = "Licentiebestand succesvol geüpload en geactiveerd. Herstarten niet vereist." @@ -4636,6 +4686,7 @@ title = "Actieve licentie" file = "Bron: licentiebestand ({{path}})" key = "Bron: licentiesleutel" type = "Type: {{type}}" + noInput = "Geef een licentiesleutel op of upload een certificaatbestand" success = "Succes" @@ -4691,7 +4742,7 @@ selectFiles = "Selecteer bestanden" selectPdfToView = "Selecteer een PDF om te bekijken" selectPdfToEdit = "Selecteer een PDF om te bewerken" chooseFromStorage = "Kies een bestand uit opslag of upload een nieuwe PDF" -chooseFromStorageMultiple = "Kies bestanden uit opslag of upload nieuwe PDF's" +chooseFromStorageMultiple = "Kies bestanden uit opslag of upload nieuwe PDF-en" loadFromStorage = "Laden vanuit opslag" filesAvailable = "bestanden beschikbaar" loading = "Laden..." @@ -4719,7 +4770,7 @@ addFiles = "Bestanden toevoegen" [fileManager] title = "PDF-bestanden uploaden" -subtitle = "Voeg bestanden toe aan je opslag voor gemakkelijke toegang in alle tools" +subtitle = "Voeg bestanden toe aan uw opslag voor gemakkelijke toegang in alle tools" filesSelected = "bestanden geselecteerd" clearSelection = "Selectie wissen" openInFileEditor = "Openen in bestandseditor" @@ -4778,7 +4829,7 @@ closeFile = "Bestand sluiten" deleteAll = "Alles verwijderen" loadingFiles = "Bestanden laden..." noFiles = "Geen bestanden beschikbaar" -noFilesFound = "Geen bestanden gevonden die overeenkomen met je zoekopdracht" +noFilesFound = "Geen bestanden gevonden die overeenkomen met uw zoekopdracht" openInPageEditor = "Openen in Pagina-editor" showAll = "Alles weergeven" sortByDate = "Sorteren op datum" @@ -4786,12 +4837,12 @@ sortByName = "Sorteren op naam" sortBySize = "Sorteren op grootte" [storage] -temporaryNotice = "Bestanden worden tijdelijk opgeslagen in je browser en kunnen automatisch worden gewist" +temporaryNotice = "Bestanden worden tijdelijk opgeslagen in uw browser en kunnen automatisch worden gewist" storageLimit = "Opslaglimiet" storageUsed = "Tijdelijke opslag gebruikt" storageFull = "Opslag is bijna vol. Overweeg enkele bestanden te verwijderen." fileTooLarge = "Bestand te groot. Maximale grootte per bestand is" -storageQuotaExceeded = "Opslagquotum overschreden. Verwijder enkele bestanden voordat je meer uploadt." +storageQuotaExceeded = "Opslagquotum overschreden. Verwijder enkele bestanden voordat u meer uploadt." approximateSize = "Geschatte grootte" [sanitize] @@ -4816,7 +4867,7 @@ placeholder = "Selecteer een PDF-bestand in het hoofdscherm om te beginnen" [sanitize.options] title = "Opschoonopties" -note = "Selecteer de elementen die je uit de PDF wilt verwijderen. Er moet minstens één optie geselecteerd zijn." +note = "Selecteer de elementen die u uit de PDF wilt verwijderen. Er moet minstens één optie geselecteerd zijn." [sanitize.options.removeJavaScript] label = "JavaScript verwijderen" @@ -4844,7 +4895,7 @@ desc = "Ingesloten lettertypen uit de PDF verwijderen" [addPassword] title = "Wachtwoord toevoegen" -desc = "Versleutel je PDF-document met een wachtwoord." +desc = "Versleutel uw PDF-document met een wachtwoord." completed = "Wachtwoordbeveiliging toegepast" submit = "Versleutelen" filenamePrefix = "versleuteld" @@ -4871,14 +4922,14 @@ label = "Sleutellengte voor versleuteling" 256bit = "256-bit (Hoog)" [addPassword.results] -title = "Versleutelde PDF's" +title = "Versleutelde PDF-en" [addPassword.tooltip.header] title = "Overzicht wachtwoordbeveiliging" [addPassword.tooltip.passwords] title = "Wachtwoordtypen" -text = "Gebruikerswachtwoorden beperken het openen van het document, terwijl eigenaarwachtwoorden bepalen wat er met het document kan worden gedaan zodra het is geopend. Je kunt beide instellen of slechts één." +text = "Gebruikerswachtwoorden beperken het openen van het document, terwijl eigenaarwachtwoorden bepalen wat er met het document kan worden gedaan zodra het is geopend. U kunt beide instellen of slechts één." bullet1 = "Gebruikerswachtwoord: vereist om de PDF te openen" bullet2 = "Eigenaarwachtwoord: beheert documentmachtigingen (niet door alle PDF-viewers ondersteund)" @@ -4927,7 +4978,7 @@ label = "Voorkom afdrukken" label = "Voorkom afdrukken in verschillende formaten" [changePermissions.results] -title = "Aangepaste PDF's" +title = "Aangepaste PDF-en" [changePermissions.tooltip.header] title = "Rechten wijzigen" @@ -4940,7 +4991,7 @@ text = "Om deze machtigingen niet wijzigbaar te maken, gebruik de tool Wachtwoor [removePassword] title = "Wachtwoord verwijderen" -desc = "Verwijder wachtwoordbeveiliging van je PDF-document." +desc = "Verwijder wachtwoordbeveiliging van uw PDF-document." tags = "veilig,ontsleutelen,beveiliging,wachtwoord verwijderen" filenamePrefix = "ontsleuteld" submit = "Verwijderen" @@ -4958,7 +5009,7 @@ failed = "Er is een fout opgetreden bij het verwijderen van het wachtwoord uit d description = "Voor het verwijderen van wachtwoordbeveiliging is het wachtwoord nodig dat is gebruikt om de PDF te versleutelen. Dit zal het document ontsleutelen, waardoor het toegankelijk wordt zonder wachtwoord." [removePassword.results] -title = "Ontsleutelde PDF's" +title = "Ontsleutelde PDF-en" [automate] title = "Automatiseren" @@ -4985,7 +5036,7 @@ title = "Aanbevolen" [automate.creation] createTitle = "Automatisering maken" editTitle = "Automatisering bewerken" -intro = "Automatiseringen voeren tools opeenvolgend uit. Voeg tools toe in de volgorde waarin je ze wilt uitvoeren." +intro = "Automatiseringen voeren tools opeenvolgend uit. Voeg tools toe in de volgorde waarin u ze wilt uitvoeren." save = "Automatisering opslaan" [automate.creation.name] @@ -5007,7 +5058,7 @@ add = "Een tool toevoegen..." [automate.creation.unsavedChanges] title = "Niet-opgeslagen wijzigingen" -message = "Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je terug wilt gaan? Alle wijzigingen gaan verloren." +message = "U heeft niet-opgeslagen wijzigingen. Weet u zeker dat u terug wilt gaan? Alle wijzigingen gaan verloren." cancel = "Annuleren" confirm = "Teruggaan" @@ -5036,13 +5087,13 @@ save = "Configuratie opslaan" securePdfIngestion = "Beveiligde PDF-invoer" securePdfIngestionDesc = "Uitgebreide PDF-verwerkingsworkflow die documenten opschoont, OCR met opschoning toepast, converteert naar PDF/A-formaat voor langdurige archivering en de bestandsgrootte optimaliseert." emailPreparation = "E-mailvoorbereiding" -emailPreparationDesc = "Optimaliseert PDF's voor e-maildistributie door bestanden te comprimeren, grote documenten op te splitsen in 20MB-stukken voor e-mailcompatibiliteit en metagegevens te verwijderen voor privacy." +emailPreparationDesc = "Optimaliseert PDF-en voor e-maildistributie door bestanden te comprimeren, grote documenten op te splitsen in 20MB-stukken voor e-mailcompatibiliteit en metagegevens te verwijderen voor privacy." secureWorkflow = "Beveiligingsworkflow" secureWorkflowDesc = "Beveiligt PDF-documenten door mogelijk kwaadaardige inhoud zoals JavaScript en ingesloten bestanden te verwijderen, en voegt vervolgens wachtwoordbeveiliging toe om ongeautoriseerde toegang te voorkomen. Wachtwoord is standaard ingesteld op 'password'." processImages = "Afbeeldingen verwerken" processImagesDesc = "Converteert meerdere afbeeldingsbestanden naar één PDF-document en past vervolgens OCR toe om doorzoekbare tekst uit de afbeeldingen te extraheren." prePublishSanitization = "Opschonen vóór publicatie" -prePublishSanitizationDesc = "Opschoonworkflow die alle verborgen metadata, JavaScript, ingesloten bestanden en aantekeningen verwijdert en formulieren afvlakt om datalekken te voorkomen voordat PDF's online worden gepubliceerd." +prePublishSanitizationDesc = "Opschoonworkflow die alle verborgen metadata, JavaScript, ingesloten bestanden en aantekeningen verwijdert en formulieren afvlakt om datalekken te voorkomen voordat PDF-en online worden gepubliceerd." [colorPicker] title = "Kies kleur" @@ -5069,6 +5120,7 @@ loading = "Laden..." back = "Terug" continue = "Doorgaan" error = "Fout" +save = "Opslaan" [config.overview] title = "Applicatieconfiguratie" @@ -5085,30 +5137,30 @@ integration = "Integratieconfiguratie" [config.account.overview] title = "Accountinstellingen" -manageAccountPreferences = "Beheer je accountvoorkeuren" -guestDescription = "Je bent aangemeld als gast. Overweeg je account hierboven te upgraden." +manageAccountPreferences = "Accountvoorkeuren beheren" +guestDescription = "U bent aangemeld als gast. Overweeg uw account hierboven op te waarderen." [config.account.upgrade] -title = "Gastaccount upgraden" -description = "Koppel je account om je geschiedenis te behouden en toegang te krijgen tot meer functies!" -socialLogin = "Upgraden met sociaal account" +title = "Gastaccount opwaarderen" +description = "Koppel uw account om uw geschiedenis te behouden en toegang te krijgen tot meer functies!" +socialLogin = "Opwaarderen met sociaal account" linkWith = "Koppelen met" -emailPassword = "of voer je e-mailadres en wachtwoord in" +emailPassword = "of voer uw e-mailadres en wachtwoord in" email = "E-mailadres" -emailPlaceholder = "Voer je e-mailadres in" +emailPlaceholder = "Voer uw e-mailadres in" password = "Wachtwoord (optioneel)" passwordPlaceholder = "Stel een wachtwoord in" passwordNote = "Laat leeg om alleen e-mailverificatie te gebruiken" -upgradeButton = "Account upgraden" +upgradeButton = "Account opwaarderen" [config.apiKeys] -intro = "Gebruik je API-sleutel om programmatisch toegang te krijgen tot Stirling PDF's verwerkingsmogelijkheden." +intro = "Gebruik uw API-sleutel om programmatisch toegang te krijgen tot Stirling PDF-en verwerkingsmogelijkheden." docsTitle = "API-documentatie" docsDescription = "Leer meer over integreren met Stirling PDF:" docsLink = "API-documentatie" schemaLink = "API-schemareferentie" usage = "Neem deze sleutel op in de X-API-KEY-header bij alle API-aanvragen." -description = "Je API-sleutel voor toegang tot Stirling's suite van PDF-tools. Kopieer hem naar je project of vernieuw om een nieuwe te genereren." +description = "Uw API-sleutel voor toegang tot Stirling's suite van PDF-tools. Kopieer hem naar uw project of vernieuw om een nieuwe te genereren." publicKeyAriaLabel = "Publieke API-sleutel" copyKeyAriaLabel = "API-sleutel kopiëren" refreshAriaLabel = "API-sleutel vernieuwen" @@ -5118,22 +5170,22 @@ totalCredits = "Totale credits" chartAriaLabel = "Creditsgebruik: inbegrepen {{includedUsed}} van {{includedTotal}}, aangekocht {{purchasedUsed}} van {{purchasedTotal}}" nextReset = "Volgende reset" lastApiUse = "Laatste API-gebruik" -overlayMessage = "Genereer een sleutel om je gebruik en beschikbare credits te zien" +overlayMessage = "Genereer een sleutel om uw gebruik en beschikbare credits te zien" label = "API-sleutel" -guestInfo = "Gasten ontvangen geen API-sleutel. Maak een account aan om een API-sleutel te krijgen die je in je toepassingen kunt gebruiken." +guestInfo = "Gasten ontvangen geen API-sleutel. Maak een account aan om een API-sleutel te krijgen die u in uw toepassingen kunt gebruiken." goToAccount = "Naar Account" -generateError = "We konden je API-sleutel niet genereren." +generateError = "We konden uw API-sleutel niet genereren." [config.apiKeys.refreshModal] title = "API-sleutels vernieuwen" -warning = "⚠️ Waarschuwing: deze actie genereert nieuwe API-sleutels en maakt je vorige sleutels ongeldig." -impact = "Eventuele applicaties of services die deze sleutels momenteel gebruiken, werken niet meer totdat je ze bijwerkt met de nieuwe sleutels." -confirmPrompt = "Weet je zeker dat je wilt doorgaan?" +warning = "⚠️ Waarschuwing: deze actie genereert nieuwe API-sleutels en maakt uw vorige sleutels ongeldig." +impact = "Eventuele applicaties of services die deze sleutels momenteel gebruiken, werken niet meer totdat u ze bijwerkt met de nieuwe sleutels." +confirmPrompt = "Weet u zeker dat u wilt doorgaan?" confirmCta = "Sleutels vernieuwen" [AddAttachmentsRequest] attachments = "Bijlagen selecteren" -info = "Selecteer bestanden om aan je PDF toe te voegen. Deze bestanden worden ingesloten en zijn toegankelijk via het bijlagenpaneel van de PDF." +info = "Selecteer bestanden om aan uw PDF toe te voegen. Deze bestanden worden ingesloten en zijn toegankelijk via het bijlagenpaneel van de PDF." selectFiles = "Bestanden selecteren om bij te voegen" placeholder = "Kies bestanden..." addMoreFiles = "Meer bestanden toevoegen..." @@ -5212,33 +5264,43 @@ noTools = "Geen tools beschikbaar" [onboarding] allTools = "This is the All Tools panel, where you can browse and select from all available PDF tools." -selectCropTool = "Laten we de tool Bijsnijden selecteren om te laten zien hoe je een van de tools gebruikt." -toolInterface = "Dit is de interface van de tool Bijsnijden. Zoals je ziet is er nog niet veel, omdat we nog geen PDF-bestanden hebben toegevoegd om mee te werken." -filesButton = "Met de knop Bestanden op de sneltoegangsbalk kun je PDF's uploaden om de tools op toe te passen." -fileSources = "Je kunt hier nieuwe bestanden uploaden of recente bestanden openen. Voor de rondleiding gebruiken we een voorbeeldbestand." -workbench = "Dit is de Werkbank - het hoofdgebied waar je je PDF's bekijkt en bewerkt." -viewSwitcher = "Gebruik deze bedieningselementen om te kiezen hoe je je PDF's wilt bekijken." -viewer = "Met de Viewer kun je je PDF's lezen en annoteren." -pageEditor = "De Pagina-editor laat je verschillende bewerkingen uitvoeren op de pagina's in je PDF's, zoals herordenen, roteren en verwijderen." -activeFiles = "De weergave Actieve bestanden toont alle PDF's die je in de tool hebt geladen en laat je kiezen welke je wilt verwerken." -fileCheckbox = "Door op een van de bestanden te klikken selecteer je het voor verwerking. Je kunt meerdere bestanden selecteren voor batchbewerkingen." -selectControls = "De rechterzijbalk bevat knoppen om snel al je actieve PDF's te selecteren/deselecteren, en knoppen om het thema of de taal van de app te wijzigen." +selectCropTool = "Laten we de tool Bijsnijden selecteren om te laten zien hoe u een van de tools gebruikt." +toolInterface = "Dit is de interface van de tool Bijsnijden. Zoals u ziet is er nog niet veel, omdat we nog geen PDF-bestanden hebben toegevoegd om mee te werken." +filesButton = "Met de knop Bestanden op de sneltoegangsbalk kunt u PDF-en uploaden om de tools op toe te passen." +fileSources = "U kunt hier nieuwe bestanden uploaden of recente bestanden openen. Voor de rondleiding gebruiken we een voorbeeldbestand." +workbench = "Dit is de Werkbank - het hoofdgebied waar u uw PDF-en bekijkt en bewerkt." +viewSwitcher = "Gebruik deze bedieningselementen om te kiezen hoe u uw PDF-en wilt bekijken." +viewer = "Met de Viewer kunt u uw PDF-en lezen en annoteren." +pageEditor = "De Pagina-editor laat u verschillende bewerkingen uitvoeren op de pagina's in uw PDF-en, zoals herordenen, roteren en verwijderen." +activeFiles = "De weergave Actieve bestanden toont alle PDF-en die u in de tool hebt geladen en laat u kiezen welke u wilt verwerken." +fileCheckbox = "Door op een van de bestanden te klikken selecteert u het voor verwerking. U kunt meerdere bestanden selecteren voor batchbewerkingen." +selectControls = "De rechterzijbalk bevat knoppen om snel al uw actieve PDF-en te selecteren/deselecteren, en knoppen om het thema of de taal van de app te wijzigen." cropSettings = "Nu we het bestand hebben geselecteerd dat we willen bijsnijden, kunnen we de tool Bijsnijden configureren om het gebied te kiezen waarnaar we de PDF willen bijsnijden." -runButton = "Zodra de tool is geconfigureerd, kun je met deze knop de tool uitvoeren op alle geselecteerde PDF's." -results = "Nadat de tool klaar is met uitvoeren, toont de stap Beoordeling een voorbeeld van de resultaten in dit paneel, en kun je de bewerking ongedaan maken of het bestand downloaden." -fileReplacement = "Het gewijzigde bestand vervangt automatisch het originele bestand in de Werkbank, zodat je het eenvoudig door meer tools kunt halen." -pinButton = "Je kunt de knop Vastzetten gebruiken als je wilt dat je bestanden actief blijven nadat er tools op zijn uitgevoerd." -wrapUp = "Je bent er klaar voor! Je hebt geleerd over de belangrijkste onderdelen van de app en hoe je ze gebruikt. Klik wanneer je wilt op de knop Help om deze rondleiding opnieuw te zien." +runButton = "Zodra de tool is geconfigureerd, kunt u met deze knop de tool uitvoeren op alle geselecteerde PDF-en." +results = "Nadat de tool klaar is met uitvoeren, toont de stap Beoordeling een voorbeeld van de resultaten in dit paneel, en kunt u de bewerking ongedaan maken of het bestand downloaden." +fileReplacement = "Het gewijzigde bestand vervangt automatisch het originele bestand in de Werkbank, zodat u het eenvoudig door meer tools kunt halen." +pinButton = "U kunt de knop Vastzetten gebruiken als u wilt dat uw bestanden actief blijven nadat er tools op zijn uitgevoerd." +wrapUp = "U bent er klaar voor! U hebt geleerd over de belangrijkste onderdelen van de app en hoe u ze gebruikt. Klik wanneer u wilt op de knop Help om deze rondleiding opnieuw te zien." previous = "Vorige" next = "Volgende" finish = "Voltooien" startTour = "Rondleiding starten" startTourDescription = "Volg een rondleiding langs de belangrijkste functies van Stirling PDF" +[onboarding.whatsNew] +quickAccess = "Begin met de balk Snelle toegang om te schakelen tussen Reader, Automate, uw bestanden en alle rondleidingen." +leftPanel = "Het linkerpaneel Hulpmiddelen toont alles wat u kunt doen. Blader door categorieën of zoek om snel een hulpmiddel te vinden." +fileUpload = "Gebruik de knop Bestanden om een PDF te uploaden of een recente te selecteren. We laden een voorbeeld zodat u de werkruimte kunt zien." +rightRail = "De Rechterbalk bevat snelle acties om bestanden te selecteren, het thema of de taal te wijzigen en resultaten te downloaden." +topBar = "De bovenste balk laat u schakelen tussen Viewer, Pagina Editor en Actieve bestanden." +pageEditorView = "Schakel over naar de Pagina-editor om pagina's te herschikken, te roteren of te verwijderen." +activeFilesView = "Gebruik Actieve Bestanden om alles te zien wat u open hebt en kies waar u aan wilt werken." +wrapUp = "Dit zijn de nieuwtjes in V2. Open het menu Tours wanneer u maar wilt om dit, de Tools-tour of de Admin-tour opnieuw te bekijken." + [onboarding.welcomeModal] title = "Welkom bij Stirling PDF!" -description = "Wil je een snelle rondleiding van 1 minuut volgen om de belangrijkste functies te leren en hoe je aan de slag gaat?" -helpHint = "Je kunt deze rondleiding altijd later openen via de knop Help linksonder." +description = "Wilt u een snelle rondleiding van 1 minuut volgen om de belangrijkste functies te leren en hoe u aan de slag gaat?" +helpHint = "U kunt deze rondleiding altijd later openen via de knop Help linksonder." startTour = "Rondleiding starten" maybeLater = "Misschien later" dontShowAgain = "Niet meer tonen" @@ -5255,40 +5317,44 @@ download = "Downloaden →" showMeAround = "Geef me een rondleiding" skipTheTour = "Rondleiding overslaan" +[onboarding.tourOverview] +title = "Touroverzicht" +body = "Stirling PDF V2 bevat tientallen tools en een vernieuwde lay-out. Maak een korte rondleiding om te zien wat er is veranderd en waar u de functionaliteiten kunt vinden die u nodig hebt." + [onboarding.serverLicense] skip = "Voor nu overslaan" -seePlans = "Plannen bekijken →" +seePlans = "Abonnementen bekijken →" upgrade = "Nu upgraden →" freeTitle = "Serverlicentie" overLimitTitle = "Serverlicentie vereist" -overLimitBody = "Onze licentie staat tot {{freeTierLimit}} gebruikers gratis per server toe. Je hebt {{overLimitUserCopy}} Stirling-gebruikers. Om zonder onderbreking door te gaan, upgrade naar het Stirling Server-plan - onbeperkte plaatsen, PDF-tekstbewerking en volledige admincontrole voor $99/server/maand." +overLimitBody = "Onze licentie staat tot {{freeTierLimit}} gebruikers gratis per server toe. U hebt {{overLimitUserCopy}} Stirling-gebruikers. Om zonder onderbreking door te gaan, upgrade naar het Stirling Server-abonnement - onbeperkte plaatsen, PDF-tekstbewerking en volledige admincontrole voor $99/server/maand." freeBody = "Onze Open-Core-licentie staat tot {{freeTierLimit}} gebruikers per server gratis toe. Om ononderbroken op te schalen, raden we het Stirling Server-abonnement aan - onbeperkte plaatsen en SSO-ondersteuning voor $99/server/maand." [onboarding.desktopInstall] title = "Downloaden" titleWithOs = "Downloaden voor {{osLabel}}" -body = "Stirling werkt het best als desktopapp. Je kunt het offline gebruiken, sneller documenten openen en lokaal op je computer bewerken." +body = "Stirling werkt het best als desktopapp. U kunt het offline gebruiken, sneller documenten openen en lokaal op uw computer bewerken." [onboarding.planOverview] adminTitle = "Admin-overzicht" -userTitle = "Planoverzicht" -adminBodyLoginEnabled = "Als admin kun je gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op je server gebruiken Stirling gratis." -adminBodyLoginDisabled = "Zodra je de loginmodus inschakelt, kun je gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op je server gebruiken Stirling gratis." -userBody = "Nodig teamgenoten uit, wijs rollen toe en houd je documenten georganiseerd in één veilige werkruimte. Schakel de loginmodus in wanneer je klaar bent om verder te groeien dan solo-gebruik." +userTitle = "Abonnementoverzicht" +adminBodyLoginEnabled = "Als admin kunt u gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op uw server gebruiken Stirling gratis." +adminBodyLoginDisabled = "Zodra u de loginmodus inschakelt, kunt u gebruikers beheren, instellingen configureren en de servergezondheid monitoren. De eerste {{freeTierLimit}} personen op uw server gebruiken Stirling gratis." +userBody = "Nodig teamgenoten uit, wijs rollen toe en houd uw documenten georganiseerd in één veilige werkruimte. Schakel de loginmodus in wanneer u klaar bent om verder te groeien dan solo-gebruik." [onboarding.securityCheck] -message = "De applicatie heeft recent belangrijke wijzigingen ondergaan. De aandacht van je serveradmin kan nodig zijn. Bevestig je rol om door te gaan." +message = "De applicatie heeft recent belangrijke wijzigingen ondergaan. De aandacht van uw serveradmin kan nodig zijn. Bevestig uw rol om door te gaan." [adminOnboarding] welcome = "Welkom bij de Beheerdersrondleiding! Laten we de krachtige enterprise-functies en instellingen voor systeembeheerders verkennen." configButton = "Klik op de knop Config om alle systeeminstellingen en beheerdersopties te openen." settingsOverview = "Dit is het instellingenpaneel. Beheerinstellingen zijn per categorie georganiseerd voor eenvoudige navigatie." -teamsAndUsers = "Beheer hier Teams en individuele gebruikers. Je kunt nieuwe gebruikers uitnodigen via e-mail, deelbare links, of zelf aangepaste accounts voor hen aanmaken." -systemCustomization = "We hebben uitgebreide manieren om de UI aan te passen: Systeeminstellingen laten je de app-naam en talen wijzigen, Functies maken servercertificaatbeheer mogelijk, en Endpoints laten je specifieke tools voor je gebruikers in- of uitschakelen." -databaseSection = "Voor geavanceerde productieomgevingen hebben we instellingen voor externe database-koppelingen zodat je kunt integreren met je bestaande infrastructuur." +teamsAndUsers = "Beheer hier Teams en individuele gebruikers. U kunt nieuwe gebruikers uitnodigen via e-mail, deelbare links, of zelf aangepaste accounts voor hen aanmaken." +systemCustomization = "We hebben uitgebreide manieren om de UI aan te passen: Systeeminstellingen laten u de app-naam en talen wijzigen, Functies maken servercertificaatbeheer mogelijk, en Endpoints laten uw specifieke tools voor uw gebruikers in- of uitschakelen." +databaseSection = "Voor geavanceerde productieomgevingen hebben we instellingen voor externe database-koppelingen zodat u kunt integreren met uw bestaande infrastructuur." connectionsSection = "De sectie Connections ondersteunt verschillende aanmeldmethoden, inclusief aangepaste SSO en SAML-providers zoals Google en GitHub, plus e-mailintegraties voor meldingen en communicatie." -adminTools = "Tot slot hebben we geavanceerde beheertools zoals Auditing om systeemactiviteit te volgen en Gebruiksanalyses om te monitoren hoe je gebruikers met het platform omgaan." -wrapUp = "Dat was de beheerdersrondleiding! Je hebt de enterprise-functies gezien die van Stirling PDF een krachtige, aanpasbare oplossing voor organisaties maken. Je kunt deze rondleiding altijd starten vanuit het Help-menu." +adminTools = "Tot slot hebben we geavanceerde beheertools zoals Auditing om systeemactiviteit te volgen en Gebruiksanalyses om te monitoren hoe u gebruikers met het platform omgaan." +wrapUp = "Dat was de beheerdersrondleiding! U hebt de enterprise-functies gezien die van Stirling PDF een krachtige, aanpasbare oplossing voor organisaties maken. U kunt deze rondleiding altijd starten vanuit het Help-menu." [workspace] title = "Werkruimte" @@ -5315,12 +5381,12 @@ disable = "Uitschakelen" deleteUser = "Gebruiker verwijderen" deleteUserSuccess = "Gebruiker succesvol verwijderd" deleteUserError = "Gebruiker verwijderen is mislukt" -confirmDelete = "Weet je zeker dat je deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." +confirmDelete = "Weet u zeker dat u deze gebruiker wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt." loginRequired = "Schakel eerst de loginmodus in" [workspace.people.inviteMembers] label = "Leden uitnodigen" -subtitle = "Typ of plak hieronder e-mailadressen, gescheiden door komma's. Je werkruimte wordt per lid gefactureerd." +subtitle = "Typ of plak hieronder e-mailadressen, gescheiden door komma's. Uw werkruimte wordt per lid gefactureerd." [workspace.people.actions] label = "Acties" @@ -5470,7 +5536,7 @@ cannotRemoveFromSystemTeam = "Kan niet uit het systeemteam verwijderen" renameTeamLabel = "Team hernoemen" deleteTeamLabel = "Team verwijderen" cannotDeleteInternal = "Kan het team Internal niet verwijderen" -confirmDelete = "Weet je zeker dat je dit team wilt verwijderen? Dit team moet leeg zijn om te kunnen verwijderen." +confirmDelete = "Weet u zeker dat u dit team wilt verwijderen? Dit team moet leeg zijn om te kunnen verwijderen." confirmRemove = "Gebruiker uit dit team verwijderen?" cannotRenameInternal = "Kan het team Internal niet hernoemen" cannotAddToInternal = "Kan geen leden toevoegen aan het team Internal" @@ -5544,18 +5610,18 @@ featureComparison = "Functievergelijking" from = "Vanaf" perMonth = "/maand" perSeat = "/plaats" -withServer = "+ Server-plan" +withServer = "+ Server-abonnement" licensedSeats = "Gelicenseerd: {{count}} plaatsen" -includedInCurrent = "Inbegrepen in je plan" -selectPlan = "Plan selecteren" +includedInCurrent = "Inbegrepen in uw abonnement" +selectPlan = "Abonnement selecteren" manage = "Beheren" [plan.manageSubscription] -description = "Beheer je abonnement, facturatie en betaalmethoden" +description = "Beheer uw abonnement, facturatie en betaalmethoden" [plan.activePlan] title = "Actief abonnement" -subtitle = "Details van je huidige abonnement" +subtitle = "Details van uw huidige abonnement" [plan.availablePlans] title = "Beschikbare abonnementen" @@ -5568,6 +5634,28 @@ contactSales = "Neem contact op met Sales" contactToUpgrade = "Neem contact met ons op om uw abonnement te upgraden of aan te passen" maxUsers = "Max. aantal gebruikers" upTo = "Tot" +getLicense = "Serverlicenties verkrijgen" +upgradeToEnterprise = "Opwaarderen naar Enterprise" +selectPeriod = "Selecteer factureringsperiode" +monthlyBilling = "Maandelijkse facturering" +yearlyBilling = "Jaarlijks facturering" +checkoutOpened = "Afrekening geopend" +checkoutInstructions = "Voltooi de aankoop op het Stripe-tabblad. Keer na betaling hier terug en vernieuw de pagina om uw licentie te activeren. U ontvangt ook een e-mail met uw licentiesleutel." +activateLicense = "Licentie activeren" + +[plan.static.licenseActivation] +checkoutOpened = "Afrekening geopend in nieuw tabblad" +instructions = "Voltooi de aankoop in het Stripe-tabblad. Zodra de betaling is voltooid, ontvangt u een e-mail met uw licentiesleutel." +enterKey = "Voer hieronder de licentiesleutel in om het abonnement te activeren:" +keyDescription = "Plak de licentiesleutel uit uw e-mail" +activate = "Licentie activeren" +doLater = "Dit doe ik later" +success = "Licentie geactiveerd!" +successMessage = "Uw licentie is met succes geactiveerd. U kunt dit venster nu sluiten." + +[plan.static.billingPortal] +title = "E-mailverificatie vereist" +message = "U moet uw e-mailadres verifiëren in het Stripe-betalingsportaal. Controleer uw e-mail voor een inloglink." [plan.period] month = "maand" @@ -5593,7 +5681,7 @@ highlight1 = "Aangepaste prijzen" highlight2 = "Toegewijde ondersteuning" highlight3 = "Nieuwste functies" requiresServer = "Server vereist" -requiresServerMessage = "Upgrade eerst naar het Server-plan voordat je naar Enterprise upgradet." +requiresServerMessage = "Upgrade eerst naar het Server-abonnement voordat u naar Enterprise opwaardeert." [plan.feature] title = "Functie" @@ -5606,9 +5694,9 @@ customPricing = "Aangepaste prijzen" [plan.licenseWarning] title = "Gratis self-hosted limiet bereikt" -body = "Je hebt {{total}} gebruikers, maar de gratis laag ondersteunt slechts {{limit}} per server. Upgrade om Stirling PDF soepel te laten draaien." +body = "U hebt {{total}} gebruikers, maar de gratis laag ondersteunt slechts {{limit}} per server. Upgrade om Stirling PDF soepel te laten draaien." overLimit = "meer dan {{limit}}" -cta = "Plannen bekijken" +cta = "Abonnementen bekijken" [subscription] renewsOn = "Wordt verlengd op {{date}}" @@ -5630,28 +5718,28 @@ currentSeats = "Huidige plaatsen" minimumSeats = "Minimum aantal plaatsen" basedOnUsers = "(huidige gebruikers)" newSeatCount = "Nieuw aantal plaatsen" -newSeatCountDescription = "Selecteer het aantal plaatsen voor je enterpriselicentie" +newSeatCountDescription = "Selecteer het aantal plaatsen voor uw enterpriselicentie" whatHappensNext = "Wat gebeurt er daarna?" -stripePortalRedirect = "Je wordt doorgestuurd naar de Stripe-facturatieportal om de wijziging in plaatsen te bekijken en te bevestigen. Het naar rato bedrag wordt automatisch berekend." +stripePortalRedirect = "U wordt doorgestuurd naar de Stripe-facturatieportal om de wijziging in plaatsen te bekijken en te bevestigen. Het naar rato bedrag wordt automatisch berekend." preparingUpdate = "Plaatsupdate voorbereiden..." seatCountTooLow = "Het aantal plaatsen moet minimaal {{minimum}} zijn (huidig aantal gebruikers)" seatCountUnchanged = "Selecteer een ander aantal plaatsen" seatsUpdated = "Plaatsen bijgewerkt" -seatsUpdatedMessage = "Je enterprise-plaatsen zijn bijgewerkt naar {{seats}}" +seatsUpdatedMessage = "Uw enterprise-plaatsen zijn bijgewerkt naar {{seats}}" updateProcessing = "Update verwerken" -updateProcessingMessage = "Je plaatsupdate wordt verwerkt. Vernieuw over enkele ogenblikken." +updateProcessingMessage = "Uw plaatsupdate wordt verwerkt. Vernieuw over enkele ogenblikken." notEnterprise = "Plaatsbeheer is alleen beschikbaar voor enterpriselicenties" [billing.portal] error = "Kon facturatieportaal niet openen" [upgradeBanner] -title = "Upgraden naar Server-plan" +title = "Upgraden naar Server-abonnement" message = "Haal het meeste uit Stirling PDF met onbeperkte gebruikers en geavanceerde functies" upgradeButton = "Nu upgraden" dismiss = "Banner sluiten" attentionTitle = "Deze server heeft aandacht van een admin nodig" -attentionBody = "Je admin moet inloggen om meer info te zien. Neem direct contact met hen op." +attentionBody = "Uw admin moet inloggen om meer info te zien. Neem direct contact met hen op." attentionBodyAdmin = "Controleer de licentievereisten om deze server compliant te houden." seeInfo = "Info bekijken" @@ -5663,45 +5751,45 @@ success = "Betaling geslaagd!" successMessage = "Uw abonnement is succesvol geactiveerd. U ontvangt binnenkort een bevestigingsmail." autoClose = "Dit venster wordt automatisch gesloten..." error = "Betalingsfout" -upgradeSuccess = "Betaling geslaagd! Je abonnement is geüpgraded. De licentie is op je server bijgewerkt. Je ontvangt binnenkort een bevestigingsmail." +upgradeSuccess = "Betaling geslaagd! Uw abonnement is geüpgraded. De licentie is op uw server bijgewerkt. U ontvangt binnenkort een bevestigingsmail." paymentSuccess = "Betaling geslaagd! Licentiesleutel ophalen..." -licenseActivated = "Licentie geactiveerd! Je licentiesleutel is opgeslagen. Er is een bevestigingsmail verzonden naar je geregistreerde e-mailadres." -licenseDelayed = "Betaling geslaagd! Je licentie wordt gegenereerd. Je ontvangt binnenkort een e-mail met je licentiesleutel. Als je deze niet binnen 10 minuten ontvangt, neem dan contact op met support." -licensePollingError = "Betaling geslaagd, maar we konden je licentiesleutel niet automatisch ophalen. Controleer je e-mail of neem contact op met support met je betalingsbevestiging." -licenseRetrievalError = "Betaling geslaagd, maar het ophalen van de licentie is mislukt. Je ontvangt je licentiesleutel via e-mail. Neem contact op met support als je deze niet binnen 10 minuten ontvangt." -syncError = "Betaling geslaagd, maar licentiesynchronisatie is mislukt. Je licentie wordt binnenkort bijgewerkt. Neem contact op met support als het probleem blijft." -licenseSaveError = "Opslaan van licentiesleutel mislukt. Neem contact op met support met je licentiesleutel om de activatie te voltooien." +licenseActivated = "Licentie geactiveerd! Uw licentiesleutel is opgeslagen. Er is een bevestigingsmail verzonden naar uw geregistreerde e-mailadres." +licenseDelayed = "Betaling geslaagd! Uw licentie wordt gegenereerd. U ontvangt binnenkort een e-mail met uw licentiesleutel. Als u deze niet binnen 10 minuten ontvangt, neem dan contact op met support." +licensePollingError = "Betaling geslaagd, maar we konden uw licentiesleutel niet automatisch ophalen. Controleer uw e-mail of neem contact op met support met uw betalingsbevestiging." +licenseRetrievalError = "Betaling geslaagd, maar het ophalen van de licentie is mislukt. U ontvangt uw licentiesleutel via e-mail. Neem contact op met support als u deze niet binnen 10 minuten ontvangt." +syncError = "Betaling geslaagd, maar licentiesynchronisatie is mislukt. Uw licentie wordt binnenkort bijgewerkt. Neem contact op met support als het probleem blijft." +licenseSaveError = "Opslaan van licentiesleutel mislukt. Neem contact op met support met uw licentiesleutel om de activatie te voltooien." paymentCanceled = "Betaling geannuleerd. Er zijn geen kosten in rekening gebracht." -syncingLicense = "Je geüpgradede licentie synchroniseren..." -generatingLicense = "Je licentiesleutel genereren..." +syncingLicense = "Uw geüpgradede licentie synchroniseren..." +generatingLicense = "Uw licentiesleutel genereren..." upgradeComplete = "Upgrade voltooid" -upgradeCompleteMessage = "Je abonnement is succesvol geüpgraded. Je bestaande licentiesleutel is bijgewerkt." +upgradeCompleteMessage = "Uw abonnement is succesvol geüpgraded. Uw bestaande licentiesleutel is bijgewerkt." stripeNotConfigured = "Stripe niet geconfigureerd" -stripeNotConfiguredMessage = "Stripe-betalintegratie is niet geconfigureerd. Neem contact op met je beheerder." +stripeNotConfiguredMessage = "Stripe-betalintegratie is niet geconfigureerd. Neem contact op met uw beheerder." monthly = "Maandelijks" yearly = "Jaarlijks" billingPeriod = "Facturatieperiode" enterpriseNote = "Plaatsen kunnen worden aangepast bij het afrekenen (1-1000)." installationId = "Installatie-ID" -licenseKey = "Je licentiesleutel" -licenseInstructions = "Dit is toegevoegd aan je installatie. Je ontvangt ook een kopie per e-mail." -canCloseWindow = "Je kunt dit venster nu sluiten." +licenseKey = "Uw licentiesleutel" +licenseInstructions = "Dit is toegevoegd aan uw installatie. U ontvangt ook een kopie per e-mail." +canCloseWindow = "U kunt dit venster nu sluiten." licenseKeyProcessing = "Licentiesleutel verwerken" -licenseDelayedMessage = "Je licentiesleutel wordt gegenereerd. Controleer binnenkort je e-mail of neem contact op met support." +licenseDelayedMessage = "Uw licentiesleutel wordt gegenereerd. Controleer binnenkort uw e-mail of neem contact op met support." perYear = "/jaar" perMonth = "/maand" emailInvalid = "Voer een geldig e-mailadres in" [payment.emailStage] -title = "Vul je e-mailadres in" -description = "We gebruiken dit om je licentiesleutel en kwitanties te sturen." +title = "Vul uw e-mailadres in" +description = "We gebruiken dit om uw licentiesleutel en kwitanties te sturen." emailLabel = "E-mailadres" emailPlaceholder = "your@email.com" continue = "Doorgaan" modalTitle = "Aan de slag - {{planName}}" [payment.planStage] -title = "Kies je facturatieperiode" +title = "Kies uw facturatieperiode" savingsNote = "Bespaar {{percent}}% met jaarlijkse facturatie" basePrice = "Basistarief" seatPrice = "Per plaats" @@ -5709,13 +5797,13 @@ totalForSeats = "Totaal ({{count}} plaatsen)" selectMonthly = "Maandelijks selecteren" selectYearly = "Jaarlijks selecteren" savePercent = "Bespaar {{percent}}%" -savingsAmount = "Je bespaart {{amount}}" +savingsAmount = "U bespaart {{amount}}" modalTitle = "Selecteer facturatieperiode - {{planName}}" billedYearly = "Jaarlijks gefactureerd à {{currency}}{{amount}}" [payment.paymentStage] -backToPlan = "Terug naar planselectie" -selectedPlan = "Geselecteerd plan" +backToPlan = "Terug naar abonnementselectie" +selectedPlan = "Geselecteerd abonnement" modalTitle = "Betaling voltooien - {{planName}}" [firstLogin] @@ -5771,6 +5859,8 @@ notAvailable = "Auditsysteem niet beschikbaar" notAvailableMessage = "Het auditsysteem is niet geconfigureerd of niet beschikbaar." disabled = "Auditlogging is uitgeschakeld" disabledMessage = "Schakel auditlogging in uw applicatieconfiguratie in om systeemevenementen te volgen." +enterpriseRequired = "Enterprise-licentie vereist" +enterpriseRequiredMessage = "Het audit-loggingsysteem is een functie voor ondernemingen. Upgrade naar een ondernemingslicentie om toegang te krijgen tot auditlogboeken en analyses." [audit.error] title = "Fout bij laden van auditsysteem" @@ -5870,11 +5960,11 @@ percentage = "Percentage" noData = "Geen gegevens beschikbaar" [backendHealth] -checking = "Backendstatus controleren..." -online = "Backend online" -offline = "Backend offline" -starting = "Backend wordt gestart..." -wait = "Wacht tot de backend is opgestart en probeer het opnieuw." +checking = "Back-endstatus controleren..." +online = "Back-end online" +offline = "Back-end offline" +starting = "Back-end wordt gestart..." +wait = "Wacht tot de back-end is opgestart en probeer het opnieuw." [encryptedPdfUnlock] unlockPrompt = "Ontgrendel PDF om door te gaan" @@ -5896,7 +5986,7 @@ placeholder = "Voer het PDF-wachtwoord in" [setup] welcome = "Welkom bij Stirling PDF" -description = "Begin door te kiezen hoe je Stirling PDF wilt gebruiken" +description = "Begin door te kiezen hoe u Stirling PDF wilt gebruiken" [setup.step1] label = "Kies modus" @@ -5908,28 +5998,28 @@ description = "Zelfgehoste server" [setup.step3] label = "Inloggen" -description = "Vul je gegevens in" +description = "Vul uw gegevens in" [setup.mode.saas] title = "Stirling Cloud" -description = "Log in met je Stirling-account" +description = "Log in met uw Stirling-account" [setup.mode.selfhosted] title = "Self-hosted server" -description = "Verbind met je eigen Stirling PDF-server" +description = "Verbind met uw eigen Stirling PDF-server" [setup.saas] title = "Inloggen bij Stirling" -subtitle = "Log in met je Stirling-account" +subtitle = "Log in met uw Stirling-account" [setup.selfhosted] title = "Inloggen bij server" -subtitle = "Vul je servergegevens in" +subtitle = "Vul uw servergegevens in" link = "of maak verbinding met een zelfgehost account" [setup.server] title = "Verbinden met server" -subtitle = "Vul de URL van je self-hosted server in" +subtitle = "Vul de URL van uw self-hosted server in" testing = "Verbinding testen..." [setup.server.type] @@ -5938,7 +6028,7 @@ selfhosted = "Zelfgehoste server" [setup.server.url] label = "Server-URL" -description = "Voer de volledige URL van je self-hosted Stirling PDF-server in" +description = "Voer de volledige URL van uw self-hosted Stirling PDF-server in" [setup.server.error] emptyUrl = "Voer een server-URL in" @@ -5955,7 +6045,7 @@ step3 = "Start de server opnieuw" [setup.login] title = "Inloggen" -subtitle = "Vul je gegevens in om door te gaan" +subtitle = "Vul uw gegevens in om door te gaan" connectingTo = "Verbinden met:" submit = "Inloggen" signInWith = "Inloggen met" @@ -5971,20 +6061,20 @@ instructionsRestart = "Start vervolgens uw server opnieuw zodat de wijzigingen v [setup.login.username] label = "Gebruikersnaam" -placeholder = "Voer je gebruikersnaam in" +placeholder = "Voer uw gebruikersnaam in" [setup.login.email] label = "E-mail" -placeholder = "Voer je e-mailadres in" +placeholder = "Voer uw e-mailadres in" [setup.login.password] label = "Wachtwoord" -placeholder = "Voer je wachtwoord in" +placeholder = "Voer uw wachtwoord in" [setup.login.error] -emptyUsername = "Vul je gebruikersnaam in" -emptyEmail = "Vul je e-mailadres in" -emptyPassword = "Vul je wachtwoord in" +emptyUsername = "Vul uw gebruikersnaam in" +emptyEmail = "Vul uw e-mailadres in" +emptyPassword = "Vul uw wachtwoord in" oauthFailed = "OAuth-login mislukt. Probeer het opnieuw." [oauth.success] @@ -6070,11 +6160,11 @@ title = "Welkom bij PDF-teksteditor (Early Access)" experimental = "Dit is een experimentele functie in actieve ontwikkeling. Reken op enige instabiliteit en problemen tijdens het gebruik." howItWorks = "Deze tool zet uw PDF om naar een bewerkbaar formaat waarin u tekst kunt wijzigen en afbeeldingen kunt herpositioneren. Wijzigingen worden opgeslagen als een nieuwe PDF." bestFor = "Werkt het best met:" -bestFor1 = "Eenvoudige PDF's met vooral tekst en afbeeldingen" +bestFor1 = "Eenvoudige PDF-en met vooral tekst en afbeeldingen" bestFor2 = "Documenten met standaard alineavormatting" bestFor3 = "Brieven, essays, rapporten en eenvoudige documenten" notIdealFor = "Niet ideaal voor:" -notIdealFor1 = "PDF's met speciale opmaak zoals opsommingstekens, tabellen of lay-outs met meerdere kolommen" +notIdealFor1 = "PDF-en met speciale opmaak zoals opsommingstekens, tabellen of lay-outs met meerdere kolommen" notIdealFor2 = "Magazines, brochures of sterk vormgegeven documenten" notIdealFor3 = "Handleidingen met complexe lay-outs" limitations = "Huidige beperkingen:" @@ -6084,7 +6174,7 @@ limitation3 = "Grote bestanden kunnen tijd kosten om te converteren en te verwer knownIssues = "Bekende problemen (worden verholpen):" issue1 = "Tekstkleur wordt momenteel niet behouden (wordt binnenkort toegevoegd)" issue2 = "Alineamodus heeft meer problemen met uitlijning en afstand - Enkele-regelmodus aanbevolen" -issue3 = "Het voorbeeld wijkt af van de geëxporteerde PDF - geëxporteerde PDF's liggen dichter bij het origineel" +issue3 = "Het voorbeeld wijkt af van de geëxporteerde PDF - geëxporteerde PDF-en liggen dichter bij het origineel" issue4 = "Uitlijning van gedraaide tekst kan handmatige aanpassing vereisen" issue5 = "Transparantie- en laageffecten kunnen afwijken van het origineel" feedback = "Dit is een functie in vroege toegang. Meld eventuele problemen zodat we kunnen verbeteren!" @@ -6126,7 +6216,7 @@ insufficientPermissions = "U hebt geen toestemming om deze actie uit te voeren." [addText] title = "Tekst toevoegen" -header = "Tekst toevoegen aan PDF's" +header = "Tekst toevoegen aan PDF-en" tags = "tekst,annotatie,label" applySignatures = "Tekst toepassen" From 3529849bcac050fb7c0af1452bedfa31ebc45293 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:47:54 +0000 Subject: [PATCH 15/62] Feature/annotations (#5260) --- .../public/locales/en-GB/translation.toml | 92 +- frontend/src/core/components/AppProviders.tsx | 17 +- .../annotation/shared/ColorPicker.tsx | 30 +- .../annotation/shared/TextInputWithFont.tsx | 25 +- .../rightRail/ViewerAnnotationControls.tsx | 11 +- .../components/viewer/AnnotationAPIBridge.tsx | 352 +++++ .../core/components/viewer/EmbedPdfViewer.tsx | 74 +- .../core/components/viewer/LocalEmbedPDF.tsx | 325 +++- .../components/viewer/SignatureAPIBridge.tsx | 21 +- .../viewer/useViewerRightRailButtons.tsx | 90 +- .../src/core/components/viewer/viewerTypes.ts | 58 + .../src/core/contexts/AnnotationContext.tsx | 26 + .../src/core/contexts/SignatureContext.tsx | 5 +- .../core/data/useTranslatedToolRegistry.tsx | 14 + .../hooks/tools/sign/useSignParameters.ts | 2 + frontend/src/core/tools/Annotate.tsx | 416 ++++++ .../core/tools/annotate/AnnotationPanel.tsx | 1314 +++++++++++++++++ .../tools/annotate/useAnnotationSelection.ts | 383 +++++ .../tools/annotate/useAnnotationStyleState.ts | 325 ++++ frontend/src/core/types/toolId.ts | 1 + frontend/src/core/utils/urlMapping.ts | 2 + 21 files changed, 3527 insertions(+), 56 deletions(-) create mode 100644 frontend/src/core/components/viewer/AnnotationAPIBridge.tsx create mode 100644 frontend/src/core/contexts/AnnotationContext.tsx create mode 100644 frontend/src/core/tools/Annotate.tsx create mode 100644 frontend/src/core/tools/annotate/AnnotationPanel.tsx create mode 100644 frontend/src/core/tools/annotate/useAnnotationSelection.ts create mode 100644 frontend/src/core/tools/annotate/useAnnotationStyleState.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 4f81d64a34..5d9f08467a 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -736,6 +736,11 @@ tags = "signature,autograph" title = "Sign" desc = "Adds signature to PDF by drawing, text or image" +[home.annotate] +tags = "annotate,highlight,draw" +title = "Annotate" +desc = "Highlight, draw, add notes and shapes in the viewer" + [home.flatten] tags = "simplify,remove,interactive" title = "Flatten" @@ -4013,23 +4018,92 @@ deleteSelected = "Delete Selected Pages" closePdf = "Close PDF" exportAll = "Export PDF" downloadSelected = "Download Selected Files" -downloadAll = "Download All" -saveAll = "Save All" +annotations = "Annotations" +exportSelected = "Export Selected Pages" +saveChanges = "Save Changes" toggleTheme = "Toggle Theme" -toggleBookmarks = "Toggle Bookmarks" language = "Language" +toggleAnnotations = "Toggle Annotations Visibility" search = "Search PDF" panMode = "Pan Mode" rotateLeft = "Rotate Left" rotateRight = "Rotate Right" toggleSidebar = "Toggle Sidebar" -exportSelected = "Export Selected Pages" -toggleAnnotations = "Toggle Annotations Visibility" -annotationMode = "Toggle Annotation Mode" +toggleBookmarks = "Toggle Bookmarks" print = "Print PDF" -draw = "Draw" -save = "Save" -saveChanges = "Save Changes" +downloadAll = "Download All" +saveAll = "Save All" + +[textAlign] +left = "Left" +center = "Center" +right = "Right" + +[annotation] +title = "Annotate" +desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required." +highlight = "Highlight" +pen = "Pen" +text = "Text box" +note = "Note" +rectangle = "Rectangle" +ellipse = "Ellipse" +select = "Select" +exit = "Exit annotation mode" +strokeWidth = "Width" +opacity = "Opacity" +strokeOpacity = "Stroke Opacity" +fillOpacity = "Fill Opacity" +fontSize = "Font size" +chooseColor = "Choose colour" +color = "Colour" +strokeColor = "Stroke Colour" +fillColor = "Fill Colour" +underline = "Underline" +strikeout = "Strikeout" +squiggly = "Squiggly" +inkHighlighter = "Freehand Highlighter" +freehandHighlighter = "Freehand Highlighter" +square = "Square" +circle = "Circle" +polygon = "Polygon" +line = "Line" +stamp = "Add Image" +textMarkup = "Text Markup" +drawing = "Drawing" +shapes = "Shapes" +notesStamps = "Notes & Stamps" +settings = "Settings" +borderOn = "Border: On" +borderOff = "Border: Off" +editInk = "Edit Pen" +editLine = "Edit Line" +editNote = "Edit Note" +editText = "Edit Text Box" +editTextMarkup = "Edit Text Markup" +editSelected = "Edit Annotation" +editSquare = "Edit Square" +editCircle = "Edit Circle" +editPolygon = "Edit Polygon" +unsupportedType = "This annotation type is not fully supported for editing." +textAlignment = "Text Alignment" +noteIcon = "Note Icon" +imagePreview = "Preview" +contents = "Text" +backgroundColor = "Background colour" +clearBackground = "Remove background" +noBackground = "No background" +stampSettings = "Stamp Settings" +savingCopy = "Preparing download..." +saveFailed = "Unable to save copy" +saveReady = "Download ready" +selectAndMove = "Select and Edit" +editSelectDescription = "Click an existing annotation to edit its colour, opacity, text, or size." +editStampHint = "To change the image, delete this stamp and add a new one." +editSwitchToSelect = "Switch to Select & Edit to edit this annotation." +undo = "Undo" +redo = "Redo" +applyChanges = "Apply Changes" [search] title = "Search PDF" diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 5fa8d7cb91..f40bd7954b 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; +import { AnnotationProvider } from "@app/contexts/AnnotationContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; @@ -95,13 +96,15 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - {children} - - - + + + + + {children} + + + + diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 04ae501bb7..21656b1f23 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core'; +import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; interface ColorPickerProps { @@ -8,6 +8,10 @@ interface ColorPickerProps { selectedColor: string; onColorChange: (color: string) => void; title?: string; + opacity?: number; + onOpacityChange?: (opacity: number) => void; + showOpacity?: boolean; + opacityLabel?: string; } export const ColorPicker: React.FC = ({ @@ -15,10 +19,15 @@ export const ColorPicker: React.FC = ({ onClose, selectedColor, onColorChange, - title + title, + opacity, + onOpacityChange, + showOpacity = false, + opacityLabel, }) => { const { t } = useTranslation(); const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); + const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity'); return ( = ({ size="lg" fullWidth /> + {showOpacity && onOpacityChange && opacity !== undefined && ( + + {resolvedOpacityLabel} + + + )} + + + + )} + + {activeTool === 'note' && ( + + {t('annotation.backgroundColor', 'Background color')} + + { + setColorPickerTarget('noteBackground'); + setIsColorPickerOpen(true); + }} + /> + + + + )} + + {['square', 'circle', 'line', 'polygon'].includes(activeTool) && ( + <> + + {t('annotation.opacity', 'Opacity')} + { + setShapeOpacity(value); + setShapeStrokeOpacity(value); + setShapeFillOpacity(value); + }} + /> + + + {activeTool === 'line' ? ( + <> + {t('annotation.strokeWidth', 'Width')} + + + ) : ( + + + {t('annotation.strokeWidth', 'Stroke')} + + + + + )} + + + )} + + )} + + + ); + + const selectedDerivedTool = selectedAnn?.object ? deriveToolFromAnnotation(selectedAnn.object) : undefined; + + const selectedAnnotationControls = selectedAnn && (() => { + const rawType = selectedAnn.object?.type; + const toolId = selectedDerivedTool ?? deriveToolFromAnnotation(selectedAnn.object); + const derivedType = + toolId === 'highlight' ? 9 + : toolId === 'underline' ? 10 + : toolId === 'squiggly' ? 11 + : toolId === 'strikeout' ? 12 + : toolId === 'line' ? 4 + : toolId === 'square' ? 5 + : toolId === 'circle' ? 6 + : toolId === 'polygon' ? 7 + : toolId === 'polyline' ? 8 + : toolId === 'text' ? 3 + : toolId === 'note' ? 3 + : toolId === 'stamp' ? 13 + : toolId === 'ink' ? 15 + : undefined; + const type = typeof rawType === 'number' ? rawType : derivedType; + + if (toolId && ['highlight', 'underline', 'strikeout', 'squiggly'].includes(toolId)) { + return ( + + + {t('annotation.editTextMarkup', 'Edit Text Markup')} + + {t('annotation.color', 'Color')} + { + setColorPickerTarget('highlight'); + setIsColorPickerOpen(true); + }} + /> + + + {t('annotation.opacity', 'Opacity')} + { + annotationApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { opacity: value / 100 } + ); + }} + /> + + + + ); + } + + if (type === 15 || toolId === 'inkHighlighter' || toolId === 'ink') { + const isHighlighter = toolId === 'inkHighlighter'; + const thicknessValue = + selectedAnn.object?.strokeWidth ?? + selectedAnn.object?.borderWidth ?? + selectedAnn.object?.lineWidth ?? + selectedAnn.object?.thickness ?? + (isHighlighter ? freehandHighlighterWidth : inkWidth); + const colorValue = selectedAnn.object?.color ?? (isHighlighter ? highlightColor : inkColor); + const opacityValue = Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || (isHighlighter ? highlightOpacity : 100)); + return ( + + + + {isHighlighter ? t('annotation.freehandHighlighter', 'Freehand Highlighter') : t('annotation.editInk', 'Edit Pen')} + + + {t('annotation.color', 'Color')} + { + setColorPickerTarget(isHighlighter ? 'highlight' : 'ink'); + setIsColorPickerOpen(true); + }} + /> + + {isHighlighter && ( + + {t('annotation.opacity', 'Opacity')} + { + setHighlightOpacity(value); + annotationApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { opacity: value / 100 } + ); + }} + /> + + )} + + {t('annotation.strokeWidth', 'Width')} + { + annotationApiRef?.current?.updateAnnotation?.( + selectedAnn.object?.pageIndex ?? 0, + selectedAnn.object?.id, + { + strokeWidth: value, + borderWidth: value, + lineWidth: value, + thickness: value, + } + ); + if (isHighlighter) { + setFreehandHighlighterWidth?.(value); + } else { + setInkWidth(value); + } + }} + /> + + + + ); + } + + if (type === 3 || toolId === 'text' || toolId === 'note') { + const isNote = toolId === 'note'; + const selectedBackground = + selectedAnn.object?.backgroundColor ?? + (isNote ? noteBackgroundColor || '#ffffff' : textBackgroundColor || '#ffffff'); + const alignValue = selectedAnn.object?.textAlign; + const currentAlign = + typeof alignValue === 'number' + ? alignValue === 1 + ? 'center' + : alignValue === 2 + ? 'right' + : 'left' + : alignValue === 'center' + ? 'center' + : alignValue === 'right' + ? 'right' + : 'left'; + + return ( + + + {isNote ? t('annotation.editNote', 'Edit Sticky Note') : t('annotation.editText', 'Edit Text Box')} + + {t('annotation.color', 'Color')} + { + setColorPickerTarget('text'); + setIsColorPickerOpen(true); + }} + /> + + + {t('annotation.backgroundColor', 'Background color')} + + { + setColorPickerTarget(isNote ? 'noteBackground' : 'textBackground'); + setIsColorPickerOpen(true); + }} + /> + + + +