Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7874d18f32 | ||
|
|
1e968c79f6 | ||
|
|
e2b88cf8b4 | ||
|
|
da49b50d27 | ||
|
|
970679640d | ||
|
|
18f571291b | ||
|
|
0abcc0ccb8 | ||
|
|
a99c104053 | ||
|
|
95fbd4647d | ||
|
|
8d4f5f16cb | ||
|
|
2ee43b2070 | ||
|
|
02aad40aa0 | ||
|
|
0328333d81 |
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(chmod:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(if [ -d logs ])",
|
||||
"Bash(then ls -la logs/)",
|
||||
"Bash(fi)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(./testing/cucumber/features/environment.py -v)",
|
||||
"Bash(./testing/cucumber/features/autojob.feature -v)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -119,9 +119,7 @@
|
||||
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
|
||||
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
|
||||
"charliermarsh.ruff", // Ruff extension for Ruff language support
|
||||
"github.vscode-github-actions", // GitHub Actions extension for Visual Studio Code
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml" // YAML extension for Visual Studio Code
|
||||
"github.vscode-github-actions" // GitHub Actions extension for Visual Studio Code
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
@@ -27,26 +26,6 @@ trim_trailing_whitespace = false
|
||||
[*.js]
|
||||
indent_size = 2
|
||||
|
||||
[*.css]
|
||||
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
|
||||
indent_size = 2
|
||||
|
||||
[*.yaml]
|
||||
# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices.
|
||||
indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
# YML files follow the same conventions as YAML files, using an indent size of 2 spaces.
|
||||
indent_size = 2
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.json]
|
||||
# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting.
|
||||
indent_size = 2
|
||||
|
||||
[*.jsonc]
|
||||
# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces.
|
||||
indent_size = 2
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# Formatting
|
||||
5f771b785130154ed47952635b7acef371ffe0ec
|
||||
7fa5e130d99227c2202ebddfdd91348176ec0c7b
|
||||
14d4fbb2a36195eedb034785e5a5ff6a47f268c6
|
||||
ee8030c1c4148062cde15c49c67d04ef03930c55
|
||||
fcd41924f5f261febfa9d9a92994671f3ebc97d6
|
||||
|
||||
# Normalize files
|
||||
55d4fda01b2f39f5b7d7b4fda5214bd7ff0fd5dd
|
||||
|
||||
+7
-7
@@ -1,10 +1,10 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Ignore all JavaScript files in a directory
|
||||
app/core/src/main/resources/static/pdfjs/* linguist-vendored
|
||||
app/core/src/main/resources/static/pdfjs/** linguist-vendored
|
||||
app/core/src/main/resources/static/pdfjs-legacy/* linguist-vendored
|
||||
app/core/src/main/resources/static/pdfjs-legacy/** linguist-vendored
|
||||
app/core/src/main/resources/static/css/bootstrap-icons.css linguist-vendored
|
||||
app/core/src/main/resources/static/css/bootstrap.min.css linguist-vendored
|
||||
app/core/src/main/resources/static/css/fonts/* linguist-vendored
|
||||
src/main/resources/static/pdfjs/* linguist-vendored
|
||||
src/main/resources/static/pdfjs/** linguist-vendored
|
||||
src/main/resources/static/pdfjs-legacy/* linguist-vendored
|
||||
src/main/resources/static/pdfjs-legacy/** linguist-vendored
|
||||
src/main/resources/static/css/bootstrap-icons.css linguist-vendored
|
||||
src/main/resources/static/css/bootstrap.min.css linguist-vendored
|
||||
src/main/resources/static/css/fonts/* linguist-vendored
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"repo_devs": [
|
||||
"Frooodle",
|
||||
"sf298",
|
||||
"Ludy87",
|
||||
"LaserKaspar",
|
||||
"sbplat",
|
||||
"reecebrowne",
|
||||
"DarioGii",
|
||||
"ConnorYoh"
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
You are a professional software engineer specializing in reviewing pull request titles.
|
||||
|
||||
Your job is to analyze a git diff and an existing PR title, then evaluate and improve the PR title.
|
||||
|
||||
You must:
|
||||
- Always return valid JSON
|
||||
- Only return the JSON response (no Markdown, no formatting)
|
||||
- Use one of these conventional commit types at the beginning of the title: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test
|
||||
- Use lowercase only, no emojis, no trailing period
|
||||
- Ensure the title is between 5 and 72 printable ASCII characters
|
||||
- Never let spelling or grammar errors affect the rating
|
||||
- If the PR title is rated 6 or higher and only contains spelling or grammar mistakes, correct it - do not rephrase it
|
||||
- If the PR title is rated below 6, generate a new, better title based on the diff
|
||||
@@ -1,157 +0,0 @@
|
||||
version: 1
|
||||
labels:
|
||||
|
||||
- label: "Bugfix"
|
||||
title: '^fix(\([^)]*\))?:|^fix:.*'
|
||||
|
||||
- label: "enhancement"
|
||||
title: '^feat(\([^)]*\))?:|^feat:.*'
|
||||
|
||||
- label: "build"
|
||||
title: '^build(\([^)]*\))?:|^build:.*'
|
||||
|
||||
- label: "chore"
|
||||
title: '^chore(\([^)]*\))?:|^chore:.*'
|
||||
|
||||
- label: "ci"
|
||||
title: '^ci(\([^)]*\))?:|^ci:.*'
|
||||
|
||||
- label: "ci"
|
||||
title: '^.*\(ci\):.*'
|
||||
|
||||
- label: "perf"
|
||||
title: '^perf(\([^)]*\))?:|^perf:.*'
|
||||
|
||||
- label: "refactor"
|
||||
title: '^refactor(\([^)]*\))?:|^refactor:.*'
|
||||
|
||||
- label: "revert"
|
||||
title: '^revert(\([^)]*\))?:|^revert:.*'
|
||||
|
||||
- label: "style"
|
||||
title: '^style(\([^)]*\))?:|^style:.*'
|
||||
|
||||
- label: "Documentation"
|
||||
title: '^docs(\([^)]*\))?:|^docs:.*'
|
||||
|
||||
- label: "Documentation"
|
||||
title: '^.*\(docs\):.*'
|
||||
|
||||
- label: "dependencies"
|
||||
title: '^deps(\([^)]*\))?:|^deps:.*'
|
||||
|
||||
- label: "dependencies"
|
||||
title: '^.*\(deps\):.*'
|
||||
|
||||
- label: 'API'
|
||||
title: '.*openapi.*|.*swagger.*|.*api.*'
|
||||
|
||||
- label: 'Translation'
|
||||
files:
|
||||
- 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
- 'app/core/src/main/resources/templates/fragments/languages.html'
|
||||
- '.github/scripts/check_language_properties.py'
|
||||
|
||||
- label: 'Front End'
|
||||
files:
|
||||
- 'app/core/src/main/resources/templates/.*'
|
||||
- 'app/proprietary/src/main/resources/templates/.*'
|
||||
- 'app/core/src/main/resources/static/.*'
|
||||
- 'app/proprietary/src/main/resources/static/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/UI/.*'
|
||||
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*'
|
||||
|
||||
- label: 'Java'
|
||||
files:
|
||||
- 'app/common/src/main/java/.*.java'
|
||||
- 'app/proprietary/src/main/java/.*.java'
|
||||
- 'app/core/src/main/java/.*.java'
|
||||
|
||||
- label: 'Back End'
|
||||
files:
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/config/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/.*'
|
||||
- 'app/core/src/main/resources/settings.yml.template'
|
||||
- 'app/core/src/main/resources/application.properties'
|
||||
- 'app/core/src/main/resources/banner.txt'
|
||||
- 'scripts/png_to_webp.py'
|
||||
- 'split_photos.py'
|
||||
- 'application.properties'
|
||||
|
||||
- label: 'Security'
|
||||
files:
|
||||
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/.*'
|
||||
- 'scripts/download-security-jar.sh'
|
||||
- '.github/workflows/dependency-review.yml'
|
||||
- '.github/workflows/scorecards.yml'
|
||||
|
||||
- label: 'API'
|
||||
files:
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/controller/api/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/model/api/.*'
|
||||
- 'app/core/src/main/java/stirling/software/SPDF/service/ApiDocService.java'
|
||||
- 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*'
|
||||
- 'scripts/png_to_webp.py'
|
||||
- 'split_photos.py'
|
||||
- '.github/workflows/swagger.yml'
|
||||
|
||||
- label: 'Documentation'
|
||||
files:
|
||||
- '.*.md'
|
||||
- 'scripts/counter_translation.py'
|
||||
- 'scripts/ignore_translation.toml'
|
||||
|
||||
- label: 'Docker'
|
||||
files:
|
||||
- '.github/workflows/build.yml'
|
||||
- '.github/workflows/push-docker.yml'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.fat'
|
||||
- 'Dockerfile.ultra-lite'
|
||||
- 'exampleYmlFiles/.*.yml'
|
||||
- 'scripts/download-security-jar.sh'
|
||||
- 'scripts/init.sh'
|
||||
- 'scripts/init-without-ocr.sh'
|
||||
- 'scripts/installFonts.sh'
|
||||
- 'test.sh'
|
||||
- 'test2.sh'
|
||||
|
||||
- label: 'Devtools'
|
||||
files:
|
||||
- '.devcontainer/.*'
|
||||
- 'Dockerfile.dev'
|
||||
- '.vscode/.*'
|
||||
- '.editorconfig'
|
||||
- '.pre-commit-config'
|
||||
- '.github/workflows/pre_commit.yml'
|
||||
- 'devGuide/.*'
|
||||
- 'devTools/.*'
|
||||
- 'devTools/.*'
|
||||
|
||||
- label: 'Test'
|
||||
files:
|
||||
- 'app/common/src/test/.*'
|
||||
- 'app/proprietary/src/test/.*'
|
||||
- 'app/core/src/test/.*'
|
||||
- 'testing/.*'
|
||||
- '.github/workflows/scorecards.yml'
|
||||
- 'exampleYmlFiles/test_cicd.yml'
|
||||
|
||||
- label: 'Github'
|
||||
files:
|
||||
- '.github/.*'
|
||||
|
||||
- label: 'Gradle'
|
||||
files:
|
||||
- 'gradle/.*'
|
||||
- 'gradlew'
|
||||
- 'gradlew.bat'
|
||||
- 'settings.gradle'
|
||||
- 'build.gradle'
|
||||
- 'app/common/build.gradle'
|
||||
- 'app/proprietary/build.gradle'
|
||||
- 'app/core/build.gradle'
|
||||
@@ -0,0 +1,99 @@
|
||||
Translation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'src/main/resources/messages_*_*.properties'
|
||||
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
|
||||
- any-glob-to-any-file: 'src/main/resources/templates/fragments/languages.html'
|
||||
|
||||
Front End:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'src/main/resources/templates/**/*'
|
||||
- any-glob-to-any-file: 'src/main/resources/static/**/*'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/**'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/UI/**/*'
|
||||
|
||||
Java:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'src/main/java/**/*.java'
|
||||
|
||||
Back End:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/**/*'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/**/*'
|
||||
- any-glob-to-any-file: 'src/main/resources/settings.yml.template'
|
||||
- any-glob-to-any-file: 'src/main/resources/application.properties'
|
||||
- any-glob-to-any-file: 'src/main/resources/banner.txt'
|
||||
- any-glob-to-any-file: 'scripts/png_to_webp.py'
|
||||
- any-glob-to-any-file: 'split_photos.py'
|
||||
|
||||
Security:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/interfaces/DatabaseInterface.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/security/**/*'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/DatabaseController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/EmailController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/H2SQLController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/DatabaseWebController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/UserController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/api/Email.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/exception/BackupNotFoundException.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/exception/NoProviderFoundExceptionjava'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/provider/**/*'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/AuthenticationType.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/ApiKeyAuthenticationToken.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/AttemptCounter.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/Authority.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/PersistentLogin.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/SessionEntity.java'
|
||||
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
|
||||
- any-glob-to-any-file: '.github/workflows/dependency-review.yml'
|
||||
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
|
||||
|
||||
API:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/config/OpenApiConfig.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/web/MetricsController.java'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/controller/api/**/*'
|
||||
- any-glob-to-any-file: 'src/main/java/stirling/software/SPDF/model/api/**/*'
|
||||
- any-glob-to-any-file: 'scripts/png_to_webp.py'
|
||||
- any-glob-to-any-file: 'split_photos.py'
|
||||
- any-glob-to-any-file: '.github/workflows/swagger.yml'
|
||||
|
||||
Documentation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: '**/*.md'
|
||||
- any-glob-to-any-file: 'scripts/counter_translation.py'
|
||||
- any-glob-to-any-file: 'scripts/ignore_translation.toml'
|
||||
|
||||
Docker:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: '.github/workflows/build.yml'
|
||||
- any-glob-to-any-file: '.github/workflows/push-docker.yml'
|
||||
- any-glob-to-any-file: 'Dockerfile'
|
||||
- any-glob-to-any-file: 'Dockerfile.fat'
|
||||
- any-glob-to-any-file: 'Dockerfile.ultra-lite'
|
||||
- any-glob-to-any-file: 'exampleYmlFiles/*.yml'
|
||||
- any-glob-to-any-file: 'scripts/download-security-jar.sh'
|
||||
- any-glob-to-any-file: 'scripts/init.sh'
|
||||
- any-glob-to-any-file: 'scripts/init-without-ocr.sh'
|
||||
- any-glob-to-any-file: 'scripts/installFonts.sh'
|
||||
- any-glob-to-any-file: 'test.sh'
|
||||
- any-glob-to-any-file: 'test2.sh'
|
||||
|
||||
Devtools:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: '.devcontainer/**/*'
|
||||
- any-glob-to-any-file: 'Dockerfile.dev'
|
||||
|
||||
Test:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'cucumber/**/*'
|
||||
- any-glob-to-any-file: 'src/test/**/*'
|
||||
- any-glob-to-any-file: 'src/testing/**/*'
|
||||
- any-glob-to-any-file: '.pre-commit-config'
|
||||
- any-glob-to-any-file: '.github/workflows/pre_commit.yml'
|
||||
- any-glob-to-any-file: '.github/workflows/scorecards.yml'
|
||||
|
||||
Github:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: '.github/**/*'
|
||||
@@ -111,70 +111,3 @@
|
||||
- name: "Devtools"
|
||||
color: "FF9E1F"
|
||||
description: "Development tools"
|
||||
- name: "Bugfix"
|
||||
color: "FF9E1F"
|
||||
description: "Pull requests that fix bugs"
|
||||
- name: "Gradle"
|
||||
color: "FF9E1F"
|
||||
description: "Pull requests that update Gradle code"
|
||||
- name: "build"
|
||||
color: "1E90FF"
|
||||
description: "Changes that affect the build system or external dependencies"
|
||||
- name: "chore"
|
||||
color: "FFD700"
|
||||
description: "Routine tasks or maintenance that don't modify src or test files"
|
||||
- name: "ci"
|
||||
color: "4682B4"
|
||||
description: "Changes to CI configuration files and scripts"
|
||||
- name: "perf"
|
||||
color: "FF69B4"
|
||||
description: "Changes that improve performance"
|
||||
- name: "refactor"
|
||||
color: "9932CC"
|
||||
description: "Code changes that neither fix a bug nor add a feature"
|
||||
- name: "revert"
|
||||
color: "DC143C"
|
||||
description: "Reverts a previous commit"
|
||||
- name: "style"
|
||||
color: "FFA500"
|
||||
description: "Changes that do not affect the meaning of the code (formatting, etc.)"
|
||||
- name: "admin"
|
||||
color: "195055"
|
||||
- name: "codex"
|
||||
color: "ededed"
|
||||
description: null
|
||||
- name: "Github"
|
||||
color: "0052CC"
|
||||
- name: "github_actions"
|
||||
color: "000000"
|
||||
description: "Pull requests that update GitHub Actions code"
|
||||
- name: "needs-changes"
|
||||
color: "A65A86"
|
||||
- name: "on-hold"
|
||||
color: "2526F9"
|
||||
- name: "python"
|
||||
color: "2b67c6"
|
||||
description: "Pull requests that update Python code"
|
||||
- name: "size:L"
|
||||
color: "eb9500"
|
||||
description: "This PR changes 100-499 lines ignoring generated files."
|
||||
- name: "size:M"
|
||||
color: "ebb800"
|
||||
description: "This PR changes 30-99 lines ignoring generated files."
|
||||
- name: "size:S"
|
||||
color: "77b800"
|
||||
description: "This PR changes 10-29 lines ignoring generated files."
|
||||
- name: "size:XL"
|
||||
color: "ff823f"
|
||||
description: "This PR changes 500-999 lines ignoring generated files."
|
||||
- name: "size:XS"
|
||||
color: "00ff00"
|
||||
description: "This PR changes 0-9 lines ignoring generated files."
|
||||
- name: "size:XXL"
|
||||
color: "ffb8b8"
|
||||
description: "This PR changes 1000+ lines ignoring generated files."
|
||||
- name: "to research"
|
||||
color: "FBCA04"
|
||||
- name: "pr-deployed"
|
||||
color: "00FF00"
|
||||
description: "Pull request has been deployed to a test environment"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Description of Changes
|
||||
|
||||
<!--
|
||||
Please provide a summary of the changes, including:
|
||||
|
||||
- What was changed
|
||||
@@ -8,7 +7,6 @@ Please provide a summary of the changes, including:
|
||||
- Any challenges encountered
|
||||
|
||||
Closes #(issue_number)
|
||||
-->
|
||||
|
||||
---
|
||||
|
||||
@@ -17,15 +15,15 @@ Closes #(issue_number)
|
||||
### 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 read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/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/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)
|
||||
- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only)
|
||||
|
||||
### UI Changes (if applicable)
|
||||
|
||||
@@ -33,4 +31,4 @@ Closes #(issue_number)
|
||||
|
||||
### 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.
|
||||
- [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details.
|
||||
|
||||
@@ -196,9 +196,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
|
||||
)
|
||||
base_dir = os.path.abspath(os.path.join(os.getcwd(), "src", "main", "resources"))
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
@@ -218,20 +216,10 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
or (
|
||||
# only local windows command
|
||||
not file_normpath.startswith(
|
||||
os.path.join(
|
||||
"", "app", "core", "src", "main", "resources", "messages_"
|
||||
)
|
||||
os.path.join("", "src", "main", "resources", "messages_")
|
||||
)
|
||||
and not file_normpath.startswith(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_",
|
||||
)
|
||||
os.path.join(os.getcwd(), "src", "main", "resources", "messages_")
|
||||
)
|
||||
)
|
||||
or not file_normpath.endswith(".properties")
|
||||
@@ -329,7 +317,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/messages_en_GB.properties)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
@@ -389,13 +377,7 @@ if __name__ == "__main__":
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_*.properties",
|
||||
os.getcwd(), "src", "main", "resources", "messages_*.properties"
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# This file is autogenerated by pip-compile with Python 3.10
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' '.github\scripts\requirements_pre_commit.in'
|
||||
#
|
||||
cfgv==3.4.0 \
|
||||
--hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
|
||||
@@ -12,25 +12,25 @@ distlib==0.3.9 \
|
||||
--hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \
|
||||
--hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403
|
||||
# via virtualenv
|
||||
filelock==3.18.0 \
|
||||
--hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
|
||||
--hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
|
||||
filelock==3.16.1 \
|
||||
--hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \
|
||||
--hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435
|
||||
# via virtualenv
|
||||
identify==2.6.12 \
|
||||
--hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \
|
||||
--hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6
|
||||
identify==2.6.5 \
|
||||
--hash=sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566 \
|
||||
--hash=sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc
|
||||
# via pre-commit
|
||||
nodeenv==1.9.1 \
|
||||
--hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
|
||||
--hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
|
||||
# via pre-commit
|
||||
platformdirs==4.3.8 \
|
||||
--hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \
|
||||
--hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4
|
||||
platformdirs==4.3.6 \
|
||||
--hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \
|
||||
--hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb
|
||||
# via virtualenv
|
||||
pre-commit==4.2.0 \
|
||||
--hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \
|
||||
--hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd
|
||||
pre-commit==4.0.1 \
|
||||
--hash=sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2 \
|
||||
--hash=sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878
|
||||
# via -r .github\scripts\requirements_pre_commit.in
|
||||
pyyaml==6.0.2 \
|
||||
--hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \
|
||||
@@ -87,7 +87,7 @@ pyyaml==6.0.2 \
|
||||
--hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \
|
||||
--hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4
|
||||
# via pre-commit
|
||||
virtualenv==20.31.2 \
|
||||
--hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \
|
||||
--hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af
|
||||
virtualenv==20.28.1 \
|
||||
--hash=sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb \
|
||||
--hash=sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329
|
||||
# via pre-commit
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
# This file is autogenerated by pip-compile with Python 3.10
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in'
|
||||
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' '.github\scripts\requirements_sync_readme.in'
|
||||
#
|
||||
tomlkit==0.13.3 \
|
||||
--hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \
|
||||
--hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0
|
||||
tomlkit==0.13.2 \
|
||||
--hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde \
|
||||
--hash=sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79
|
||||
# via -r .github\scripts\requirements_sync_readme.in
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
name: Auto PR V2 Deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
if: github.event.action != 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
|
||||
pr_number: ${{ github.event.number }}
|
||||
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
|
||||
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check deployment conditions
|
||||
id: check-conditions
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
echo "PR Title: $PR_TITLE"
|
||||
echo "PR Author: $PR_AUTHOR"
|
||||
echo "PR Branch: $PR_BRANCH"
|
||||
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# Define authorized users
|
||||
authorized_users=(
|
||||
"Frooodle"
|
||||
"sf298"
|
||||
"Ludy87"
|
||||
"LaserKaspar"
|
||||
"sbplat"
|
||||
"reecebrowne"
|
||||
"DarioGii"
|
||||
"ConnorYoh"
|
||||
"EthanHealy01"
|
||||
)
|
||||
|
||||
# Check if author is in the authorized list
|
||||
is_authorized=false
|
||||
for user in "${authorized_users[@]}"; do
|
||||
if [[ "$PR_AUTHOR" == "$user" ]]; then
|
||||
is_authorized=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If PR is targeting V2 and user is authorized, deploy unconditionally
|
||||
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
|
||||
echo "✅ Deployment forced: PR targets V2 and author is authorized."
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise, continue with original keyword checks
|
||||
has_v2_keyword=false
|
||||
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
|
||||
has_v2_keyword=true
|
||||
fi
|
||||
|
||||
has_branch_keyword=false
|
||||
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
|
||||
has_branch_keyword=true
|
||||
fi
|
||||
|
||||
if [[ "$is_authorized" == "true" && ( "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ) ]]; then
|
||||
echo "✅ Deployment conditions met"
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "❌ Deployment conditions not met"
|
||||
echo " - Authorized user: $is_authorized"
|
||||
echo " - Has V2 keyword in title: $has_v2_keyword"
|
||||
echo " - Has V2/React keyword in branch: $has_branch_keyword"
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get PR repository and ref
|
||||
id: get-pr-info
|
||||
if: steps.check-conditions.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
# For forks, use the full repository name, for internal PRs use the current repo
|
||||
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
||||
repository="${{ github.event.pull_request.head.repo.full_name }}"
|
||||
else
|
||||
repository="${{ github.repository }}"
|
||||
fi
|
||||
|
||||
echo "repository=$repository" >> $GITHUB_OUTPUT
|
||||
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true'
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: main
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Add deployment started comment
|
||||
id: deployment-started
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
|
||||
// Delete previous V2 deployment comments to avoid clutter
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const v2Comments = comments.filter(comment =>
|
||||
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
|
||||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
|
||||
comment.body.includes('❌ **V2 Auto-deployment failed**')
|
||||
);
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
console.log(`Deleting old V2 comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
// Create new deployment started comment
|
||||
const { data: newComment } = await github.rest.issues.createComment({
|
||||
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.`
|
||||
});
|
||||
|
||||
return newComment.id;
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: ${{ needs.check-pr.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-pr.outputs.pr_ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Get commit hashes for frontend and backend
|
||||
id: commit-hashes
|
||||
run: |
|
||||
# Get last commit that touched the frontend folder, docker/frontend, or docker/compose
|
||||
FRONTEND_HASH=$(git log -1 --format="%H" -- frontend/ docker/frontend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
BACKEND_HASH="no-backend-changes"
|
||||
fi
|
||||
|
||||
echo "Frontend hash: $FRONTEND_HASH"
|
||||
echo "Backend hash: $BACKEND_HASH"
|
||||
|
||||
echo "frontend_hash=$FRONTEND_HASH" >> $GITHUB_OUTPUT
|
||||
echo "backend_hash=$BACKEND_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Short hashes for tags
|
||||
if [ "$FRONTEND_HASH" = "no-frontend-changes" ]; then
|
||||
echo "frontend_short=no-frontend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "frontend_short=${FRONTEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ "$BACKEND_HASH" = "no-backend-changes" ]; then
|
||||
echo "backend_short=no-backend" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "backend_short=${BACKEND_HASH:0:8}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check if frontend image exists
|
||||
id: check-frontend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Frontend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Check if backend image exists
|
||||
id: check-backend
|
||||
run: |
|
||||
if docker manifest inspect ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "Backend image already exists, skipping build"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
echo "Backend image needs to be built"
|
||||
fi
|
||||
|
||||
- name: Build and push V2 frontend image
|
||||
if: steps.check-frontend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/frontend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Build and push V2 backend image
|
||||
if: steps.check-backend.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/backend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
build-args: VERSION_TAG=v2-alpha
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Deploy V2 to VPS
|
||||
id: deploy
|
||||
run: |
|
||||
# Use same port strategy as regular PRs - just the PR number
|
||||
V2_PORT=${{ needs.check-pr.outputs.pr_number }}
|
||||
BACKEND_PORT=$((V2_PORT + 10000)) # Backend on higher port to avoid conflicts
|
||||
|
||||
# Create docker-compose for V2 with separate frontend and backend
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
services:
|
||||
stirling-pdf-v2-backend:
|
||||
container_name: stirling-pdf-v2-backend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-backend-${{ steps.commit-hashes.outputs.backend_short }}
|
||||
ports:
|
||||
- "${BACKEND_PORT}:8080" # Backend API port
|
||||
volumes:
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/data:/usr/share/tessdata:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/config:/configs:rw
|
||||
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
UI_APPNAME: "Stirling-PDF V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
UI_HOMEDESCRIPTION: "V2 PR#${{ needs.check-pr.outputs.pr_number }} - Frontend/Backend Split Architecture"
|
||||
UI_APPNAMENAVBAR: "V2 PR#${{ needs.check-pr.outputs.pr_number }}"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
restart: on-failure:5
|
||||
|
||||
stirling-pdf-v2-frontend:
|
||||
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
ports:
|
||||
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
|
||||
environment:
|
||||
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
|
||||
depends_on:
|
||||
- stirling-pdf-v2-backend
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
# Deploy to VPS
|
||||
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
|
||||
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
|
||||
# Create V2 PR-specific directories
|
||||
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
|
||||
|
||||
# Move docker-compose file to correct location
|
||||
mv /tmp/docker-compose-v2.yml /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/docker-compose.yml
|
||||
|
||||
# Stop any existing container and clean up
|
||||
cd /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}
|
||||
docker-compose down --remove-orphans 2>/dev/null || true
|
||||
|
||||
# Start the new container
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
|
||||
# Clean up unused Docker resources to save space
|
||||
docker system prune -af --volumes
|
||||
|
||||
# Clean up old backend/frontend images (older than 2 weeks)
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
|
||||
ENDSSH
|
||||
|
||||
# Set port for output
|
||||
echo "v2_port=${V2_PORT}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post V2 deployment URL to PR
|
||||
if: success()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ needs.check-pr.outputs.pr_number }};
|
||||
const v2Port = ${{ steps.deploy.outputs.v2_port }};
|
||||
|
||||
// Delete the "deploying..." comment since we're posting the final result
|
||||
const deploymentStartedId = ${{ steps.deployment-started.outputs.result }};
|
||||
if (deploymentStartedId) {
|
||||
console.log(`Deleting deployment started comment: ${deploymentStartedId}`);
|
||||
try {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: deploymentStartedId
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Could not delete deployment started comment: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
|
||||
|
||||
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
|
||||
`Your V2 PR with the new frontend/backend split architecture has been deployed!\n\n` +
|
||||
`🔗 **V2 Test URL:** [${deploymentUrl}](${deploymentUrl})\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.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
cleanup-v2-deployment:
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Clean up V2 deployment comments
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
|
||||
// Find and delete V2 deployment comments
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const v2Comments = comments.filter(c =>
|
||||
c.body?.includes("## 🚀 V2 Auto-Deployment Complete!") &&
|
||||
c.user?.type === "Bot"
|
||||
);
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
console.log(`Deleted V2 deployment comment (ID: ${comment.id})`);
|
||||
}
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup V2 deployment
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
|
||||
echo "Found V2 PR directory, proceeding with cleanup..."
|
||||
|
||||
# Stop and remove V2 containers
|
||||
cd /stirling/V2-PR-${{ github.event.pull_request.number }}
|
||||
docker-compose down || true
|
||||
|
||||
# Go back to root before removal
|
||||
cd /
|
||||
|
||||
# Remove V2 PR-specific directories
|
||||
rm -rf /stirling/V2-PR-${{ github.event.pull_request.number }}
|
||||
|
||||
# Clean up V2 containers by name (in case compose cleanup missed them)
|
||||
docker rm -f stirling-pdf-v2-frontend-pr-${{ github.event.pull_request.number }} || true
|
||||
docker rm -f stirling-pdf-v2-backend-pr-${{ github.event.pull_request.number }} || true
|
||||
|
||||
echo "V2 cleanup completed"
|
||||
else
|
||||
echo "V2 PR directory not found, nothing to clean up"
|
||||
fi
|
||||
|
||||
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
|
||||
|
||||
# Note: We don't remove the commit-based images since they can be reused across PRs
|
||||
# Only remove PR-specific containers and directories
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
continue-on-error: true
|
||||
|
||||
@@ -6,18 +6,20 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write # Required for adding reactions to comments
|
||||
pull-requests: read # Required for reading PR information
|
||||
|
||||
jobs:
|
||||
check-comment:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, 'prdeploy') ||
|
||||
contains(github.event.comment.body, 'deploypr')
|
||||
contains(github.event.comment.body, 'prdeploy') ||
|
||||
contains(github.event.comment.body, 'deploypr')
|
||||
)
|
||||
&&
|
||||
(
|
||||
@@ -28,7 +30,6 @@ jobs:
|
||||
github.event.comment.user.login == 'sbplat' ||
|
||||
github.event.comment.user.login == 'reecebrowne' ||
|
||||
github.event.comment.user.login == 'DarioGii' ||
|
||||
github.event.comment.user.login == 'EthanHealy01' ||
|
||||
github.event.comment.user.login == 'ConnorYoh'
|
||||
)
|
||||
outputs:
|
||||
@@ -36,23 +37,18 @@ jobs:
|
||||
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
|
||||
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
|
||||
comment_id: ${{ github.event.comment.id }}
|
||||
disable_security: ${{ steps.check-security-flag.outputs.disable_security }}
|
||||
enable_pro: ${{ steps.check-pro-flag.outputs.enable_pro }}
|
||||
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
|
||||
enable_security: ${{ steps.check-security-flag.outputs.enable_security }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
# Generate GitHub App token
|
||||
- name: Generate GitHub App Token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -88,7 +84,7 @@ jobs:
|
||||
|
||||
core.setOutput('repository', repository);
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
|
||||
|
||||
- name: Check for security/login flag
|
||||
id: check-security-flag
|
||||
env:
|
||||
@@ -96,36 +92,17 @@ jobs:
|
||||
run: |
|
||||
if [[ "$COMMENT_BODY" == *"security"* ]] || [[ "$COMMENT_BODY" == *"login"* ]]; then
|
||||
echo "Security flags detected in comment"
|
||||
echo "disable_security=false" >> $GITHUB_OUTPUT
|
||||
echo "enable_security=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No security flags detected in comment"
|
||||
echo "disable_security=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for pro flag
|
||||
id: check-pro-flag
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
if [[ "$COMMENT_BODY" == *"pro"* ]] || [[ "$COMMENT_BODY" == *"premium"* ]]; then
|
||||
echo "pro flags detected in comment"
|
||||
echo "enable_pro=true" >> $GITHUB_OUTPUT
|
||||
echo "enable_enterprise=false" >> $GITHUB_OUTPUT
|
||||
elif [[ "$COMMENT_BODY" == *"enterprise"* ]]; then
|
||||
echo "enterprise flags detected in comment"
|
||||
echo "enable_enterprise=true" >> $GITHUB_OUTPUT
|
||||
echo "enable_pro=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No pro or enterprise flags detected in comment"
|
||||
echo "enable_pro=false" >> $GITHUB_OUTPUT
|
||||
echo "enable_enterprise=false" >> $GITHUB_OUTPUT
|
||||
echo "enable_security=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Add 'in_progress' reaction to comment
|
||||
id: add-eyes-reaction
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
console.log(`Adding eyes reaction to comment ID: ${context.payload.comment.id}`);
|
||||
try {
|
||||
@@ -147,23 +124,18 @@ jobs:
|
||||
needs: check-comment
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
- name: Generate GitHub App Token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
@@ -173,7 +145,7 @@ jobs:
|
||||
with:
|
||||
repository: ${{ needs.check-comment.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-comment.outputs.pr_ref }}
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
@@ -183,17 +155,23 @@ jobs:
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: |
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then
|
||||
export DISABLE_ADDITIONAL_FEATURES=true
|
||||
if [ "${{ needs.check-comment.outputs.enable_security }}" == "true" ]; then
|
||||
export DOCKER_ENABLE_SECURITY=true
|
||||
else
|
||||
export DISABLE_ADDITIONAL_FEATURES=false
|
||||
export DOCKER_ENABLE_SECURITY=false
|
||||
fi
|
||||
./gradlew clean build
|
||||
env:
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
@@ -221,31 +199,16 @@ jobs:
|
||||
id: deploy
|
||||
run: |
|
||||
# Set security settings based on flags
|
||||
if [ "${{ needs.check-comment.outputs.disable_security }}" == "false" ]; then
|
||||
DISABLE_ADDITIONAL_FEATURES="false"
|
||||
if [ "${{ needs.check-comment.outputs.enable_security }}" == "true" ]; then
|
||||
DOCKER_SECURITY="true"
|
||||
LOGIN_SECURITY="true"
|
||||
SECURITY_STATUS="🔒 Security Enabled"
|
||||
else
|
||||
DISABLE_ADDITIONAL_FEATURES="true"
|
||||
DOCKER_SECURITY="false"
|
||||
LOGIN_SECURITY="false"
|
||||
SECURITY_STATUS="Security Disabled"
|
||||
fi
|
||||
|
||||
# Set pro/enterprise settings (enterprise implies pro)
|
||||
if [ "${{ needs.check-comment.outputs.enable_enterprise }}" == "true" ]; then
|
||||
PREMIUM_ENABLED="true"
|
||||
PREMIUM_KEY="${{ secrets.ENTERPRISE_KEY }}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
|
||||
elif [ "${{ needs.check-comment.outputs.enable_pro }}" == "true" ]; then
|
||||
PREMIUM_ENABLED="true"
|
||||
PREMIUM_KEY="${{ secrets.PREMIUM_KEY }}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="true"
|
||||
else
|
||||
PREMIUM_ENABLED="false"
|
||||
PREMIUM_KEY=""
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED="false"
|
||||
fi
|
||||
|
||||
# First create the docker-compose content locally
|
||||
cat > docker-compose.yml << EOF
|
||||
version: '3.3'
|
||||
@@ -260,7 +223,7 @@ jobs:
|
||||
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/config:/configs:rw
|
||||
- /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "${DISABLE_ADDITIONAL_FEATURES}"
|
||||
DOCKER_ENABLE_SECURITY: "${DOCKER_SECURITY}"
|
||||
SECURITY_ENABLELOGIN: "${LOGIN_SECURITY}"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
UI_APPNAME: "Stirling-PDF PR#${{ needs.check-comment.outputs.pr_number }}"
|
||||
@@ -269,9 +232,6 @@ jobs:
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
METRICS_ENABLED: "true"
|
||||
SYSTEM_GOOGLEVISIBILITY: "false"
|
||||
PREMIUM_KEY: "${PREMIUM_KEY}"
|
||||
PREMIUM_ENABLED: "${PREMIUM_ENABLED}"
|
||||
PREMIUM_PROFEATURES_AUDIT_ENABLED: "${PREMIUM_PROFEATURES_AUDIT_ENABLED}"
|
||||
restart: on-failure:5
|
||||
EOF
|
||||
|
||||
@@ -290,7 +250,7 @@ jobs:
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
ENDSSH
|
||||
|
||||
|
||||
# Set output for use in PR comment
|
||||
echo "security_status=${SECURITY_STATUS}" >> $GITHUB_ENV
|
||||
|
||||
@@ -298,7 +258,7 @@ jobs:
|
||||
if: success()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
console.log(`Adding rocket reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
|
||||
try {
|
||||
@@ -314,26 +274,11 @@ jobs:
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// add label to PR
|
||||
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['pr-deployed']
|
||||
});
|
||||
console.log(`Added 'pr-deployed' label to PR #${prNumber}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to add label to PR: ${error.message}`);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
- name: Add failure reaction to comment
|
||||
if: failure()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
console.log(`Adding -1 reaction to comment ID: ${{ needs.check-comment.outputs.comment_id }}`);
|
||||
try {
|
||||
@@ -353,7 +298,7 @@ jobs:
|
||||
if: success()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const { GITHUB_REPOSITORY } = process.env;
|
||||
const [repoOwner, repoName] = GITHUB_REPOSITORY.split('/');
|
||||
@@ -373,11 +318,3 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -f ../private.key docker-compose.yml
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: PR Deployment cleanup
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
permissions:
|
||||
@@ -13,99 +13,25 @@ env:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
if: github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
if: github.event.action == 'closed'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Remove 'pr-deployed' label if present
|
||||
id: remove-label-comment
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const prNumber = ${{ github.event.pull_request.number }};
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// Get all labels on the PR
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const hasLabel = labels.some(label => label.name === 'pr-deployed');
|
||||
|
||||
if (hasLabel) {
|
||||
console.log("Label 'pr-deployed' found. Removing...");
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: 'pr-deployed'
|
||||
});
|
||||
} else {
|
||||
console.log("Label 'pr-deployed' not found. Nothing to do.");
|
||||
}
|
||||
|
||||
// Find existing bot comments about the deployment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
const deploymentComments = comments.filter(c =>
|
||||
c.body?.includes("## 🚀 PR Test Deployment") &&
|
||||
c.user?.type === "Bot"
|
||||
);
|
||||
|
||||
if (deploymentComments.length > 0) {
|
||||
for (const comment of deploymentComments) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
console.log(`Deleted deployment comment (ID: ${comment.id})`);
|
||||
}
|
||||
} else {
|
||||
console.log("No matching deployment comments found.");
|
||||
}
|
||||
|
||||
// Set flag if either label or comment was present
|
||||
const hasDeploymentComment = deploymentComments.length > 0;
|
||||
core.setOutput('present', (hasLabel || hasDeploymentComment) ? 'true' : 'false');
|
||||
|
||||
|
||||
- name: Set up SSH
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
|
||||
sudo chmod 600 ../private.key
|
||||
|
||||
- name: Cleanup PR deployment
|
||||
if: steps.remove-label-comment.outputs.present == 'true'
|
||||
id: cleanup
|
||||
run: |
|
||||
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
|
||||
@@ -131,11 +57,3 @@ jobs:
|
||||
echo "NO_CLEANUP_NEEDED"
|
||||
fi
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -f ../private.key
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
name: AI - PR Title Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
branches: [main]
|
||||
|
||||
permissions: # required for secure-repo hardening
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ai-title-review:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
models: read
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git to suppress detached HEAD warning
|
||||
run: git config --global advice.detachedHead false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Check if actor is repo developer
|
||||
id: actor
|
||||
run: |
|
||||
if [[ "${{ github.actor }}" == *"[bot]" ]]; then
|
||||
echo "PR opened by a bot – skipping AI title review."
|
||||
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f .github/config/repo_devs.json ]; then
|
||||
echo "Error: .github/config/repo_devs.json not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Validate JSON and extract repo_devs
|
||||
REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; })
|
||||
# Convert developer list into Bash array
|
||||
mapfile -t DEVS_ARRAY <<< "$REPO_DEVS"
|
||||
if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then
|
||||
echo "is_repo_dev=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_repo_dev=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get PR diff
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: get_diff
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff
|
||||
echo "diff<<EOF" >> $GITHUB_OUTPUT
|
||||
cat pr.diff >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check and sanitize PR title
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: sanitize_pr_title
|
||||
env:
|
||||
PR_TITLE_RAW: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
# Sanitize PR title: max 72 characters, only printable characters
|
||||
PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g')
|
||||
if [[ ${#PR_TITLE} -lt 5 ]]; then
|
||||
echo "PR title is too short. Must be at least 5 characters." >&2
|
||||
fi
|
||||
echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
prompt: |
|
||||
Based on the following input data:
|
||||
|
||||
{
|
||||
"diff": "${{ steps.get_diff.outputs.diff }}",
|
||||
"pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}"
|
||||
}
|
||||
|
||||
Respond ONLY with valid JSON in the format:
|
||||
{
|
||||
"improved_rating": <0-10>,
|
||||
"improved_ai_title_rating": <0-10>,
|
||||
"improved_title": "<ai generated title>"
|
||||
}
|
||||
|
||||
- name: Validate and set SCRIPT_OUTPUT
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
run: |
|
||||
cat <<EOF > ai_response.json
|
||||
${{ steps.ai-title-analysis.outputs.response }}
|
||||
EOF
|
||||
|
||||
# Validate JSON structure
|
||||
jq -e '
|
||||
(keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and
|
||||
(.improved_rating | type == "number" and . >= 0 and . <= 10) and
|
||||
(.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and
|
||||
(.improved_title | type == "string")
|
||||
' ai_response.json
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Invalid AI response format" >&2
|
||||
cat ai_response.json >&2
|
||||
exit 1
|
||||
fi
|
||||
# Parse JSON fields
|
||||
IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json)
|
||||
IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json)
|
||||
# Limit comment length to 1000 characters
|
||||
COMMENT=$(cat <<EOF
|
||||
## 🤖 AI PR Title Suggestion
|
||||
|
||||
**PR-Title Rating**: $IMPROVED_RATING/10
|
||||
|
||||
### ⬇️ Suggested Title (copy & paste):
|
||||
|
||||
\`\`\`
|
||||
$IMPROVED_TITLE
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
*Generated by GitHub Models AI*
|
||||
EOF
|
||||
)
|
||||
echo "$COMMENT" > /tmp/ai-title-comment.md
|
||||
# Log input and output to the GitHub Step Summary
|
||||
echo "### 🤖 AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```bash' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat ai_response.json >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Post comment on PR if needed
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8');
|
||||
const { GITHUB_REPOSITORY } = process.env;
|
||||
const [owner, repo] = GITHUB_REPOSITORY.split('/');
|
||||
const issue_number = context.issue.number;
|
||||
|
||||
const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/);
|
||||
const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null;
|
||||
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
|
||||
|
||||
const existing = comments.data.find(c =>
|
||||
c.user?.login === expectedActor &&
|
||||
c.body.includes("## 🤖 AI PR Title Suggestion")
|
||||
);
|
||||
|
||||
if (rating === null) {
|
||||
console.log("No rating found in AI response – skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rating <= 5) {
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
console.log("Updated existing suggestion comment.");
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body
|
||||
});
|
||||
console.log("Created new suggestion comment.");
|
||||
}
|
||||
} else {
|
||||
const praise = `## 🤖 AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\n✅ No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo,
|
||||
comment_id: existing.id,
|
||||
body: praise
|
||||
});
|
||||
console.log("Replaced suggestion with praise.");
|
||||
} else {
|
||||
console.log("Rating > 5 and no existing comment – skipping comment.");
|
||||
}
|
||||
}
|
||||
|
||||
- name: is not repo dev
|
||||
if: steps.actor.outputs.is_repo_dev != 'true'
|
||||
run: |
|
||||
exit 0 # Skip the AI title review for non-repo developers
|
||||
|
||||
- name: Clean up
|
||||
if: always()
|
||||
run: |
|
||||
rm -f pr.diff ai_response.json /tmp/ai-title-comment.md
|
||||
echo "Cleaned up temporary files."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -0,0 +1,27 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Apply Labels
|
||||
uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
configuration-path: .github/labeler-config.yml
|
||||
sync-labels: true
|
||||
@@ -1,35 +0,0 @@
|
||||
name: "Auto Pull Request Labeler V2"
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0
|
||||
with:
|
||||
config_path: .github/labeler-config-srvaroa.yml
|
||||
use_local_config: false
|
||||
fail_on_error: true
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ steps.setup-bot.outputs.token }}"
|
||||
+19
-112
@@ -2,9 +2,9 @@ name: Build repo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main", "V2", "V2-gha"]
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,11 +21,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
jdk-version: [17, 21]
|
||||
spring-security: [true, false]
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -38,127 +37,35 @@ jobs:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
- name: Build with Gradle and no spring security
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.spring-security }}
|
||||
DOCKER_ENABLE_SECURITY: false
|
||||
|
||||
- name: Check Test Reports Exist
|
||||
id: check-reports
|
||||
if: always()
|
||||
run: |
|
||||
declare -a dirs=(
|
||||
"app/core/build/reports/tests/"
|
||||
"app/core/build/test-results/"
|
||||
"app/common/build/reports/tests/"
|
||||
"app/common/build/test-results/"
|
||||
"app/proprietary/build/reports/tests/"
|
||||
"app/proprietary/build/test-results/"
|
||||
)
|
||||
missing_reports=()
|
||||
for dir in "${dirs[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
missing_reports+=("$dir")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing_reports[@]} -gt 0 ]; then
|
||||
echo "ERROR: The following required test report directories are missing:"
|
||||
printf '%s\n' "${missing_reports[@]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required test report directories are present"
|
||||
- name: Build with Gradle and with spring security
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DOCKER_ENABLE_SECURITY: true
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}
|
||||
path: |
|
||||
app/core/build/reports/tests/
|
||||
app/core/build/test-results/
|
||||
app/core/build/reports/problems/
|
||||
app/common/build/reports/tests/
|
||||
app/common/build/test-results/
|
||||
app/common/build/reports/problems/
|
||||
app/proprietary/build/reports/tests/
|
||||
app/proprietary/build/test-results/
|
||||
app/proprietary/build/reports/problems/
|
||||
build/reports/tests/
|
||||
build/test-results/
|
||||
build/reports/problems/
|
||||
retention-days: 3
|
||||
if-no-files-found: warn
|
||||
|
||||
check-generateOpenApiDocs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
|
||||
- name: Generate OpenAPI documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
|
||||
frontend-validation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests (if available)
|
||||
run: |
|
||||
cd frontend
|
||||
npm test --passWithNoTests --watchAll=false || true
|
||||
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
common/build/reports/tests/
|
||||
common/build/test-results/
|
||||
common/build/reports/problems/
|
||||
retention-days: 3
|
||||
|
||||
check-licence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -202,7 +109,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -216,11 +123,11 @@ jobs:
|
||||
distribution: "adopt"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.37.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo curl -SL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
- name: Set up Python
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "src/main/resources/messages_*.properties"
|
||||
|
||||
permissions:
|
||||
contents: read # Allow read access to repository content
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write # Allow writing to pull requests
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -61,20 +61,7 @@ jobs:
|
||||
run: |
|
||||
echo "Fetching PR changed files..."
|
||||
echo "Getting list of changed files from PR..."
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for properties files, handle case where no matches are found
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No properties files changed in this PR"
|
||||
echo "Workflow will exit early as no relevant files to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found $(wc -l < changed_files.txt) matching properties files"
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt # Filter only matching property files
|
||||
|
||||
- name: Determine reference file test
|
||||
id: determine-file
|
||||
@@ -115,11 +102,8 @@ jobs:
|
||||
|
||||
// Filter for relevant files based on the PR changes
|
||||
const changedFiles = files
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
.map(file => file.filename)
|
||||
.filter(file => /^src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file));
|
||||
|
||||
console.log("Changed files:", changedFiles);
|
||||
|
||||
@@ -157,12 +141,12 @@ jobs:
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
|
||||
if (changedFiles.includes("src/main/resources/messages_en_GB.properties")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "src/main/resources/messages_en_GB.properties",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
@@ -174,7 +158,7 @@ jobs:
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "src/main/resources/messages_en_GB.properties",
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
@@ -269,12 +253,3 @@ jobs:
|
||||
run: |
|
||||
echo "Failing the job because errors were detected."
|
||||
exit 1
|
||||
|
||||
- name: Cleanup temporary files
|
||||
if: always()
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf pr-branch
|
||||
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
name: Frontend License Report Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
pull_request:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-frontend-license-report:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Generate frontend license report
|
||||
working-directory: frontend
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo "LICENSE_WARNINGS_EXIST=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "LICENSE_WARNINGS_EXIST=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get all comments on the PR
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter for license check comments
|
||||
const licenseComments = comments.filter(comment =>
|
||||
comment.body.includes('## ✅ Frontend License Check Passed') ||
|
||||
comment.body.includes('## ❌ Frontend License Check Failed')
|
||||
);
|
||||
|
||||
// Delete old license check comments
|
||||
for (const comment of licenseComments) {
|
||||
console.log(`Deleting old license check comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
}
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const hasWarnings = process.env.LICENSE_WARNINGS_EXIST === 'true';
|
||||
|
||||
let commentBody;
|
||||
|
||||
if (hasWarnings) {
|
||||
// Read warnings file to get specific issues
|
||||
const fs = require('fs');
|
||||
let warningDetails = '';
|
||||
try {
|
||||
const warnings = JSON.parse(fs.readFileSync('frontend/src/assets/license-warnings.json', 'utf8'));
|
||||
warningDetails = warnings.warnings.map(w => `- ${w.message}`).join('\n');
|
||||
} catch (e) {
|
||||
warningDetails = 'Unable to read warning details';
|
||||
}
|
||||
|
||||
commentBody = `## ❌ Frontend License Check Failed
|
||||
|
||||
The frontend license check has detected compatibility warnings that require review:
|
||||
|
||||
${warningDetails}
|
||||
|
||||
**Action Required:** Please review these licenses to ensure they are acceptable for your use case before merging.
|
||||
|
||||
_This check will fail the PR until license issues are resolved._`;
|
||||
} else {
|
||||
commentBody = `## ✅ Frontend License Check Passed
|
||||
|
||||
All frontend licenses have been validated and no compatibility warnings were detected.
|
||||
|
||||
The frontend license report has been updated successfully.`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
- name: Fail workflow if license warnings exist (PR only)
|
||||
if: github.event_name == 'pull_request' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: |
|
||||
echo "❌ License warnings detected. Failing the workflow."
|
||||
exit 1
|
||||
|
||||
# Push Event: Commit license files and create PR
|
||||
- name: Commit changes (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git add frontend/src/assets/3rdPartyLicenses.json
|
||||
# Note: Do NOT commit license-warnings.json - it's only for PR review
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare PR body (Push only)
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
PR_BODY="Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
|
||||
This PR updates the frontend license report based on changes to package.json dependencies."
|
||||
|
||||
if [ "${{ env.LICENSE_WARNINGS_EXIST }}" = "true" ]; then
|
||||
PR_BODY="$PR_BODY
|
||||
|
||||
## ⚠️ License Compatibility Warnings
|
||||
|
||||
The following licenses may require review for corporate compatibility:
|
||||
|
||||
$(cat frontend/src/assets/license-warnings.json | jq -r '.warnings[].message')
|
||||
|
||||
Please review these licenses to ensure they are acceptable for your use case."
|
||||
fi
|
||||
|
||||
echo "PR_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$PR_BODY" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: update-frontend-3rd-party-licenses
|
||||
base: V2
|
||||
title: "Update Frontend 3rd Party Licenses"
|
||||
body: ${{ env.PR_BODY }}
|
||||
labels: Licenses,github-actions,frontend
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
- name: Enable Pull Request Automerge (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'false'
|
||||
run: gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Add review required label (Push only)
|
||||
if: github.event_name == 'push' && steps.cpr.outputs.pull-request-operation == 'created' && env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
run: gh pr edit "${{ steps.cpr.outputs.pull-request-number }}" --add-label "license-review-required"
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.setup-bot.outputs.token }}
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
distribution: "adopt"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
|
||||
- name: Check licenses for compatibility
|
||||
run: ./gradlew clean checkLicense
|
||||
@@ -57,11 +57,11 @@ jobs:
|
||||
|
||||
- name: Move and rename license file
|
||||
run: |
|
||||
mv build/reports/dependency-license/index.json app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
mv build/reports/dependency-license/index.json src/main/resources/static/3rdPartyLicenses.json
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git add app/core/src/main/resources/static/3rdPartyLicenses.json
|
||||
git add src/main/resources/static/3rdPartyLicenses.json
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
title: "Update 3rd Party Licenses"
|
||||
body: |
|
||||
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot]
|
||||
labels: Licenses,github-actions
|
||||
labels: licenses,github-actions
|
||||
draft: false
|
||||
delete-branch: true
|
||||
sign-commits: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -21,46 +21,42 @@ jobs:
|
||||
versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
# ✅ Get version from Gradle
|
||||
# Get version number
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
run: |
|
||||
VERSION=$(./gradlew printVersion --quiet | tail -1)
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# ✅ Get Mac-specific version from Gradle
|
||||
- name: Get version number mac
|
||||
id: versionNumberMac
|
||||
run: |
|
||||
VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1)
|
||||
echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT
|
||||
VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}')
|
||||
CURRENT_YEAR=$(date +'%Y')
|
||||
IFS='.' read -r -a VERSION_PARTS <<< "$VERSION"
|
||||
MAC_VERSION="$CURRENT_YEAR.${VERSION_PARTS[1]:-0}.${VERSION_PARTS[2]:-0}"
|
||||
echo "versionNumberMac=$MAC_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
build-portable:
|
||||
needs: read_versions
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
enable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
- enable_security: true
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
- enable_security: false
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -72,21 +68,21 @@ jobs:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
- name: Generate jar (With Security=${{ matrix.enable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Rename binaries
|
||||
run: |
|
||||
mkdir ./binaries
|
||||
mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe
|
||||
mv ./app/core/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
mv ./build/libs/Stirling-PDF-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -102,15 +98,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
enable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
- enable_security: true
|
||||
file_suffix: "with-login-"
|
||||
- disable_security: true
|
||||
- enable_security: false
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -148,7 +144,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -160,7 +156,7 @@ jobs:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -175,7 +171,7 @@ jobs:
|
||||
- name: Build Installer
|
||||
run: ./gradlew build jpackage -x test --info
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
DOCKER_ENABLE_SECURITY: false
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
BROWSER_OPEN: true
|
||||
|
||||
@@ -238,7 +234,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -252,7 +248,7 @@ jobs:
|
||||
|
||||
- name: Install Cosign
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
- name: Generate key pair
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -301,7 +297,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -310,7 +306,7 @@ jobs:
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
|
||||
with:
|
||||
tag_name: v${{ needs.read_versions.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -30,25 +30,25 @@ jobs:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Run Gradle Command
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
DOCKER_ENABLE_SECURITY: false
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Install cosign
|
||||
if: github.ref == 'refs/heads/master'
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -77,7 +77,6 @@ jobs:
|
||||
- name: Generate tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -87,11 +86,11 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push main Dockerfile
|
||||
id: build-push-regular
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
@@ -154,6 +153,7 @@ jobs:
|
||||
- name: Generate tags fat
|
||||
id: meta3
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
|
||||
@@ -163,11 +163,11 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
|
||||
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push main Dockerfile fat
|
||||
id: build-push-fat
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
|
||||
@@ -13,17 +13,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
enable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
- enable_security: true
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
- enable_security: false
|
||||
file_suffix: ""
|
||||
outputs:
|
||||
version: ${{ steps.versionNumber.outputs.versionNumber }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -35,14 +35,14 @@ jobs:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
- name: Generate jar (Disable Security=${{ matrix.disable_security }})
|
||||
- name: Generate jar (With Security=${{ matrix.enable_security }})
|
||||
run: ./gradlew clean createExe
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
|
||||
DOCKER_ENABLE_SECURITY: ${{ matrix.enable_security }}
|
||||
STIRLING_PDF_DESKTOP_UI: false
|
||||
|
||||
- name: Get version number
|
||||
@@ -75,15 +75,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
enable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
- enable_security: true
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
- enable_security: false
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
run: ls -R
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
- name: Generate key pair
|
||||
run: cosign generate-key-pair
|
||||
@@ -153,15 +153,15 @@ jobs:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
disable_security: [true, false]
|
||||
enable_security: [true, false]
|
||||
include:
|
||||
- disable_security: false
|
||||
- enable_security: true
|
||||
file_suffix: "-with-login"
|
||||
- disable_security: true
|
||||
- enable_security: false
|
||||
file_suffix: ""
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
name: signed${{ matrix.file_suffix }}
|
||||
|
||||
- name: Upload binaries, attestations and signatures to Release and create GitHub Release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
uses: softprops/action-gh-release@01570a1f39cb168c169c802c3bceb9e93fb10974 # v2.1.0
|
||||
with:
|
||||
tag_name: v${{ needs.build.outputs.version }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -74,6 +74,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -27,13 +27,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
|
||||
- name: Build and analyze with Gradle
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
DISABLE_ADDITIONAL_FEATURES: false
|
||||
DOCKER_ENABLE_SECURITY: true
|
||||
STIRLING_PDF_DESKTOP_UI: true
|
||||
run: |
|
||||
./gradlew clean build sonar \
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,10 +26,10 @@ jobs:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1
|
||||
- uses: gradle/actions/setup-gradle@8379f6a1328ee0e06e2bb424dadb7b159856a326 # v4.4.0
|
||||
|
||||
- name: Generate Swagger documentation
|
||||
run: ./gradlew :stirling-pdf:generateOpenApiDocs
|
||||
run: ./gradlew generateOpenApiDocs
|
||||
|
||||
- name: Upload Swagger Documentation to SwaggerHub
|
||||
run: ./gradlew swaggerhubUpload
|
||||
|
||||
@@ -8,8 +8,8 @@ on:
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "src/main/resources/messages_*.properties"
|
||||
- "src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
permissions:
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -41,11 +41,11 @@ jobs:
|
||||
|
||||
- name: Sync translation property files
|
||||
run: |
|
||||
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
|
||||
python .github/scripts/check_language_properties.py --reference-file "src/main/resources/messages_en_GB.properties" --branch main
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add app/core/src/main/resources/messages_*.properties
|
||||
git add src/main/resources/messages_*.properties
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -57,8 +57,8 @@ jobs:
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
git add README.md scripts/ignore_translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
|
||||
git add README.md
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync README.md" || echo "No changes detected"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
@@ -101,4 +101,4 @@ jobs:
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
app/core/src/main/resources/messages_*.properties
|
||||
src/main/resources/messages_*.properties
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -28,10 +28,10 @@ jobs:
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew clean build
|
||||
env:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
DOCKER_ENABLE_SECURITY: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Get version number
|
||||
id: versionNumber
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
- /stirling/test-${{ github.sha }}/config:/configs:rw
|
||||
- /stirling/test-${{ github.sha }}/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "true"
|
||||
DOCKER_ENABLE_SECURITY: "false"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
SYSTEM_DEFAULTLOCALE: en-GB
|
||||
UI_APPNAME: "Stirling-PDF Test"
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+1
-10
@@ -13,7 +13,6 @@ local.properties
|
||||
.recommenders
|
||||
.classpath
|
||||
.project
|
||||
*.local.json
|
||||
version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
@@ -27,7 +26,6 @@ clientWebUI/
|
||||
!cucumber/exampleFiles/
|
||||
!cucumber/exampleFiles/example_html.zip
|
||||
exampleYmlFiles/stirling/
|
||||
stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
@@ -126,12 +124,6 @@ SwaggerDoc.json
|
||||
*.rar
|
||||
*.db
|
||||
/build
|
||||
/app/core/build
|
||||
/app/common/build
|
||||
/app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -197,9 +189,8 @@ id_ed25519.pub
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
|
||||
|
||||
|
||||
**/jcef-bundle/
|
||||
|
||||
# node_modules
|
||||
node_modules/
|
||||
*.mjs
|
||||
|
||||
+4
-14
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.0
|
||||
rev: v0.11.11
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -16,13 +16,13 @@ repos:
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls
|
||||
- --ignore-words-list=
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
exclude: (.vscode|.devcontainer|src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.27.2
|
||||
rev: v8.26.0
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
@@ -34,13 +34,3 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
|
||||
# rev: v16.21.1
|
||||
# hooks:
|
||||
# - id: stylelint
|
||||
# additional_dependencies:
|
||||
# - stylelint@16.21.1
|
||||
# - stylelint-config-standard@38.0.0
|
||||
# - "@stylistic/stylelint-plugin@3.1.3"
|
||||
# files: \.(css)$
|
||||
# args: [--fix]
|
||||
Vendored
+1
-4
@@ -15,9 +15,6 @@
|
||||
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
|
||||
"GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code
|
||||
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
|
||||
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
|
||||
"redhat.vscode-yaml", // YAML extension for Visual Studio Code
|
||||
"charliermarsh.ruff" // Ruff code formatter for Python to follow the Ruff Style Guide
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+3
-59
@@ -3,39 +3,9 @@
|
||||
"editor.guides.bracketPairs": "active",
|
||||
"editor.guides.bracketPairsHorizontal": "active",
|
||||
"cSpell.enabled": false,
|
||||
"[feature]": {
|
||||
"editor.defaultFormatter": "alexkrechik.cucumberautocomplete"
|
||||
},
|
||||
"[java]": {
|
||||
"editor.defaultFormatter": "josevseb.google-java-format-for-vs-code"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "stylelint.vscode-stylelint"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"[gradle-kotlin-dsl]": {
|
||||
"editor.defaultFormatter": "vscjava.vscode-gradle"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "yzhang.markdown-all-in-one"
|
||||
},
|
||||
"[gradle-build]": {
|
||||
"editor.defaultFormatter": "vscjava.vscode-gradle"
|
||||
},
|
||||
"[gradle]": {
|
||||
"editor.defaultFormatter": "vscjava.vscode-gradle"
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.defaultFormatter": "redhat.vscode-yaml"
|
||||
},
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "interactive",
|
||||
"java.format.enabled": true,
|
||||
@@ -79,17 +49,11 @@
|
||||
".venv*/",
|
||||
".vscode/",
|
||||
"bin/",
|
||||
"app/core/bin/",
|
||||
"app/common/bin/",
|
||||
"app/proprietary/bin/",
|
||||
"common/bin/",
|
||||
"build/",
|
||||
"app/core/build/",
|
||||
"app/common/build/",
|
||||
"app/proprietary/build/",
|
||||
"common/build/",
|
||||
"configs/",
|
||||
"app/core/configs/",
|
||||
"customFiles/",
|
||||
"app/core/customFiles/",
|
||||
"docs/",
|
||||
"exampleYmlFiles",
|
||||
"gradle/",
|
||||
@@ -101,9 +65,7 @@
|
||||
".git-blame-ignore-revs",
|
||||
".gitattributes",
|
||||
".gitignore",
|
||||
"app/core/.gitignore",
|
||||
"app/common/.gitignore",
|
||||
"app/proprietary/.gitignore",
|
||||
"common/.gitignore",
|
||||
".pre-commit-config.yaml",
|
||||
],
|
||||
// Enables signature help in Java.
|
||||
@@ -121,22 +83,4 @@
|
||||
"spring.initializr.defaultLanguage": "Java",
|
||||
"spring.initializr.defaultGroupId": "stirling.software.SPDF",
|
||||
"spring.initializr.defaultArtifactId": "SPDF",
|
||||
"java.jdt.ls.lombokSupport.enabled": true,
|
||||
"html.format.wrapLineLength": 127,
|
||||
"html.format.enable": true,
|
||||
"html.format.indentInnerHtml": true,
|
||||
"html.format.unformatted": "script,style,textarea",
|
||||
"html.format.contentUnformatted": "pre,code",
|
||||
"html.format.extraLiners": "head,body,/html",
|
||||
"html.format.wrapAttributes": "force",
|
||||
"html.format.wrapAttributesIndentSize": 2,
|
||||
"html.format.indentHandlebars": true,
|
||||
"html.format.preserveNewLines": true,
|
||||
"html.format.maxPreserveNewLines": 2,
|
||||
"stylelint.configFile": "devTools/.stylelintrc.json",
|
||||
"java.project.sourcePaths": [
|
||||
"app/core/src/main/java",
|
||||
"app/common/src/main/java",
|
||||
"app/proprietary/src/main/java"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Build and Test
|
||||
- **Build project**: `./gradlew clean build`
|
||||
- **Run locally**: `./gradlew bootRun`
|
||||
- **Full test suite**: `./test.sh` (builds all Docker variants and runs comprehensive tests)
|
||||
- **Code formatting**: `./gradlew spotlessApply` (runs automatically before compilation)
|
||||
|
||||
### Docker Development
|
||||
- **Build ultra-lite**: `docker build -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .`
|
||||
- **Build standard**: `docker build -t stirlingtools/stirling-pdf:latest -f ./Dockerfile .`
|
||||
- **Build fat version**: `docker build -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .`
|
||||
- **Example compose files**: Located in `exampleYmlFiles/` directory
|
||||
|
||||
### Security Mode Development
|
||||
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
|
||||
|
||||
### Frontend Development
|
||||
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
|
||||
- **Tech Stack**: Vite + React + TypeScript + Mantine UI + TailwindCSS
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
|
||||
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection)
|
||||
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
|
||||
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking
|
||||
- **Parameters**: Tool parameter handling patterns still being standardized
|
||||
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application with Thymeleaf templating
|
||||
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
|
||||
### Controller Architecture
|
||||
- **API Controllers** (`src/main/java/.../controller/api/`): REST endpoints for PDF operations
|
||||
- Organized by function: converters, security, misc, pipeline
|
||||
- Follow pattern: `@RestController` + `@RequestMapping("/api/v1/...")`
|
||||
- **Web Controllers** (`src/main/java/.../controller/web/`): Serve Thymeleaf templates
|
||||
- Pattern: `@Controller` + return template names
|
||||
|
||||
### Key Components
|
||||
- **SPDFApplication.java**: Main application class with desktop UI and browser launching logic
|
||||
- **ConfigInitializer**: Handles runtime configuration and settings files
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Component Architecture
|
||||
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
- Frontend: JSON files in `frontend/public/locales/` (converted from .properties)
|
||||
- Conversion Script: `scripts/convert_properties_to_json.py`
|
||||
|
||||
### Configuration Modes
|
||||
- **Ultra-lite**: Basic PDF operations only
|
||||
- **Standard**: Full feature set
|
||||
- **Fat**: Pre-downloaded dependencies for air-gapped environments
|
||||
- **Security Mode**: Adds authentication, user management, and enterprise features
|
||||
|
||||
### Testing Strategy
|
||||
- **Integration Tests**: Cucumber tests in `testing/cucumber/`
|
||||
- **Docker Testing**: `test.sh` validates all Docker variants
|
||||
- **Manual Testing**: No unit tests currently - relies on UI and API testing
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Local Development**:
|
||||
- Backend: `./gradlew bootRun` (runs on localhost:8080)
|
||||
- Frontend: `cd frontend && npm run dev` (runs on localhost:5173, proxies to backend)
|
||||
2. **Docker Testing**: Use `./test.sh` before submitting PRs
|
||||
3. **Code Style**: Spotless enforces Google Java Format automatically
|
||||
4. **Translations**:
|
||||
- Backend: Use helper scripts in `/scripts` for multi-language updates
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
- No apologies or conversational filler
|
||||
- Answer questions directly without preamble
|
||||
- Explain reasoning concisely when asked
|
||||
- Avoid unnecessary elaboration
|
||||
|
||||
## Decision Making
|
||||
- Ask clarifying questions before making assumptions
|
||||
- Stop and ask when uncertain about project-specific details
|
||||
- Confirm approach before making structural changes
|
||||
- Request guidance on preferences (cross-platform vs specific tools, etc.)
|
||||
- Verify understanding of requirements before proceeding
|
||||
+2
-13
@@ -25,7 +25,7 @@ Please make sure your Pull Request adheres to the following guidelines:
|
||||
|
||||
## Translations
|
||||
|
||||
If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](devGuide/HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it!
|
||||
If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it!
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -37,18 +37,7 @@ First, make sure you've read the section [Pull Requests](#pull-requests).
|
||||
|
||||
If, at any point in time, you have a question, please feel free to ask in the same issue thread or in our [Discord](https://discord.gg/FJUSXUSYec).
|
||||
|
||||
## Developer Documentation
|
||||
|
||||
For technical guides, setup instructions, and development resources, please see our [Developer Documentation](devGuide/) which includes:
|
||||
|
||||
- [Developer Guide](devGuide/DeveloperGuide.md) - Main setup and architecture guide
|
||||
- [Exception Handling Guide](devGuide/EXCEPTION_HANDLING_GUIDE.md) - Error handling patterns and i18n
|
||||
- [Translation Guide](devGuide/HowToAddNewLanguage.md) - Adding new languages
|
||||
- And more in the [devGuide folder](devGuide/)
|
||||
|
||||
For configuration and usage guides, see:
|
||||
- [Database Guide](DATABASE.md) - Database setup and configuration
|
||||
- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration
|
||||
Developers should review our [Developer Guide](DeveloperGuide.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+46
-150
@@ -2,38 +2,21 @@
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. **Stirling 2.0** represents a complete frontend rewrite, replacing the legacy Thymeleaf-based UI with a modern React SPA (Single Page Application).
|
||||
|
||||
This guide focuses on developing for Stirling 2.0, including both the React frontend and Spring Boot backend development workflows.
|
||||
Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This guide focuses on Docker-based development and testing, which is the recommended approach for working with the full version of Stirling-PDF.
|
||||
|
||||
## 2. Project Overview
|
||||
|
||||
**Stirling 2.0** is built using:
|
||||
Stirling-PDF is built using:
|
||||
|
||||
**Backend:**
|
||||
- Spring Boot (Java 17+, JDK 21 recommended)
|
||||
- PDFBox for core PDF operations
|
||||
- LibreOffice for document conversions
|
||||
- qpdf for PDF optimization
|
||||
- Spring Security (optional, controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- Lombok for reducing boilerplate code
|
||||
|
||||
**Frontend (React SPA):**
|
||||
- React + TypeScript
|
||||
- Vite for build tooling and development server
|
||||
- Mantine UI component library
|
||||
- TailwindCSS for styling
|
||||
- PDF.js for client-side PDF rendering
|
||||
- PDF-LIB.js for client-side PDF manipulation
|
||||
- IndexedDB for client-side file storage and thumbnails
|
||||
- i18next for internationalization
|
||||
|
||||
**Infrastructure:**
|
||||
- Docker for containerization
|
||||
- Gradle for build management
|
||||
|
||||
**Legacy (reference only during development):**
|
||||
- Thymeleaf templates (being completely replaced in 2.0)
|
||||
- Spring Boot + Thymeleaf
|
||||
- PDFBox
|
||||
- LibreOffice
|
||||
- qpdf
|
||||
- HTML, CSS, JavaScript
|
||||
- Docker
|
||||
- PDF.js
|
||||
- PDF-LIB.js
|
||||
- Lombok
|
||||
|
||||
## 3. Development Environment Setup
|
||||
|
||||
@@ -41,8 +24,7 @@ This guide focuses on developing for Stirling 2.0, including both the React fron
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Node.js 18+ and npm (required for frontend development)
|
||||
- Java JDK 17 or later
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
|
||||
### Setup Steps
|
||||
@@ -73,46 +55,16 @@ Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, do
|
||||
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
|
||||
5. **Frontend Setup (Required for Stirling 2.0)**
|
||||
Navigate to the frontend directory and install dependencies using npm.
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling-PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
|
||||
|
||||
## 4. Stirling 2.0 Development Workflow
|
||||
|
||||
### Frontend Development (React)
|
||||
The frontend is a React SPA that runs independently during development:
|
||||
|
||||
1. **Start the backend**: Run the Spring Boot application (serves API endpoints on localhost:8080)
|
||||
2. **Start the frontend dev server**: Navigate to the frontend directory and run the development server (serves UI on localhost:5173)
|
||||
3. **Development flow**: The Vite dev server automatically proxies API calls to the backend
|
||||
|
||||
### File Storage Architecture
|
||||
Stirling 2.0 uses client-side file storage:
|
||||
- **IndexedDB**: Stores files locally in the browser with automatic thumbnail generation
|
||||
- **PDF.js**: Handles client-side PDF rendering and processing
|
||||
- **URL Parameters**: Support for deep linking and tool state persistence
|
||||
|
||||
### Legacy Code Reference
|
||||
The existing Thymeleaf templates remain in the codebase during development as reference material but will be completely removed for the 2.0 release.
|
||||
|
||||
## 5. Project Structure
|
||||
## 4. Project Structure
|
||||
|
||||
```bash
|
||||
Stirling-PDF/
|
||||
├── .github/ # GitHub-specific files (workflows, issue templates)
|
||||
├── configs/ # Configuration files used by stirling at runtime (generated at runtime)
|
||||
├── frontend/ # React SPA frontend (Stirling 2.0)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── tools/ # Tool-specific React components
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── services/ # API and utility services
|
||||
│ │ ├── types/ # TypeScript type definitions
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ ├── public/
|
||||
│ │ └── locales/ # Internationalization files (JSON)
|
||||
│ ├── package.json # Frontend dependencies
|
||||
│ └── vite.config.ts # Vite configuration
|
||||
├── cucumber/ # Cucumber test files
|
||||
│ ├── features/
|
||||
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
|
||||
├── docs/ # Documentation files
|
||||
├── exampleYmlFiles/ # Example YAML configuration files
|
||||
@@ -132,14 +84,16 @@ Stirling-PDF/
|
||||
│ │ │ ├── service/
|
||||
│ │ │ └── utils/
|
||||
│ │ └── resources/
|
||||
│ │ ├── static/ # Legacy static assets (reference only)
|
||||
│ │ ├── static/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── pdfjs/
|
||||
│ │ └── templates/ # Legacy Thymeleaf templates (reference only)
|
||||
│ │ └── templates/
|
||||
│ └── test/
|
||||
├── testing/ # Cucumber and integration tests
|
||||
│ └── cucumber/ # Cucumber test files
|
||||
│ └── java/
|
||||
│ └── stirling/
|
||||
│ └── software/
|
||||
│ └── SPDF/
|
||||
├── build.gradle # Gradle build configuration
|
||||
├── Dockerfile # Main Dockerfile
|
||||
├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version
|
||||
@@ -148,7 +102,7 @@ Stirling-PDF/
|
||||
└── test.sh # Test script to deploy all docker versions and run cuke tests
|
||||
```
|
||||
|
||||
## 6. Docker-based Development
|
||||
## 5. Docker-based Development
|
||||
|
||||
Stirling-PDF offers several Docker versions:
|
||||
|
||||
@@ -160,9 +114,9 @@ Stirling-PDF offers several Docker versions:
|
||||
|
||||
Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as:
|
||||
|
||||
- `docker-compose-latest.yml`: Latest version without login and security features
|
||||
- `docker-compose-latest-security.yml`: Latest version with login and security features enabled
|
||||
- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled
|
||||
- `docker-compose-latest.yml`: Latest version without security features
|
||||
- `docker-compose-latest-security.yml`: Latest version with security features enabled
|
||||
- `docker-compose-latest-fat-security.yml`: Fat version with security features enabled
|
||||
|
||||
These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`:
|
||||
|
||||
@@ -183,11 +137,11 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./stirling/latest/data:/usr/share/tessdata:rw
|
||||
- ./stirling/latest/config:/configs:rw
|
||||
- ./stirling/latest/logs:/logs:rw
|
||||
- /stirling/latest/data:/usr/share/tessdata:rw
|
||||
- /stirling/latest/config:/configs:rw
|
||||
- /stirling/latest/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
@@ -216,7 +170,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
export DOCKER_ENABLE_SECURITY=false # or true for security-enabled builds
|
||||
```
|
||||
|
||||
2. Build the project with Gradle:
|
||||
@@ -239,16 +193,16 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite .
|
||||
```
|
||||
|
||||
For the fat version (with login and security features enabled):
|
||||
For the fat version (with security enabled):
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=false
|
||||
export DOCKER_ENABLE_SECURITY=true
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
|
||||
|
||||
## 7. Testing
|
||||
## 6. Testing
|
||||
|
||||
### Comprehensive Testing Script
|
||||
|
||||
@@ -274,15 +228,6 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
|
||||
|
||||
2. Access the application at `http://localhost:8080` and manually test all features developed.
|
||||
|
||||
### Frontend Development Testing (Stirling 2.0)
|
||||
|
||||
For React frontend development:
|
||||
|
||||
1. Start the backend: Run the Spring Boot application to serve API endpoints on localhost:8080
|
||||
2. Start the frontend dev server: Navigate to the frontend directory and run the development server on localhost:5173
|
||||
3. The Vite dev server automatically proxies API calls to the backend
|
||||
4. Test React components, UI interactions, and IndexedDB file operations using browser developer tools
|
||||
|
||||
### Local Testing (Java and UI Components)
|
||||
|
||||
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
|
||||
@@ -313,7 +258,7 @@ Important notes:
|
||||
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
|
||||
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
|
||||
|
||||
## 8. Contributing
|
||||
## 7. Contributing
|
||||
|
||||
1. Fork the repository on GitHub.
|
||||
2. Create a new branch for your feature or bug fix.
|
||||
@@ -338,11 +283,11 @@ When you raise a PR:
|
||||
|
||||
Address any issues that arise from these checks before finalizing your pull request.
|
||||
|
||||
## 9. API Documentation
|
||||
## 8. API Documentation
|
||||
|
||||
API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/).
|
||||
|
||||
## 10. Customization
|
||||
## 9. Customization
|
||||
|
||||
Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include:
|
||||
|
||||
@@ -361,7 +306,7 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
|
||||
|
||||
Refer to the main README for a full list of customization options.
|
||||
|
||||
## 11. Language Translations
|
||||
## 10. Language Translations
|
||||
|
||||
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
|
||||
|
||||
@@ -381,62 +326,13 @@ Remember to test your changes thoroughly to ensure they don't break any existing
|
||||
|
||||
## Code examples
|
||||
|
||||
### React Component Development (Stirling 2.0)
|
||||
|
||||
For Stirling 2.0, new features are built as React components instead of Thymeleaf templates:
|
||||
|
||||
#### Creating a New Tool Component
|
||||
|
||||
1. **Create the React Component:**
|
||||
```typescript
|
||||
// frontend/src/tools/NewTool.tsx
|
||||
import { useState } from 'react';
|
||||
import { Button, FileInput, Container } from '@mantine/core';
|
||||
|
||||
interface NewToolProps {
|
||||
params: Record<string, any>;
|
||||
updateParams: (updates: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export default function NewTool({ params, updateParams }: NewToolProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const handleProcess = async () => {
|
||||
// Process files using API or client-side logic
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FileInput
|
||||
multiple
|
||||
accept="application/pdf"
|
||||
onChange={setFiles}
|
||||
/>
|
||||
<Button onClick={handleProcess}>Process</Button>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add API Integration:**
|
||||
```typescript
|
||||
// Use existing API endpoints or create new ones
|
||||
const response = await fetch('/api/v1/new-tool', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
3. **Register in Tool Picker:**
|
||||
Update the tool picker component to include the new tool with proper routing and URL parameter support.
|
||||
|
||||
### Legacy Reference: Overview of Thymeleaf
|
||||
### Overview of Thymeleaf
|
||||
|
||||
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
|
||||
|
||||
### Thymeleaf overview
|
||||
|
||||
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
|
||||
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
|
||||
|
||||
Some examples of this are:
|
||||
|
||||
@@ -488,7 +384,7 @@ This would generate n entries of tr for each person in exampleData
|
||||
### Adding a New Feature to the Backend (API)
|
||||
|
||||
1. **Create a New Controller:**
|
||||
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
|
||||
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
|
||||
|
||||
@@ -515,7 +411,7 @@ This would generate n entries of tr for each person in exampleData
|
||||
```
|
||||
|
||||
2. **Define the Service Layer:** (Not required but often useful)
|
||||
- Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory.
|
||||
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
|
||||
- Implement the business logic for the new feature.
|
||||
|
||||
```java
|
||||
@@ -567,7 +463,7 @@ This would generate n entries of tr for each person in exampleData
|
||||
### Adding a New Feature to the Frontend (UI)
|
||||
|
||||
1. **Create a New Thymeleaf Template:**
|
||||
- Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory.
|
||||
- Create a new HTML file in the `src/main/resources/templates` directory.
|
||||
- Use Thymeleaf attributes to dynamically generate content.
|
||||
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
|
||||
|
||||
@@ -611,7 +507,7 @@ This would generate n entries of tr for each person in exampleData
|
||||
```
|
||||
|
||||
2. **Create a New Controller for the UI:**
|
||||
- Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory.
|
||||
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/ui` directory.
|
||||
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
|
||||
|
||||
```java
|
||||
@@ -641,7 +537,7 @@ This would generate n entries of tr for each person in exampleData
|
||||
|
||||
3. **Update the Navigation Bar:**
|
||||
- Add a link to the new feature page in the navigation bar.
|
||||
- Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file.
|
||||
- Update the `src/main/resources/templates/fragments/navbar.html` file.
|
||||
|
||||
```html
|
||||
<li class="nav-item">
|
||||
@@ -655,7 +551,7 @@ When adding a new feature or modifying existing ones in Stirling-PDF, you'll nee
|
||||
|
||||
### 1. Locate Existing Language Files
|
||||
|
||||
Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like:
|
||||
Find the existing `messages.properties` files in the `src/main/resources` directory. You'll see files like:
|
||||
|
||||
- `messages.properties` (default, usually English)
|
||||
- `messages_en_GB.properties`
|
||||
|
||||
@@ -1,41 +1,17 @@
|
||||
# Backend Dockerfile - Java Spring Boot with all dependencies and build stage
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Main stage
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts /scripts
|
||||
COPY pipeline /pipeline
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
# first /app directory is for the build stage, second is for the final image
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
#COPY src/main/resources/static/fonts/*.otf /usr/share/fonts/opentype/noto/
|
||||
COPY build/libs/*.jar app.jar
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Backend"
|
||||
LABEL org.opencontainers.image.description="Backend service for Stirling-PDF - Java Spring Boot with PDF processing capabilities"
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF"
|
||||
LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more."
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
@@ -44,10 +20,10 @@ LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark"
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
ENV DOCKER_ENABLE_SECURITY=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
@@ -58,13 +34,10 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
PATH=$PATH:/opt/venv/bin
|
||||
|
||||
# JDK for app and all dependencies
|
||||
|
||||
# JDK for app
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
@@ -75,6 +48,7 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
qpdf \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
@@ -87,13 +61,11 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF (unpaper for descew and other advanced features)
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
@@ -107,17 +79,17 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
chmod +x /scripts/init.sh && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# dockerfile.dev
|
||||
|
||||
# Basisimage: Gradle mit JDK 17 (Debian-basiert)
|
||||
FROM gradle:8.14-jdk17
|
||||
|
||||
# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren
|
||||
USER root
|
||||
|
||||
# Set GRADLE_HOME und füge Gradle zum PATH hinzu
|
||||
ENV GRADLE_HOME=/opt/gradle
|
||||
ENV PATH="$GRADLE_HOME/bin:$PATH"
|
||||
|
||||
# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
sudo \
|
||||
libreoffice \
|
||||
poppler-utils \
|
||||
qpdf \
|
||||
# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-eng \
|
||||
fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine \
|
||||
python3-uno \
|
||||
python3-venv \
|
||||
# ss -tln
|
||||
iproute2 \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setze die Environment Variable für setuptools
|
||||
ENV SETUPTOOLS_USE_DISTUTILS=local
|
||||
|
||||
# Installation der benötigten Python-Pakete
|
||||
RUN python3 -m venv --system-site-packages /opt/venv \
|
||||
&& . /opt/venv/bin/activate \
|
||||
&& pip install --upgrade pip setuptools \
|
||||
&& pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit
|
||||
|
||||
# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY . /workspace
|
||||
|
||||
RUN adduser --disabled-password --gecos '' devuser \
|
||||
&& chown -R devuser:devuser /home/devuser /workspace
|
||||
RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \
|
||||
&& chmod 0440 /etc/sudoers.d/devuser
|
||||
|
||||
# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben)
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN chmod +x /workspace/.devcontainer/git-init.sh
|
||||
RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh
|
||||
|
||||
# Wechsel zum Nicht‑Root Benutzer
|
||||
USER devuser
|
||||
@@ -1,4 +1,3 @@
|
||||
# Backend fat Dockerfile - Java Spring Boot with all dependencies and build stage
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
@@ -6,9 +5,6 @@ COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
@@ -17,8 +13,8 @@ WORKDIR /app
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
# Build the application with DOCKER_ENABLE_SECURITY=false
|
||||
RUN DOCKER_ENABLE_SECURITY=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
@@ -28,14 +24,13 @@ FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be02
|
||||
# Copy necessary files
|
||||
COPY scripts /scripts
|
||||
COPY pipeline /pipeline
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
# first /app directory is for the build stage, second is for the final image
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
COPY src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
COPY --from=build /app/build/libs/*.jar app.jar
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
ENV DOCKER_ENABLE_SECURITY=false \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
@@ -48,11 +43,8 @@ ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
PATH=$PATH:/opt/venv/bin
|
||||
|
||||
|
||||
# JDK for app
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
@@ -77,13 +69,12 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF (unpaper for descew and other advanced featues)
|
||||
unpaper \
|
||||
qpdf \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
|
||||
# CV
|
||||
py3-opencv \
|
||||
@@ -98,16 +89,16 @@ RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/a
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
chmod +x /scripts/init.sh && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
@@ -1,51 +1,24 @@
|
||||
# Backend ultra-lite Dockerfile - Java Spring Boot with minimal dependencies and build stage
|
||||
# Build the application
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project to the working directory
|
||||
COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Main stage
|
||||
# use alpine
|
||||
FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Set Environment Variables
|
||||
ENV DISABLE_ADDITIONAL_FEATURES=true \
|
||||
ENV DOCKER_ENABLE_SECURITY=false \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
UMASK=022
|
||||
|
||||
# Copy necessary files
|
||||
COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh
|
||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
COPY pipeline /pipeline
|
||||
COPY --from=build /app/app/core/build/libs/*.jar app.jar
|
||||
COPY build/libs/*.jar app.jar
|
||||
|
||||
# Set up necessary directories and permissions
|
||||
RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
@@ -62,10 +35,10 @@ RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /et
|
||||
su-exec \
|
||||
openjdk21-jre && \
|
||||
# User permissions
|
||||
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \
|
||||
mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto && \
|
||||
chmod +x /scripts/*.sh && \
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar
|
||||
|
||||
# Set environment variables
|
||||
@@ -75,4 +48,4 @@ EXPOSE 8080/tcp
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-jar", "/app.jar"]
|
||||
@@ -10,7 +10,7 @@ Fork Stirling-PDF and create a new branch out of `main`.
|
||||
|
||||
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
|
||||
|
||||
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
|
||||
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/templates/fragments/languages.html)
|
||||
|
||||
|
||||
For example, to add Polish, you would add:
|
||||
@@ -25,7 +25,7 @@ The `data-bs-language-code` is the code used to reference the file in the next s
|
||||
|
||||
Start by copying the existing English property file:
|
||||
|
||||
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
|
||||
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/src/main/resources/messages_en_GB.properties)
|
||||
|
||||
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
|
||||
|
||||
@@ -61,16 +61,8 @@ Make sure to place the entry under the correct language section. This helps main
|
||||
|
||||
#### Windows command
|
||||
|
||||
```powershell
|
||||
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
|
||||
```ps
|
||||
python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_pl_PL.properties
|
||||
|
||||
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
|
||||
```
|
||||
|
||||
#### Linux command
|
||||
|
||||
```bash
|
||||
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
|
||||
|
||||
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
|
||||
python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --check-file src\main\resources\messages_pl_PL.properties
|
||||
```
|
||||
@@ -1,13 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Stirling PDF Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "app/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "app/proprietary/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
Copyright (c) 2024 Stirling Tools
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -29,7 +29,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
- API for integration with external scripts
|
||||
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
|
||||
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
|
||||
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
|
||||
- Enterprise features like SSO see [here](https://docs.stirlingpdf.com/Enterprise%20Edition)
|
||||
|
||||
## PDF Features
|
||||
|
||||
@@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -168,7 +168,7 @@ Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
|
||||
|
||||
Join our community:
|
||||
- [Contribution Guidelines](CONTRIBUTING.md)
|
||||
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
|
||||
- [Developer Guide](devGuide/DeveloperGuide.md)
|
||||
- [Translation Guide (How to add custom languages)](HowToAddNewLanguage.md)
|
||||
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
- [Discord Community](https://discord.gg/HYmhKj45pU)
|
||||
- [Developer Guide](DeveloperGuide.md)
|
||||
|
||||
@@ -124,18 +124,10 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License 1.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License - v 1.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License v2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "Eclipse Public License v. 2.0"
|
||||
@@ -1,33 +0,0 @@
|
||||
// Configure bootRun to disable it or point to a main class
|
||||
bootRun {
|
||||
enabled = false
|
||||
}
|
||||
spotless {
|
||||
java {
|
||||
target sourceSets.main.allJava
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-web'
|
||||
api 'org.springframework.boot:spring-boot-starter-aop'
|
||||
api 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.18.0'
|
||||
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
|
||||
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
|
||||
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.9'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9"
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.3'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.3'
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package stirling.software.common.annotations;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
|
||||
/**
|
||||
* Shortcut for a POST endpoint that is executed through the Stirling "auto‑job" framework.
|
||||
*
|
||||
* <p>Behaviour notes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>The endpoint is registered with {@code POST} and, by default, consumes {@code
|
||||
* multipart/form-data} unless you override {@link #consumes()}.
|
||||
* <li>When the client supplies {@code ?async=true} the call is handed to {@link
|
||||
* stirling.software.common.service.JobExecutorService JobExecutorService} where it may be
|
||||
* queued, retried, tracked and subject to time‑outs. For synchronous (default) invocations
|
||||
* these advanced options are ignored.
|
||||
* <li>Progress information (see {@link #trackProgress()}) is stored in {@link
|
||||
* stirling.software.common.service.TaskManager TaskManager} and can be polled via <code>
|
||||
* GET /api/v1/general/job/{id}</code>.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Unless stated otherwise an attribute only affects <em>async</em> execution.
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@RequestMapping(method = RequestMethod.POST)
|
||||
public @interface AutoJobPostMapping {
|
||||
|
||||
/** Alias for {@link RequestMapping#value} – the path mapping of the endpoint. */
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "value")
|
||||
String[] value() default {};
|
||||
|
||||
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
|
||||
String[] consumes() default {"multipart/form-data"};
|
||||
|
||||
/**
|
||||
* Maximum execution time in milliseconds before the job is aborted. A negative value means "use
|
||||
* the application default".
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
long timeout() default -1;
|
||||
|
||||
/**
|
||||
* Total number of attempts (initial + retries). Must be at least 1. Retries are executed
|
||||
* with exponential back‑off.
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
int retryCount() default 1;
|
||||
|
||||
/**
|
||||
* Record percentage / note updates so they can be retrieved via the REST status endpoint.
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
boolean trackProgress() default true;
|
||||
|
||||
/**
|
||||
* If {@code true} the job may be placed in a queue instead of being rejected when resources are
|
||||
* scarce.
|
||||
*
|
||||
* <p>Only honoured when {@code async=true}.
|
||||
*/
|
||||
boolean queueable() default false;
|
||||
|
||||
/**
|
||||
* Relative resource weight (1–100) used by the scheduler to prioritise / throttle jobs. Values
|
||||
* below 1 are clamped to 1, values above 100 to 100.
|
||||
*/
|
||||
int resourceWeight() default 50;
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package stirling.software.common.aop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.*;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.FileOrUploadService;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobExecutorService;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Order(0) // Highest precedence - executes before audit aspects
|
||||
public class AutoJobAspect {
|
||||
|
||||
private static final Duration RETRY_BASE_DELAY = Duration.ofMillis(100);
|
||||
|
||||
private final JobExecutorService jobExecutorService;
|
||||
private final HttpServletRequest request;
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
public Object wrapWithJobExecution(
|
||||
ProceedingJoinPoint joinPoint, AutoJobPostMapping autoJobPostMapping) {
|
||||
// This aspect will run before any audit aspects due to @Order(0)
|
||||
// Extract parameters from the request and annotation
|
||||
boolean async = Boolean.parseBoolean(request.getParameter("async"));
|
||||
log.debug(
|
||||
"AutoJobAspect: Processing {} {} with async={}",
|
||||
request.getMethod(),
|
||||
request.getRequestURI(),
|
||||
async);
|
||||
long timeout = autoJobPostMapping.timeout();
|
||||
int retryCount = autoJobPostMapping.retryCount();
|
||||
boolean trackProgress = autoJobPostMapping.trackProgress();
|
||||
|
||||
log.debug(
|
||||
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
|
||||
async,
|
||||
timeout > 0 ? timeout : "default",
|
||||
retryCount,
|
||||
trackProgress);
|
||||
|
||||
// Process arguments in-place to avoid type mismatch issues
|
||||
Object[] args = processArgsInPlace(joinPoint.getArgs(), async);
|
||||
|
||||
// Extract queueable and resourceWeight parameters and validate
|
||||
boolean queueable = autoJobPostMapping.queueable();
|
||||
int resourceWeight = Math.max(1, Math.min(100, autoJobPostMapping.resourceWeight()));
|
||||
|
||||
// Integrate with the JobExecutorService
|
||||
if (retryCount <= 1) {
|
||||
// No retries needed, simple execution
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
try {
|
||||
// Note: Progress tracking is handled in TaskManager/JobExecutorService
|
||||
// The trackProgress flag controls whether detailed progress is stored
|
||||
// for REST API queries, not WebSocket notifications
|
||||
return joinPoint.proceed(args);
|
||||
} catch (Throwable ex) {
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution: {}",
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
throw new RuntimeException(ex);
|
||||
}
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
} else {
|
||||
// Use retry logic
|
||||
return executeWithRetries(
|
||||
joinPoint,
|
||||
args,
|
||||
async,
|
||||
timeout,
|
||||
retryCount,
|
||||
trackProgress,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
}
|
||||
}
|
||||
|
||||
private Object executeWithRetries(
|
||||
ProceedingJoinPoint joinPoint,
|
||||
Object[] args,
|
||||
boolean async,
|
||||
long timeout,
|
||||
int maxRetries,
|
||||
boolean trackProgress,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
|
||||
// Keep jobId reference for progress tracking in TaskManager
|
||||
AtomicReference<String> jobIdRef = new AtomicReference<>();
|
||||
|
||||
return jobExecutorService.runJobGeneric(
|
||||
async,
|
||||
() -> {
|
||||
// Use iterative approach instead of recursion to avoid stack overflow
|
||||
Throwable lastException = null;
|
||||
|
||||
// Attempt counter starts at 1 for first try
|
||||
for (int currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt++) {
|
||||
try {
|
||||
if (trackProgress && async) {
|
||||
// Get jobId for progress tracking in TaskManager
|
||||
// This enables REST API progress queries, not WebSocket
|
||||
if (jobIdRef.get() == null) {
|
||||
jobIdRef.set(getJobIdFromContext());
|
||||
}
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Tracking progress for job {} (attempt {}/{})",
|
||||
jobId,
|
||||
currentAttempt,
|
||||
maxRetries);
|
||||
// Progress is tracked in TaskManager for REST API access
|
||||
// No WebSocket notifications sent here
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to execute the operation
|
||||
return joinPoint.proceed(args);
|
||||
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
ex);
|
||||
|
||||
// Check if we should retry
|
||||
if (currentAttempt < maxRetries) {
|
||||
log.info(
|
||||
"Retrying operation, attempt {}/{}",
|
||||
currentAttempt + 1,
|
||||
maxRetries);
|
||||
|
||||
if (trackProgress && async) {
|
||||
String jobId = jobIdRef.get();
|
||||
if (jobId != null) {
|
||||
log.debug(
|
||||
"Recording retry attempt for job {} in TaskManager",
|
||||
jobId);
|
||||
// Retry info is tracked in TaskManager for REST API access
|
||||
}
|
||||
}
|
||||
|
||||
// Use non-blocking delay for all retry attempts to avoid blocking
|
||||
// threads
|
||||
// For sync jobs this avoids starving the tomcat thread pool under
|
||||
// load
|
||||
long delayMs = RETRY_BASE_DELAY.toMillis() * currentAttempt;
|
||||
|
||||
// Execute the retry after a delay through the JobExecutorService
|
||||
// rather than blocking the current thread with sleep
|
||||
CompletableFuture<Object> delayedRetry = new CompletableFuture<>();
|
||||
|
||||
// Use a delayed executor for non-blocking delay
|
||||
CompletableFuture.delayedExecutor(delayMs, TimeUnit.MILLISECONDS)
|
||||
.execute(
|
||||
() -> {
|
||||
// Continue the retry loop in the next iteration
|
||||
// We can't return from here directly since
|
||||
// we're in a Runnable
|
||||
delayedRetry.complete(null);
|
||||
});
|
||||
|
||||
// Wait for the delay to complete before continuing
|
||||
try {
|
||||
delayedRetry.join();
|
||||
} catch (Exception e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// No more retries, we'll throw the exception after the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastException != null) {
|
||||
throw new RuntimeException(
|
||||
"Job failed after "
|
||||
+ maxRetries
|
||||
+ " attempts: "
|
||||
+ lastException.getMessage(),
|
||||
lastException);
|
||||
}
|
||||
|
||||
// This should never happen if lastException is properly tracked
|
||||
throw new RuntimeException("Job failed but no exception was recorded");
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes arguments in-place to handle file resolution and async file persistence. This
|
||||
* approach avoids type mismatch issues by modifying the original objects directly.
|
||||
*
|
||||
* @param originalArgs The original arguments
|
||||
* @param async Whether this is an async operation
|
||||
* @return The original array with processed arguments
|
||||
*/
|
||||
private Object[] processArgsInPlace(Object[] originalArgs, boolean async) {
|
||||
if (originalArgs == null || originalArgs.length == 0) {
|
||||
return originalArgs;
|
||||
}
|
||||
|
||||
// Process all arguments in-place
|
||||
for (int i = 0; i < originalArgs.length; i++) {
|
||||
Object arg = originalArgs[i];
|
||||
|
||||
if (arg instanceof PDFFile pdfFile) {
|
||||
// Case 1: fileId is provided but no fileInput
|
||||
if (pdfFile.getFileInput() == null && pdfFile.getFileId() != null) {
|
||||
try {
|
||||
log.debug("Using fileId {} to get file content", pdfFile.getFileId());
|
||||
MultipartFile file = fileStorage.retrieveFile(pdfFile.getFileId());
|
||||
pdfFile.setFileInput(file);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(
|
||||
"Failed to resolve file by ID: " + pdfFile.getFileId(), e);
|
||||
}
|
||||
}
|
||||
// Case 2: For async requests, we need to make a copy of the MultipartFile
|
||||
else if (async && pdfFile.getFileInput() != null) {
|
||||
try {
|
||||
log.debug("Making persistent copy of uploaded file for async processing");
|
||||
MultipartFile originalFile = pdfFile.getFileInput();
|
||||
String fileId = fileStorage.storeFile(originalFile);
|
||||
|
||||
// Store the fileId for later reference
|
||||
pdfFile.setFileId(fileId);
|
||||
|
||||
// Replace the original MultipartFile with our persistent copy
|
||||
MultipartFile persistentFile = fileStorage.retrieveFile(fileId);
|
||||
pdfFile.setFileInput(persistentFile);
|
||||
|
||||
log.debug("Created persistent file copy with fileId: {}", fileId);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(
|
||||
"Failed to create persistent copy of uploaded file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return originalArgs;
|
||||
}
|
||||
|
||||
private String getJobIdFromContext() {
|
||||
try {
|
||||
return (String) request.getAttribute("jobId");
|
||||
} catch (Exception e) {
|
||||
log.debug("Could not retrieve job ID from context: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package stirling.software.common.config;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Configuration for the temporary file management system. Sets up the necessary beans and
|
||||
* configures system properties.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class TempFileConfiguration {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Create the TempFileRegistry bean.
|
||||
*
|
||||
* @return A new TempFileRegistry instance
|
||||
*/
|
||||
@Bean
|
||||
public TempFileRegistry tempFileRegistry() {
|
||||
return new TempFileRegistry();
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initTempFileConfig() {
|
||||
try {
|
||||
ApplicationProperties.TempFileManagement tempFiles =
|
||||
applicationProperties.getSystem().getTempFileManagement();
|
||||
String customTempDirectory = tempFiles.getBaseTmpDir();
|
||||
|
||||
// Create the temp directory if it doesn't exist
|
||||
Path tempDir = Path.of(customTempDirectory);
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
log.info("Created temporary directory: {}", tempDir);
|
||||
}
|
||||
|
||||
log.debug("Temporary file configuration initialized");
|
||||
log.debug("Using temp directory: {}", customTempDirectory);
|
||||
log.debug("Temp file prefix: {}", tempFiles.getPrefix());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize temporary file configuration", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package stirling.software.common.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Handles cleanup of temporary files on application shutdown. Implements Spring's DisposableBean
|
||||
* interface to ensure cleanup happens during normal application shutdown.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TempFileShutdownHook implements DisposableBean {
|
||||
|
||||
private final TempFileRegistry registry;
|
||||
|
||||
public TempFileShutdownHook(TempFileRegistry registry) {
|
||||
this.registry = registry;
|
||||
|
||||
// Register a JVM shutdown hook as a backup in case Spring's
|
||||
// DisposableBean mechanism doesn't trigger (e.g., during a crash)
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(this::cleanupTempFiles));
|
||||
}
|
||||
|
||||
/** Spring's DisposableBean interface method. Called during normal application shutdown. */
|
||||
@Override
|
||||
public void destroy() {
|
||||
log.info("Application shutting down, cleaning up temporary files");
|
||||
cleanupTempFiles();
|
||||
}
|
||||
|
||||
/** Clean up all registered temporary files and directories. */
|
||||
private void cleanupTempFiles() {
|
||||
try {
|
||||
// Clean up all registered files
|
||||
Set<Path> files = registry.getAllRegisteredFiles();
|
||||
int deletedCount = 0;
|
||||
|
||||
for (Path file : files) {
|
||||
try {
|
||||
if (Files.exists(file)) {
|
||||
Files.deleteIfExists(file);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp file during shutdown: {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up all registered directories
|
||||
Set<Path> directories = registry.getTempDirectories();
|
||||
for (Path dir : directories) {
|
||||
try {
|
||||
if (Files.exists(dir)) {
|
||||
GeneralUtils.deleteDirectory(dir);
|
||||
deletedCount++;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory during shutdown: {}", dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Shutdown cleanup complete. Deleted {} temporary files/directories",
|
||||
deletedCount);
|
||||
|
||||
// Clear the registry
|
||||
registry.clear();
|
||||
} catch (Exception e) {
|
||||
log.error("Error during shutdown cleanup", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
package stirling.software.common.model.api.converters;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class EmlToPdfRequest extends PDFFile {
|
||||
|
||||
// fileInput is inherited from PDFFile
|
||||
|
||||
@Schema(
|
||||
description = "Include email attachments in the PDF output",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
example = "false")
|
||||
private boolean includeAttachments = false;
|
||||
|
||||
@Schema(
|
||||
description = "Maximum attachment size in MB to include (default 10MB, range: 1-100)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
example = "10",
|
||||
minimum = "1",
|
||||
maximum = "100")
|
||||
private int maxAttachmentSizeMB = 10;
|
||||
|
||||
@Schema(
|
||||
description = "Download HTML intermediate file instead of PDF",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
example = "false")
|
||||
private boolean downloadHtml = false;
|
||||
|
||||
@Schema(
|
||||
description = "Include CC and BCC recipients in header (if available)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
example = "true")
|
||||
private boolean includeAllRecipients = true;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package stirling.software.common.model.job;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Represents the result of a job execution. Used by the TaskManager to store job results. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class JobResult {
|
||||
|
||||
/** The job ID */
|
||||
private String jobId;
|
||||
|
||||
/** Flag indicating if the job is complete */
|
||||
private boolean complete;
|
||||
|
||||
/** Error message if the job failed */
|
||||
private String error;
|
||||
|
||||
/** List of result files for jobs that produce files */
|
||||
@JsonIgnore private List<ResultFile> resultFiles;
|
||||
|
||||
/** Time when the job was created */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** Time when the job was completed */
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
/** The actual result object, if not a file */
|
||||
private Object result;
|
||||
|
||||
/**
|
||||
* Notes attached to this job for tracking purposes. Uses CopyOnWriteArrayList for thread safety
|
||||
* when notes are added concurrently.
|
||||
*/
|
||||
private final List<String> notes = new CopyOnWriteArrayList<>();
|
||||
|
||||
/**
|
||||
* Create a new JobResult with the given job ID
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return A new JobResult
|
||||
*/
|
||||
public static JobResult createNew(String jobId) {
|
||||
return JobResult.builder()
|
||||
.jobId(jobId)
|
||||
.complete(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as complete with a general result
|
||||
*
|
||||
* @param result The result object
|
||||
*/
|
||||
public void completeWithResult(Object result) {
|
||||
this.complete = true;
|
||||
this.result = result;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as failed with an error message
|
||||
*
|
||||
* @param error The error message
|
||||
*/
|
||||
public void failWithError(String error) {
|
||||
this.complete = true;
|
||||
this.error = error;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as complete with multiple file results
|
||||
*
|
||||
* @param resultFiles The list of result files
|
||||
*/
|
||||
public void completeWithFiles(List<ResultFile> resultFiles) {
|
||||
this.complete = true;
|
||||
this.resultFiles = new ArrayList<>(resultFiles);
|
||||
this.completedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this job as complete with a single file result (convenience method)
|
||||
*
|
||||
* @param fileId The file ID of the result
|
||||
* @param fileName The file name
|
||||
* @param contentType The content type of the file
|
||||
* @param fileSize The size of the file in bytes
|
||||
*/
|
||||
public void completeWithSingleFile(
|
||||
String fileId, String fileName, String contentType, long fileSize) {
|
||||
ResultFile resultFile =
|
||||
ResultFile.builder()
|
||||
.fileId(fileId)
|
||||
.fileName(fileName)
|
||||
.contentType(contentType)
|
||||
.fileSize(fileSize)
|
||||
.build();
|
||||
completeWithFiles(List.of(resultFile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this job has file results
|
||||
*
|
||||
* @return true if this job has file results, false otherwise
|
||||
*/
|
||||
public boolean hasFiles() {
|
||||
return resultFiles != null && !resultFiles.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this job has multiple file results
|
||||
*
|
||||
* @return true if this job has multiple file results, false otherwise
|
||||
*/
|
||||
public boolean hasMultipleFiles() {
|
||||
return resultFiles != null && resultFiles.size() > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all result files
|
||||
*
|
||||
* @return List of result files
|
||||
*/
|
||||
public List<ResultFile> getAllResultFiles() {
|
||||
if (resultFiles != null && !resultFiles.isEmpty()) {
|
||||
return Collections.unmodifiableList(resultFiles);
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a note to this job
|
||||
*
|
||||
* @param note The note to add
|
||||
*/
|
||||
public void addNote(String note) {
|
||||
this.notes.add(note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes attached to this job
|
||||
*
|
||||
* @return An unmodifiable view of the notes list
|
||||
*/
|
||||
public List<String> getNotes() {
|
||||
return Collections.unmodifiableList(notes);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package stirling.software.common.model.job;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Represents a single file result from a job execution */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ResultFile {
|
||||
|
||||
/** The file ID for accessing the file */
|
||||
private String fileId;
|
||||
|
||||
/** The original file name */
|
||||
private String fileName;
|
||||
|
||||
/** MIME type of the file */
|
||||
private String contentType;
|
||||
|
||||
/** Size of the file in bytes */
|
||||
private long fileSize;
|
||||
}
|
||||
-449
@@ -1,449 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Service to periodically clean up temporary files. Runs scheduled tasks to delete old temp files
|
||||
* and directories.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TempFileCleanupService {
|
||||
|
||||
private final TempFileRegistry registry;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("machineType")
|
||||
private String machineType;
|
||||
|
||||
// Maximum recursion depth for directory traversal
|
||||
private static final int MAX_RECURSION_DEPTH = 5;
|
||||
|
||||
// File patterns that identify our temp files
|
||||
private static final Predicate<String> IS_OUR_TEMP_FILE =
|
||||
fileName ->
|
||||
fileName.startsWith("stirling-pdf-")
|
||||
|| fileName.startsWith("output_")
|
||||
|| fileName.startsWith("compressedPDF")
|
||||
|| fileName.startsWith("pdf-save-")
|
||||
|| fileName.startsWith("pdf-stream-")
|
||||
|| fileName.startsWith("PDFBox")
|
||||
|| fileName.startsWith("input_")
|
||||
|| fileName.startsWith("overlay-");
|
||||
|
||||
// File patterns that identify common system temp files
|
||||
private static final Predicate<String> IS_SYSTEM_TEMP_FILE =
|
||||
fileName ->
|
||||
fileName.matches("lu\\d+[a-z0-9]*\\.tmp")
|
||||
|| fileName.matches("ocr_process\\d+")
|
||||
|| (fileName.startsWith("tmp") && !fileName.contains("jetty"))
|
||||
|| fileName.startsWith("OSL_PIPE_")
|
||||
|| (fileName.endsWith(".tmp") && !fileName.contains("jetty"));
|
||||
|
||||
// File patterns that should be excluded from cleanup
|
||||
private static final Predicate<String> SHOULD_SKIP =
|
||||
fileName ->
|
||||
fileName.contains("jetty")
|
||||
|| fileName.startsWith("jetty-")
|
||||
|| "proc".equals(fileName)
|
||||
|| "sys".equals(fileName)
|
||||
|| "dev".equals(fileName)
|
||||
|| "hsperfdata_stirlingpdfuser".equals(fileName)
|
||||
|| fileName.startsWith("hsperfdata_")
|
||||
|| ".pdfbox.cache".equals(fileName);
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// Create necessary directories
|
||||
ensureDirectoriesExist();
|
||||
|
||||
// Perform startup cleanup if enabled
|
||||
if (applicationProperties.getSystem().getTempFileManagement().isStartupCleanup()) {
|
||||
runStartupCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure that all required temp directories exist */
|
||||
private void ensureDirectoriesExist() {
|
||||
try {
|
||||
ApplicationProperties.TempFileManagement tempFiles =
|
||||
applicationProperties.getSystem().getTempFileManagement();
|
||||
|
||||
// Create the main temp directory
|
||||
String customTempDirectory = tempFiles.getBaseTmpDir();
|
||||
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
|
||||
Path tempDir = Path.of(customTempDirectory);
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
log.info("Created temp directory: {}", tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Create LibreOffice temp directory
|
||||
String libreOfficeTempDir = tempFiles.getLibreofficeDir();
|
||||
if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) {
|
||||
Path loTempDir = Path.of(libreOfficeTempDir);
|
||||
if (!Files.exists(loTempDir)) {
|
||||
Files.createDirectories(loTempDir);
|
||||
log.info("Created LibreOffice temp directory: {}", loTempDir);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating temp directories", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduled task to clean up old temporary files. Runs at the configured interval. */
|
||||
@Scheduled(
|
||||
fixedDelayString =
|
||||
"#{applicationProperties.system.tempFileManagement.cleanupIntervalMinutes}",
|
||||
timeUnit = TimeUnit.MINUTES)
|
||||
public void scheduledCleanup() {
|
||||
log.info("Running scheduled temporary file cleanup");
|
||||
long maxAgeMillis = tempFileManager.getMaxAgeMillis();
|
||||
|
||||
// Clean up registered temp files (managed by TempFileRegistry)
|
||||
int registeredDeletedCount = tempFileManager.cleanupOldTempFiles(maxAgeMillis);
|
||||
log.info("Cleaned up {} registered temporary files", registeredDeletedCount);
|
||||
|
||||
// Clean up registered temp directories
|
||||
int directoriesDeletedCount = 0;
|
||||
for (Path directory : registry.getTempDirectories()) {
|
||||
try {
|
||||
if (Files.exists(directory)) {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
directoriesDeletedCount++;
|
||||
log.debug("Cleaned up temporary directory: {}", directory);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up temporary directory: {}", directory, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up PDFBox cache file
|
||||
cleanupPDFBoxCache();
|
||||
|
||||
// Clean up unregistered temp files based on our cleanup strategy
|
||||
boolean containerMode = isContainerMode();
|
||||
int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis);
|
||||
|
||||
if (registeredDeletedCount > 0
|
||||
|| unregisteredDeletedCount > 0
|
||||
|| directoriesDeletedCount > 0) {
|
||||
log.info(
|
||||
"Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories",
|
||||
registeredDeletedCount,
|
||||
unregisteredDeletedCount,
|
||||
directoriesDeletedCount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform startup cleanup of stale temporary files from previous runs. This is especially
|
||||
* important in Docker environments where temp files persist between container restarts.
|
||||
*/
|
||||
private void runStartupCleanup() {
|
||||
boolean containerMode = isContainerMode();
|
||||
|
||||
log.info(
|
||||
"Running in {} mode, using {} cleanup strategy",
|
||||
machineType,
|
||||
containerMode ? "aggressive" : "conservative");
|
||||
|
||||
// For startup cleanup, we use a longer timeout for non-container environments
|
||||
long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours
|
||||
|
||||
int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis);
|
||||
log.info(
|
||||
"Startup cleanup complete. Deleted {} temporary files/directories",
|
||||
totalDeletedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up unregistered temporary files across all configured temp directories.
|
||||
*
|
||||
* @param containerMode Whether we're in container mode (more aggressive cleanup)
|
||||
* @param isScheduled Whether this is a scheduled cleanup or startup cleanup
|
||||
* @param maxAgeMillis Maximum age of files to clean in milliseconds
|
||||
* @return Number of files deleted
|
||||
*/
|
||||
private int cleanupUnregisteredFiles(
|
||||
boolean containerMode, boolean isScheduled, long maxAgeMillis) {
|
||||
AtomicInteger totalDeletedCount = new AtomicInteger(0);
|
||||
|
||||
try {
|
||||
ApplicationProperties.TempFileManagement tempFiles =
|
||||
applicationProperties.getSystem().getTempFileManagement();
|
||||
Path[] dirsToScan;
|
||||
if (tempFiles.isCleanupSystemTemp()
|
||||
&& tempFiles.getSystemTempDir() != null
|
||||
&& !tempFiles.getSystemTempDir().isEmpty()) {
|
||||
Path systemTempPath = getSystemTempPath();
|
||||
dirsToScan =
|
||||
new Path[] {
|
||||
systemTempPath,
|
||||
Path.of(tempFiles.getBaseTmpDir()),
|
||||
Path.of(tempFiles.getLibreofficeDir())
|
||||
};
|
||||
} else {
|
||||
dirsToScan =
|
||||
new Path[] {
|
||||
Path.of(tempFiles.getBaseTmpDir()),
|
||||
Path.of(tempFiles.getLibreofficeDir())
|
||||
};
|
||||
}
|
||||
|
||||
// Process each directory
|
||||
Arrays.stream(dirsToScan)
|
||||
.filter(Files::exists)
|
||||
.forEach(
|
||||
tempDir -> {
|
||||
try {
|
||||
String phase = isScheduled ? "scheduled" : "startup";
|
||||
log.debug(
|
||||
"Scanning directory for {} cleanup: {}",
|
||||
phase,
|
||||
tempDir);
|
||||
|
||||
AtomicInteger dirDeletedCount = new AtomicInteger(0);
|
||||
cleanupDirectoryStreaming(
|
||||
tempDir,
|
||||
containerMode,
|
||||
0,
|
||||
maxAgeMillis,
|
||||
isScheduled,
|
||||
path -> {
|
||||
dirDeletedCount.incrementAndGet();
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"Deleted temp file during {} cleanup: {}",
|
||||
phase,
|
||||
path);
|
||||
}
|
||||
});
|
||||
|
||||
int count = dirDeletedCount.get();
|
||||
totalDeletedCount.addAndGet(count);
|
||||
if (count > 0) {
|
||||
log.info(
|
||||
"Cleaned up {} files/directories in {}",
|
||||
count,
|
||||
tempDir);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error during cleanup of directory: {}", tempDir, e);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("Error during cleanup of unregistered files", e);
|
||||
}
|
||||
|
||||
return totalDeletedCount.get();
|
||||
}
|
||||
|
||||
/** Get the system temp directory path based on configuration or system property. */
|
||||
private Path getSystemTempPath() {
|
||||
String systemTempDir =
|
||||
applicationProperties.getSystem().getTempFileManagement().getSystemTempDir();
|
||||
if (systemTempDir != null && !systemTempDir.isEmpty()) {
|
||||
return Path.of(systemTempDir);
|
||||
} else {
|
||||
return Path.of(System.getProperty("java.io.tmpdir"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine if we're running in a container environment. */
|
||||
private boolean isContainerMode() {
|
||||
return "Docker".equals(machineType) || "Kubernetes".equals(machineType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clean up a directory using a streaming approach to reduce memory usage.
|
||||
*
|
||||
* @param directory The directory to clean
|
||||
* @param containerMode Whether we're in container mode (more aggressive cleanup)
|
||||
* @param depth Current recursion depth
|
||||
* @param maxAgeMillis Maximum age of files to delete
|
||||
* @param isScheduled Whether this is a scheduled cleanup (vs startup)
|
||||
* @param onDeleteCallback Callback function when a file is deleted
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
private void cleanupDirectoryStreaming(
|
||||
Path directory,
|
||||
boolean containerMode,
|
||||
int depth,
|
||||
long maxAgeMillis,
|
||||
boolean isScheduled,
|
||||
Consumer<Path> onDeleteCallback)
|
||||
throws IOException {
|
||||
|
||||
if (depth > MAX_RECURSION_DEPTH) {
|
||||
log.debug("Maximum directory recursion depth reached for: {}", directory);
|
||||
return;
|
||||
}
|
||||
|
||||
java.util.List<Path> subdirectories = new java.util.ArrayList<>();
|
||||
|
||||
try (Stream<Path> pathStream = Files.list(directory)) {
|
||||
pathStream.forEach(
|
||||
path -> {
|
||||
try {
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (SHOULD_SKIP.test(fileName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isDirectory(path)) {
|
||||
subdirectories.add(path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (registry.contains(path.toFile())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDeleteFile(path, fileName, containerMode, maxAgeMillis)) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
onDeleteCallback.accept(path);
|
||||
} catch (IOException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage()
|
||||
.contains("being used by another process")) {
|
||||
log.debug("File locked, skipping delete: {}", path);
|
||||
} else {
|
||||
log.warn("Failed to delete temp file: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Error processing path: {}", path, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (Path subdirectory : subdirectories) {
|
||||
try {
|
||||
cleanupDirectoryStreaming(
|
||||
subdirectory,
|
||||
containerMode,
|
||||
depth + 1,
|
||||
maxAgeMillis,
|
||||
isScheduled,
|
||||
onDeleteCallback);
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing subdirectory: {}", subdirectory, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine if a file should be deleted based on its name, age, and other criteria. */
|
||||
private boolean shouldDeleteFile(
|
||||
Path path, String fileName, boolean containerMode, long maxAgeMillis) {
|
||||
// First check if it matches our known temp file patterns
|
||||
boolean isOurTempFile = IS_OUR_TEMP_FILE.test(fileName);
|
||||
boolean isSystemTempFile = IS_SYSTEM_TEMP_FILE.test(fileName);
|
||||
|
||||
// Normal operation - check against temp file patterns
|
||||
boolean shouldDelete = isOurTempFile || (containerMode && isSystemTempFile);
|
||||
|
||||
// Get file info for age checks
|
||||
long lastModified = 0;
|
||||
long currentTime = System.currentTimeMillis();
|
||||
boolean isEmptyFile = false;
|
||||
|
||||
try {
|
||||
lastModified = Files.getLastModifiedTime(path).toMillis();
|
||||
// Special case for zero-byte files - these are often corrupted temp files
|
||||
if (Files.size(path) == 0) {
|
||||
isEmptyFile = true;
|
||||
// For empty files, use a shorter timeout (5 minutes)
|
||||
// Delete empty files older than 5 minutes
|
||||
if ((currentTime - lastModified) > 5 * 60 * 1000) {
|
||||
shouldDelete = true;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not check file info, skipping: {}", path);
|
||||
}
|
||||
|
||||
// Check file age against maxAgeMillis only if it's not an empty file that we've already
|
||||
// decided to delete
|
||||
if (!isEmptyFile && shouldDelete && maxAgeMillis > 0) {
|
||||
// In normal mode, check age against maxAgeMillis
|
||||
shouldDelete = (currentTime - lastModified) > maxAgeMillis;
|
||||
}
|
||||
|
||||
return shouldDelete;
|
||||
}
|
||||
|
||||
/** Clean up LibreOffice temporary files. This method is called after LibreOffice operations. */
|
||||
public void cleanupLibreOfficeTempFiles() {
|
||||
// Cleanup known LibreOffice temp directories
|
||||
try {
|
||||
Set<Path> directories = registry.getTempDirectories();
|
||||
for (Path dir : directories) {
|
||||
if (dir.getFileName().toString().contains("libreoffice") && Files.exists(dir)) {
|
||||
// For directories containing "libreoffice", delete all contents
|
||||
// but keep the directory itself for future use
|
||||
cleanupDirectoryStreaming(
|
||||
dir,
|
||||
isContainerMode(),
|
||||
0,
|
||||
0, // age doesn't matter for LibreOffice cleanup
|
||||
false,
|
||||
path -> log.debug("Cleaned up LibreOffice temp file: {}", path));
|
||||
log.debug("Cleaned up LibreOffice temp directory contents: {}", dir);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up LibreOffice temp files", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up PDFBox cache file from user home directory. This cache file can grow large and
|
||||
* should be periodically cleaned.
|
||||
*/
|
||||
private void cleanupPDFBoxCache() {
|
||||
try {
|
||||
Path userHome = Path.of(System.getProperty("user.home"));
|
||||
Path pdfboxCache = userHome.resolve(".pdfbox.cache");
|
||||
|
||||
if (Files.exists(pdfboxCache)) {
|
||||
Files.deleteIfExists(pdfboxCache);
|
||||
log.debug("Cleaned up PDFBox cache file: {}", pdfboxCache);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up PDFBox cache file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Helper class that provides access to the ApplicationContext. Useful for getting beans in classes
|
||||
* that are not managed by Spring.
|
||||
*/
|
||||
@Component
|
||||
public class ApplicationContextProvider implements ApplicationContextAware {
|
||||
|
||||
private static ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext context) throws BeansException {
|
||||
applicationContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bean by class type.
|
||||
*
|
||||
* @param <T> The type of the bean
|
||||
* @param beanClass The class of the bean
|
||||
* @return The bean instance, or null if not found
|
||||
*/
|
||||
public static <T> T getBean(Class<T> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return applicationContext.getBean(beanClass);
|
||||
} catch (BeansException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bean by name and class type.
|
||||
*
|
||||
* @param <T> The type of the bean
|
||||
* @param name The name of the bean
|
||||
* @param beanClass The class of the bean
|
||||
* @return The bean instance, or null if not found
|
||||
*/
|
||||
public static <T> T getBean(String name, Class<T> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return applicationContext.getBean(name, beanClass);
|
||||
} catch (BeansException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bean of the specified type exists.
|
||||
*
|
||||
* @param beanClass The class of the bean
|
||||
* @return true if the bean exists, false otherwise
|
||||
*/
|
||||
public static boolean containsBean(Class<?> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
applicationContext.getBean(beanClass);
|
||||
return true;
|
||||
} catch (BeansException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PageMode;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class AttachmentUtils {
|
||||
|
||||
/**
|
||||
* Sets the PDF catalog viewer preferences to display attachments in the viewer.
|
||||
*
|
||||
* @param document The <code>PDDocument</code> to modify.
|
||||
* @param pageMode The <code>PageMode</code> to set for the PDF viewer. <code>PageMode</code>
|
||||
* values: <code>UseNone</code>, <code>UseOutlines</code>, <code>UseThumbs</code>, <code>
|
||||
* FullScreen</code>, <code>UseOC</code>, <code>UseAttachments</code>.
|
||||
*/
|
||||
public static void setCatalogViewerPreferences(PDDocument document, PageMode pageMode) {
|
||||
try {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
if (catalog != null) {
|
||||
COSDictionary catalogDict = catalog.getCOSObject();
|
||||
|
||||
catalog.setPageMode(pageMode);
|
||||
catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue());
|
||||
|
||||
COSDictionary viewerPrefs =
|
||||
(COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES);
|
||||
if (viewerPrefs == null) {
|
||||
viewerPrefs = new COSDictionary();
|
||||
catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs);
|
||||
}
|
||||
|
||||
viewerPrefs.setName(
|
||||
COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue());
|
||||
|
||||
viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true);
|
||||
|
||||
log.info(
|
||||
"Set PDF PageMode to UseAttachments to automatically show attachments pane");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to set catalog viewer preferences for attachments", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,327 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.text.MessageFormat;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Utility class for handling exceptions with internationalized error messages. Provides consistent
|
||||
* error handling and user-friendly messages across the application.
|
||||
*/
|
||||
@Slf4j
|
||||
public class ExceptionUtils {
|
||||
|
||||
/**
|
||||
* Create an IOException with internationalized message for PDF corruption.
|
||||
*
|
||||
* @param cause the original exception
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createPdfCorruptedException(Exception cause) {
|
||||
return createPdfCorruptedException(null, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOException with internationalized message for PDF corruption with context.
|
||||
*
|
||||
* @param context additional context (e.g., "during merge", "during image extraction")
|
||||
* @param cause the original exception
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createPdfCorruptedException(String context, Exception cause) {
|
||||
String message;
|
||||
if (context != null && !context.isEmpty()) {
|
||||
message =
|
||||
String.format(
|
||||
"Error %s: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.",
|
||||
context);
|
||||
} else {
|
||||
message =
|
||||
"PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.";
|
||||
}
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOException with internationalized message for multiple corrupted PDFs.
|
||||
*
|
||||
* @param cause the original exception
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createMultiplePdfCorruptedException(Exception cause) {
|
||||
String message =
|
||||
"One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them.";
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOException with internationalized message for PDF encryption issues.
|
||||
*
|
||||
* @param cause the original exception
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createPdfEncryptionException(Exception cause) {
|
||||
String message =
|
||||
"The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy.";
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOException with internationalized message for PDF password issues.
|
||||
*
|
||||
* @param cause the original exception
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createPdfPasswordException(Exception cause) {
|
||||
String message =
|
||||
"The PDF Document is passworded and either the password was not provided or was incorrect";
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOException with internationalized message for file processing errors.
|
||||
*
|
||||
* @param operation the operation being performed (e.g., "merge", "split", "convert")
|
||||
* @param cause the original exception
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createFileProcessingException(String operation, Exception cause) {
|
||||
String message =
|
||||
String.format(
|
||||
"An error occurred while processing the file during %s operation: %s",
|
||||
operation, cause.getMessage());
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic IOException with internationalized message.
|
||||
*
|
||||
* @param messageKey the i18n message key
|
||||
* @param defaultMessage the default message if i18n is not available
|
||||
* @param cause the original exception
|
||||
* @param args optional arguments for the message
|
||||
* @return IOException with user-friendly message
|
||||
*/
|
||||
public static IOException createIOException(
|
||||
String messageKey, String defaultMessage, Exception cause, Object... args) {
|
||||
String message = MessageFormat.format(defaultMessage, args);
|
||||
return new IOException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic RuntimeException with internationalized message.
|
||||
*
|
||||
* @param messageKey the i18n message key
|
||||
* @param defaultMessage the default message if i18n is not available
|
||||
* @param cause the original exception
|
||||
* @param args optional arguments for the message
|
||||
* @return RuntimeException with user-friendly message
|
||||
*/
|
||||
public static RuntimeException createRuntimeException(
|
||||
String messageKey, String defaultMessage, Exception cause, Object... args) {
|
||||
String message = MessageFormat.format(defaultMessage, args);
|
||||
return new RuntimeException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IllegalArgumentException with internationalized message.
|
||||
*
|
||||
* @param messageKey the i18n message key
|
||||
* @param defaultMessage the default message if i18n is not available
|
||||
* @param args optional arguments for the message
|
||||
* @return IllegalArgumentException with user-friendly message
|
||||
*/
|
||||
public static IllegalArgumentException createIllegalArgumentException(
|
||||
String messageKey, String defaultMessage, Object... args) {
|
||||
String message = MessageFormat.format(defaultMessage, args);
|
||||
return new IllegalArgumentException(message);
|
||||
}
|
||||
|
||||
/** Create file validation exceptions. */
|
||||
public static IllegalArgumentException createHtmlFileRequiredException() {
|
||||
return createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", "HTML or ZIP");
|
||||
}
|
||||
|
||||
public static IllegalArgumentException createPdfFileRequiredException() {
|
||||
return createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", "PDF");
|
||||
}
|
||||
|
||||
public static IllegalArgumentException createInvalidPageSizeException(String size) {
|
||||
return createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "page size", size);
|
||||
}
|
||||
|
||||
/** Create OCR-related exceptions. */
|
||||
public static IOException createOcrLanguageRequiredException() {
|
||||
return createIOException(
|
||||
"error.optionsNotSpecified", "{0} options are not specified", null, "OCR language");
|
||||
}
|
||||
|
||||
public static IOException createOcrInvalidLanguagesException() {
|
||||
return createIOException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
null,
|
||||
"OCR languages",
|
||||
"none of the selected languages are valid");
|
||||
}
|
||||
|
||||
public static IOException createOcrToolsUnavailableException() {
|
||||
return createIOException(
|
||||
"error.toolNotInstalled", "{0} is not installed", null, "OCR tools");
|
||||
}
|
||||
|
||||
/** Create system requirement exceptions. */
|
||||
public static IOException createPythonRequiredForWebpException() {
|
||||
return createIOException(
|
||||
"error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion");
|
||||
}
|
||||
|
||||
/** Create file operation exceptions. */
|
||||
public static IOException createFileNotFoundException(String fileId) {
|
||||
return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId);
|
||||
}
|
||||
|
||||
public static RuntimeException createPdfaConversionFailedException() {
|
||||
return createRuntimeException(
|
||||
"error.conversionFailed", "{0} conversion failed", null, "PDF/A");
|
||||
}
|
||||
|
||||
public static IllegalArgumentException createInvalidComparatorException() {
|
||||
return createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid {0} format: {1}",
|
||||
"comparator",
|
||||
"only 'greater', 'equal', and 'less' are supported");
|
||||
}
|
||||
|
||||
/** Create compression-related exceptions. */
|
||||
public static RuntimeException createMd5AlgorithmException(Exception cause) {
|
||||
return createRuntimeException(
|
||||
"error.algorithmNotAvailable", "{0} algorithm not available", cause, "MD5");
|
||||
}
|
||||
|
||||
public static IllegalArgumentException createCompressionOptionsException() {
|
||||
return createIllegalArgumentException(
|
||||
"error.optionsNotSpecified",
|
||||
"{0} options are not specified",
|
||||
"compression (expected output size and optimize level)");
|
||||
}
|
||||
|
||||
public static IOException createGhostscriptCompressionException() {
|
||||
return createIOException(
|
||||
"error.commandFailed", "{0} command failed", null, "Ghostscript compression");
|
||||
}
|
||||
|
||||
public static IOException createGhostscriptCompressionException(Exception cause) {
|
||||
return createIOException(
|
||||
"error.commandFailed", "{0} command failed", cause, "Ghostscript compression");
|
||||
}
|
||||
|
||||
public static IOException createQpdfCompressionException(Exception cause) {
|
||||
return createIOException("error.commandFailed", "{0} command failed", cause, "QPDF");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
|
||||
*
|
||||
* @param e the exception to check
|
||||
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
|
||||
* message
|
||||
*/
|
||||
public static IOException handlePdfException(IOException e) {
|
||||
return handlePdfException(e, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an exception indicates a corrupted PDF and wrap it with appropriate message.
|
||||
*
|
||||
* @param e the exception to check
|
||||
* @param context additional context for the error
|
||||
* @return the original exception if not PDF corruption, or a new IOException with user-friendly
|
||||
* message
|
||||
*/
|
||||
public static IOException handlePdfException(IOException e, String context) {
|
||||
if (PdfErrorUtils.isCorruptedPdfError(e)) {
|
||||
return createPdfCorruptedException(context, e);
|
||||
}
|
||||
|
||||
if (isEncryptionError(e)) {
|
||||
return createPdfEncryptionException(e);
|
||||
}
|
||||
|
||||
if (isPasswordError(e)) {
|
||||
return createPdfPasswordException(e);
|
||||
}
|
||||
|
||||
return e; // Return original exception if no specific handling needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an exception indicates a PDF encryption/decryption error.
|
||||
*
|
||||
* @param e the exception to check
|
||||
* @return true if it's an encryption error, false otherwise
|
||||
*/
|
||||
public static boolean isEncryptionError(IOException e) {
|
||||
String message = e.getMessage();
|
||||
if (message == null) return false;
|
||||
|
||||
return message.contains("BadPaddingException")
|
||||
|| message.contains("Given final block not properly padded")
|
||||
|| message.contains("AES initialization vector not fully read")
|
||||
|| message.contains("Failed to decrypt");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an exception indicates a PDF password error.
|
||||
*
|
||||
* @param e the exception to check
|
||||
* @return true if it's a password error, false otherwise
|
||||
*/
|
||||
public static boolean isPasswordError(IOException e) {
|
||||
String message = e.getMessage();
|
||||
if (message == null) return false;
|
||||
|
||||
return message.contains("password is incorrect")
|
||||
|| message.contains("Password is not provided")
|
||||
|| message.contains("PDF contains an encryption dictionary");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an exception with appropriate level based on its type.
|
||||
*
|
||||
* @param operation the operation being performed
|
||||
* @param e the exception that occurred
|
||||
*/
|
||||
public static void logException(String operation, Exception e) {
|
||||
if (PdfErrorUtils.isCorruptedPdfError(e)) {
|
||||
log.warn("PDF corruption detected during {}: {}", operation, e.getMessage());
|
||||
} else if (e instanceof IOException
|
||||
&& (isEncryptionError((IOException) e) || isPasswordError((IOException) e))) {
|
||||
log.info("PDF security issue during {}: {}", operation, e.getMessage());
|
||||
} else {
|
||||
log.error("Unexpected error during {}", operation, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create common validation exceptions. */
|
||||
public static IllegalArgumentException createInvalidArgumentException(String argumentName) {
|
||||
return createIllegalArgumentException(
|
||||
"error.invalidArgument", "Invalid argument: {0}", argumentName);
|
||||
}
|
||||
|
||||
public static IllegalArgumentException createInvalidArgumentException(
|
||||
String argumentName, String value) {
|
||||
return createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", argumentName, value);
|
||||
}
|
||||
|
||||
public static IllegalArgumentException createNullArgumentException(String argumentName) {
|
||||
return createIllegalArgumentException(
|
||||
"error.argumentRequired", "{0} must not be null", argumentName);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/** Utility class for detecting and handling PDF-related errors. */
|
||||
public class PdfErrorUtils {
|
||||
|
||||
/**
|
||||
* Checks if an IOException indicates a corrupted PDF file.
|
||||
*
|
||||
* @param e the IOException to check
|
||||
* @return true if the error indicates PDF corruption, false otherwise
|
||||
*/
|
||||
public static boolean isCorruptedPdfError(IOException e) {
|
||||
return isCorruptedPdfError(e.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any Exception indicates a corrupted PDF file.
|
||||
*
|
||||
* @param e the Exception to check
|
||||
* @return true if the error indicates PDF corruption, false otherwise
|
||||
*/
|
||||
public static boolean isCorruptedPdfError(Exception e) {
|
||||
return isCorruptedPdfError(e.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error message indicates a corrupted PDF file.
|
||||
*
|
||||
* @param message the error message to check
|
||||
* @return true if the message indicates PDF corruption, false otherwise
|
||||
*/
|
||||
private static boolean isCorruptedPdfError(String message) {
|
||||
if (message == null) return false;
|
||||
|
||||
// Check for common corruption indicators
|
||||
return message.contains("Missing root object specification")
|
||||
|| message.contains("Header doesn't contain versioninfo")
|
||||
|| message.contains("Expected trailer")
|
||||
|| message.contains("Invalid PDF")
|
||||
|| message.contains("Corrupted")
|
||||
|| message.contains("damaged")
|
||||
|| message.contains("Unknown dir object")
|
||||
|| message.contains("Can't dereference COSObject")
|
||||
|| message.contains("parseCOSString string should start with")
|
||||
|| message.contains("ICCBased colorspace array must have a stream")
|
||||
|| message.contains("1-based index not found")
|
||||
|| message.contains("Invalid dictionary, found:")
|
||||
|| message.contains("AES initialization vector not fully read")
|
||||
|| message.contains("BadPaddingException")
|
||||
|| message.contains("Given final block not properly padded")
|
||||
|| message.contains("End-of-File, expected line");
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Utility class to access Spring managed beans from non-Spring managed classes. This is especially
|
||||
* useful for classes that are instantiated by frameworks or created dynamically.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class SpringContextHolder implements ApplicationContextAware {
|
||||
|
||||
private static ApplicationContext applicationContext;
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
SpringContextHolder.applicationContext = applicationContext;
|
||||
log.debug("Spring context holder initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Spring bean by class type
|
||||
*
|
||||
* @param <T> The bean type
|
||||
* @param beanClass The bean class
|
||||
* @return The bean instance, or null if not found
|
||||
*/
|
||||
public static <T> T getBean(Class<T> beanClass) {
|
||||
if (applicationContext == null) {
|
||||
log.warn(
|
||||
"Application context not initialized when attempting to get bean of type {}",
|
||||
beanClass.getName());
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return applicationContext.getBean(beanClass);
|
||||
} catch (BeansException e) {
|
||||
log.error("Error getting bean of type {}: {}", beanClass.getName(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Spring bean by name
|
||||
*
|
||||
* @param <T> The bean type
|
||||
* @param beanName The bean name
|
||||
* @return The bean instance, or null if not found
|
||||
*/
|
||||
public static <T> T getBean(String beanName) {
|
||||
if (applicationContext == null) {
|
||||
log.warn(
|
||||
"Application context not initialized when attempting to get bean '{}'",
|
||||
beanName);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
T bean = (T) applicationContext.getBean(beanName);
|
||||
return bean;
|
||||
} catch (BeansException e) {
|
||||
log.error("Error getting bean '{}': {}", beanName, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the application context is initialized
|
||||
*
|
||||
* @return true if initialized, false otherwise
|
||||
*/
|
||||
public static boolean isInitialized() {
|
||||
return applicationContext != null;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* A wrapper class for a temporary directory that implements AutoCloseable. Can be used with
|
||||
* try-with-resources for automatic cleanup.
|
||||
*/
|
||||
@Slf4j
|
||||
public class TempDirectory implements AutoCloseable {
|
||||
|
||||
private final TempFileManager manager;
|
||||
private final Path directory;
|
||||
|
||||
public TempDirectory(TempFileManager manager) throws IOException {
|
||||
this.manager = manager;
|
||||
this.directory = manager.createTempDirectory();
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return directory;
|
||||
}
|
||||
|
||||
public String getAbsolutePath() {
|
||||
return directory.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
public boolean exists() {
|
||||
return java.nio.file.Files.exists(directory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
manager.deleteTempDirectory(directory);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TempDirectory{" + directory.toAbsolutePath() + "}";
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* A wrapper class for a temporary file that implements AutoCloseable. Can be used with
|
||||
* try-with-resources for automatic cleanup.
|
||||
*/
|
||||
@Slf4j
|
||||
public class TempFile implements AutoCloseable {
|
||||
|
||||
private final TempFileManager manager;
|
||||
private final File file;
|
||||
|
||||
public TempFile(TempFileManager manager, String suffix) throws IOException {
|
||||
this.manager = manager;
|
||||
this.file = manager.createTempFile(suffix);
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return file.toPath();
|
||||
}
|
||||
|
||||
public String getAbsolutePath() {
|
||||
return file.getAbsolutePath();
|
||||
}
|
||||
|
||||
public boolean exists() {
|
||||
return file.exists();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
manager.deleteTempFile(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TempFile{" + file.getAbsolutePath() + "}";
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Service for managing temporary files in Stirling-PDF. Provides methods for creating, tracking,
|
||||
* and cleaning up temporary files.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TempFileManager {
|
||||
|
||||
private final TempFileRegistry registry;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Create a temporary file with the Stirling-PDF prefix. The file is automatically registered
|
||||
* with the registry.
|
||||
*
|
||||
* @param suffix The suffix for the temporary file
|
||||
* @return The created temporary file
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
public File createTempFile(String suffix) throws IOException {
|
||||
ApplicationProperties.TempFileManagement tempFiles =
|
||||
applicationProperties.getSystem().getTempFileManagement();
|
||||
Path tempFilePath;
|
||||
String customTempDirectory = tempFiles.getBaseTmpDir();
|
||||
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
|
||||
Path tempDir = Path.of(customTempDirectory);
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
tempFilePath = Files.createTempFile(tempDir, tempFiles.getPrefix(), suffix);
|
||||
} else {
|
||||
tempFilePath = Files.createTempFile(tempFiles.getPrefix(), suffix);
|
||||
}
|
||||
File tempFile = tempFilePath.toFile();
|
||||
return registry.register(tempFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory with the Stirling-PDF prefix. The directory is automatically
|
||||
* registered with the registry.
|
||||
*
|
||||
* @return The created temporary directory
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
public Path createTempDirectory() throws IOException {
|
||||
ApplicationProperties.TempFileManagement tempFiles =
|
||||
applicationProperties.getSystem().getTempFileManagement();
|
||||
Path tempDirPath;
|
||||
String customTempDirectory = tempFiles.getBaseTmpDir();
|
||||
if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
|
||||
Path tempDir = Path.of(customTempDirectory);
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
tempDirPath = Files.createTempDirectory(tempDir, tempFiles.getPrefix());
|
||||
} else {
|
||||
tempDirPath = Files.createTempDirectory(tempFiles.getPrefix());
|
||||
}
|
||||
return registry.registerDirectory(tempDirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a MultipartFile to a temporary File and register it. This is a wrapper around
|
||||
* GeneralUtils.convertMultipartFileToFile that ensures the created temp file is registered.
|
||||
*
|
||||
* @param multipartFile The MultipartFile to convert
|
||||
* @return The created temporary file
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
public File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||
File tempFile = GeneralUtils.convertMultipartFileToFile(multipartFile);
|
||||
return registry.register(tempFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a temporary file and unregister it from the registry.
|
||||
*
|
||||
* @param file The file to delete
|
||||
* @return true if the file was deleted successfully, false otherwise
|
||||
*/
|
||||
public boolean deleteTempFile(File file) {
|
||||
if (file != null && file.exists()) {
|
||||
boolean deleted = file.delete();
|
||||
if (deleted) {
|
||||
registry.unregister(file);
|
||||
log.debug("Deleted temp file: {}", file.getAbsolutePath());
|
||||
} else {
|
||||
log.warn("Failed to delete temp file: {}", file.getAbsolutePath());
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a temporary file and unregister it from the registry.
|
||||
*
|
||||
* @param path The path to delete
|
||||
* @return true if the file was deleted successfully, false otherwise
|
||||
*/
|
||||
public boolean deleteTempFile(Path path) {
|
||||
if (path != null) {
|
||||
try {
|
||||
boolean deleted = Files.deleteIfExists(path);
|
||||
if (deleted) {
|
||||
registry.unregister(path);
|
||||
log.debug("Deleted temp file: {}", path.toString());
|
||||
} else {
|
||||
log.debug("Temp file already deleted or does not exist: {}", path.toString());
|
||||
}
|
||||
return deleted;
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp file: {}", path.toString(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a temporary directory and all its contents.
|
||||
*
|
||||
* @param directory The directory to delete
|
||||
*/
|
||||
public void deleteTempDirectory(Path directory) {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
try {
|
||||
GeneralUtils.deleteDirectory(directory);
|
||||
log.debug("Deleted temp directory: {}", directory.toString());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp directory: {}", directory.toString(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an existing file with the registry.
|
||||
*
|
||||
* @param file The file to register
|
||||
* @return The same file for method chaining
|
||||
*/
|
||||
public File register(File file) {
|
||||
if (file != null && file.exists()) {
|
||||
return registry.register(file);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old temporary files based on age.
|
||||
*
|
||||
* @param maxAgeMillis Maximum age in milliseconds for temp files
|
||||
* @return Number of files deleted
|
||||
*/
|
||||
public int cleanupOldTempFiles(long maxAgeMillis) {
|
||||
int deletedCount = 0;
|
||||
|
||||
// Get files older than max age
|
||||
Set<Path> oldFiles = registry.getFilesOlderThan(maxAgeMillis);
|
||||
|
||||
// Delete each old file
|
||||
for (Path file : oldFiles) {
|
||||
if (deleteTempFile(file)) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Cleaned up {} old temporary files", deletedCount);
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum age for temporary files in milliseconds.
|
||||
*
|
||||
* @return Maximum age in milliseconds
|
||||
*/
|
||||
public long getMaxAgeMillis() {
|
||||
long maxAgeHours =
|
||||
applicationProperties.getSystem().getTempFileManagement().getMaxAgeHours();
|
||||
return Duration.ofHours(maxAgeHours).toMillis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique temporary file name with the Stirling-PDF prefix.
|
||||
*
|
||||
* @param type Type identifier for the temp file
|
||||
* @param extension File extension (without the dot)
|
||||
* @return A unique temporary file name
|
||||
*/
|
||||
public String generateTempFileName(String type, String extension) {
|
||||
String tempFilePrefix =
|
||||
applicationProperties.getSystem().getTempFileManagement().getPrefix();
|
||||
String uuid = UUID.randomUUID().toString().substring(0, 8);
|
||||
return tempFilePrefix + type + "-" + uuid + "." + extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a known LibreOffice temporary directory. This is used when integrating with
|
||||
* LibreOffice for file conversions.
|
||||
*
|
||||
* @return The LibreOffice temp directory
|
||||
* @throws IOException If directory creation fails
|
||||
*/
|
||||
public Path registerLibreOfficeTempDir() throws IOException {
|
||||
ApplicationProperties.TempFileManagement tempFiles =
|
||||
applicationProperties.getSystem().getTempFileManagement();
|
||||
Path loTempDir;
|
||||
String libreOfficeTempDir = tempFiles.getLibreofficeDir();
|
||||
String customTempDirectory = tempFiles.getBaseTmpDir();
|
||||
|
||||
// First check if explicitly configured
|
||||
if (libreOfficeTempDir != null && !libreOfficeTempDir.isEmpty()) {
|
||||
loTempDir = Path.of(libreOfficeTempDir);
|
||||
}
|
||||
// Next check if we have a custom temp directory
|
||||
else if (customTempDirectory != null && !customTempDirectory.isEmpty()) {
|
||||
loTempDir = Path.of(customTempDirectory, "libreoffice");
|
||||
}
|
||||
// Fall back to system temp dir with our application prefix
|
||||
else {
|
||||
loTempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf-libreoffice");
|
||||
}
|
||||
|
||||
if (!Files.exists(loTempDir)) {
|
||||
Files.createDirectories(loTempDir);
|
||||
}
|
||||
|
||||
return registry.registerDirectory(loTempDir);
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Central registry for tracking temporary files created by Stirling-PDF. Maintains a thread-safe
|
||||
* collection of paths with their creation timestamps.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TempFileRegistry {
|
||||
|
||||
private final ConcurrentMap<Path, Instant> registeredFiles = new ConcurrentHashMap<>();
|
||||
private final Set<Path> thirdPartyTempFiles =
|
||||
Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
private final Set<Path> tempDirectories = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
/**
|
||||
* Register a temporary file with the registry.
|
||||
*
|
||||
* @param file The temporary file to track
|
||||
* @return The same file for method chaining
|
||||
*/
|
||||
public File register(File file) {
|
||||
if (file != null) {
|
||||
registeredFiles.put(file.toPath(), Instant.now());
|
||||
log.debug("Registered temp file: {}", file.getAbsolutePath());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a temporary path with the registry.
|
||||
*
|
||||
* @param path The temporary path to track
|
||||
* @return The same path for method chaining
|
||||
*/
|
||||
public Path register(Path path) {
|
||||
if (path != null) {
|
||||
registeredFiles.put(path, Instant.now());
|
||||
log.debug("Registered temp path: {}", path.toString());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a temporary directory to be cleaned up.
|
||||
*
|
||||
* @param directory Directory to register
|
||||
* @return The same directory for method chaining
|
||||
*/
|
||||
public Path registerDirectory(Path directory) {
|
||||
if (directory != null && Files.isDirectory(directory)) {
|
||||
tempDirectories.add(directory);
|
||||
log.debug("Registered temp directory: {}", directory.toString());
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a third-party temporary file that requires special handling.
|
||||
*
|
||||
* @param file The third-party temp file
|
||||
* @return The same file for method chaining
|
||||
*/
|
||||
public File registerThirdParty(File file) {
|
||||
if (file != null) {
|
||||
thirdPartyTempFiles.add(file.toPath());
|
||||
log.debug("Registered third-party temp file: {}", file.getAbsolutePath());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a file from the registry.
|
||||
*
|
||||
* @param file The file to unregister
|
||||
*/
|
||||
public void unregister(File file) {
|
||||
if (file != null) {
|
||||
registeredFiles.remove(file.toPath());
|
||||
thirdPartyTempFiles.remove(file.toPath());
|
||||
log.debug("Unregistered temp file: {}", file.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a path from the registry.
|
||||
*
|
||||
* @param path The path to unregister
|
||||
*/
|
||||
public void unregister(Path path) {
|
||||
if (path != null) {
|
||||
registeredFiles.remove(path);
|
||||
thirdPartyTempFiles.remove(path);
|
||||
log.debug("Unregistered temp path: {}", path.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered temporary files.
|
||||
*
|
||||
* @return Set of registered file paths
|
||||
*/
|
||||
public Set<Path> getAllRegisteredFiles() {
|
||||
return registeredFiles.keySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get temporary files older than the specified duration in milliseconds.
|
||||
*
|
||||
* @param maxAgeMillis Maximum age in milliseconds
|
||||
* @return Set of paths older than the specified age
|
||||
*/
|
||||
public Set<Path> getFilesOlderThan(long maxAgeMillis) {
|
||||
Instant cutoffTime = Instant.now().minusMillis(maxAgeMillis);
|
||||
return registeredFiles.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().isBefore(cutoffTime))
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered third-party temporary files.
|
||||
*
|
||||
* @return Set of third-party file paths
|
||||
*/
|
||||
public Set<Path> getThirdPartyTempFiles() {
|
||||
return thirdPartyTempFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered temporary directories.
|
||||
*
|
||||
* @return Set of temporary directory paths
|
||||
*/
|
||||
public Set<Path> getTempDirectories() {
|
||||
return tempDirectories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is registered in the registry.
|
||||
*
|
||||
* @param file The file to check
|
||||
* @return True if the file is registered, false otherwise
|
||||
*/
|
||||
public boolean contains(File file) {
|
||||
if (file == null) {
|
||||
return false;
|
||||
}
|
||||
Path path = file.toPath();
|
||||
return registeredFiles.containsKey(path) || thirdPartyTempFiles.contains(path);
|
||||
}
|
||||
|
||||
/** Clear all registry data. */
|
||||
public void clear() {
|
||||
registeredFiles.clear();
|
||||
thirdPartyTempFiles.clear();
|
||||
tempDirectories.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
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.function.Function;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Utility class for handling temporary files with proper cleanup. Provides helper methods and
|
||||
* wrappers to ensure temp files are properly cleaned up.
|
||||
*/
|
||||
@Slf4j
|
||||
public class TempFileUtil {
|
||||
|
||||
/**
|
||||
* A collection of temporary files that implements AutoCloseable. All files in the collection
|
||||
* are cleaned up when close() is called.
|
||||
*/
|
||||
public static class TempFileCollection implements AutoCloseable {
|
||||
private final TempFileManager manager;
|
||||
private final List<File> tempFiles = new ArrayList<>();
|
||||
|
||||
public TempFileCollection(TempFileManager manager) {
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
public File addTempFile(String suffix) throws IOException {
|
||||
File file = manager.createTempFile(suffix);
|
||||
tempFiles.add(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
public List<File> getFiles() {
|
||||
return new ArrayList<>(tempFiles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (File file : tempFiles) {
|
||||
manager.deleteTempFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with a temporary file, ensuring cleanup in a finally block.
|
||||
*
|
||||
* @param <R> The return type of the function
|
||||
* @param tempFileManager The temp file manager
|
||||
* @param suffix File suffix (e.g., ".pdf")
|
||||
* @param function The function to execute with the temp file
|
||||
* @return The result of the function
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
public static <R> R withTempFile(
|
||||
TempFileManager tempFileManager, String suffix, Function<File, R> function)
|
||||
throws IOException {
|
||||
File tempFile = tempFileManager.createTempFile(suffix);
|
||||
try {
|
||||
return function.apply(tempFile);
|
||||
} finally {
|
||||
tempFileManager.deleteTempFile(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with multiple temporary files, ensuring cleanup in a finally block.
|
||||
*
|
||||
* @param <R> The return type of the function
|
||||
* @param tempFileManager The temp file manager
|
||||
* @param count Number of temp files to create
|
||||
* @param suffix File suffix (e.g., ".pdf")
|
||||
* @param function The function to execute with the temp files
|
||||
* @return The result of the function
|
||||
* @throws IOException If an I/O error occurs
|
||||
*/
|
||||
public static <R> R withMultipleTempFiles(
|
||||
TempFileManager tempFileManager,
|
||||
int count,
|
||||
String suffix,
|
||||
Function<List<File>, R> function)
|
||||
throws IOException {
|
||||
List<File> tempFiles = new ArrayList<>(count);
|
||||
try {
|
||||
for (int i = 0; i < count; i++) {
|
||||
tempFiles.add(tempFileManager.createTempFile(suffix));
|
||||
}
|
||||
return function.apply(tempFiles);
|
||||
} finally {
|
||||
for (File file : tempFiles) {
|
||||
tempFileManager.deleteTempFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely delete a list of temporary files, logging any errors.
|
||||
*
|
||||
* @param files The list of files to delete
|
||||
*/
|
||||
public static void safeDeleteFiles(List<Path> files) {
|
||||
if (files == null) return;
|
||||
|
||||
for (Path file : files) {
|
||||
if (file == null) continue;
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(file);
|
||||
log.debug("Deleted temp file: {}", file);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp file: {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an already created temp file with the registry. Use this for files created outside
|
||||
* of TempFileManager.
|
||||
*
|
||||
* @param tempFileManager The temp file manager
|
||||
* @param file The file to register
|
||||
* @return The registered file
|
||||
*/
|
||||
public static File registerExistingTempFile(TempFileManager tempFileManager, File file) {
|
||||
if (tempFileManager != null && file != null && file.exists()) {
|
||||
return tempFileManager.register(file);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
public class ValidationUtil {
|
||||
|
||||
public static boolean isStringEmpty(String input) {
|
||||
return input == null || input.isBlank();
|
||||
}
|
||||
|
||||
public static boolean isCollectionEmpty(Collection<String> input) {
|
||||
return input == null || input.isEmpty();
|
||||
}
|
||||
}
|
||||
-464
@@ -1,464 +0,0 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
/**
|
||||
* Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic.
|
||||
*/
|
||||
public class TempFileCleanupServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Mock
|
||||
private TempFileRegistry registry;
|
||||
|
||||
@Mock
|
||||
private TempFileManager tempFileManager;
|
||||
|
||||
@Mock
|
||||
private ApplicationProperties applicationProperties;
|
||||
|
||||
@Mock
|
||||
private ApplicationProperties.System system;
|
||||
|
||||
@Mock
|
||||
private ApplicationProperties.TempFileManagement tempFileManagement;
|
||||
|
||||
@InjectMocks
|
||||
private TempFileCleanupService cleanupService;
|
||||
|
||||
private Path systemTempDir;
|
||||
private Path customTempDir;
|
||||
private Path libreOfficeTempDir;
|
||||
|
||||
@BeforeEach
|
||||
public void setup() throws IOException {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
|
||||
// Create test directories
|
||||
systemTempDir = tempDir.resolve("systemTemp");
|
||||
customTempDir = tempDir.resolve("customTemp");
|
||||
libreOfficeTempDir = tempDir.resolve("libreOfficeTemp");
|
||||
|
||||
Files.createDirectories(systemTempDir);
|
||||
Files.createDirectories(customTempDir);
|
||||
Files.createDirectories(libreOfficeTempDir);
|
||||
|
||||
// Configure ApplicationProperties mocks
|
||||
when(applicationProperties.getSystem()).thenReturn(system);
|
||||
when(system.getTempFileManagement()).thenReturn(tempFileManagement);
|
||||
when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString());
|
||||
when(tempFileManagement.getLibreofficeDir()).thenReturn(libreOfficeTempDir.toString());
|
||||
when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString());
|
||||
when(tempFileManagement.isStartupCleanup()).thenReturn(false);
|
||||
when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false);
|
||||
when(tempFileManagement.getCleanupIntervalMinutes()).thenReturn(30L);
|
||||
|
||||
// Set machineType using reflection (still needed for this field)
|
||||
ReflectionTestUtils.setField(cleanupService, "machineType", "Standard");
|
||||
|
||||
when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L); // 1 hour
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testScheduledCleanup_RegisteredFiles() {
|
||||
// Arrange
|
||||
when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(5); // 5 files deleted
|
||||
Set<Path> registeredDirs = new HashSet<>();
|
||||
registeredDirs.add(tempDir.resolve("registeredDir"));
|
||||
when(registry.getTempDirectories()).thenReturn(registeredDirs);
|
||||
|
||||
// Act
|
||||
cleanupService.scheduledCleanup();
|
||||
|
||||
// Assert
|
||||
verify(tempFileManager).cleanupOldTempFiles(anyLong());
|
||||
verify(registry, times(1)).getTempDirectories();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCleanupTempFilePatterns() throws IOException {
|
||||
// Arrange - Create various temp files
|
||||
Path ourTempFile1 = Files.createFile(systemTempDir.resolve("output_123.pdf"));
|
||||
Path ourTempFile2 = Files.createFile(systemTempDir.resolve("compressedPDF456.pdf"));
|
||||
Path ourTempFile3 = Files.createFile(customTempDir.resolve("stirling-pdf-789.tmp"));
|
||||
Path ourTempFile4 = Files.createFile(customTempDir.resolve("pdf-save-123-456.tmp"));
|
||||
Path ourTempFile5 = Files.createFile(libreOfficeTempDir.resolve("input_file.pdf"));
|
||||
|
||||
// Old temporary files
|
||||
Path oldTempFile = Files.createFile(systemTempDir.resolve("output_old.pdf"));
|
||||
|
||||
// System temp files that should be cleaned in container mode
|
||||
Path sysTempFile1 = Files.createFile(systemTempDir.resolve("lu123abc.tmp"));
|
||||
Path sysTempFile2 = Files.createFile(customTempDir.resolve("ocr_process123"));
|
||||
Path sysTempFile3 = Files.createFile(customTempDir.resolve("tmp_upload.tmp"));
|
||||
|
||||
// Files that should be preserved
|
||||
Path jettyFile1 = Files.createFile(systemTempDir.resolve("jetty-123.tmp"));
|
||||
Path jettyFile2 = Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp"));
|
||||
Path regularFile = Files.createFile(systemTempDir.resolve("important.txt"));
|
||||
|
||||
// Create a nested directory with temp files
|
||||
Path nestedDir = Files.createDirectories(systemTempDir.resolve("nested"));
|
||||
Path nestedTempFile = Files.createFile(nestedDir.resolve("output_nested.pdf"));
|
||||
|
||||
// Empty file (special case)
|
||||
Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp"));
|
||||
|
||||
// Configure mock registry to say these files aren't registered
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// The set of files that will be deleted in our test
|
||||
Set<Path> deletedFiles = new HashSet<>();
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Mock Files.list for each directory we'll process
|
||||
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
|
||||
.thenReturn(Stream.of(
|
||||
ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1,
|
||||
jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(customTempDir)))
|
||||
.thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir)))
|
||||
.thenReturn(Stream.of(ourTempFile5));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(nestedDir)))
|
||||
.thenReturn(Stream.of(nestedTempFile));
|
||||
|
||||
// Configure Files.isDirectory for each path
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true);
|
||||
mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false);
|
||||
|
||||
// Configure Files.exists to return true for all paths
|
||||
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
|
||||
// Configure Files.getLastModifiedTime to return different times based on file names
|
||||
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
// For files with "old" in the name, return a timestamp older than maxAgeMillis
|
||||
if (fileName.contains("old")) {
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 5000000);
|
||||
}
|
||||
// For empty.tmp file, return a timestamp older than 5 minutes (for empty file test)
|
||||
else if (fileName.equals("empty.tmp")) {
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000);
|
||||
}
|
||||
// For all other files, return a recent timestamp
|
||||
else {
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 60000); // 1 minute ago
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Files.size to return different sizes based on file names
|
||||
mockedFiles.when(() -> Files.size(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
// Return 0 bytes for the empty file
|
||||
if (fileName.equals("empty.tmp")) {
|
||||
return 0L;
|
||||
}
|
||||
// Return normal size for all other files
|
||||
else {
|
||||
return 1024L; // 1 KB
|
||||
}
|
||||
});
|
||||
|
||||
// For deleteIfExists, track which files would be deleted
|
||||
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
deletedFiles.add(path);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act - set containerMode to false for this test
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
|
||||
invokeCleanupDirectoryStreaming(customTempDir, false, 0, 3600000);
|
||||
invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 0, 3600000);
|
||||
|
||||
// Assert - Only old temp files and empty files should be deleted
|
||||
assertTrue(deletedFiles.contains(oldTempFile), "Old temp file should be deleted");
|
||||
assertTrue(deletedFiles.contains(emptyFile), "Empty file should be deleted");
|
||||
|
||||
// Regular temp files should not be deleted because they're too new
|
||||
assertFalse(deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved");
|
||||
assertFalse(deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved");
|
||||
assertFalse(deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved");
|
||||
assertFalse(deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved");
|
||||
assertFalse(deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved");
|
||||
|
||||
// System temp files should not be deleted in non-container mode
|
||||
assertFalse(deletedFiles.contains(sysTempFile1), "System temp file should be preserved in non-container mode");
|
||||
assertFalse(deletedFiles.contains(sysTempFile2), "System temp file should be preserved in non-container mode");
|
||||
assertFalse(deletedFiles.contains(sysTempFile3), "System temp file should be preserved in non-container mode");
|
||||
|
||||
// Jetty files and regular files should never be deleted
|
||||
assertFalse(deletedFiles.contains(jettyFile1), "Jetty file should be preserved");
|
||||
assertFalse(deletedFiles.contains(jettyFile2), "File with jetty in name should be preserved");
|
||||
assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testContainerModeCleanup() throws IOException {
|
||||
// Arrange - Create various temp files
|
||||
Path ourTempFile = Files.createFile(systemTempDir.resolve("output_123.pdf"));
|
||||
Path sysTempFile = Files.createFile(systemTempDir.resolve("lu123abc.tmp"));
|
||||
Path regularFile = Files.createFile(systemTempDir.resolve("important.txt"));
|
||||
|
||||
// Configure mock registry to say these files aren't registered
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// The set of files that will be deleted in our test
|
||||
Set<Path> deletedFiles = new HashSet<>();
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Mock Files.list for systemTempDir
|
||||
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
|
||||
.thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile));
|
||||
|
||||
// Configure Files.isDirectory
|
||||
mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false);
|
||||
|
||||
// Configure Files.exists
|
||||
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
|
||||
// Configure Files.getLastModifiedTime to return recent timestamps
|
||||
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
.thenReturn(FileTime.fromMillis(System.currentTimeMillis() - 60000)); // 1 minute ago
|
||||
|
||||
// Configure Files.size to return normal size
|
||||
mockedFiles.when(() -> Files.size(any(Path.class)))
|
||||
.thenReturn(1024L); // 1 KB
|
||||
|
||||
// For deleteIfExists, track which files would be deleted
|
||||
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
deletedFiles.add(path);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0);
|
||||
|
||||
// Assert - In container mode, both our temp files and system temp files should be deleted
|
||||
// regardless of age (when maxAgeMillis is 0)
|
||||
assertTrue(deletedFiles.contains(ourTempFile), "Our temp file should be deleted in container mode");
|
||||
assertTrue(deletedFiles.contains(sysTempFile), "System temp file should be deleted in container mode");
|
||||
assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmptyFileHandling() throws IOException {
|
||||
// Arrange - Create an empty file
|
||||
Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp"));
|
||||
Path recentEmptyFile = Files.createFile(systemTempDir.resolve("recent_empty.tmp"));
|
||||
|
||||
// Configure mock registry to say these files aren't registered
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// The set of files that will be deleted in our test
|
||||
Set<Path> deletedFiles = new HashSet<>();
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Mock Files.list for systemTempDir
|
||||
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
|
||||
.thenReturn(Stream.of(emptyFile, recentEmptyFile));
|
||||
|
||||
// Configure Files.isDirectory
|
||||
mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false);
|
||||
|
||||
// Configure Files.exists
|
||||
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
|
||||
// Configure Files.getLastModifiedTime to return different times based on file names
|
||||
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (fileName.equals("empty.tmp")) {
|
||||
// More than 5 minutes old
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000);
|
||||
} else {
|
||||
// Less than 5 minutes old
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 2 * 60 * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Files.size to return 0 for empty files
|
||||
mockedFiles.when(() -> Files.size(any(Path.class)))
|
||||
.thenReturn(0L);
|
||||
|
||||
// For deleteIfExists, track which files would be deleted
|
||||
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
deletedFiles.add(path);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
|
||||
|
||||
// Assert
|
||||
assertTrue(deletedFiles.contains(emptyFile),
|
||||
"Empty file older than 5 minutes should be deleted");
|
||||
assertFalse(deletedFiles.contains(recentEmptyFile),
|
||||
"Empty file newer than 5 minutes should not be deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRecursiveDirectoryCleaning() throws IOException {
|
||||
// Arrange - Create a nested directory structure with temp files
|
||||
Path dir1 = Files.createDirectories(systemTempDir.resolve("dir1"));
|
||||
Path dir2 = Files.createDirectories(dir1.resolve("dir2"));
|
||||
Path dir3 = Files.createDirectories(dir2.resolve("dir3"));
|
||||
|
||||
Path tempFile1 = Files.createFile(dir1.resolve("output_1.pdf"));
|
||||
Path tempFile2 = Files.createFile(dir2.resolve("output_2.pdf"));
|
||||
Path tempFile3 = Files.createFile(dir3.resolve("output_old_3.pdf"));
|
||||
|
||||
// Configure mock registry to say these files aren't registered
|
||||
when(registry.contains(any(File.class))).thenReturn(false);
|
||||
|
||||
// The set of files that will be deleted in our test
|
||||
Set<Path> deletedFiles = new HashSet<>();
|
||||
|
||||
// Use MockedStatic to mock Files operations
|
||||
try (MockedStatic<Files> mockedFiles = mockStatic(Files.class)) {
|
||||
// Mock Files.list for each directory
|
||||
mockedFiles.when(() -> Files.list(eq(systemTempDir)))
|
||||
.thenReturn(Stream.of(dir1));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir1)))
|
||||
.thenReturn(Stream.of(tempFile1, dir2));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir2)))
|
||||
.thenReturn(Stream.of(tempFile2, dir3));
|
||||
|
||||
mockedFiles.when(() -> Files.list(eq(dir3)))
|
||||
.thenReturn(Stream.of(tempFile3));
|
||||
|
||||
// Configure Files.isDirectory for each path
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true);
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(dir2))).thenReturn(true);
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(dir3))).thenReturn(true);
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(tempFile1))).thenReturn(false);
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(tempFile2))).thenReturn(false);
|
||||
mockedFiles.when(() -> Files.isDirectory(eq(tempFile3))).thenReturn(false);
|
||||
|
||||
// Configure Files.exists to return true for all paths
|
||||
mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true);
|
||||
|
||||
// Configure Files.getLastModifiedTime to return different times based on file names
|
||||
mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
String fileName = path.getFileName().toString();
|
||||
|
||||
if (fileName.contains("old")) {
|
||||
// Old file
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 5000000);
|
||||
} else {
|
||||
// Recent file
|
||||
return FileTime.fromMillis(System.currentTimeMillis() - 60000);
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Files.size to return normal size
|
||||
mockedFiles.when(() -> Files.size(any(Path.class)))
|
||||
.thenReturn(1024L);
|
||||
|
||||
// For deleteIfExists, track which files would be deleted
|
||||
mockedFiles.when(() -> Files.deleteIfExists(any(Path.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Path path = invocation.getArgument(0);
|
||||
deletedFiles.add(path);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Act
|
||||
invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000);
|
||||
|
||||
// Debug - print what was deleted
|
||||
System.out.println("Deleted files: " + deletedFiles);
|
||||
System.out.println("Looking for: " + tempFile3);
|
||||
|
||||
// Assert
|
||||
assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved");
|
||||
assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved");
|
||||
assertTrue(deletedFiles.contains(tempFile3), "Old temp file in nested directory should be deleted");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to invoke the private cleanupDirectoryStreaming method using reflection
|
||||
*/
|
||||
private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis)
|
||||
throws IOException {
|
||||
try {
|
||||
// Create a consumer that tracks deleted files
|
||||
AtomicInteger deleteCount = new AtomicInteger(0);
|
||||
Consumer<Path> deleteCallback = path -> deleteCount.incrementAndGet();
|
||||
|
||||
// Get the method with updated signature
|
||||
var method = TempFileCleanupService.class.getDeclaredMethod(
|
||||
"cleanupDirectoryStreaming",
|
||||
Path.class, boolean.class, int.class, long.class, boolean.class, Consumer.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
// Invoke the method with appropriate parameters
|
||||
method.invoke(cleanupService, directory, containerMode, depth, maxAgeMillis, false, deleteCallback);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Matcher for exact path equality
|
||||
private static Path eq(Path path) {
|
||||
return argThat(arg -> arg != null && arg.equals(path));
|
||||
}
|
||||
}
|
||||
@@ -1,893 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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 org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@DisplayName("EML to PDF Conversion tests")
|
||||
class EmlToPdfTest {
|
||||
|
||||
// Focus on testing EML to HTML conversion functionality since the PDF conversion relies on WeasyPrint
|
||||
// But HTML to PDF conversion is also briefly tested at PdfConversionTests class.
|
||||
private void testEmailConversion(String emlContent, String[] expectedContent, boolean includeAttachments) throws IOException {
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = includeAttachments ? createRequestWithAttachments() : createBasicRequest();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
for (String expected : expectedContent) {
|
||||
assertTrue(htmlResult.contains(expected), "HTML should contain: " + expected);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Core EML Parsing")
|
||||
class CoreParsingTests {
|
||||
@Test
|
||||
@DisplayName("Should parse simple text email correctly")
|
||||
void parseSimpleTextEmail() throws IOException {
|
||||
String emlContent = createSimpleTextEmail(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Simple Test Subject",
|
||||
"This is a simple plain text email body.");
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"Simple Test Subject",
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"This is a simple plain text email body",
|
||||
"<!DOCTYPE html>"
|
||||
}, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should parse email with missing Subject and To headers")
|
||||
void parseEmailWithMissingHeaders() throws IOException {
|
||||
String emlContent = createEmailWithCustomHeaders();
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains("sender@example.com"));
|
||||
assertTrue(htmlResult.contains("This is an email body"));
|
||||
assertTrue(htmlResult.contains("<title></title>") ||
|
||||
htmlResult.contains("<title>No Subject</title>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should parse HTML email with styling")
|
||||
void parseHtmlEmailWithStyling() throws IOException {
|
||||
String htmlBody = "<html><head><style>.header{color:blue;font-weight:bold;}" +
|
||||
".content{margin:10px;}.footer{font-size:12px;}</style></head>" +
|
||||
"<body><div class=\"header\">Important Notice</div>" +
|
||||
"<div class=\"content\">This is <strong>HTML content</strong> with styling.</div>" +
|
||||
"<div class=\"footer\">Best regards</div></body></html>";
|
||||
|
||||
String emlContent = createHtmlEmail(
|
||||
"html@example.com", "user@example.com", "HTML Email Test", htmlBody);
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"HTML Email Test",
|
||||
"Important Notice",
|
||||
"HTML content",
|
||||
"font-weight: bold"
|
||||
}, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should parse multipart email with attachments")
|
||||
void parseMultipartEmailWithAttachments() throws IOException {
|
||||
String boundary = "----=_Part_" + getTimestamp();
|
||||
String emlContent = createMultipartEmailWithAttachment(
|
||||
"multipart@example.com",
|
||||
"user@example.com",
|
||||
"Multipart Email Test",
|
||||
"This email has both text content and an attachment.",
|
||||
boundary,
|
||||
"document.txt",
|
||||
"Sample attachment content");
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"Multipart Email Test",
|
||||
"This email has both text content"
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Email Encoding Support")
|
||||
class EncodingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle international characters and UTF-8")
|
||||
void handleInternationalCharacters() throws IOException {
|
||||
String bodyWithIntlChars = "Hello! 你好 Привет مرحبا Hëllö Thañks! Önë Mörë";
|
||||
String emlContent = createSimpleTextEmail(
|
||||
"intl@example.com",
|
||||
"user@example.com",
|
||||
"International Characters Test",
|
||||
bodyWithIntlChars);
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"你好", "Привет", "مرحبا", "Hëllö", "Önë", "Mörë"
|
||||
}, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should decode quoted-printable content correctly")
|
||||
void decodeQuotedPrintableContent() throws IOException {
|
||||
String content = createQuotedPrintableEmail(
|
||||
);
|
||||
|
||||
testEmailConversion(content, new String[] {
|
||||
"Quoted-Printable Test",
|
||||
"This is quoted printable content with special chars: éàè."
|
||||
}, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should decode Base64 content")
|
||||
void decodeBase64Content() throws IOException {
|
||||
String originalText = "This is Base64 encoded content: éàü ñ";
|
||||
String content = createBase64Email(
|
||||
originalText);
|
||||
|
||||
testEmailConversion(content, new String[] {
|
||||
"Base64 Test", "Base64 encoded content"
|
||||
}, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should correctly handle inline images with CID references")
|
||||
void handleInlineImages() throws IOException {
|
||||
String boundary = "----=_Part_CID_1234567890";
|
||||
String cid = "image123@example.com";
|
||||
String htmlBody = "<html><body><p>Here is an image:</p><img src=\"cid:" + cid + "\"></body></html>";
|
||||
String imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
|
||||
|
||||
String emlContent = createEmailWithInlineImage(htmlBody, boundary, cid, imageBase64);
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createRequestWithAttachments();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains("data:image/png;base64," + imageBase64));
|
||||
assertFalse(htmlResult.contains("cid:" + cid));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("HTML Output Quality")
|
||||
class HtmlOutputTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate valid HTML structure")
|
||||
void generateValidHtmlStructure() throws IOException {
|
||||
String emlContent = createSimpleTextEmail(
|
||||
"structure@test.com",
|
||||
"user@test.com",
|
||||
"HTML Structure Test",
|
||||
"Testing HTML structure output");
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"<!DOCTYPE html>", "<html", "</html>", "HTML Structure Test"
|
||||
}, false);
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, createBasicRequest());
|
||||
assertTrue(htmlResult.length() > 100, "HTML should have substantial content");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve safe CSS and remove problematic styles")
|
||||
void handleCssStylesCorrectly() throws IOException {
|
||||
String styledHtml = "<html><head><style>" +
|
||||
".safe { color: blue; font-size: 14px; }" +
|
||||
".problematic { position: fixed; word-break: break-all; }" +
|
||||
".good { margin: 10px; padding: 5px; }" +
|
||||
"</style></head><body>" +
|
||||
"<div class=\"safe\">Safe styling</div>" +
|
||||
"<div class=\"problematic\">Problematic styling</div>" +
|
||||
"<div class=\"good\">Good styling</div>" +
|
||||
"</body></html>";
|
||||
|
||||
String emlContent = createHtmlEmail("css@test.com", "user@test.com", "CSS Test", styledHtml);
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains("color: blue"));
|
||||
assertTrue(htmlResult.contains("font-size: 14px"));
|
||||
assertTrue(htmlResult.contains("margin: 10px"));
|
||||
|
||||
assertFalse(htmlResult.contains("position: fixed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle complex nested HTML structures")
|
||||
void handleComplexNestedHtml() throws IOException {
|
||||
String complexHtml = "<html><head><title>Complex Email</title></head><body>" +
|
||||
"<div class=\"container\"><header><h1>Email Header</h1></header><main><section>" +
|
||||
"<p>Paragraph with <a href=\"https://example.com\">link</a></p><ul>" +
|
||||
"<li>List item 1</li><li>List item 2 with <em>emphasis</em></li></ul><table>" +
|
||||
"<tr><td>Cell 1</td><td>Cell 2</td></tr><tr><td>Cell 3</td><td>Cell 4</td></tr>" +
|
||||
"</table></section></main></div></body></html>";
|
||||
|
||||
String emlContent = createHtmlEmail(
|
||||
"complex@test.com", "user@test.com", "Complex HTML Test", complexHtml);
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"Email Header", "List item 2", "Cell 3", "example.com"
|
||||
}, false);
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, createBasicRequest());
|
||||
assertTrue(htmlResult.length() > 300, "HTML should have substantial content");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Error Handling & Edge Cases")
|
||||
class ErrorHandlingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject null input")
|
||||
void rejectNullInput() {
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
Exception exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> EmlToPdf.convertEmlToHtml(null, request));
|
||||
assertTrue(exception.getMessage().contains("EML file is empty or null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject empty input")
|
||||
void rejectEmptyInput() {
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
Exception exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> EmlToPdf.convertEmlToHtml(new byte[0], request));
|
||||
assertTrue(exception.getMessage().contains("EML file is empty or null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle malformed EML gracefully")
|
||||
void handleMalformedEmlGracefully() {
|
||||
String malformedEml = """
|
||||
From: sender@test.com
|
||||
Subject: Malformed EML
|
||||
This line breaks header format
|
||||
Content-Type: text/plain
|
||||
|
||||
Body content""";
|
||||
|
||||
byte[] emlBytes = malformedEml.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
try {
|
||||
String result = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
assertNotNull(result, "Result should not be null");
|
||||
assertFalse(result.isEmpty(), "Result should not be empty");
|
||||
} catch (Exception e) {
|
||||
assertNotNull(e.getMessage(), "Exception message should not be null");
|
||||
assertFalse(e.getMessage().isEmpty(), "Exception message should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject invalid EML format")
|
||||
void rejectInvalidEmlFormat() {
|
||||
byte[] invalidEml = "This is definitely not an EML file".getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
Exception exception = assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> EmlToPdf.convertEmlToHtml(invalidEml, request));
|
||||
assertTrue(exception.getMessage().contains("Invalid EML file format"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Advanced Parsing Tests (Jakarta Mail)")
|
||||
class AdvancedParsingTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully parse email using advanced parser")
|
||||
void initializeDependencyMailSession() {
|
||||
assertDoesNotThrow(() -> {
|
||||
String emlContent = createSimpleTextEmail(
|
||||
"Dependency@test.com",
|
||||
"user@test.com",
|
||||
"Dependency Mail Test",
|
||||
"Testing Dependency Mail integration.");
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains("Dependency Mail Test"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should parse complex MIME structures and select HTML part")
|
||||
void parseComplexMimeStructures() throws IOException {
|
||||
String boundary = "----=_Advanced_1234567890";
|
||||
String textBody = "This is the plain text part.";
|
||||
String htmlBody = "<html><body><h1>This is the HTML part</h1></body></html>";
|
||||
String emlContent = createMultipartAlternativeEmail(textBody, htmlBody, boundary);
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createRequestWithAttachments();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains("This is the HTML part"));
|
||||
assertFalse(htmlResult.contains("This is the plain text part"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Additional Correctness Tests")
|
||||
class AdditionalCorrectnessTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle email with only an attachment and no body")
|
||||
void handleAttachmentOnlyEmail() throws IOException {
|
||||
String boundary = "----=_Part_AttachmentOnly_1234567890";
|
||||
String emlContent = createMultipartEmailWithAttachment(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Attachment Only Test",
|
||||
"",
|
||||
boundary,
|
||||
"data.bin",
|
||||
"binary data");
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"Attachment Only Test", "data.bin", "No content available"
|
||||
}, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle mixed inline and regular attachments")
|
||||
void handleMixedAttachments() throws IOException {
|
||||
String boundary = "----=_Part_MixedAttachments_1234567890";
|
||||
String cid = "inline_image@example.com";
|
||||
String htmlBody = "<html><body><img src=\"cid:" + cid + "\"></body></html>";
|
||||
String imageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR42mNkAAIAAAoAAb6A/yoAAAAASUVORK5CYII=";
|
||||
String attachmentText = "This is a text attachment.";
|
||||
|
||||
String emlContent = createEmailWithMixedAttachments(
|
||||
htmlBody, boundary, cid, imageBase64, attachmentText);
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createRequestWithAttachments();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains("data:image/png;base64," + imageBase64));
|
||||
assertTrue(htmlResult.contains("text.txt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle non-standard but valid character sets like ISO-8859-1")
|
||||
void handleIso88591Charset() throws IOException {
|
||||
String subject = "Subject with special characters: ñ é ü";
|
||||
String body = "Body with special characters: ñ é ü";
|
||||
|
||||
String emlContent = createSimpleTextEmailWithCharset(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
subject,
|
||||
body,
|
||||
"ISO-8859-1");
|
||||
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.ISO_8859_1);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
|
||||
|
||||
assertNotNull(htmlResult);
|
||||
assertTrue(htmlResult.contains(subject));
|
||||
assertTrue(htmlResult.contains(body));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle emails with extremely long lines")
|
||||
void handleLongLines() throws IOException {
|
||||
StringBuilder longLine = new StringBuilder("This is a very long line: ");
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
longLine.append("word").append(i).append(" ");
|
||||
}
|
||||
|
||||
String emlContent = createSimpleTextEmail(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
"Long Line Test",
|
||||
longLine.toString());
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"Long Line Test", "This is a very long line", "word999"
|
||||
}, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle .eml files as attachments")
|
||||
void handleEmlAttachment() throws IOException {
|
||||
String boundary = "----=_Part_EmlAttachment_1234567890";
|
||||
String innerEmlContent = createSimpleTextEmail(
|
||||
"inner@example.com",
|
||||
"inner_recipient@example.com",
|
||||
"Inner Email Subject",
|
||||
"This is the body of the attached email.");
|
||||
|
||||
String emlContent = createEmailWithEmlAttachment(
|
||||
boundary,
|
||||
innerEmlContent);
|
||||
|
||||
testEmailConversion(emlContent, new String[] {
|
||||
"Fwd: Inner Email Subject",
|
||||
"Please see the attached email.",
|
||||
"attached_email.eml"
|
||||
}, true);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"windows-1252", "ISO-8859-2", "KOI8-R", "Shift_JIS"})
|
||||
@DisplayName("Should handle various character encodings")
|
||||
void handleVariousEncodings(String charset) throws IOException {
|
||||
String subject = "Encoding Test";
|
||||
String body = "Testing " + charset + " encoding";
|
||||
|
||||
String emlContent = createSimpleTextEmailWithCharset(
|
||||
"sender@example.com",
|
||||
"recipient@example.com",
|
||||
subject,
|
||||
body,
|
||||
charset);
|
||||
|
||||
testEmailConversion(emlContent, new String[] {subject, body}, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("PDF Conversion Tests")
|
||||
class PdfConversionTests {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory mockPdfDocumentFactory;
|
||||
|
||||
@Mock private PDDocument mockPdDocument;
|
||||
|
||||
@Mock private TempFileManager mockTempFileManager;
|
||||
|
||||
@Test
|
||||
@DisplayName("Should convert EML to PDF without attachments when not requested")
|
||||
void convertEmlToPdfWithoutAttachments() throws Exception {
|
||||
String emlContent =
|
||||
createSimpleTextEmail("from@test.com", "to@test.com", "Subject", "Body");
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
|
||||
PDDocument testPdf = new PDDocument();
|
||||
testPdf.addPage(new PDPage());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
testPdf.save(baos);
|
||||
testPdf.close();
|
||||
byte[] fakePdfBytes = baos.toByteArray();
|
||||
|
||||
when(mockPdfDocumentFactory.load(any(byte[].class))).thenReturn(mockPdDocument);
|
||||
when(mockPdDocument.getNumberOfPages()).thenReturn(1);
|
||||
|
||||
try (MockedStatic<FileToPdf> fileToPdf = mockStatic(FileToPdf.class)) {
|
||||
fileToPdf
|
||||
.when(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
any(),
|
||||
any(byte[].class),
|
||||
anyString(),
|
||||
anyBoolean(),
|
||||
any(TempFileManager.class)))
|
||||
.thenReturn(fakePdfBytes);
|
||||
|
||||
byte[] resultPdf =
|
||||
EmlToPdf.convertEmlToPdf(
|
||||
"weasyprint",
|
||||
request,
|
||||
emlBytes,
|
||||
"test.eml",
|
||||
false,
|
||||
mockPdfDocumentFactory,
|
||||
mockTempFileManager);
|
||||
|
||||
assertArrayEquals(fakePdfBytes, resultPdf);
|
||||
|
||||
try (PDDocument resultDoc = mockPdfDocumentFactory.load(resultPdf)) {
|
||||
assertNotNull(resultDoc);
|
||||
assertTrue(resultDoc.getNumberOfPages() > 0);
|
||||
}
|
||||
|
||||
fileToPdf.verify(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
any(),
|
||||
any(byte[].class),
|
||||
anyString(),
|
||||
anyBoolean(),
|
||||
any(TempFileManager.class)));
|
||||
verify(mockPdfDocumentFactory).load(resultPdf);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should convert EML to PDF with attachments when requested")
|
||||
void convertEmlToPdfWithAttachments() throws Exception {
|
||||
String boundary = "----=_Part_1234567890";
|
||||
String emlContent = createMultipartEmailWithAttachment(
|
||||
"multipart@example.com",
|
||||
"user@example.com",
|
||||
"Multipart Email Test",
|
||||
"This email has both text content and an attachment.",
|
||||
boundary,
|
||||
"document.txt",
|
||||
"Sample attachment content");
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createRequestWithAttachments();
|
||||
|
||||
PDDocument testPdf = new PDDocument();
|
||||
testPdf.addPage(new PDPage());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
testPdf.save(baos);
|
||||
testPdf.close();
|
||||
byte[] fakePdfBytes = baos.toByteArray();
|
||||
|
||||
when(mockPdfDocumentFactory.load(any(byte[].class))).thenReturn(mockPdDocument);
|
||||
when(mockPdDocument.getNumberOfPages()).thenReturn(1);
|
||||
|
||||
try (MockedStatic<FileToPdf> fileToPdf = mockStatic(FileToPdf.class)) {
|
||||
fileToPdf
|
||||
.when(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
any(),
|
||||
any(byte[].class),
|
||||
anyString(),
|
||||
anyBoolean(),
|
||||
any(TempFileManager.class)))
|
||||
.thenReturn(fakePdfBytes);
|
||||
|
||||
try (MockedStatic<EmlToPdf> ignored =
|
||||
mockStatic(
|
||||
EmlToPdf.class,
|
||||
invocation -> {
|
||||
String methodName = invocation.getMethod().getName();
|
||||
return switch (methodName) {
|
||||
case "shouldAttachFiles" -> true;
|
||||
case "attachFilesToPdf" -> fakePdfBytes;
|
||||
default -> invocation.callRealMethod();
|
||||
};
|
||||
})) {
|
||||
byte[] resultPdf =
|
||||
EmlToPdf.convertEmlToPdf(
|
||||
"weasyprint",
|
||||
request,
|
||||
emlBytes,
|
||||
"test.eml",
|
||||
false,
|
||||
mockPdfDocumentFactory,
|
||||
mockTempFileManager);
|
||||
|
||||
assertArrayEquals(fakePdfBytes, resultPdf);
|
||||
|
||||
try (PDDocument resultDoc = mockPdfDocumentFactory.load(resultPdf)) {
|
||||
assertNotNull(resultDoc);
|
||||
assertTrue(resultDoc.getNumberOfPages() > 0);
|
||||
}
|
||||
|
||||
fileToPdf.verify(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
any(),
|
||||
any(byte[].class),
|
||||
anyString(),
|
||||
anyBoolean(),
|
||||
any(TempFileManager.class)));
|
||||
|
||||
verify(mockPdfDocumentFactory).load(resultPdf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle errors during EML to PDF conversion")
|
||||
void handleErrorsDuringConversion() {
|
||||
String emlContent =
|
||||
createSimpleTextEmail("from@test.com", "to@test.com", "Subject", "Body");
|
||||
byte[] emlBytes = emlContent.getBytes(StandardCharsets.UTF_8);
|
||||
EmlToPdfRequest request = createBasicRequest();
|
||||
String errorMessage = "Conversion failed";
|
||||
|
||||
try (MockedStatic<FileToPdf> fileToPdf = mockStatic(FileToPdf.class)) {
|
||||
fileToPdf
|
||||
.when(
|
||||
() ->
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
anyString(),
|
||||
any(),
|
||||
any(byte[].class),
|
||||
anyString(),
|
||||
anyBoolean(),
|
||||
any(TempFileManager.class)))
|
||||
.thenThrow(new IOException(errorMessage));
|
||||
|
||||
IOException exception = assertThrows(
|
||||
IOException.class,
|
||||
() -> EmlToPdf.convertEmlToPdf(
|
||||
"weasyprint",
|
||||
request,
|
||||
emlBytes,
|
||||
"test.eml",
|
||||
false,
|
||||
mockPdfDocumentFactory,
|
||||
mockTempFileManager));
|
||||
|
||||
assertTrue(exception.getMessage().contains(errorMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private String getTimestamp() {
|
||||
java.time.ZonedDateTime fixedDateTime = java.time.ZonedDateTime.of(2023, 1, 1, 12, 0, 0, 0, java.time.ZoneId.of("GMT"));
|
||||
return java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(fixedDateTime);
|
||||
}
|
||||
private String createSimpleTextEmail(String from, String to, String subject, String body) {
|
||||
return createSimpleTextEmailWithCharset(from, to, subject, body, "UTF-8");
|
||||
}
|
||||
|
||||
private String createSimpleTextEmailWithCharset(String from, String to, String subject, String body, String charset) {
|
||||
return String.format(
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/plain; charset=%s\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
from, to, subject, getTimestamp(), charset, body);
|
||||
}
|
||||
|
||||
private String createEmailWithCustomHeaders() {
|
||||
return String.format(
|
||||
"From: sender@example.com\nDate: %s\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
getTimestamp(), "This is an email body with some headers missing.");
|
||||
}
|
||||
|
||||
private String createHtmlEmail(String from, String to, String subject, String htmlBody) {
|
||||
return String.format(
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nContent-Type: text/html; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n\n%s",
|
||||
from, to, subject, getTimestamp(), htmlBody);
|
||||
}
|
||||
|
||||
private String createMultipartEmailWithAttachment(String from, String to, String subject, String body,
|
||||
String boundary, String filename, String attachmentContent) {
|
||||
String encodedContent = Base64.getEncoder().encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
from, to, subject, getTimestamp(), boundary, boundary, body, boundary, filename, encodedContent, boundary);
|
||||
}
|
||||
|
||||
private String createEmailWithEmlAttachment(String boundary, String attachmentEmlContent) {
|
||||
String encodedContent = Base64.getEncoder().encodeToString(attachmentEmlContent.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: message/rfc822; name="%s"
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
"outer@example.com", "outer_recipient@example.com", "Fwd: Inner Email Subject", getTimestamp(), boundary, boundary, "Please see the attached email.", boundary, "attached_email.eml", "attached_email.eml", encodedContent, boundary);
|
||||
}
|
||||
|
||||
private String createMultipartAlternativeEmail(String textBody, String htmlBody, String boundary) {
|
||||
return String.format(
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
"sender@example.com", "receiver@example.com", "Multipart/Alternative Test", getTimestamp(),
|
||||
boundary, boundary, textBody, boundary, htmlBody, boundary);
|
||||
}
|
||||
|
||||
private String createQuotedPrintableEmail() {
|
||||
return String.format(
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\n%s",
|
||||
"sender@example.com", "recipient@example.com", "Quoted-Printable Test", getTimestamp(), "This is quoted=20printable content with special chars: =C3=A9=C3=A0=C3=A8.");
|
||||
}
|
||||
|
||||
private String createBase64Email(String body) {
|
||||
String encodedBody = Base64.getEncoder().encodeToString(body.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
"From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: base64\n\n%s",
|
||||
"sender@example.com", "recipient@example.com", "Base64 Test", getTimestamp(), encodedBody);
|
||||
}
|
||||
|
||||
private String createEmailWithInlineImage(String htmlBody, String boundary, String contentId, String base64Image) {
|
||||
return String.format(
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/related; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
|
||||
--%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
"sender@example.com", "receiver@example.com", "Inline Image Test", getTimestamp(),
|
||||
boundary, boundary, htmlBody, boundary, contentId, base64Image, boundary);
|
||||
}
|
||||
|
||||
private String createEmailWithMixedAttachments(String htmlBody, String boundary, String contentId,
|
||||
String base64Image, String attachmentBody) {
|
||||
String encodedAttachment = Base64.getEncoder().encodeToString(attachmentBody.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format(
|
||||
"""
|
||||
From: %s
|
||||
To: %s
|
||||
Subject: %s
|
||||
Date: %s
|
||||
Content-Type: multipart/mixed; boundary="%s"
|
||||
|
||||
--%s
|
||||
Content-Type: multipart/related; boundary="related-%s"
|
||||
|
||||
--related-%s
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
|
||||
%s
|
||||
|
||||
--related-%s
|
||||
Content-Type: image/png
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <%s>
|
||||
Content-Disposition: inline; filename="image.png"
|
||||
|
||||
%s
|
||||
|
||||
--related-%s--
|
||||
|
||||
--%s
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Disposition: attachment; filename="%s"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
%s
|
||||
|
||||
--%s--""",
|
||||
"sender@example.com", "receiver@example.com", "Mixed Attachments Test", getTimestamp(),
|
||||
boundary, boundary, boundary, boundary, htmlBody, boundary, contentId, base64Image, boundary,
|
||||
boundary, "text.txt", encodedAttachment, boundary);
|
||||
}
|
||||
// Creates a basic EmlToPdfRequest with default settings
|
||||
private EmlToPdfRequest createBasicRequest() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(false);
|
||||
return request;
|
||||
}
|
||||
|
||||
private EmlToPdfRequest createRequestWithAttachments() {
|
||||
EmlToPdfRequest request = new EmlToPdfRequest();
|
||||
request.setIncludeAttachments(true);
|
||||
request.setMaxAttachmentSizeMB(10);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
class SpringContextHolderTest {
|
||||
|
||||
private ApplicationContext mockApplicationContext;
|
||||
private SpringContextHolder contextHolder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockApplicationContext = mock(ApplicationContext.class);
|
||||
contextHolder = new SpringContextHolder();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSetApplicationContext() {
|
||||
// Act
|
||||
contextHolder.setApplicationContext(mockApplicationContext);
|
||||
|
||||
// Assert
|
||||
assertTrue(SpringContextHolder.isInitialized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBean_ByType() {
|
||||
// Arrange
|
||||
contextHolder.setApplicationContext(mockApplicationContext);
|
||||
TestBean expectedBean = new TestBean();
|
||||
when(mockApplicationContext.getBean(TestBean.class)).thenReturn(expectedBean);
|
||||
|
||||
// Act
|
||||
TestBean result = SpringContextHolder.getBean(TestBean.class);
|
||||
|
||||
// Assert
|
||||
assertSame(expectedBean, result);
|
||||
verify(mockApplicationContext).getBean(TestBean.class);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void testGetBean_ApplicationContextNotSet() {
|
||||
// Don't set application context
|
||||
|
||||
// Act
|
||||
TestBean result = SpringContextHolder.getBean(TestBean.class);
|
||||
|
||||
// Assert
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBean_BeanNotFound() {
|
||||
// Arrange
|
||||
contextHolder.setApplicationContext(mockApplicationContext);
|
||||
when(mockApplicationContext.getBean(TestBean.class)).thenThrow(new org.springframework.beans.BeansException("Bean not found") {});
|
||||
|
||||
// Act
|
||||
TestBean result = SpringContextHolder.getBean(TestBean.class);
|
||||
|
||||
// Assert
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
// Simple test class
|
||||
private static class TestBean {
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
### Eclipse ###
|
||||
.metadata
|
||||
bin/
|
||||
tmp/
|
||||
*.tmp
|
||||
*.bak
|
||||
*.exe
|
||||
*.swp
|
||||
*~.nib
|
||||
local.properties
|
||||
.settings/
|
||||
.loadpath
|
||||
.recommenders
|
||||
.classpath
|
||||
.project
|
||||
version.properties
|
||||
|
||||
#### Stirling-PDF Files ###
|
||||
pipeline/watchedFolders/
|
||||
pipeline/finishedFolders/
|
||||
customFiles/
|
||||
configs/
|
||||
watchedFolders/
|
||||
clientWebUI/
|
||||
!cucumber/
|
||||
!cucumber/exampleFiles/
|
||||
!cucumber/exampleFiles/example_html.zip
|
||||
exampleYmlFiles/stirling/
|
||||
/testing/file_snapshots
|
||||
SwaggerDoc.json
|
||||
|
||||
# Gradle
|
||||
.gradle
|
||||
.lock
|
||||
|
||||
# External tool builders
|
||||
.externalToolBuilders/
|
||||
|
||||
# Locally stored "Eclipse launch configurations"
|
||||
*.launch
|
||||
|
||||
# PyDev specific (Python IDE for Eclipse)
|
||||
*.pydevproject
|
||||
|
||||
# CDT-specific (C/C++ Development Tooling)
|
||||
.cproject
|
||||
|
||||
# CDT- autotools
|
||||
.autotools
|
||||
|
||||
# Java annotation processor (APT)
|
||||
.factorypath
|
||||
|
||||
# PDT-specific (PHP Development Tools)
|
||||
.buildpath
|
||||
|
||||
# sbteclipse plugin
|
||||
.target
|
||||
|
||||
# Tern plugin
|
||||
.tern-project
|
||||
|
||||
# TeXlipse plugin
|
||||
.texlipse
|
||||
|
||||
# STS (Spring Tool Suite)
|
||||
.springBeans
|
||||
|
||||
# Code Recommenders
|
||||
.recommenders/
|
||||
|
||||
# Annotation Processing
|
||||
.apt_generated/
|
||||
.apt_generated_test/
|
||||
|
||||
# Scala IDE specific (Scala & Java development for Eclipse)
|
||||
.cache-main
|
||||
.scala_dependencies
|
||||
.worksheet
|
||||
|
||||
# Uncomment this line if you wish to ignore the project description file.
|
||||
# Typically, this file would be tracked if it contains build/dependency configurations:
|
||||
#.project
|
||||
|
||||
### Eclipse Patch ###
|
||||
# Spring Boot Tooling
|
||||
.sts4-cache/
|
||||
|
||||
### Git ###
|
||||
# Created by git for backups. To disable backups in Git:
|
||||
# $ git config --global mergetool.keepBackup false
|
||||
*.orig
|
||||
|
||||
# Created by git when using merge tools for conflicts
|
||||
*.BACKUP.*
|
||||
*.BASE.*
|
||||
*.LOCAL.*
|
||||
*.REMOTE.*
|
||||
*_BACKUP_*.txt
|
||||
*_BASE_*.txt
|
||||
*_LOCAL_*.txt
|
||||
*_REMOTE_*.txt
|
||||
|
||||
### Java ###
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
*.db
|
||||
/build/*
|
||||
/app/core/build/*
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Virtual environments
|
||||
.env*
|
||||
.venv*
|
||||
env*/
|
||||
venv*/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# VS Code
|
||||
/.vscode/**/*
|
||||
!/.vscode/settings.json
|
||||
!/.vscode/extensions.json
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea/
|
||||
*.iml
|
||||
out/
|
||||
|
||||
# Ignore Mac DS_Store files
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# cucumber
|
||||
/cucumber/reports/**
|
||||
|
||||
# Certs and Security Files
|
||||
*.p12
|
||||
*.pk8
|
||||
*.pem
|
||||
*.crt
|
||||
*.cer
|
||||
*.cert
|
||||
*.der
|
||||
*.key
|
||||
*.csr
|
||||
*.kdbx
|
||||
*.jks
|
||||
*.asc
|
||||
|
||||
# SSH Keys
|
||||
*.pub
|
||||
*.priv
|
||||
id_rsa
|
||||
id_rsa.pub
|
||||
id_ecdsa
|
||||
id_ecdsa.pub
|
||||
id_ed25519
|
||||
id_ed25519.pub
|
||||
.ssh/
|
||||
*ssh
|
||||
|
||||
# cache
|
||||
.cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
|
||||
**/jcef-bundle/
|
||||
|
||||
# node_modules
|
||||
node_modules/
|
||||
@@ -1,155 +0,0 @@
|
||||
apply plugin: 'org.springframework.boot'
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://build.shibboleth.net/maven/releases' }
|
||||
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
|
||||
}
|
||||
|
||||
configurations {
|
||||
developmentOnly
|
||||
runtimeClasspath {
|
||||
extendsFrom developmentOnly
|
||||
}
|
||||
}
|
||||
|
||||
spotless {
|
||||
java {
|
||||
target sourceSets.main.allJava
|
||||
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
||||
|
||||
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
||||
toggleOffOn()
|
||||
trimTrailingWhitespace()
|
||||
leadingTabsToSpaces()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|
||||
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
|
||||
&& project.getProperty('STIRLING_PDF_DESKTOP_UI') != 'false')) {
|
||||
implementation 'me.friwi:jcefmaven:132.3.1'
|
||||
implementation 'org.openjfx:javafx-controls:21'
|
||||
implementation 'org.openjfx:javafx-swing:21'
|
||||
}
|
||||
|
||||
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|
||||
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
|
||||
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
|
||||
implementation project(':proprietary')
|
||||
}
|
||||
|
||||
implementation project(':common')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
||||
implementation 'com.posthog.java:posthog:1.2.0'
|
||||
implementation 'commons-io:commons-io:2.19.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.15.1'
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
|
||||
// General PDF dependencies
|
||||
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
implementation ('technology.tabula:tabula:1.0.5') {
|
||||
exclude group: 'org.slf4j', module: 'slf4j-simple'
|
||||
exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
|
||||
exclude group: 'com.google.code.gson', module: 'gson'
|
||||
}
|
||||
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
||||
implementation 'com.opencsv:opencsv:5.11.2' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
||||
|
||||
// Batik
|
||||
implementation 'org.apache.xmlgraphics:batik-all:1.19'
|
||||
|
||||
// TwelveMonkeys
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-batik:$imageioVersion"
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-bmp:$imageioVersion"
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-jpeg:$imageioVersion"
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-tiff:$imageioVersion"
|
||||
runtimeOnly "com.twelvemonkeys.imageio:imageio-webp:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-hdr:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-icns:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-iff:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-pcx:$imageioVersion@
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-pict:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-pnm:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-psd:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-sgi:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-tga:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-thumbsdb:$imageioVersion"
|
||||
// runtimeOnly "com.twelvemonkeys.imageio:imageio-xwd:$imageioVersion"
|
||||
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
resources {
|
||||
srcDirs += ['../configs']
|
||||
}
|
||||
java {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
|
||||
exclude 'stirling/software/SPDF/UI/impl/**'
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
test {
|
||||
java {
|
||||
if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true'
|
||||
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
|
||||
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) {
|
||||
exclude 'stirling/software/proprietary/security/**'
|
||||
}
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
|
||||
exclude 'stirling/software/SPDF/UI/impl/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Disable regular jar
|
||||
jar {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
// Configure and enable bootJar for this project
|
||||
bootJar {
|
||||
enabled = true
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
zip64 = true
|
||||
|
||||
// Don't include all dependencies directly like the old jar task did
|
||||
// from {
|
||||
// configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
|
||||
// }
|
||||
|
||||
// Exclude signature files to prevent "Invalid signature file digest" errors
|
||||
exclude 'META-INF/*.SF'
|
||||
exclude 'META-INF/*.DSA'
|
||||
exclude 'META-INF/*.RSA'
|
||||
exclude 'META-INF/*.EC'
|
||||
|
||||
manifest {
|
||||
attributes(
|
||||
'Implementation-Title': 'Stirling-PDF',
|
||||
'Implementation-Version': project.version
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Configure main class for Spring Boot
|
||||
springBoot {
|
||||
mainClass = 'stirling.software.SPDF.SPDFApplication'
|
||||
}
|
||||
|
||||
bootJar.dependsOn ':common:jar'
|
||||
bootJar.dependsOn ':proprietary:jar'
|
||||
-266
@@ -1,266 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Slf4j
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class EditTableOfContentsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(value = "/extract-bookmarks", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
PDDocument document = null;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
|
||||
|
||||
if (outline == null) {
|
||||
log.info("No outline/bookmarks found in PDF");
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
return extractBookmarkItems(document, outline);
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> extractBookmarkItems(
|
||||
PDDocument document, PDDocumentOutline outline) throws Exception {
|
||||
List<Map<String, Object>> bookmarks = new ArrayList<>();
|
||||
PDOutlineItem current = outline.getFirstChild();
|
||||
|
||||
while (current != null) {
|
||||
Map<String, Object> bookmark = new HashMap<>();
|
||||
|
||||
// Get bookmark title
|
||||
String title = current.getTitle();
|
||||
bookmark.put("title", title);
|
||||
|
||||
// Get page number (1-based for UI purposes)
|
||||
PDPage page = current.findDestinationPage(document);
|
||||
if (page != null) {
|
||||
int pageIndex = document.getPages().indexOf(page);
|
||||
bookmark.put("pageNumber", pageIndex + 1);
|
||||
} else {
|
||||
bookmark.put("pageNumber", 1);
|
||||
}
|
||||
|
||||
// Process children if any
|
||||
PDOutlineItem child = current.getFirstChild();
|
||||
if (child != null) {
|
||||
List<Map<String, Object>> children = new ArrayList<>();
|
||||
PDOutlineNode parent = current;
|
||||
|
||||
while (child != null) {
|
||||
// Recursively process child items
|
||||
Map<String, Object> childBookmark = processChild(document, child);
|
||||
children.add(childBookmark);
|
||||
child = child.getNextSibling();
|
||||
}
|
||||
|
||||
bookmark.put("children", children);
|
||||
} else {
|
||||
bookmark.put("children", new ArrayList<>());
|
||||
}
|
||||
|
||||
bookmarks.add(bookmark);
|
||||
current = current.getNextSibling();
|
||||
}
|
||||
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
private Map<String, Object> processChild(PDDocument document, PDOutlineItem item)
|
||||
throws Exception {
|
||||
Map<String, Object> bookmark = new HashMap<>();
|
||||
|
||||
// Get bookmark title
|
||||
String title = item.getTitle();
|
||||
bookmark.put("title", title);
|
||||
|
||||
// Get page number (1-based for UI purposes)
|
||||
PDPage page = item.findDestinationPage(document);
|
||||
if (page != null) {
|
||||
int pageIndex = document.getPages().indexOf(page);
|
||||
bookmark.put("pageNumber", pageIndex + 1);
|
||||
} else {
|
||||
bookmark.put("pageNumber", 1);
|
||||
}
|
||||
|
||||
// Process children if any
|
||||
PDOutlineItem child = item.getFirstChild();
|
||||
if (child != null) {
|
||||
List<Map<String, Object>> children = new ArrayList<>();
|
||||
|
||||
while (child != null) {
|
||||
// Recursively process child items
|
||||
Map<String, Object> childBookmark = processChild(document, child);
|
||||
children.add(childBookmark);
|
||||
child = child.getNextSibling();
|
||||
}
|
||||
|
||||
bookmark.put("children", children);
|
||||
} else {
|
||||
bookmark.put("children", new ArrayList<>());
|
||||
}
|
||||
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/edit-table-of-contents", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Edit Table of Contents",
|
||||
description = "Add or edit bookmarks/table of contents in a PDF document.")
|
||||
public ResponseEntity<byte[]> editTableOfContents(
|
||||
@ModelAttribute EditTableOfContentsRequest request) throws Exception {
|
||||
MultipartFile file = request.getFileInput();
|
||||
PDDocument document = null;
|
||||
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
|
||||
// Parse the bookmark data from JSON
|
||||
List<BookmarkItem> bookmarks =
|
||||
objectMapper.readValue(
|
||||
request.getBookmarkData(), new TypeReference<List<BookmarkItem>>() {});
|
||||
|
||||
// Create a new document outline
|
||||
PDDocumentOutline outline = new PDDocumentOutline();
|
||||
document.getDocumentCatalog().setDocumentOutline(outline);
|
||||
|
||||
// Add bookmarks to the outline
|
||||
addBookmarksToOutline(document, outline, bookmarks);
|
||||
|
||||
// Save the document to a byte array
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
String filename = file.getOriginalFilename().replaceFirst("[.][^.]+$", "");
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(), filename + "_with_toc.pdf", MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addBookmarksToOutline(
|
||||
PDDocument document, PDDocumentOutline outline, List<BookmarkItem> bookmarks) {
|
||||
for (BookmarkItem bookmark : bookmarks) {
|
||||
PDOutlineItem item = createOutlineItem(document, bookmark);
|
||||
outline.addLast(item);
|
||||
|
||||
if (bookmark.getChildren() != null && !bookmark.getChildren().isEmpty()) {
|
||||
addChildBookmarks(document, item, bookmark.getChildren());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addChildBookmarks(
|
||||
PDDocument document, PDOutlineItem parent, List<BookmarkItem> children) {
|
||||
for (BookmarkItem child : children) {
|
||||
PDOutlineItem item = createOutlineItem(document, child);
|
||||
parent.addLast(item);
|
||||
|
||||
if (child.getChildren() != null && !child.getChildren().isEmpty()) {
|
||||
addChildBookmarks(document, item, child.getChildren());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PDOutlineItem createOutlineItem(PDDocument document, BookmarkItem bookmark) {
|
||||
PDOutlineItem item = new PDOutlineItem();
|
||||
item.setTitle(bookmark.getTitle());
|
||||
|
||||
// Get the target page - adjust for 0-indexed pages in PDFBox
|
||||
int pageIndex = bookmark.getPageNumber() - 1;
|
||||
if (pageIndex < 0) {
|
||||
pageIndex = 0;
|
||||
} else if (pageIndex >= document.getNumberOfPages()) {
|
||||
pageIndex = document.getNumberOfPages() - 1;
|
||||
}
|
||||
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
item.setDestination(page);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// Inner class to represent bookmarks in JSON
|
||||
public static class BookmarkItem {
|
||||
private String title;
|
||||
private int pageNumber;
|
||||
private List<BookmarkItem> children = new ArrayList<>();
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public int getPageNumber() {
|
||||
return pageNumber;
|
||||
}
|
||||
|
||||
public void setPageNumber(int pageNumber) {
|
||||
this.pageNumber = pageNumber;
|
||||
}
|
||||
|
||||
public List<BookmarkItem> getChildren() {
|
||||
return children;
|
||||
}
|
||||
|
||||
public void setChildren(List<BookmarkItem> children) {
|
||||
this.children = children;
|
||||
}
|
||||
}
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
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.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.EmlToPdf;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertEmlToPDF {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/eml/pdf")
|
||||
@Operation(
|
||||
summary = "Convert EML to PDF",
|
||||
description =
|
||||
"This endpoint converts EML (email) files to PDF format with extensive"
|
||||
+ " customization options. Features include font settings, image constraints, display modes, attachment handling,"
|
||||
+ " and HTML debug output. Input: EML file, Output: PDF"
|
||||
+ " or HTML file. Type: SISO")
|
||||
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
|
||||
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
String originalFilename = inputFile.getOriginalFilename();
|
||||
|
||||
// Validate input
|
||||
if (inputFile.isEmpty()) {
|
||||
log.error("No file provided for EML to PDF conversion.");
|
||||
return ResponseEntity.badRequest()
|
||||
.body("No file provided".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
if (originalFilename == null || originalFilename.trim().isEmpty()) {
|
||||
log.error("Filename is null or empty.");
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Please provide a valid filename".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// Validate file type - support EML
|
||||
String lowerFilename = originalFilename.toLowerCase();
|
||||
if (!lowerFilename.endsWith(".eml")) {
|
||||
log.error("Invalid file type for EML to PDF: {}", originalFilename);
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Please upload a valid EML file".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String baseFilename = Filenames.toSimpleFileName(originalFilename); // Use Filenames utility
|
||||
|
||||
try {
|
||||
byte[] fileBytes = inputFile.getBytes();
|
||||
|
||||
if (request.isDownloadHtml()) {
|
||||
try {
|
||||
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
|
||||
log.info("Successfully converted EML to HTML: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
htmlContent.getBytes(StandardCharsets.UTF_8),
|
||||
baseFilename + ".html",
|
||||
MediaType.TEXT_HTML);
|
||||
} catch (IOException | IllegalArgumentException e) {
|
||||
log.error("HTML conversion failed for {}", originalFilename, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
("HTML conversion failed: " + e.getMessage())
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert EML to PDF with enhanced options
|
||||
try {
|
||||
byte[] pdfBytes =
|
||||
EmlToPdf.convertEmlToPdf(
|
||||
runtimePathConfig
|
||||
.getWeasyPrintPath(), // Use configured WeasyPrint path
|
||||
request,
|
||||
fileBytes,
|
||||
originalFilename,
|
||||
false,
|
||||
pdfDocumentFactory,
|
||||
tempFileManager);
|
||||
|
||||
if (pdfBytes == null || pdfBytes.length == 0) {
|
||||
log.error("PDF conversion failed - empty output for {}", originalFilename);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
"PDF conversion failed - empty output"
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
log.info("Successfully converted EML to PDF: {}", originalFilename);
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfBytes, baseFilename + ".pdf", MediaType.APPLICATION_PDF);
|
||||
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("EML to PDF conversion was interrupted for {}", originalFilename, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IllegalArgumentException e) {
|
||||
String errorMessage = buildErrorMessage(e, originalFilename);
|
||||
log.error(
|
||||
"EML to PDF conversion failed for {}: {}",
|
||||
originalFilename,
|
||||
errorMessage,
|
||||
e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(errorMessage.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException e) {
|
||||
String errorMessage = buildErrorMessage(e, originalFilename);
|
||||
log.error(
|
||||
"EML to PDF conversion failed for {}: {}",
|
||||
originalFilename,
|
||||
errorMessage,
|
||||
e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(errorMessage.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("File processing error for EML to PDF: {}", originalFilename, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("File processing error".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static @NotNull String buildErrorMessage(Exception e, String originalFilename) {
|
||||
String errorMessage;
|
||||
if (e.getMessage() != null && e.getMessage().contains("Invalid EML")) {
|
||||
errorMessage =
|
||||
"Invalid EML file format. Please ensure you've uploaded a valid email"
|
||||
+ " file ("
|
||||
+ originalFilename
|
||||
+ ").";
|
||||
} else if (e.getMessage() != null && e.getMessage().contains("WeasyPrint")) {
|
||||
errorMessage =
|
||||
"PDF generation failed for "
|
||||
+ originalFilename
|
||||
+ ". This may be due to complex email formatting.";
|
||||
} else {
|
||||
errorMessage = "Conversion failed for " + originalFilename + ": " + e.getMessage();
|
||||
}
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
-700
@@ -1,700 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDStream;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
|
||||
import org.apache.pdfbox.pdmodel.font.PDTrueTypeFont;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType0Font;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.color.PDOutputIntent;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationTextMarkup;
|
||||
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.AdobePDFSchema;
|
||||
import org.apache.xmpbox.schema.DublinCoreSchema;
|
||||
import org.apache.xmpbox.schema.PDFAIdentificationSchema;
|
||||
import org.apache.xmpbox.schema.XMPBasicSchema;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Slf4j
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
public class ConvertPDFToPDFA {
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/pdfa")
|
||||
@Operation(
|
||||
summary = "Convert a PDF to a PDF/A",
|
||||
description =
|
||||
"This endpoint converts a PDF file to a PDF/A file using LibreOffice. PDF/A is a format designed for long-term archiving of digital documents. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> pdfToPdfA(@ModelAttribute PdfToPdfARequest request)
|
||||
throws Exception {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
String outputFormat = request.getOutputFormat();
|
||||
|
||||
// Validate input file type
|
||||
if (!"application/pdf".equals(inputFile.getContentType())) {
|
||||
log.error("Invalid input file type: {}", inputFile.getContentType());
|
||||
throw ExceptionUtils.createPdfFileRequiredException();
|
||||
}
|
||||
|
||||
// Get the original filename without extension
|
||||
String originalFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
if (originalFileName == null || originalFileName.trim().isEmpty()) {
|
||||
originalFileName = "output.pdf";
|
||||
}
|
||||
String baseFileName =
|
||||
originalFileName.contains(".")
|
||||
? originalFileName.substring(0, originalFileName.lastIndexOf('.'))
|
||||
: originalFileName;
|
||||
|
||||
Path tempInputFile = null;
|
||||
byte[] fileBytes;
|
||||
Path loPdfPath = null; // Used for LibreOffice conversion output
|
||||
File preProcessedFile = null;
|
||||
int pdfaPart = 2;
|
||||
|
||||
try {
|
||||
// Save uploaded file to temp location
|
||||
tempInputFile = Files.createTempFile("input_", ".pdf");
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Branch conversion based on desired output PDF/A format
|
||||
if ("pdfa".equals(outputFormat)) {
|
||||
preProcessedFile = tempInputFile.toFile();
|
||||
} else {
|
||||
pdfaPart = 1;
|
||||
preProcessedFile = preProcessHighlights(tempInputFile.toFile());
|
||||
}
|
||||
Set<String> missingFonts = new HashSet<>();
|
||||
boolean needImgs = false;
|
||||
try (PDDocument doc = Loader.loadPDF(preProcessedFile)) {
|
||||
missingFonts = findUnembeddedFontNames(doc);
|
||||
needImgs = (pdfaPart == 1) && hasTransparentImages(doc);
|
||||
if (!missingFonts.isEmpty() || needImgs) {
|
||||
// Run LibreOffice conversion to get flattened images and embedded fonts
|
||||
loPdfPath = runLibreOfficeConversion(preProcessedFile.toPath(), pdfaPart);
|
||||
}
|
||||
}
|
||||
fileBytes =
|
||||
convertToPdfA(
|
||||
preProcessedFile.toPath(), loPdfPath, pdfaPart, missingFonts, needImgs);
|
||||
|
||||
String outputFilename = baseFileName + "_PDFA.pdf";
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, outputFilename, MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
// Clean up temporary files
|
||||
if (tempInputFile != null) {
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
}
|
||||
if (loPdfPath != null && loPdfPath.getParent() != null) {
|
||||
FileUtils.deleteDirectory(loPdfPath.getParent().toFile());
|
||||
}
|
||||
if (preProcessedFile != null) {
|
||||
Files.deleteIfExists(preProcessedFile.toPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge fonts & flattened images from loPdfPath into basePdfPath, then run the standard
|
||||
* PDFBox/A pipeline.
|
||||
*
|
||||
* @param basePdfPath Path to the original (or highlight‐preprocessed) PDF
|
||||
* @param loPdfPath Path to the LibreOffice–flattened PDF/A, or null if not used
|
||||
* @param pdfaPart 1 (PDF/A-1B) or 2 (PDF/A-2B)
|
||||
* @return the final PDF/A bytes
|
||||
*/
|
||||
private byte[] convertToPdfA(
|
||||
Path basePdfPath,
|
||||
Path loPdfPath,
|
||||
int pdfaPart,
|
||||
Set<String> missingFonts,
|
||||
boolean importImages)
|
||||
throws Exception {
|
||||
try (PDDocument baseDoc = Loader.loadPDF(basePdfPath.toFile())) {
|
||||
|
||||
if (loPdfPath != null) {
|
||||
try (PDDocument loDoc = Loader.loadPDF(loPdfPath.toFile())) {
|
||||
if (!missingFonts.isEmpty()) {
|
||||
embedMissingFonts(loDoc, baseDoc, missingFonts);
|
||||
}
|
||||
if (importImages) {
|
||||
importFlattenedImages(loDoc, baseDoc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return processWithPDFBox(baseDoc, pdfaPart);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] processWithPDFBox(PDDocument document, int pdfaPart) throws Exception {
|
||||
|
||||
removeElementsForPdfA(document, pdfaPart);
|
||||
|
||||
mergeAndAddXmpMetadata(document, pdfaPart);
|
||||
|
||||
addICCProfileIfNotPresent(document);
|
||||
|
||||
// Mark the document as PDF/A
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
catalog.setMetadata(
|
||||
document.getDocumentCatalog().getMetadata()); // Ensure metadata is linked
|
||||
catalog.setViewerPreferences(
|
||||
new PDViewerPreferences(catalog.getCOSObject())); // PDF/A best practice
|
||||
document.getDocument().setVersion(pdfaPart == 1 ? 1.4f : 1.7f);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
if (pdfaPart == 1) {
|
||||
document.save(baos, CompressParameters.NO_COMPRESSION);
|
||||
} else {
|
||||
document.save(baos);
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
private Path runLibreOfficeConversion(Path tempInputFile, int pdfaPart) throws Exception {
|
||||
// Create temp output directory
|
||||
Path tempOutputDir = Files.createTempDirectory("output_");
|
||||
|
||||
// Determine PDF/A filter based on requested format
|
||||
String pdfFilter =
|
||||
pdfaPart == 2
|
||||
? "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"2\"}}"
|
||||
: "pdf:writer_pdf_Export:{\"SelectPdfVersion\":{\"type\":\"long\",\"value\":\"1\"}}";
|
||||
|
||||
// Prepare LibreOffice command
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--convert-to",
|
||||
pdfFilter,
|
||||
"--outdir",
|
||||
tempOutputDir.toString(),
|
||||
tempInputFile.toString()));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (returnCode.getRc() != 0) {
|
||||
log.error("PDF/A conversion failed with return code: {}", returnCode.getRc());
|
||||
throw ExceptionUtils.createPdfaConversionFailedException();
|
||||
}
|
||||
|
||||
// Get the output file
|
||||
File[] outputFiles = tempOutputDir.toFile().listFiles();
|
||||
if (outputFiles == null || outputFiles.length != 1) {
|
||||
throw ExceptionUtils.createPdfaConversionFailedException();
|
||||
}
|
||||
return outputFiles[0].toPath();
|
||||
}
|
||||
|
||||
private void embedMissingFonts(PDDocument loDoc, PDDocument baseDoc, Set<String> missingFonts)
|
||||
throws IOException {
|
||||
List<PDPage> loPages = new ArrayList<>();
|
||||
loDoc.getPages().forEach(loPages::add);
|
||||
List<PDPage> basePages = new ArrayList<>();
|
||||
baseDoc.getPages().forEach(basePages::add);
|
||||
|
||||
for (int i = 0; i < loPages.size(); i++) {
|
||||
PDResources loRes = loPages.get(i).getResources();
|
||||
PDResources baseRes = basePages.get(i).getResources();
|
||||
|
||||
for (COSName fontKey : loRes.getFontNames()) {
|
||||
PDFont loFont = loRes.getFont(fontKey);
|
||||
if (loFont == null) continue;
|
||||
|
||||
String psName = loFont.getName();
|
||||
if (!missingFonts.contains(psName)) continue;
|
||||
|
||||
PDFontDescriptor desc = loFont.getFontDescriptor();
|
||||
if (desc == null) continue;
|
||||
|
||||
PDStream fontStream = null;
|
||||
if (desc.getFontFile() != null) {
|
||||
fontStream = desc.getFontFile();
|
||||
} else if (desc.getFontFile2() != null) {
|
||||
fontStream = desc.getFontFile2();
|
||||
} else if (desc.getFontFile3() != null) {
|
||||
fontStream = desc.getFontFile3();
|
||||
}
|
||||
if (fontStream == null) continue;
|
||||
|
||||
try (InputStream in = fontStream.createInputStream()) {
|
||||
PDFont newFont = null;
|
||||
try {
|
||||
newFont = PDType0Font.load(baseDoc, in, false);
|
||||
} catch (IOException e1) {
|
||||
try {
|
||||
newFont = PDTrueTypeFont.load(baseDoc, in, null);
|
||||
} catch (IOException | IllegalArgumentException e2) {
|
||||
log.error("Could not embed font {}: {}", psName, e2.getMessage());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (newFont != null) {
|
||||
baseRes.put(fontKey, newFont);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<String> findUnembeddedFontNames(PDDocument doc) throws IOException {
|
||||
Set<String> missing = new HashSet<>();
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDResources res = page.getResources();
|
||||
for (COSName name : res.getFontNames()) {
|
||||
PDFont font = res.getFont(name);
|
||||
if (font != null && !font.isEmbedded()) {
|
||||
missing.add(font.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private void importFlattenedImages(PDDocument loDoc, PDDocument baseDoc) throws IOException {
|
||||
List<PDPage> loPages = new ArrayList<>();
|
||||
loDoc.getPages().forEach(loPages::add);
|
||||
List<PDPage> basePages = new ArrayList<>();
|
||||
baseDoc.getPages().forEach(basePages::add);
|
||||
|
||||
for (int i = 0; i < loPages.size(); i++) {
|
||||
PDPage loPage = loPages.get(i);
|
||||
PDPage basePage = basePages.get(i);
|
||||
|
||||
PDResources loRes = loPage.getResources();
|
||||
PDResources baseRes = basePage.getResources();
|
||||
Set<COSName> toReplace = detectTransparentXObjects(basePage);
|
||||
|
||||
for (COSName name : toReplace) {
|
||||
PDXObject loXo = loRes.getXObject(name);
|
||||
if (!(loXo instanceof PDImageXObject img)) continue;
|
||||
|
||||
PDImageXObject newImg = LosslessFactory.createFromImage(baseDoc, img.getImage());
|
||||
|
||||
// replace the resource under the same name
|
||||
baseRes.put(name, newImg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Set<COSName> detectTransparentXObjects(PDPage page) {
|
||||
Set<COSName> transparentObjects = new HashSet<>();
|
||||
PDResources res = page.getResources();
|
||||
if (res == null) return transparentObjects;
|
||||
|
||||
for (COSName name : res.getXObjectNames()) {
|
||||
try {
|
||||
PDXObject xo = res.getXObject(name);
|
||||
if (xo instanceof PDImageXObject img) {
|
||||
COSDictionary d = img.getCOSObject();
|
||||
if (d.containsKey(COSName.SMASK)
|
||||
|| isTransparencyGroup(d)
|
||||
|| d.getBoolean(COSName.INTERPOLATE, false)) {
|
||||
transparentObjects.add(name);
|
||||
}
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
log.error("Error processing XObject {}: {}", name.getName(), ioe.getMessage());
|
||||
}
|
||||
}
|
||||
return transparentObjects;
|
||||
}
|
||||
|
||||
private boolean isTransparencyGroup(COSDictionary dict) {
|
||||
COSBase g = dict.getDictionaryObject(COSName.GROUP);
|
||||
return g instanceof COSDictionary gd
|
||||
&& COSName.TRANSPARENCY.equals(gd.getCOSName(COSName.S));
|
||||
}
|
||||
|
||||
private boolean hasTransparentImages(PDDocument doc) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
PDResources res = page.getResources();
|
||||
if (res == null) continue;
|
||||
for (COSName name : res.getXObjectNames()) {
|
||||
try {
|
||||
PDXObject xo = res.getXObject(name);
|
||||
if (xo instanceof PDImageXObject img) {
|
||||
COSDictionary dict = img.getCOSObject();
|
||||
if (dict.containsKey(COSName.SMASK)) return true;
|
||||
COSBase g = dict.getDictionaryObject(COSName.GROUP);
|
||||
if (g instanceof COSDictionary gd
|
||||
&& COSName.TRANSPARENCY.equals(gd.getCOSName(COSName.S))) {
|
||||
return true;
|
||||
}
|
||||
if (dict.getBoolean(COSName.INTERPOLATE, false)) return true;
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
log.error("Error processing XObject {}: {}", name.getName(), ioe.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void sanitizePdfA(COSBase base, PDResources resources, int pdfaPart) {
|
||||
if (base instanceof COSDictionary dict) {
|
||||
if (pdfaPart == 1) {
|
||||
// Remove transparency-related elements
|
||||
COSBase group = dict.getDictionaryObject(COSName.GROUP);
|
||||
if (group instanceof COSDictionary gDict
|
||||
&& COSName.TRANSPARENCY.equals(gDict.getCOSName(COSName.S))) {
|
||||
dict.removeItem(COSName.GROUP);
|
||||
}
|
||||
|
||||
dict.removeItem(COSName.SMASK);
|
||||
// Transparency blending constants (/CA, /ca) — disallowed in PDF/A-1
|
||||
dict.removeItem(COSName.CA);
|
||||
dict.removeItem(COSName.getPDFName("ca"));
|
||||
}
|
||||
|
||||
// Interpolation (non-deterministic image scaling) — required to be false
|
||||
if (dict.containsKey(COSName.INTERPOLATE)
|
||||
&& dict.getBoolean(COSName.INTERPOLATE, true)) {
|
||||
dict.setBoolean(COSName.INTERPOLATE, false);
|
||||
}
|
||||
|
||||
// Remove common forbidden features (for PDF/A 1 and 2)
|
||||
dict.removeItem(COSName.JAVA_SCRIPT);
|
||||
dict.removeItem(COSName.getPDFName("JS"));
|
||||
dict.removeItem(COSName.getPDFName("RichMedia"));
|
||||
dict.removeItem(COSName.getPDFName("Movie"));
|
||||
dict.removeItem(COSName.getPDFName("Sound"));
|
||||
dict.removeItem(COSName.getPDFName("Launch"));
|
||||
dict.removeItem(COSName.URI);
|
||||
dict.removeItem(COSName.getPDFName("GoToR"));
|
||||
dict.removeItem(COSName.EMBEDDED_FILES);
|
||||
dict.removeItem(COSName.FILESPEC);
|
||||
|
||||
// Recurse through all entries in the dictionary
|
||||
for (Map.Entry<COSName, COSBase> entry : dict.entrySet()) {
|
||||
sanitizePdfA(entry.getValue(), resources, pdfaPart);
|
||||
}
|
||||
|
||||
} else if (base instanceof COSArray arr) {
|
||||
// Recursively sanitize each item in the array
|
||||
for (COSBase item : arr) {
|
||||
sanitizePdfA(item, resources, pdfaPart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void removeElementsForPdfA(PDDocument doc, int pdfaPart) {
|
||||
|
||||
if (pdfaPart == 1) {
|
||||
// Remove Optional Content (Layers) - not allowed in PDF/A-1
|
||||
doc.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("OCProperties"));
|
||||
}
|
||||
|
||||
for (PDPage page : doc.getPages()) {
|
||||
if (pdfaPart == 1) {
|
||||
page.setAnnotations(Collections.emptyList());
|
||||
}
|
||||
PDResources res = page.getResources();
|
||||
// Clean page-level dictionary
|
||||
sanitizePdfA(page.getCOSObject(), res, pdfaPart);
|
||||
|
||||
// sanitize each Form XObject
|
||||
if (res != null) {
|
||||
for (COSName name : res.getXObjectNames()) {
|
||||
try {
|
||||
PDXObject xo = res.getXObject(name);
|
||||
if (xo instanceof PDFormXObject form) {
|
||||
sanitizePdfA(form.getCOSObject(), res, pdfaPart);
|
||||
} else if (xo instanceof PDImageXObject img) {
|
||||
sanitizePdfA(img.getCOSObject(), res, pdfaPart);
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
log.error("Cannot load XObject {}: {}", name.getName(), ioe.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Embbeds the XMP metadata required for PDF/A compliance. */
|
||||
private void mergeAndAddXmpMetadata(PDDocument document, int pdfaPart) throws Exception {
|
||||
PDMetadata existingMetadata = document.getDocumentCatalog().getMetadata();
|
||||
XMPMetadata xmp;
|
||||
|
||||
// Load existing XMP if available
|
||||
if (existingMetadata != null) {
|
||||
try (InputStream xmpStream = existingMetadata.createInputStream()) {
|
||||
DomXmpParser parser = new DomXmpParser();
|
||||
parser.setStrictParsing(false);
|
||||
xmp = parser.parse(xmpStream);
|
||||
} catch (Exception e) {
|
||||
xmp = XMPMetadata.createXMPMetadata();
|
||||
}
|
||||
} else {
|
||||
xmp = XMPMetadata.createXMPMetadata();
|
||||
}
|
||||
|
||||
PDDocumentInformation docInfo = document.getDocumentInformation();
|
||||
if (docInfo == null) {
|
||||
docInfo = new PDDocumentInformation();
|
||||
}
|
||||
|
||||
String originalCreator = Optional.ofNullable(docInfo.getCreator()).orElse("Unknown");
|
||||
String originalProducer = Optional.ofNullable(docInfo.getProducer()).orElse("Unknown");
|
||||
|
||||
// Only keep the original creator so it can match xmp creator tool for compliance
|
||||
DublinCoreSchema dcSchema = xmp.getDublinCoreSchema();
|
||||
if (dcSchema != null) {
|
||||
List<String> existingCreators = dcSchema.getCreators();
|
||||
if (existingCreators != null) {
|
||||
for (String creator : new ArrayList<>(existingCreators)) {
|
||||
dcSchema.removeCreator(creator);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dcSchema = xmp.createAndAddDublinCoreSchema();
|
||||
}
|
||||
dcSchema.addCreator(originalCreator);
|
||||
|
||||
PDFAIdentificationSchema pdfaSchema =
|
||||
(PDFAIdentificationSchema) xmp.getSchema(PDFAIdentificationSchema.class);
|
||||
if (pdfaSchema == null) {
|
||||
pdfaSchema = xmp.createAndAddPDFAIdentificationSchema();
|
||||
}
|
||||
pdfaSchema.setPart(pdfaPart);
|
||||
pdfaSchema.setConformance("B");
|
||||
|
||||
XMPBasicSchema xmpBasicSchema = xmp.getXMPBasicSchema();
|
||||
if (xmpBasicSchema == null) {
|
||||
xmpBasicSchema = xmp.createAndAddXMPBasicSchema();
|
||||
}
|
||||
|
||||
AdobePDFSchema adobePdfSchema = xmp.getAdobePDFSchema();
|
||||
if (adobePdfSchema == null) {
|
||||
adobePdfSchema = xmp.createAndAddAdobePDFSchema();
|
||||
}
|
||||
|
||||
docInfo.setCreator(originalCreator);
|
||||
xmpBasicSchema.setCreatorTool(originalCreator);
|
||||
|
||||
docInfo.setProducer(originalProducer);
|
||||
adobePdfSchema.setProducer(originalProducer);
|
||||
|
||||
String originalAuthor = docInfo.getAuthor();
|
||||
if (originalAuthor != null && !originalAuthor.isBlank()) {
|
||||
docInfo.setAuthor(null);
|
||||
// If the author is set, we keep it in the XMP metadata
|
||||
if (!originalCreator.equals(originalAuthor)) {
|
||||
dcSchema.addCreator(originalAuthor);
|
||||
}
|
||||
}
|
||||
|
||||
String title = docInfo.getTitle();
|
||||
if (title != null && !title.isBlank()) {
|
||||
dcSchema.setTitle(title);
|
||||
}
|
||||
String subject = docInfo.getSubject();
|
||||
if (subject != null && !subject.isBlank()) {
|
||||
dcSchema.addSubject(subject);
|
||||
}
|
||||
String keywords = docInfo.getKeywords();
|
||||
if (keywords != null && !keywords.isBlank()) {
|
||||
adobePdfSchema.setKeywords(keywords);
|
||||
}
|
||||
|
||||
// Set creation and modification dates
|
||||
Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
|
||||
Calendar originalCreationDate = docInfo.getCreationDate();
|
||||
if (originalCreationDate == null) {
|
||||
originalCreationDate = now;
|
||||
}
|
||||
docInfo.setCreationDate(originalCreationDate);
|
||||
xmpBasicSchema.setCreateDate(originalCreationDate);
|
||||
|
||||
docInfo.setModificationDate(now);
|
||||
xmpBasicSchema.setModifyDate(now);
|
||||
xmpBasicSchema.setMetadataDate(now);
|
||||
|
||||
// Serialize the created metadata so it can be attached to the existent metadata
|
||||
ByteArrayOutputStream xmpOut = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(xmp, xmpOut, true);
|
||||
|
||||
PDMetadata newMetadata = new PDMetadata(document);
|
||||
newMetadata.importXMPMetadata(xmpOut.toByteArray());
|
||||
document.getDocumentCatalog().setMetadata(newMetadata);
|
||||
}
|
||||
|
||||
private void addICCProfileIfNotPresent(PDDocument document) throws Exception {
|
||||
if (document.getDocumentCatalog().getOutputIntents().isEmpty()) {
|
||||
try (InputStream colorProfile = getClass().getResourceAsStream("/icc/sRGB2014.icc")) {
|
||||
PDOutputIntent outputIntent = new PDOutputIntent(document, colorProfile);
|
||||
outputIntent.setInfo("sRGB IEC61966-2.1");
|
||||
outputIntent.setOutputCondition("sRGB IEC61966-2.1");
|
||||
outputIntent.setOutputConditionIdentifier("sRGB IEC61966-2.1");
|
||||
outputIntent.setRegistryName("http://www.color.org");
|
||||
document.getDocumentCatalog().addOutputIntent(outputIntent);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load ICC profile: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private File preProcessHighlights(File inputPdf) throws Exception {
|
||||
|
||||
try (PDDocument document = Loader.loadPDF(inputPdf)) {
|
||||
|
||||
for (PDPage page : document.getPages()) {
|
||||
// Retrieve the annotations on the page.
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
for (PDAnnotation annot : annotations) {
|
||||
// Process only highlight annotations.
|
||||
if ("Highlight".equals(annot.getSubtype())
|
||||
&& annot instanceof PDAnnotationTextMarkup highlight) {
|
||||
// Create a new appearance stream with the same bounding box.
|
||||
float[] colorComponents =
|
||||
highlight.getColor() != null
|
||||
? highlight.getColor().getComponents()
|
||||
: new float[] {1f, 1f, 0f};
|
||||
Color highlightColor =
|
||||
new Color(
|
||||
colorComponents[0], colorComponents[1], colorComponents[2]);
|
||||
|
||||
float[] quadPoints = highlight.getQuadPoints();
|
||||
if (quadPoints != null) {
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.PREPEND,
|
||||
true,
|
||||
true)) {
|
||||
|
||||
cs.setStrokingColor(highlightColor);
|
||||
cs.setLineWidth(0.05f);
|
||||
float spacing = 2f;
|
||||
// Draw diagonal lines across the highlight area to simulate
|
||||
// transparency.
|
||||
for (int i = 0; i < quadPoints.length; i += 8) {
|
||||
float minX =
|
||||
Math.min(
|
||||
Math.min(quadPoints[i], quadPoints[i + 2]),
|
||||
Math.min(quadPoints[i + 4], quadPoints[i + 6]));
|
||||
float maxX =
|
||||
Math.max(
|
||||
Math.max(quadPoints[i], quadPoints[i + 2]),
|
||||
Math.max(quadPoints[i + 4], quadPoints[i + 6]));
|
||||
float minY =
|
||||
Math.min(
|
||||
Math.min(quadPoints[i + 1], quadPoints[i + 3]),
|
||||
Math.min(quadPoints[i + 5], quadPoints[i + 7]));
|
||||
float maxY =
|
||||
Math.max(
|
||||
Math.max(quadPoints[i + 1], quadPoints[i + 3]),
|
||||
Math.max(quadPoints[i + 5], quadPoints[i + 7]));
|
||||
|
||||
float width = maxX - minX;
|
||||
float height = maxY - minY;
|
||||
|
||||
for (float y = minY; y <= maxY; y += spacing) {
|
||||
float len = Math.min(width, maxY - y);
|
||||
cs.moveTo(minX, y);
|
||||
cs.lineTo(minX + len, y + len);
|
||||
}
|
||||
for (float x = minX + spacing; x <= maxX; x += spacing) {
|
||||
float len = Math.min(maxX - x, height);
|
||||
cs.moveTo(x, minY);
|
||||
cs.lineTo(x + len, minY + len);
|
||||
}
|
||||
}
|
||||
|
||||
cs.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
page.getAnnotations().remove(highlight);
|
||||
COSDictionary pageDict = page.getCOSObject();
|
||||
|
||||
if (pageDict.containsKey(COSName.GROUP)) {
|
||||
COSDictionary groupDict =
|
||||
(COSDictionary) pageDict.getDictionaryObject(COSName.GROUP);
|
||||
|
||||
if (groupDict != null) {
|
||||
if (COSName.TRANSPARENCY
|
||||
.getName()
|
||||
.equalsIgnoreCase(groupDict.getNameAsString(COSName.S))) {
|
||||
pageDict.removeItem(COSName.GROUP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Save the modified document to a temporary file.
|
||||
File preProcessedFile = Files.createTempFile("preprocessed_", ".pdf").toFile();
|
||||
document.save(preProcessedFile);
|
||||
return preProcessedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddAttachmentRequest;
|
||||
import stirling.software.SPDF.service.AttachmentServiceInterface;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
public class AttachmentController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private final AttachmentServiceInterface pdfAttachmentService;
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/add-attachments")
|
||||
@Operation(
|
||||
summary = "Add attachments to PDF",
|
||||
description =
|
||||
"This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO")
|
||||
public ResponseEntity<byte[]> addAttachments(@ModelAttribute AddAttachmentRequest request)
|
||||
throws IOException {
|
||||
MultipartFile fileInput = request.getFileInput();
|
||||
List<MultipartFile> attachments = request.getAttachments();
|
||||
|
||||
PDDocument document =
|
||||
pdfAttachmentService.addAttachment(
|
||||
pdfDocumentFactory.load(fileInput, false), attachments);
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
Filenames.toSimpleFileName(fileInput.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_with_attachments.pdf");
|
||||
}
|
||||
}
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Config", description = "Configuration APIs")
|
||||
@RequestMapping("/api/v1/config")
|
||||
@RequiredArgsConstructor
|
||||
@Hidden
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
Map<String, Object> configData = new HashMap<>();
|
||||
|
||||
try {
|
||||
// Get AppConfig bean
|
||||
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
||||
|
||||
// Extract key configuration values from AppConfig
|
||||
configData.put("baseUrl", appConfig.getBaseUrl());
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appName", applicationProperties.getUi().getAppName());
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
configData.put(
|
||||
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
configData.put("privacyPolicy", applicationProperties.getLegal().getPrivacyPolicy());
|
||||
configData.put("cookiePolicy", applicationProperties.getLegal().getCookiePolicy());
|
||||
configData.put("impressum", applicationProperties.getLegal().getImpressum());
|
||||
configData.put(
|
||||
"accessibilityStatement",
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
|
||||
// Try to get EEAppConfig values if available
|
||||
try {
|
||||
if (applicationContext.containsBean("runningProOrHigher")) {
|
||||
configData.put(
|
||||
"runningProOrHigher",
|
||||
applicationContext.getBean("runningProOrHigher", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("runningEE")) {
|
||||
configData.put(
|
||||
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("GoogleDriveEnabled")) {
|
||||
configData.put(
|
||||
"GoogleDriveEnabled",
|
||||
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
applicationContext.getBean("SSOAutoLogin", Boolean.class));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// EE features not available, continue without them
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
// Return basic config if there are any issues
|
||||
configData.put("error", "Unable to retrieve full configuration");
|
||||
return ResponseEntity.ok(configData);
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/endpoint-enabled")
|
||||
public ResponseEntity<Boolean> isEndpointEnabled(@RequestParam String endpoint) {
|
||||
boolean enabled = endpointConfiguration.isEndpointEnabled(endpoint);
|
||||
return ResponseEntity.ok(enabled);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-enabled")
|
||||
public ResponseEntity<Map<String, Boolean>> areEndpointsEnabled(
|
||||
@RequestParam String endpoints) {
|
||||
Map<String, Boolean> result = new HashMap<>();
|
||||
String[] endpointArray = endpoints.split(",");
|
||||
for (String endpoint : endpointArray) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(trimmedEndpoint, endpointConfiguration.isEndpointEnabled(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempDirectory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class OCRController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isOcrMyPdfEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("OCRmyPDF");
|
||||
}
|
||||
|
||||
private boolean isTesseractEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("tesseract");
|
||||
}
|
||||
|
||||
/** Gets the list of available Tesseract languages from the tessdata directory */
|
||||
public List<String> getAvailableTesseractLanguages() {
|
||||
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
|
||||
File[] files = new File(tessdataDir).listFiles();
|
||||
if (files == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return Arrays.stream(files)
|
||||
.filter(file -> file.getName().endsWith(".traineddata"))
|
||||
.map(file -> file.getName().replace(".traineddata", ""))
|
||||
.filter(lang -> !"osd".equalsIgnoreCase(lang))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/ocr-pdf")
|
||||
@Operation(
|
||||
summary = "Process a PDF file with OCR",
|
||||
description =
|
||||
"This endpoint processes a PDF file using OCR (Optical Character Recognition). "
|
||||
+ "Users can specify languages, sidecar, deskew, clean, cleanFinal, ocrType, ocrRenderType, and removeImagesAfter options. "
|
||||
+ "Uses OCRmyPDF if available, falls back to Tesseract. Input:PDF Output:PDF Type:SI-Conditional")
|
||||
public ResponseEntity<byte[]> processPdfWithOCR(
|
||||
@ModelAttribute ProcessPdfWithOcrRequest request)
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = request.getFileInput();
|
||||
List<String> selectedLanguages = request.getLanguages();
|
||||
Boolean sidecar = request.isSidecar();
|
||||
Boolean deskew = request.isDeskew();
|
||||
Boolean clean = request.isClean();
|
||||
Boolean cleanFinal = request.isCleanFinal();
|
||||
String ocrType = request.getOcrType();
|
||||
String ocrRenderType = request.getOcrRenderType();
|
||||
Boolean removeImagesAfter = request.isRemoveImagesAfter();
|
||||
|
||||
if (selectedLanguages == null || selectedLanguages.isEmpty()) {
|
||||
throw ExceptionUtils.createOcrLanguageRequiredException();
|
||||
}
|
||||
|
||||
if (!"hocr".equals(ocrRenderType) && !"sandwich".equals(ocrRenderType)) {
|
||||
throw new IOException("ocrRenderType wrong");
|
||||
}
|
||||
|
||||
// Get available Tesseract languages
|
||||
List<String> availableLanguages = getAvailableTesseractLanguages();
|
||||
|
||||
// Validate selected languages
|
||||
selectedLanguages =
|
||||
selectedLanguages.stream().filter(availableLanguages::contains).toList();
|
||||
|
||||
if (selectedLanguages.isEmpty()) {
|
||||
throw ExceptionUtils.createOcrInvalidLanguagesException();
|
||||
}
|
||||
|
||||
// Use try-with-resources for proper temp file management
|
||||
try (TempFile tempInputFile = new TempFile(tempFileManager, ".pdf");
|
||||
TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
inputFile.transferTo(tempInputFile.getFile());
|
||||
|
||||
TempFile sidecarTextFile = null;
|
||||
|
||||
try {
|
||||
// Use OCRmyPDF if available (no fallback - error if it fails)
|
||||
if (isOcrMyPdfEnabled()) {
|
||||
if (sidecar != null && sidecar) {
|
||||
sidecarTextFile = new TempFile(tempFileManager, ".txt");
|
||||
}
|
||||
|
||||
processWithOcrMyPdf(
|
||||
selectedLanguages,
|
||||
sidecar,
|
||||
deskew,
|
||||
clean,
|
||||
cleanFinal,
|
||||
ocrType,
|
||||
ocrRenderType,
|
||||
removeImagesAfter,
|
||||
tempInputFile.getPath(),
|
||||
tempOutputFile.getPath(),
|
||||
sidecarTextFile != null ? sidecarTextFile.getPath() : null);
|
||||
log.info("OCRmyPDF processing completed successfully");
|
||||
}
|
||||
// Use Tesseract only if OCRmyPDF is not available
|
||||
else if (isTesseractEnabled()) {
|
||||
processWithTesseract(
|
||||
selectedLanguages,
|
||||
ocrType,
|
||||
tempInputFile.getPath(),
|
||||
tempOutputFile.getPath());
|
||||
log.info("Tesseract processing completed successfully");
|
||||
} else {
|
||||
throw ExceptionUtils.createOcrToolsUnavailableException();
|
||||
}
|
||||
|
||||
// Read the processed PDF file
|
||||
byte[] pdfBytes = Files.readAllBytes(tempOutputFile.getPath());
|
||||
|
||||
// Return the OCR processed PDF as a response
|
||||
String outputFilename =
|
||||
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_OCR.pdf";
|
||||
|
||||
if (sidecar != null && sidecar && sidecarTextFile != null) {
|
||||
// Create a zip file containing both the PDF and the text file
|
||||
String outputZipFilename =
|
||||
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_OCR.zip";
|
||||
|
||||
try (TempFile tempZipFile = new TempFile(tempFileManager, ".zip");
|
||||
ZipOutputStream zipOut =
|
||||
new ZipOutputStream(
|
||||
Files.newOutputStream(tempZipFile.getPath()))) {
|
||||
|
||||
// Add PDF file to the zip
|
||||
ZipEntry pdfEntry = new ZipEntry(outputFilename);
|
||||
zipOut.putNextEntry(pdfEntry);
|
||||
zipOut.write(pdfBytes);
|
||||
zipOut.closeEntry();
|
||||
|
||||
// Add text file to the zip
|
||||
ZipEntry txtEntry = new ZipEntry(outputFilename.replace(".pdf", ".txt"));
|
||||
zipOut.putNextEntry(txtEntry);
|
||||
Files.copy(sidecarTextFile.getPath(), zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
zipOut.finish();
|
||||
|
||||
byte[] zipBytes = Files.readAllBytes(tempZipFile.getPath());
|
||||
|
||||
// Return the zip file containing both the PDF and the text file
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, outputZipFilename, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
} else {
|
||||
// Return the OCR processed PDF as a response
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Clean up sidecar temp file if created
|
||||
if (sidecarTextFile != null) {
|
||||
try {
|
||||
sidecarTextFile.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to close sidecar temp file", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processWithOcrMyPdf(
|
||||
List<String> selectedLanguages,
|
||||
Boolean sidecar,
|
||||
Boolean deskew,
|
||||
Boolean clean,
|
||||
Boolean cleanFinal,
|
||||
String ocrType,
|
||||
String ocrRenderType,
|
||||
Boolean removeImagesAfter,
|
||||
Path tempInputFile,
|
||||
Path tempOutputFile,
|
||||
Path sidecarTextPath)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
// Build OCRmyPDF command
|
||||
String languageOption = String.join("+", selectedLanguages);
|
||||
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
Arrays.asList(
|
||||
"ocrmypdf",
|
||||
"--verbose",
|
||||
"2",
|
||||
"--output-type",
|
||||
"pdf",
|
||||
"--pdf-renderer",
|
||||
ocrRenderType));
|
||||
|
||||
if (sidecar != null && sidecar && sidecarTextPath != null) {
|
||||
command.add("--sidecar");
|
||||
command.add(sidecarTextPath.toString());
|
||||
}
|
||||
|
||||
if (deskew != null && deskew) {
|
||||
command.add("--deskew");
|
||||
}
|
||||
if (clean != null && clean) {
|
||||
command.add("--clean");
|
||||
}
|
||||
if (cleanFinal != null && cleanFinal) {
|
||||
command.add("--clean-final");
|
||||
}
|
||||
if (ocrType != null && !"".equals(ocrType)) {
|
||||
if ("skip-text".equals(ocrType)) {
|
||||
command.add("--skip-text");
|
||||
} else if ("force-ocr".equals(ocrType)) {
|
||||
command.add("--force-ocr");
|
||||
}
|
||||
}
|
||||
|
||||
command.addAll(
|
||||
Arrays.asList(
|
||||
"--language",
|
||||
languageOption,
|
||||
tempInputFile.toString(),
|
||||
tempOutputFile.toString()));
|
||||
|
||||
// Run CLI command
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0
|
||||
&& result.getMessages().contains("multiprocessing/synchronize.py")
|
||||
&& result.getMessages().contains("OSError: [Errno 38] Function not implemented")) {
|
||||
command.add("--jobs");
|
||||
command.add("1");
|
||||
result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.OCR_MY_PDF)
|
||||
.runCommandWithOutputHandling(command);
|
||||
}
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
throw new IOException("OCRmyPDF failed with return code: " + result.getRc());
|
||||
}
|
||||
|
||||
// Remove images from the OCR processed PDF if the flag is set to true
|
||||
if (removeImagesAfter != null && removeImagesAfter) {
|
||||
try (TempFile tempPdfWithoutImages = new TempFile(tempFileManager, "_no_images.pdf")) {
|
||||
List<String> gsCommand =
|
||||
Arrays.asList(
|
||||
"gs",
|
||||
"-sDEVICE=pdfwrite",
|
||||
"-dFILTERIMAGE",
|
||||
"-o",
|
||||
tempPdfWithoutImages.getPath().toString(),
|
||||
tempOutputFile.toString());
|
||||
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(gsCommand);
|
||||
|
||||
// Replace output file with version without images
|
||||
Files.copy(
|
||||
tempPdfWithoutImages.getPath(),
|
||||
tempOutputFile,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processWithTesseract(
|
||||
List<String> selectedLanguages, String ocrType, Path tempInputFile, Path tempOutputFile)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
// Create temp directory for Tesseract processing
|
||||
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
|
||||
File tempOutputDir = new File(tempDir.getPath().toFile(), "output");
|
||||
File tempImagesDir = new File(tempDir.getPath().toFile(), "images");
|
||||
File finalOutputFile = new File(tempDir.getPath().toFile(), "final_output.pdf");
|
||||
|
||||
// Create directories
|
||||
tempOutputDir.mkdirs();
|
||||
tempImagesDir.mkdirs();
|
||||
|
||||
PDFMergerUtility merger = new PDFMergerUtility();
|
||||
merger.setDestinationFileName(finalOutputFile.toString());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
|
||||
PDPage page = document.getPage(pageNum);
|
||||
boolean hasText = false;
|
||||
|
||||
// Check for existing text
|
||||
try (PDDocument tempDoc = new PDDocument()) {
|
||||
tempDoc.addPage(page);
|
||||
PDFTextStripper stripper = new PDFTextStripper();
|
||||
hasText = !stripper.getText(tempDoc).trim().isEmpty();
|
||||
}
|
||||
|
||||
boolean shouldOcr =
|
||||
switch (ocrType) {
|
||||
case "skip-text" -> !hasText;
|
||||
case "force-ocr" -> true;
|
||||
default -> true;
|
||||
};
|
||||
|
||||
File pageOutputPath =
|
||||
new File(tempOutputDir, String.format("page_%d.pdf", pageNum));
|
||||
|
||||
if (shouldOcr) {
|
||||
// Convert page to image
|
||||
BufferedImage image = pdfRenderer.renderImageWithDPI(pageNum, 300);
|
||||
File imagePath =
|
||||
new File(tempImagesDir, String.format("page_%d.png", pageNum));
|
||||
ImageIO.write(image, "png", imagePath);
|
||||
|
||||
// Build OCR command
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("tesseract");
|
||||
command.add(imagePath.toString());
|
||||
command.add(
|
||||
new File(tempOutputDir, String.format("page_%d", pageNum))
|
||||
.toString());
|
||||
command.add("-l");
|
||||
command.add(String.join("+", selectedLanguages));
|
||||
command.add("pdf"); // Always output PDF
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.TESSERACT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
throw ExceptionUtils.createRuntimeException(
|
||||
"error.commandFailed",
|
||||
"{0} command failed with exit code: {1}",
|
||||
null,
|
||||
"Tesseract",
|
||||
result.getRc());
|
||||
}
|
||||
|
||||
// Add OCR'd PDF to merger
|
||||
merger.addSource(pageOutputPath);
|
||||
} else {
|
||||
// Save original page without OCR
|
||||
try (PDDocument pageDoc = new PDDocument()) {
|
||||
pageDoc.addPage(page);
|
||||
pageDoc.save(pageOutputPath);
|
||||
merger.addSource(pageOutputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all pages into final PDF
|
||||
merger.mergeDocuments(null);
|
||||
|
||||
// Copy final output to the expected location
|
||||
Files.copy(
|
||||
finalOutputFile.toPath(),
|
||||
tempOutputFile,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class RepairController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
private boolean isGhostscriptEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
private boolean isQpdfEnabled() {
|
||||
return endpointConfiguration.isGroupEnabled("qpdf");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/repair")
|
||||
@Operation(
|
||||
summary = "Repair a PDF file",
|
||||
description =
|
||||
"This endpoint repairs a given PDF file by running Ghostscript (primary), qpdf (fallback), or PDFBox (if no external tools available). The PDF is"
|
||||
+ " first saved to a temporary location, repaired, read back, and then"
|
||||
+ " returned as a response. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> repairPdf(@ModelAttribute PDFFile file)
|
||||
throws IOException, InterruptedException {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
|
||||
// Use TempFile with try-with-resources for automatic cleanup
|
||||
try (TempFile tempInputFile = new TempFile(tempFileManager, ".pdf");
|
||||
TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
// Save the uploaded file to the temporary location
|
||||
inputFile.transferTo(tempInputFile.getFile());
|
||||
|
||||
boolean repairSuccess = false;
|
||||
|
||||
// Try Ghostscript first if available
|
||||
if (isGhostscriptEnabled()) {
|
||||
try {
|
||||
List<String> gsCommand = new ArrayList<>();
|
||||
gsCommand.add("gs");
|
||||
gsCommand.add("-o");
|
||||
gsCommand.add(tempOutputFile.getPath().toString());
|
||||
gsCommand.add("-sDEVICE=pdfwrite");
|
||||
gsCommand.add(tempInputFile.getPath().toString());
|
||||
|
||||
ProcessExecutorResult gsResult =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(gsCommand);
|
||||
|
||||
if (gsResult.getRc() == 0) {
|
||||
repairSuccess = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Log and continue to QPDF fallback
|
||||
log.warn("Ghostscript repair failed, trying QPDF fallback: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to QPDF if Ghostscript failed or not available
|
||||
if (!repairSuccess && isQpdfEnabled()) {
|
||||
List<String> qpdfCommand = new ArrayList<>();
|
||||
qpdfCommand.add("qpdf");
|
||||
qpdfCommand.add("--replace-input"); // Automatically fixes problems it can
|
||||
qpdfCommand.add("--qdf"); // Linearizes and normalizes PDF structure
|
||||
qpdfCommand.add("--object-streams=disable"); // Can help with some corruptions
|
||||
qpdfCommand.add(tempInputFile.getPath().toString());
|
||||
qpdfCommand.add(tempOutputFile.getPath().toString());
|
||||
|
||||
ProcessExecutorResult qpdfResult =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)
|
||||
.runCommandWithOutputHandling(qpdfCommand);
|
||||
|
||||
repairSuccess = true;
|
||||
}
|
||||
|
||||
// Use PDFBox as last resort if no external tools are available
|
||||
if (!repairSuccess) {
|
||||
if (!isGhostscriptEnabled() && !isQpdfEnabled()) {
|
||||
// Basic PDFBox repair - load and save to fix structural issues
|
||||
try (var document = pdfDocumentFactory.load(tempInputFile.getFile())) {
|
||||
document.save(tempOutputFile.getFile());
|
||||
repairSuccess = true;
|
||||
}
|
||||
} else {
|
||||
throw new IOException("PDF repair failed with available tools");
|
||||
}
|
||||
}
|
||||
|
||||
// Read the repaired PDF file
|
||||
byte[] pdfBytes = pdfDocumentFactory.loadToBytes(tempOutputFile.getFile());
|
||||
|
||||
// Return the repaired PDF as a response
|
||||
String outputFilename =
|
||||
Filenames.toSimpleFileName(inputFile.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "")
|
||||
+ "_repaired.pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package stirling.software.SPDF.model.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
public class EditTableOfContentsRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "Bookmark structure in JSON format",
|
||||
example =
|
||||
"[{\"title\":\"Chapter 1\",\"pageNumber\":1,\"children\":[{\"title\":\"Section 1.1\",\"pageNumber\":2}]}]")
|
||||
private String bookmarkData;
|
||||
|
||||
@Schema(
|
||||
description = "Whether to replace existing bookmarks or append to them",
|
||||
example = "true")
|
||||
private Boolean replaceExisting;
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package stirling.software.SPDF.model.api.misc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AddAttachmentRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The image file to be overlaid onto the PDF.",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
format = "binary")
|
||||
private List<MultipartFile> attachments;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.apache.pdfbox.pdmodel.PageMode;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AttachmentService implements AttachmentServiceInterface {
|
||||
|
||||
@Override
|
||||
public PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
|
||||
throws IOException {
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document);
|
||||
Map<String, PDComplexFileSpecification> existingNames;
|
||||
|
||||
try {
|
||||
Map<String, PDComplexFileSpecification> names = embeddedFilesTree.getNames();
|
||||
|
||||
if (names == null) {
|
||||
log.debug("No existing embedded files found, creating new names map.");
|
||||
existingNames = new HashMap<>();
|
||||
} else {
|
||||
existingNames = new HashMap<>(names);
|
||||
log.debug("Embedded files: {}", existingNames.keySet());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Could not retrieve existing embedded files", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
attachments.forEach(
|
||||
attachment -> {
|
||||
String filename = attachment.getOriginalFilename();
|
||||
|
||||
try {
|
||||
PDEmbeddedFile embeddedFile =
|
||||
new PDEmbeddedFile(document, attachment.getInputStream());
|
||||
embeddedFile.setSize((int) attachment.getSize());
|
||||
embeddedFile.setCreationDate(new GregorianCalendar());
|
||||
embeddedFile.setModDate(new GregorianCalendar());
|
||||
String contentType = attachment.getContentType();
|
||||
if (StringUtils.isNotBlank(contentType)) {
|
||||
embeddedFile.setSubtype(contentType);
|
||||
}
|
||||
|
||||
// Create attachments specification and associate embedded attachment with
|
||||
// file
|
||||
PDComplexFileSpecification fileSpecification =
|
||||
new PDComplexFileSpecification();
|
||||
fileSpecification.setFile(filename);
|
||||
fileSpecification.setFileUnicode(filename);
|
||||
fileSpecification.setFileDescription("Embedded attachment: " + filename);
|
||||
fileSpecification.setEmbeddedFile(embeddedFile);
|
||||
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
|
||||
|
||||
existingNames.put(filename, fileSpecification);
|
||||
|
||||
log.info("Added attachment: {} ({} bytes)", filename, attachment.getSize());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to create embedded file for attachment: {}", filename, e);
|
||||
}
|
||||
});
|
||||
|
||||
embeddedFilesTree.setNames(existingNames);
|
||||
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private PDEmbeddedFilesNameTreeNode getEmbeddedFilesTree(PDDocument document) {
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
PDDocumentNameDictionary documentNames = catalog.getNames();
|
||||
|
||||
if (documentNames == null) {
|
||||
documentNames = new PDDocumentNameDictionary(catalog);
|
||||
}
|
||||
|
||||
catalog.setNames(documentNames);
|
||||
PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles();
|
||||
|
||||
if (embeddedFilesTree == null) {
|
||||
embeddedFilesTree = new PDEmbeddedFilesNameTreeNode();
|
||||
documentNames.setEmbeddedFiles(embeddedFilesTree);
|
||||
}
|
||||
return embeddedFilesTree;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface AttachmentServiceInterface {
|
||||
|
||||
PDDocument addAttachment(PDDocument document, List<MultipartFile> attachments)
|
||||
throws IOException;
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
package stirling.software.common.controller;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.JobResult;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobQueue;
|
||||
import stirling.software.common.service.TaskManager;
|
||||
|
||||
/** REST controller for job-related endpoints */
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JobController {
|
||||
|
||||
private final TaskManager taskManager;
|
||||
private final FileStorage fileStorage;
|
||||
private final JobQueue jobQueue;
|
||||
private final HttpServletRequest request;
|
||||
|
||||
/**
|
||||
* Get the status of a job
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The job result
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}")
|
||||
public ResponseEntity<?> getJobStatus(@PathVariable("jobId") String jobId) {
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Check if the job is in the queue and add queue information
|
||||
if (!result.isComplete() && jobQueue.isJobQueued(jobId)) {
|
||||
int position = jobQueue.getJobPosition(jobId);
|
||||
Map<String, Object> resultWithQueueInfo =
|
||||
Map.of(
|
||||
"jobResult",
|
||||
result,
|
||||
"queueInfo",
|
||||
Map.of("inQueue", true, "position", position));
|
||||
return ResponseEntity.ok(resultWithQueueInfo);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the result of a job
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return The job result
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}/result")
|
||||
public ResponseEntity<?> getJobResult(@PathVariable("jobId") String jobId) {
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!result.isComplete()) {
|
||||
return ResponseEntity.badRequest().body("Job is not complete yet");
|
||||
}
|
||||
|
||||
if (result.getError() != null) {
|
||||
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
|
||||
}
|
||||
|
||||
// Handle multiple files - return metadata for client to download individually
|
||||
if (result.hasMultipleFiles()) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(
|
||||
Map.of(
|
||||
"jobId",
|
||||
jobId,
|
||||
"hasMultipleFiles",
|
||||
true,
|
||||
"files",
|
||||
result.getAllResultFiles()));
|
||||
}
|
||||
|
||||
// Handle single file (download directly)
|
||||
if (result.hasFiles() && !result.hasMultipleFiles()) {
|
||||
try {
|
||||
List<ResultFile> files = result.getAllResultFiles();
|
||||
ResultFile singleFile = files.get(0);
|
||||
byte[] fileContent = fileStorage.retrieveBytes(singleFile.getFileId());
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", singleFile.getContentType())
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
createContentDispositionHeader(singleFile.getFileName()))
|
||||
.body(fileContent);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving file for job {}: {}", jobId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Error retrieving file: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result.getResult());
|
||||
}
|
||||
|
||||
// Admin-only endpoints have been moved to AdminJobController in the proprietary package
|
||||
|
||||
/**
|
||||
* Cancel a job by its ID
|
||||
*
|
||||
* <p>This method should only allow cancellation of jobs that were created by the current user.
|
||||
* The jobId should be part of the user's session or otherwise linked to their identity.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return Response indicating whether the job was cancelled
|
||||
*/
|
||||
@DeleteMapping("/api/v1/general/job/{jobId}")
|
||||
public ResponseEntity<?> cancelJob(@PathVariable("jobId") String jobId) {
|
||||
log.debug("Request to cancel job: {}", jobId);
|
||||
|
||||
// Verify that this job belongs to the current user
|
||||
// We can use the current request's session to validate ownership
|
||||
Object sessionJobIds = request.getSession().getAttribute("userJobIds");
|
||||
if (sessionJobIds == null
|
||||
|| !(sessionJobIds instanceof java.util.Set)
|
||||
|| !((java.util.Set<?>) sessionJobIds).contains(jobId)) {
|
||||
// Either no jobs in session or jobId doesn't match user's jobs
|
||||
log.warn("Unauthorized attempt to cancel job: {}", jobId);
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "You are not authorized to cancel this job"));
|
||||
}
|
||||
|
||||
// First check if the job is in the queue
|
||||
boolean cancelled = false;
|
||||
int queuePosition = -1;
|
||||
|
||||
if (jobQueue.isJobQueued(jobId)) {
|
||||
queuePosition = jobQueue.getJobPosition(jobId);
|
||||
cancelled = jobQueue.cancelJob(jobId);
|
||||
log.info("Cancelled queued job: {} (was at position {})", jobId, queuePosition);
|
||||
}
|
||||
|
||||
// If not in queue or couldn't cancel, try to cancel in TaskManager
|
||||
if (!cancelled) {
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result != null && !result.isComplete()) {
|
||||
// Mark as error with cancellation message
|
||||
taskManager.setError(jobId, "Job was cancelled by user");
|
||||
cancelled = true;
|
||||
log.info("Marked job as cancelled in TaskManager: {}", jobId);
|
||||
}
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"Job cancelled successfully",
|
||||
"wasQueued",
|
||||
queuePosition >= 0,
|
||||
"queuePosition",
|
||||
queuePosition >= 0 ? queuePosition : "n/a"));
|
||||
} else {
|
||||
// Job not found or already complete
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
} else if (result.isComplete()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("message", "Cannot cancel job that is already complete"));
|
||||
} else {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("message", "Failed to cancel job for unknown reason"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of files for a job
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @return List of files for the job
|
||||
*/
|
||||
@GetMapping("/api/v1/general/job/{jobId}/result/files")
|
||||
public ResponseEntity<?> getJobFiles(@PathVariable("jobId") String jobId) {
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
if (result == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!result.isComplete()) {
|
||||
return ResponseEntity.badRequest().body("Job is not complete yet");
|
||||
}
|
||||
|
||||
if (result.getError() != null) {
|
||||
return ResponseEntity.badRequest().body("Job failed: " + result.getError());
|
||||
}
|
||||
|
||||
List<ResultFile> files = result.getAllResultFiles();
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"jobId", jobId,
|
||||
"fileCount", files.size(),
|
||||
"files", files));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for an individual file by its file ID
|
||||
*
|
||||
* @param fileId The file ID
|
||||
* @return The file metadata
|
||||
*/
|
||||
@GetMapping("/api/v1/general/files/{fileId}/metadata")
|
||||
public ResponseEntity<?> getFileMetadata(@PathVariable("fileId") String fileId) {
|
||||
try {
|
||||
// Verify file exists
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Find the file metadata from any job that contains this file
|
||||
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
|
||||
|
||||
if (resultFile != null) {
|
||||
return ResponseEntity.ok(resultFile);
|
||||
} else {
|
||||
// File exists but no metadata found, get basic info efficiently
|
||||
long fileSize = fileStorage.getFileSize(fileId);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"fileId",
|
||||
fileId,
|
||||
"fileName",
|
||||
"unknown",
|
||||
"contentType",
|
||||
"application/octet-stream",
|
||||
"fileSize",
|
||||
fileSize));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving file metadata {}: {}", fileId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Error retrieving file metadata: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an individual file by its file ID
|
||||
*
|
||||
* @param fileId The file ID
|
||||
* @return The file content
|
||||
*/
|
||||
@GetMapping("/api/v1/general/files/{fileId}")
|
||||
public ResponseEntity<?> downloadFile(@PathVariable("fileId") String fileId) {
|
||||
try {
|
||||
// Verify file exists
|
||||
if (!fileStorage.fileExists(fileId)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// Retrieve file content
|
||||
byte[] fileContent = fileStorage.retrieveBytes(fileId);
|
||||
|
||||
// Find the file metadata from any job that contains this file
|
||||
// This is for getting the original filename and content type
|
||||
ResultFile resultFile = taskManager.findResultFileByFileId(fileId);
|
||||
|
||||
String fileName = resultFile != null ? resultFile.getFileName() : "download";
|
||||
String contentType =
|
||||
resultFile != null ? resultFile.getContentType() : "application/octet-stream";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", contentType)
|
||||
.header("Content-Disposition", createContentDispositionHeader(fileName))
|
||||
.body(fileContent);
|
||||
} catch (Exception e) {
|
||||
log.error("Error retrieving file {}: {}", fileId, e.getMessage(), e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Error retrieving file: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Content-Disposition header with UTF-8 filename support
|
||||
*
|
||||
* @param fileName The filename to encode
|
||||
* @return Content-Disposition header value
|
||||
*/
|
||||
private String createContentDispositionHeader(String fileName) {
|
||||
try {
|
||||
String encodedFileName =
|
||||
URLEncoder.encode(fileName, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20"); // URLEncoder uses + for spaces, but we want %20
|
||||
return "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encodedFileName;
|
||||
} catch (Exception e) {
|
||||
// Fallback to basic filename if encoding fails
|
||||
return "attachment; filename=\"" + fileName + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,276 +0,0 @@
|
||||
/* Main bookmark container styles */
|
||||
.bookmark-editor {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-color, #ced4da);
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--bg-container, var(--md-sys-color-surface, #edf0f5));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-editor {
|
||||
--border-color: #495057;
|
||||
--bg-container: var(--md-sys-color-surface, #15202a);
|
||||
}
|
||||
|
||||
/* Bookmark item styles */
|
||||
.bookmark-item {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border-item, #e9ecef);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--bg-item, var(--md-sys-color-surface-container-lowest, #ffffff));
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-item {
|
||||
--border-item: var(--md-sys-color-surface-variant, #444b53);
|
||||
--bg-item: var(--md-sys-color-surface-container-low, #24282e);
|
||||
}
|
||||
|
||||
/* Bookmark header (collapsible part) */
|
||||
.bookmark-header {
|
||||
padding: 12px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-header, var(--md-sys-color-surface-container, #f1f3f5));
|
||||
border-bottom: 1px solid var(--border-header, var(--md-sys-color-outline-variant, #e9ecef));
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bookmark-header:hover {
|
||||
background-color: var(--bg-header-hover, var(--md-sys-state-hover-opacity, #e9ecef));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-header {
|
||||
--bg-header: var(--md-sys-color-surface-container-high, #3a424a);
|
||||
--bg-header-hover: var(--md-sys-color-surface-container-highest, #434a52);
|
||||
--border-header: var(--md-sys-color-outline-variant, #444b53);
|
||||
}
|
||||
|
||||
/* Bookmark content (inside accordion) */
|
||||
.bookmark-content {
|
||||
padding: 15px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
/* Children container */
|
||||
.bookmark-children {
|
||||
margin-top: 10px;
|
||||
margin-left: 20px;
|
||||
padding-left: 15px;
|
||||
border-left: 2px solid var(--border-children, #6c757d);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-children {
|
||||
--border-children: #6c757d;
|
||||
}
|
||||
|
||||
/* Level indicators */
|
||||
.bookmark-level-indicator {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted, #6c757d);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-level-indicator {
|
||||
--text-muted: #adb5bd;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-bookmark-action {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
margin-right: 6px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.btn-bookmark-action:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Visual distinction for child vs sibling buttons using theme colors */
|
||||
.btn-add-child {
|
||||
background-color: var(--btn-add-child-bg, var(--md-sys-color-surface-container-low, #e9ecef));
|
||||
color: var(--btn-add-child-color, var(--md-nav-section-color-other, #72bd54));
|
||||
border-color: var(--btn-add-child-border, var(--md-nav-section-color-other, #72bd54));
|
||||
}
|
||||
|
||||
.btn-add-child::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 50%;
|
||||
width: 5px;
|
||||
height: 1px;
|
||||
background-color: var(--btn-add-child-border, var(--md-nav-section-color-other, #72bd54));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-add-child {
|
||||
--btn-add-child-bg: var(--md-sys-color-surface-container, #28323a);
|
||||
--btn-add-child-color: var(--favourite-add, #9ed18c);
|
||||
--btn-add-child-border: var(--favourite-add, #9ed18c);
|
||||
}
|
||||
|
||||
.btn-add-sibling {
|
||||
background-color: var(--btn-add-sibling-bg, var(--md-sys-color-surface-container-low, #e9ecef));
|
||||
color: var(--btn-add-sibling-color, var(--md-sys-color-primary, #0060aa));
|
||||
border-color: var(--btn-add-sibling-border, var(--md-sys-color-primary, #0060aa));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .btn-add-sibling {
|
||||
--btn-add-sibling-bg: var(--md-sys-color-surface-container, #28323a);
|
||||
--btn-add-sibling-color: var(--md-sys-color-primary, #a2c9ff);
|
||||
--btn-add-sibling-border: var(--md-sys-color-primary, #a2c9ff);
|
||||
}
|
||||
|
||||
.bookmark-actions-header {
|
||||
display: flex;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.bookmark-actions-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--border-subtle-color, #dee2e6);
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-actions-content {
|
||||
--border-subtle-color: #495057;
|
||||
}
|
||||
|
||||
/* Main actions section */
|
||||
.bookmark-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Collapse/expand icons */
|
||||
.toggle-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsed .toggle-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Title and page display in header */
|
||||
.bookmark-title-preview {
|
||||
font-weight: 500;
|
||||
margin-right: 10px;
|
||||
color: var(--text-primary, var(--md-sys-color-on-surface, #212529));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-title-preview {
|
||||
--text-primary: var(--md-sys-color-on-surface, #e9ecef);
|
||||
}
|
||||
|
||||
.bookmark-page-preview {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-secondary, var(--md-sys-color-on-surface-variant, #6c757d));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-page-preview {
|
||||
--text-secondary: var(--md-sys-color-on-surface-variant, #adb5bd);
|
||||
}
|
||||
|
||||
/* Input text colors */
|
||||
.bookmark-content input,
|
||||
.bookmark-content label {
|
||||
color: var(--input-text, var(--md-sys-color-on-surface, #212529));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-content input,
|
||||
[data-bs-theme="dark"] .bookmark-content label {
|
||||
--input-text: var(--md-sys-color-on-surface, #e9ecef);
|
||||
background-color: var(--input-bg, var(--md-sys-color-surface-container-high, #3a424a));
|
||||
border-color: var(--input-border, var(--md-sys-color-outline, #495057));
|
||||
}
|
||||
|
||||
/* We've removed the drag handle since it's not functional */
|
||||
|
||||
/* Add button at the top level */
|
||||
.btn-add-bookmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.btn-add-bookmark::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 50%;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.btn-add-bookmark.top-level::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Relationship indicators */
|
||||
.bookmark-relationship-indicator {
|
||||
position: absolute;
|
||||
left: -15px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.relationship-line {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
background-color: var(--relationship-color, var(--md-nav-section-color-other, #72bd54));
|
||||
}
|
||||
|
||||
.relationship-arrow {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
background-color: var(--relationship-color, var(--md-nav-section-color-other, #72bd54));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .bookmark-relationship-indicator {
|
||||
--relationship-color: var(--favourite-add, #9ed18c);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-bookmarks {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: var(--text-muted, var(--md-sys-color-on-surface-variant, #6c757d));
|
||||
background-color: var(--bg-empty, var(--md-sys-color-surface-container-lowest, #ffffff));
|
||||
border-radius: 0.375rem;
|
||||
border: 1px dashed var(--border-empty, var(--md-sys-color-outline, #ced4da));
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] .empty-bookmarks {
|
||||
--text-muted: var(--md-sys-color-on-surface-variant, #adb5bd);
|
||||
--bg-empty: var(--md-sys-color-surface-container-low, #24282e);
|
||||
--border-empty: var(--md-sys-color-outline, #495057);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
#searchBar {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem 3.5rem;
|
||||
border: 1px solid var(--md-sys-color-outline-variant);
|
||||
border-radius: 3rem;
|
||||
outline-color: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
#filtersContainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
color: var(--md-sys-color-secondary);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.filter-button:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
margin: 0.75rem 1rem;
|
||||
border: 0.1rem solid transparent;
|
||||
}
|
||||
|
||||
.features-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.feature-group-legacy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feature-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
margin-bottom: 15px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feature-group-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 3fr));
|
||||
gap: 30px 30px;
|
||||
overflow: hidden;
|
||||
margin: -20px;
|
||||
padding: 20px;
|
||||
box-sizing:content-box;
|
||||
}
|
||||
|
||||
.feature-group-container.animated-group {
|
||||
transition: 0.5s all;
|
||||
}
|
||||
|
||||
.feature-group-legacy.collapsed>.feature-group-container {
|
||||
max-height: 0 !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header-expand-button {
|
||||
transition: 0.5s all;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.header-expand-button.collapsed {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
border: 1px solid var(--md-sys-color-surface-5);
|
||||
border-radius: 1.75rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
background: var(--md-sys-color-surface-5);
|
||||
transition:
|
||||
transform 0.3s,
|
||||
border 0.3s;
|
||||
transform-origin: center center;
|
||||
outline: 0px solid transparent;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
.feature-card a {
|
||||
text-decoration: none;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-card .card-text {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
cursor: pointer;
|
||||
transform: scale(1.08);
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
}
|
||||
|
||||
.card-title.text-primary {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.home-card-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.favorite-icon {
|
||||
display: none !important;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
#tool-icon {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#tool-text {
|
||||
margin: 0.0rem 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Only show the favorite icons when the parent card is being hovered over */
|
||||
.feature-card:hover .favorite-icon {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.favorite-icon img {
|
||||
filter: brightness(0) invert(var(--md-theme-filter-color));
|
||||
}
|
||||
|
||||
.favorite-icon:hover .material-symbols-rounded {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.favorite-icon .material-symbols-rounded.fill{
|
||||
color: #f5c000;
|
||||
}
|
||||
|
||||
.jumbotron {
|
||||
padding: 3rem 3rem;
|
||||
/* Reduce vertical padding */
|
||||
}
|
||||
|
||||
.lookatme {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.lookatme::after {
|
||||
color: #e33100;
|
||||
text-shadow: 0 0 5px #e33100;
|
||||
/* in the html, the data-lookatme-text attribute must */
|
||||
/* contain the same text as the .lookatme element */
|
||||
content: attr(data-lookatme-text);
|
||||
padding: inherit;
|
||||
position: absolute;
|
||||
inset: 0 0 0 0;
|
||||
z-index: 1;
|
||||
/* 20 steps / 2 seconds = 10fps */
|
||||
-webkit-animation: 2s infinite Pulse steps(20);
|
||||
animation: 2s infinite Pulse steps(20);
|
||||
}
|
||||
|
||||
@keyframes Pulse {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.update-notice {
|
||||
animation: scale 1s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes scale {
|
||||
0% {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
/* Base Container Styles */
|
||||
.multi-tool-container {
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
label {
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding: 0rem 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 16px !important;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--theme-color-outline-variant);
|
||||
flex-grow: 5;
|
||||
}
|
||||
|
||||
/* Action Bar Styles */
|
||||
.mt-action-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: none;
|
||||
backdrop-filter: blur(2px);
|
||||
top: 10px;
|
||||
z-index: 11;
|
||||
padding: 1.25rem;
|
||||
border-radius: 2rem;
|
||||
margin: 0px 25px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mt-action-bar > * {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-file-uploader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mt-action-bar svg,
|
||||
.mt-action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mt-action-bar .mt-filename {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Action Button Styles */
|
||||
.mt-action-btn {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2rem;
|
||||
z-index: 12;
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 20px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Card and Container Styles */
|
||||
.bg-card {
|
||||
background-color: var(--md-sys-color-surface-5);
|
||||
border-radius: 3rem;
|
||||
padding: 15px 0;
|
||||
margin-left: 55px;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#pages-container-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
border-radius: 25px;
|
||||
min-height: 275px;
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
#pages-container {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
#pages-container-wrapper::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
#pages-container-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--scroll-bar-color);
|
||||
}
|
||||
|
||||
#pages-container-wrapper::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background: var(--scroll-bar-thumb);
|
||||
}
|
||||
|
||||
#pages-container-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scroll-bar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Page Container Base Styles */
|
||||
.page-container {
|
||||
display: inline-block;
|
||||
list-style-type: none;
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
line-height: 50px;
|
||||
margin-top: 15px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Responsive Page Container Sizes */
|
||||
@media only screen and (max-width: 480px) {
|
||||
.page-container {
|
||||
width: calc(100vw - 90px);
|
||||
height: calc(100vw - 90px);
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
#pages-container { gap: 10px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 481px) and (max-width: 768px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 120px - 12px) / 2);
|
||||
height: calc((100vw - 120px - 12px) / 2);
|
||||
max-width: 250px;
|
||||
max-height: 250px;
|
||||
margin: 6px;
|
||||
}
|
||||
#pages-container { gap: 12px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 769px) and (max-width: 1199px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 140px - 45px) / 3);
|
||||
height: calc((100vw - 140px - 45px) / 3);
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
margin: 7px;
|
||||
}
|
||||
#pages-container { gap: 15px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1200px) and (max-width: 1280px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 160px - 60px) / 4);
|
||||
height: calc((100vw - 160px - 60px) / 4);
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
margin: 8px;
|
||||
}
|
||||
#pages-container { gap: 17px; }
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1281px) {
|
||||
.page-container {
|
||||
width: calc((100vw - 180px - 80px) / 5);
|
||||
height: calc((100vw - 180px - 80px) / 5);
|
||||
max-width: 190px;
|
||||
max-height: 190px;
|
||||
margin: 10px;
|
||||
}
|
||||
#pages-container { gap: 20px; }
|
||||
}
|
||||
|
||||
/* Split Page Styles */
|
||||
.page-container.split-before {
|
||||
border-left: 1px dashed var(--md-sys-color-on-surface);
|
||||
padding-left: -1px;
|
||||
}
|
||||
|
||||
.page-container.split-before:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.page-container:first-child .pdf-actions_split-file-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* RTL Language Support */
|
||||
.page-container:last-child:lang(ar),
|
||||
.page-container:last-child:lang(he),
|
||||
.page-container:last-child:lang(fa),
|
||||
.page-container:last-child:lang(ur),
|
||||
.page-container:last-child:lang(ckb),
|
||||
.page-container:last-child:lang(ks),
|
||||
.page-container:last-child:lang(kk),
|
||||
.page-container:last-child:lang(uz),
|
||||
.page-container:last-child:lang(ky),
|
||||
.page-container:last-child:lang(bal),
|
||||
.page-container:last-child:lang(dv),
|
||||
.page-container:last-child:lang(ps),
|
||||
.page-container:last-child:lang(sdg),
|
||||
.page-container:last-child:lang(syr),
|
||||
.page-container:last-child:lang(mzn),
|
||||
.page-container:last-child:lang(tgl),
|
||||
.page-container:last-child:lang(pnb),
|
||||
.page-container:last-child:lang(ug),
|
||||
.page-container:last-child:lang(nqo),
|
||||
.page-container:last-child:lang(bqi) {
|
||||
margin-left: auto !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Page Image Styles */
|
||||
.page-container img {
|
||||
max-width: calc(100% - 8px);
|
||||
max-height: calc(100% - 8px);
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
translate: -50% -50%;
|
||||
box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.384);
|
||||
border-radius: 4px;
|
||||
transition: rotate 0.3s;
|
||||
}
|
||||
|
||||
/* Page Number Styles */
|
||||
.page-number {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
line-height: normal;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background-color: rgba(162, 201, 255, 0.8);
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
z-index: 2;
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* Tool Header and Button Styles */
|
||||
.tool-header {
|
||||
margin: 0.5rem 1rem 1rem;
|
||||
}
|
||||
|
||||
#select-pages-button {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
#add-pdf-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Selected Pages Container Styles */
|
||||
.selected-pages-container {
|
||||
background-color: var(--md-sys-color-surface);
|
||||
border-radius: 16px;
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.selected-pages-container h3 {
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selected-pages-header {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.selected-pages-header h5 {
|
||||
margin: 0 0 8px 0 !important;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
.selected-pages-header #csv-input {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
padding: 0 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.selected-pages-header #csv-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--md-sys-color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Pages List Styles */
|
||||
.pages-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
min-height: 0;
|
||||
max-height: 10rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
width: 7rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.selected-page-number {
|
||||
width: 4rem;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
cursor: pointer;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
/* Zoom Level Responsive Styles */
|
||||
@media only screen and (min-width: 2000px) {
|
||||
.mt-action-btn {
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 2.5rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 22px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 2560px) {
|
||||
.mt-action-btn {
|
||||
gap: 15px;
|
||||
padding: 15px 30px;
|
||||
border-radius: 3rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 28px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 3840px) {
|
||||
.mt-action-btn {
|
||||
gap: 20px;
|
||||
padding: 20px 40px;
|
||||
border-radius: 4rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
border-radius: 40px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 7680px) {
|
||||
.mt-action-btn {
|
||||
gap: 40px;
|
||||
padding: 40px 80px;
|
||||
border-radius: 8rem;
|
||||
}
|
||||
.mt-action-btn .btn {
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
border-radius: 80px;
|
||||
}
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Zoom-responsive Sidebar Styles */
|
||||
@media only screen and (max-width: 1280px) {
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mt-action-btn {
|
||||
position: fixed !important;
|
||||
left: 10px !important;
|
||||
top: 80px !important;
|
||||
bottom: 20px !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
border-radius: 16px !important;
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: calc(100vh - 100px) !important;
|
||||
overflow-y: auto !important;
|
||||
scrollbar-width: none !important;
|
||||
-ms-overflow-style: none !important;
|
||||
z-index: 12;
|
||||
width: 70px !important;
|
||||
gap: 8px !important;
|
||||
padding: 12px 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn:has(.btn:nth-last-child(n+7)) {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.mt-action-btn::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
flex-shrink: 0;
|
||||
font-size: 16px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 20px !important;
|
||||
line-height: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24 !important;
|
||||
}
|
||||
|
||||
.multi-tool-container {
|
||||
margin-left: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.mt-action-bar {
|
||||
margin-left: 25px !important;
|
||||
margin-right: 25px !important;
|
||||
}
|
||||
|
||||
.mt-action-btn:not(:has(*:nth-child(6))) {
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile and High Zoom Responsive Styles */
|
||||
@media only screen and (max-width: 960px) {
|
||||
.mt-action-btn {
|
||||
left: 8px !important;
|
||||
top: 75px !important;
|
||||
bottom: 15px !important;
|
||||
height: calc(100vh - 90px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.mt-action-btn {
|
||||
left: 5px !important;
|
||||
width: 60px !important;
|
||||
top: calc(var(--navbar-height, 60px) + 10px) !important;
|
||||
bottom: 10px !important;
|
||||
height: calc(100vh - var(--navbar-height, 60px) - 20px) !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
#pages-container {
|
||||
margin: 0 auto !important;
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: calc(100vw - 55px) !important;
|
||||
height: calc(100vw - 60px) !important;
|
||||
max-width: 350px !important;
|
||||
max-height: 350px !important;
|
||||
margin: 5px auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.mt-action-btn {
|
||||
left: 2px !important;
|
||||
top: calc(var(--navbar-height, 60px) + 5px) !important;
|
||||
bottom: 5px !important;
|
||||
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
|
||||
width: 50px !important;
|
||||
gap: 6px !important;
|
||||
padding: 10px 6px !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 10px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 384px) {
|
||||
.mt-action-btn {
|
||||
left: 2px !important;
|
||||
top: calc(var(--navbar-height, 60px) + 5px) !important;
|
||||
bottom: 5px !important;
|
||||
height: calc(100vh - var(--navbar-height, 60px) - 10px) !important;
|
||||
width: 40px !important;
|
||||
gap: 4px !important;
|
||||
padding: 8px 4px !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
border-radius: 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.mt-action-btn .btn .material-symbols-rounded {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
width: calc(100vw - 55px) !important;
|
||||
height: calc(100vw - 60px) !important;
|
||||
max-width: 280px !important;
|
||||
max-height: 280px !important;
|
||||
margin: 3px auto !important;
|
||||
}
|
||||
|
||||
.page-container img {
|
||||
max-width: calc(100% - 4px) !important;
|
||||
max-height: calc(100% - 4px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
:root {
|
||||
/* Colors */
|
||||
--md-sys-color-primary: rgb(162 201 255);
|
||||
--md-sys-color-surface-tint: rgb(162 201 255);
|
||||
--md-sys-color-on-primary: rgb(0 49 92);
|
||||
--md-sys-color-primary-container: rgb(0 118 208);
|
||||
--md-sys-color-on-primary-container: rgb(255 255 255);
|
||||
--md-sys-color-secondary: rgb(169 201 246);
|
||||
--md-sys-color-on-secondary: rgb(12 49 87);
|
||||
--md-sys-color-secondary-container: rgb(29 62 100);
|
||||
--md-sys-color-on-secondary-container: rgb(180 210 255);
|
||||
--md-sys-color-tertiary: rgb(193 194 248);
|
||||
--md-sys-color-on-tertiary: rgb(42 44 88);
|
||||
--md-sys-color-tertiary-container: rgb(110 112 161);
|
||||
--md-sys-color-on-tertiary-container: rgb(255 255 255);
|
||||
--md-sys-color-error: rgb(255 180 171);
|
||||
--md-sys-color-on-error: rgb(105 0 5);
|
||||
--md-sys-color-error-container: rgb(147 0 10);
|
||||
--md-sys-color-on-error-container: rgb(255 218 214);
|
||||
--md-sys-color-background: rgb(15 20 26);
|
||||
--md-sys-color-on-background: rgb(223 226 235);
|
||||
--md-sys-color-surface: rgb(15 20 26);
|
||||
--md-sys-color-on-surface: rgb(223 226 235);
|
||||
--md-sys-color-surface-variant: rgb(64 71 83);
|
||||
--md-sys-color-on-surface-variant: rgb(192 199 213);
|
||||
--md-sys-color-outline: rgb(138 145 158);
|
||||
--md-sys-color-outline-variant: rgb(64 71 83);
|
||||
--md-sys-color-shadow: rgb(0 0 0);
|
||||
--md-sys-color-scrim: rgb(0 0 0);
|
||||
--md-sys-color-inverse-surface: rgb(223 226 235);
|
||||
--md-sys-color-inverse-on-surface: rgb(45 49 55);
|
||||
--md-sys-color-inverse-primary: rgb(0 96 170);
|
||||
--md-sys-color-primary-fixed: rgb(211 228 255);
|
||||
--md-sys-color-on-primary-fixed: rgb(0 28 56);
|
||||
--md-sys-color-primary-fixed-dim: rgb(162 201 255);
|
||||
--md-sys-color-on-primary-fixed-variant: rgb(0 72 130);
|
||||
--md-sys-color-secondary-fixed: rgb(211 228 255);
|
||||
--md-sys-color-on-secondary-fixed: rgb(0 28 56);
|
||||
--md-sys-color-secondary-fixed-dim: rgb(169 201 246);
|
||||
--md-sys-color-on-secondary-fixed-variant: rgb(40 72 111);
|
||||
--md-sys-color-tertiary-fixed: rgb(225 224 255);
|
||||
--md-sys-color-on-tertiary-fixed: rgb(20 22 66);
|
||||
--md-sys-color-tertiary-fixed-dim: rgb(193 194 248);
|
||||
--md-sys-color-on-tertiary-fixed-variant: rgb(64 67 112);
|
||||
--md-sys-color-surface-dim: rgb(15 20 26);
|
||||
--md-sys-color-surface-bright: rgb(53 57 64);
|
||||
--md-sys-color-surface-container-lowest: rgb(10 14 20);
|
||||
--md-sys-color-surface-container-low: rgb(24 28 34);
|
||||
--md-sys-color-surface-container: rgb(28 32 38);
|
||||
--md-sys-color-surface-container-high: rgb(38 42 49);
|
||||
--md-sys-color-surface-container-highest: rgb(49 53 60);
|
||||
/* Tools Color */
|
||||
--md-nav-section-color-opacity: 1;
|
||||
--md-nav-on-section-color-opacity: 1;
|
||||
--md-nav-section-color-sign: rgba(25, 101, 212, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-sign: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-organize: rgba(120, 130, 255, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-organize: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-convert: rgba(25, 177, 212, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-convert: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-convertto: rgba(104, 220, 149, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-convertto: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-security: rgba(255, 120, 146, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-security: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-other: rgba(72, 189, 84, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-other: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-advance: rgba(245, 84, 84, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-advance: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-image: rgba(212, 172, 25, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-image: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-word: rgba(61, 153, 245, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-word: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-section-color-ppt: rgba(255, 128, 0, var(--md-nav-section-color-opacity));
|
||||
--md-nav-on-section-color-ppt: rgba(28, 27, 31, var(--md-nav-on-section-color-opacity));
|
||||
--md-nav-color-on-separator: rgb(24 28 34);
|
||||
--md-nav-background: rgb(15 20 26);
|
||||
--favourite-add: #9ed18c;
|
||||
--favourite-remove: palevioletred;
|
||||
}
|
||||
Binary file not shown.
@@ -1,76 +0,0 @@
|
||||
class FileIconFactory {
|
||||
static createFileIcon(fileExtension) {
|
||||
let ext = fileExtension.toLowerCase();
|
||||
switch (ext) {
|
||||
case "pdf":
|
||||
return this.createPDFIcon();
|
||||
case "csv":
|
||||
return this.createCSVIcon();
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
return this.createXLSXIcon();
|
||||
case "jpe":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "png":
|
||||
case "bmp":
|
||||
case "ico":
|
||||
case "svg":
|
||||
case "svgz":
|
||||
case "tif":
|
||||
case "tiff":
|
||||
case "ai":
|
||||
case "drw":
|
||||
case "pct":
|
||||
case "psp":
|
||||
case "xcf":
|
||||
case "psd":
|
||||
case "raw":
|
||||
case "webp":
|
||||
case "heic":
|
||||
return this.createImageIcon();
|
||||
default:
|
||||
return this.createUnknownFileIcon();
|
||||
}
|
||||
}
|
||||
|
||||
static createPDFIcon() {
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-filetype-pdf" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM1.6 11.85H0v3.999h.791v-1.342h.803q.43 0 .732-.173.305-.175.463-.474a1.4 1.4 0 0 0 .161-.677q0-.375-.158-.677a1.2 1.2 0 0 0-.46-.477q-.3-.18-.732-.179m.545 1.333a.8.8 0 0 1-.085.38.57.57 0 0 1-.238.241.8.8 0 0 1-.375.082H.788V12.48h.66q.327 0 .512.181.185.183.185.522m1.217-1.333v3.999h1.46q.602 0 .998-.237a1.45 1.45 0 0 0 .595-.689q.196-.45.196-1.084 0-.63-.196-1.075a1.43 1.43 0 0 0-.589-.68q-.396-.234-1.005-.234zm.791.645h.563q.371 0 .609.152a.9.9 0 0 1 .354.454q.118.302.118.753a2.3 2.3 0 0 1-.068.592 1.1 1.1 0 0 1-.196.422.8.8 0 0 1-.334.252 1.3 1.3 0 0 1-.483.082h-.563zm3.743 1.763v1.591h-.79V11.85h2.548v.653H7.896v1.117h1.606v.638z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
static createImageIcon() {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M216-144q-30 0-51-21.5T144-216v-528q0-29 21-50.5t51-21.5h528q30 0 51 21.5t21 50.5v528q0 29-21 50.5T744-144H216Zm48-144h432L552-480 444-336l-72-96-108 144Z"/></svg>`;
|
||||
}
|
||||
|
||||
static createCSVIcon() {
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-filetype-csv" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2h-1v-1h1a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5zM3.517 14.841a1.13 1.13 0 0 0 .401.823q.195.162.478.252.284.091.665.091.507 0 .859-.158.354-.158.539-.54.185-.382.185-.816 0-.335-.123-.628a1.4 1.4 0 0 0-.366-.486 1.8 1.8 0 0 0-.614-.314 2.8 2.8 0 0 0-.865-.118 2.1 2.1 0 0 0-.614.094 1.4 1.4 0 0 0-.471.264 1.1 1.1 0 0 0-.298.429.9.9 0 0 0-.103.539h.606a.4.4 0 0 1 .096-.258.5.5 0 0 1 .213-.164.6.6 0 0 1 .33-.082.7.7 0 0 1 .458.132.4.4 0 0 1 .153.372.4.4 0 0 1-.085.235.7.7 0 0 1-.25.192 1.4 1.4 0 0 1-.407.115c-.127.023-.266.05-.416.081a1.8 1.8 0 0 0-.534.187 1.2 1.2 0 0 0-.382.346 1 1 0 0 0-.138.537q0 .295.101.517M8.717 14.841a1.13 1.13 0 0 0 .401.823q.195.162.478.252.284.091.665.091.507 0 .859-.158.354-.158.539-.54.185-.382.185-.816 0-.335-.123-.628a1.4 1.4 0 0 0-.366-.486 1.8 1.8 0 0 0-.614-.314 2.8 2.8 0 0 0-.865-.118 2.1 2.1 0 0 0-.614.094 1.4 1.4 0 0 0-.471.264 1.1 1.1 0 0 0-.298.429.9.9 0 0 0-.103.539h.606a.4.4 0 0 1 .096-.258.5.5 0 0 1 .213-.164.6.6 0 0 1 .33-.082.7.7 0 0 1 .458.132.4.4 0 0 1 .153.372.4.4 0 0 1-.085.235.7.7 0 0 1-.25.192 1.4 1.4 0 0 1-.407.115c-.127.023-.266.05-.416.081a1.8 1.8 0 0 0-.534.187 1.2 1.2 0 0 0-.382.346 1 1 0 0 0-.138.537q0 .295.101.517M14.229 13.12v.506H11.85v-.506h1.063v-1.277H11.85v-.506h2.379v.506h-1.063z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
static createXLSXIcon() {
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-file-earmark-excel" viewBox="0 0 16 16">
|
||||
<path d="M5.884 6.68a.5.5 0 1 0-.768.64L7.349 10l-2.233 2.68a.5.5 0 0 0 .768.64L8 10.781l2.116 2.54a.5.5 0 0 0 .768-.641L8.651 10l2.233-2.68a.5.5 0 0 0-.768-.64L8 9.219l-2.116-2.54z"/>
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
static createUnknownFileIcon() {
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="bi bi-file-earmark" viewBox="0 0 16 16">
|
||||
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5z"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default FileIconFactory;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user