Compare commits

...
47 Commits
Author SHA1 Message Date
Anthony StirlingandGitHub 877caee396 Merge branch 'main' into org 2025-08-09 15:41:28 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
979f302277 🌐 Sync Translations + Update README Progress Table (#4159)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation. Below are the details of the
changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes
in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-09 15:33:08 +01:00
Balázs SzücsandGitHub dd0bf194cd Update Hungarian translation for new update related strings (#4152)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-09 15:31:28 +01:00
LudyandGitHub 05b5771c89 fix(saml): correct ClassPathResource handling for IdP metadata and add null-guard for privateKey (#4157)
## Description of Changes

**What was changed**
- In `getIdpMetadataUri()`, use
`idpMetadataUri.substring("classpath:".length())` so the `classpath:`
scheme (including the colon) is stripped correctly before creating the
`ClassPathResource`.
- In `getPrivateKey()`, add a null check (`if (privateKey == null)
return null;`) to avoid a potential `NullPointerException` when the
property is unset.

**Why the change was made**
- The previous substring used `"classpath".length()` (without the
colon), leaving a leading `:` in the path (e.g., `:/saml/idp.xml`) which
breaks `ClassPathResource` resolution and can prevent SAML bootstrapping
when `idpMetadataUri` uses the `classpath:` scheme.
- The null-guard aligns the method with defensive coding practices and
prevents runtime errors when no private key is configured.


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-09 15:09:50 +01:00
Anthony StirlingandGitHub 299ce03dda Update CODEOWNERS (#4158)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-09 15:09:26 +01:00
Anthony StirlingandGitHub 5e01b15d3c Update .files.yaml for V2 (#4156)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-09 12:03:24 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
3938a07c13 🌐 Sync Translations + Update README Progress Table (#4155)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation. Below are the details of the
changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes
in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-08 23:13:33 +01:00
albanobattistellaandGitHub e8b5ae0474 Update messages_it_IT.properties (#4154)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-08 23:07:20 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
796873134f Update 3rd Party Licenses (#4122)
Auto-generated by stirlingbot[bot]

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-08 15:54:00 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
678a9bc463 🤖 format everything with pre-commit by stirlingbot (#4150)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-08 15:53:45 +01:00
Anthony StirlingandGitHub 71ac4283b2 PSD (#4146)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-08 15:39:47 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
6675a8af99 🌐 Sync Translations + Update README Progress Table (#4143)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation. Below are the details of the
changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes
in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

---------

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-08 15:37:18 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
e6a77e83da 🤖 format everything with pre-commit by stirlingbot (#4144)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-08 15:36:58 +01:00
Anthony StirlingGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>Reece BrowneReece Brownea <a>
774b500159 get updates advanced (#4124)
# Description of Changes
This pull request introduces a comprehensive update to the application's
update notification and modal system, enhancing both the backend logic
and the user interface for update alerts. The changes include a new
modal dialog for update details, improved internationalization (i18n)
support, dynamic fetching of update information, and context-aware
download links. These improvements make update notifications clearer,
more informative, and tailored to the user's installation type.

**Key changes:**

**1. Update Notification and Modal System Overhaul**
- Added a new modal dialog (`showUpdateModal`) that displays detailed
update information, including current, latest, and latest stable
versions, update priority, breaking changes, migration guides, and a
list of available updates. The modal dynamically fetches and displays
full update details and adapts to dark mode.
([[app/core/src/main/resources/static/js/githubVersion.jsR206-R387](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aR206-R387)])
- Enhanced the update button logic to reflect update priority visually
(e.g., urgent/normal/minor), store summary data, and trigger the modal
on click.
([[app/core/src/main/resources/static/js/githubVersion.jsL74-R190](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aL74-R190)])
- Improved the update check process to use a new summary API endpoint
and handle missing or failed update data gracefully.
[[1]](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aL19-R108)],
[[2]](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aL74-R190)])

**2. Context-Aware Download Links**
- Introduced `getDownloadUrl()` to generate download links based on the
user's machine type and security configuration, ensuring only relevant
installers or jars are offered.
([[app/core/src/main/resources/static/js/githubVersion.jsL19-R108](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aL19-R108)])

**3. Internationalization (i18n) Enhancements**
- Added new i18n keys for all update-related modal and notification
strings in `messages_en_GB.properties`.
([[app/core/src/main/resources/messages_en_GB.propertiesR369-R400](diffhunk://#diff-ee1c6999a33498cfa3abba4a384e73a8b8269856899438de80560c965079a9fdR369-R400)])
- Injected all necessary i18n constants into the frontend via
`navbar.html` for use in the modal and notifications.
([[app/core/src/main/resources/templates/fragments/navbar.htmlR14-R51](diffhunk://#diff-e7ef383033ea52a00c96e71d5d2c1ff08829078fa5c84c8e48e1bf8f48861ec6R14-R51)])

**4. General UI and Code Improvements**
- Ensured update button styling is reset before applying new styles and
improved accessibility by hiding the settings modal when the update
modal is shown.
[[1]](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aR138)],
[[2]](diffhunk://#diff-5a6376050581cc6f1fb0b6266af4d8a3db1332879459afd3a073b274b5ab637aR206-R387)])

These changes collectively provide a more robust, user-friendly, and
maintainable update notification experience.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Reece Browne <reecebrowne1995@gmail.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Co-authored-by: a <a>
2025-08-08 14:19:19 +01:00
Balázs SzücsandGitHub 65e894870c refactor(eml-to-pdf): Improve readability, maintainability, and overall standards compliance (#4065)
# Description of Changes
refactor(eml-to-pdf): Enhance compliance with PDF/ISO standards and MIME
specifications

This commit refactors the EML-to-PDF conversion utility to improve
standards compliance, implementing requirements from multiple RFCs and
ISO specifications:

### Standards Compliance Implemented:
• **PDF Standards (ISO 32000-1:2008)**: Added PDF version validation in
`attachFilesToPdf()`
  to ensure 1.7+ compatibility for Unicode file embeddings
• **MIME Processing (RFC 2045/2046)**: Implemented case-insensitive MIME
type handling
in `processPartAdvanced()` with `toLowerCase(Locale.ROOT)` normalization
• **Content Encoding (RFC 2047)**: Enhanced `safeMimeDecode()` with
UTF-8→ISO-8859-1
  charset fallback chains for robust header decoding
• **Content-ID Processing (RFC 2392)**: Added proper Content-ID
stripping with
  `replaceAll("[<>]", "")` for embedded image references
• **Multipart Safety (RFC 2046)** (best practice, not compliance
related): Implemented recursion depth limiting (max 10 levels)
• **processMultipartAdvanced()**, setCatalogViewerPreferences used to
set PageMode.USE_ATTACHMENTS, but PDF spec 12.2 (Viewer Preferences)
requires a /ViewerPreferences dictionary for full control (e.g.,
/DisplayDocTitle). Docs suggested setting additional prefs like
/NonFullScreenPageMode to ensure attachments panel opens reliably across
viewers
• **addAttachmentAnnotationToPage**, annotations are set to
/Invisible=true but must remain interactive. PDF spec 12.5.6.15 (File
Attachment Annotations) requires /F flags to control print/view (e.g.,
NoPrint if not printable).

### Technical Improvements:
• **Coordinate System Handling**: Added rotation-aware coordinate
transformations
  in PDF annotation placement following ISO 32000-1 Section 8.3
• **Charset Fallbacks**: Implemented progressive charset detection with
UTF-8
  primary and ISO-8859-1 fallback in MIME decoding
• **Error Resilience**: Enhanced exception handling with specific error
types and
  proper resource cleanup using try-with-resources patterns
• **HTML5 Compliance**: Updated email HTML generation with proper
DOCTYPE and
  charset declarations for browser compatibility

### Security & Robustness:
• **Input Validation**: Added comprehensive null checks and boundary
validation
  throughout attachment and multipart processing
• **XSS Prevention**: All user content now processed through
`escapeHtml()` or
  `CustomHtmlSanitizer` before HTML generation

### Code Quality:
• **Method Signatures**: Updated `processMultipartAdvanced()` to include
depth
  parameter for recursion tracking
• **Switch Expressions**: Modernized switch statements to use Java 17+
arrow syntax
  where applicable
• **Documentation**: Added inline RFC/ISO references for
compliance-critical sections

All changes maintain backward compatibility while significantly
improving standards
adherence. Tested with various EML formats.

No major change. No change in tests. No change in aesthetic of the
resulting PDF. No change change in "user space" (except when user relied
on compliance of aforementioned stuff then a major improvement)

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] 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.
2025-08-08 13:14:57 +01:00
LudyandGitHub b6ff1dd7f6 chore: update development configs, formatting tools, and CI enhancements (#4130)
# Description of Changes

- **What was changed**  
- Bumped `java.format.settings.google.version` to **1.28.0** in
`.devcontainer/devcontainer.json` and `.vscode/settings.json`.
- Expanded ignore patterns in `.devcontainer/devcontainer.json` to cover
`app/core/`, `app/common/`, `app/proprietary/` directories.
- Added a new top‐level `.dockerignore` to exclude build artifacts,
virtual environments, logs, OS files, and markdown docs.
- Consolidated EditorConfig YAML globs into `*.{yml,yaml}` to remove
duplication.
- Fixed missing newline in `.github/config/.files.yaml` and added label
metadata (`from_name`/`description`) in `.github/labels.yml`.
  - Updated `build.gradle`:
- Introduced `junitPlatformVersion = "1.12.2"` and replaced hard-coded
launcher versions.
- Applied the `jacoco` plugin across all subprojects and configured
`jacocoTestReport` (XML + HTML).
    - Wire-up `jacocoTestReport` to run after tests.  
- **Why the change was made**  
- Ensure all formatting tools (Google Java Format) stay in sync across
editors and containers.
- Clean up ignore rules to prevent build artifacts and sensitive files
from creeping into images and repos.
- Improve CI visibility by generating code-coverage reports with JaCoCo.
- Keep GitHub configuration files well-formed and enrich label
definitions for automation.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-08 12:52:51 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bb8edffaab build(deps): bump actions/ai-inference from 1.2.3 to 1.2.4 (#4119)
Bumps [actions/ai-inference](https://github.com/actions/ai-inference)
from 1.2.3 to 1.2.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/ai-inference/releases">actions/ai-inference's
releases</a>.</em></p>
<blockquote>
<h2>v1.2.4</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump <code>@​github/local-action</code> from 3.2.1 to 5.1.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/ai-inference/pull/63">actions/ai-inference#63</a></li>
<li>Bump <code>@​rollup/rollup-linux-x64-gnu</code> from 4.43.0 to
4.45.1 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/ai-inference/pull/65">actions/ai-inference#65</a></li>
<li>Bump jest and <code>@​types/jest</code> by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/ai-inference/pull/66">actions/ai-inference#66</a></li>
<li>Tidy up package.json by <a
href="https://github.com/maraisr"><code>@​maraisr</code></a> in <a
href="https://redirect.github.com/actions/ai-inference/pull/69">actions/ai-inference#69</a></li>
<li>Moves project to using vitest by <a
href="https://github.com/maraisr"><code>@​maraisr</code></a> in <a
href="https://redirect.github.com/actions/ai-inference/pull/70">actions/ai-inference#70</a></li>
<li>Move some linter files out of the root and use GitHub's shared
prettier config by <a
href="https://github.com/maraisr"><code>@​maraisr</code></a> in <a
href="https://redirect.github.com/actions/ai-inference/pull/72">actions/ai-inference#72</a></li>
<li>chore(deps): bump <code>@​rollup/rollup-linux-x64-gnu</code> from
4.45.1 to 4.46.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/ai-inference/pull/76">actions/ai-inference#76</a></li>
<li>chore(deps): bump actions/publish-action from 0.2.2 to 0.3.0 in the
actions-minor group by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/actions/ai-inference/pull/74">actions/ai-inference#74</a></li>
<li>Separate out MCP token by <a
href="https://github.com/sgoedecke"><code>@​sgoedecke</code></a> in <a
href="https://redirect.github.com/actions/ai-inference/pull/83">actions/ai-inference#83</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/ai-inference/compare/v1...v1.2.4">https://github.com/actions/ai-inference/compare/v1...v1.2.4</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/ai-inference/commit/4b591cc5298ae9428fff72279eced918e444c409"><code>4b591cc</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/ai-inference/issues/83">#83</a>
from actions/sgoedecke/separate-mcp</li>
<li><a
href="https://github.com/actions/ai-inference/commit/ea24ec2ed4aeb6d19b8936f886e8fb5741527467"><code>ea24ec2</code></a>
Update README.md</li>
<li><a
href="https://github.com/actions/ai-inference/commit/b9f9444fb79985625c6981038caa00e6346408ed"><code>b9f9444</code></a>
update docs</li>
<li><a
href="https://github.com/actions/ai-inference/commit/419f171f16e478d9f608f4fe3fabe29f5dcdccae"><code>419f171</code></a>
Separate out MCP token</li>
<li><a
href="https://github.com/actions/ai-inference/commit/fc8527d1d95538cb45e8f49fbc95dc9e5681b849"><code>fc8527d</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/ai-inference/issues/74">#74</a>
from actions/dependabot/github_actions/actions-minor-e...</li>
<li><a
href="https://github.com/actions/ai-inference/commit/719349dfcccd7fbeee25901255dbf1a59c88d3fb"><code>719349d</code></a>
Merge branch 'main' into
dependabot/github_actions/actions-minor-e893b3f303</li>
<li><a
href="https://github.com/actions/ai-inference/commit/2762750922fa62f91dc6203df8b23c2684d5a52b"><code>2762750</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/ai-inference/issues/76">#76</a>
from actions/dependabot/npm_and_yarn/rollup/rollup-lin...</li>
<li><a
href="https://github.com/actions/ai-inference/commit/9386906af5e73f620356d314711097696823d770"><code>9386906</code></a>
chore(deps): bump <code>@​rollup/rollup-linux-x64-gnu</code> from 4.45.1
to 4.46.0</li>
<li><a
href="https://github.com/actions/ai-inference/commit/ca9eff70517ac00934ae11b2c2164fde6c48954e"><code>ca9eff7</code></a>
chore(deps): bump actions/publish-action in the actions-minor group</li>
<li><a
href="https://github.com/actions/ai-inference/commit/6bef1d0031bbc403a1e49d373ab67e03c2eb60ae"><code>6bef1d0</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/ai-inference/issues/72">#72</a>
from actions/mr/linters</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/ai-inference/compare/9693b137b6566bb66055a713613bf4f0493701eb...4b591cc5298ae9428fff72279eced918e444c409">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/ai-inference&package-manager=github_actions&previous-version=1.2.3&new-version=1.2.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-08 12:50:30 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b91bfac416 build(deps): bump docker/login-action from 3.4.0 to 3.5.0 (#4118)
Bumps [docker/login-action](https://github.com/docker/login-action) from
3.4.0 to 3.5.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v3.5.0</h2>
<ul>
<li>Support dual-stack endpoints for AWS ECR by <a
href="https://github.com/Spacefish"><code>@​Spacefish</code></a> <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/874">docker/login-action#874</a>
<a
href="https://redirect.github.com/docker/login-action/pull/876">docker/login-action#876</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> to 3.859.0 in <a
href="https://redirect.github.com/docker/login-action/pull/860">docker/login-action#860</a>
<a
href="https://redirect.github.com/docker/login-action/pull/878">docker/login-action#878</a></li>
<li>Bump <code>@​aws-sdk/client-ecr-public</code> to 3.859.0 in <a
href="https://redirect.github.com/docker/login-action/pull/860">docker/login-action#860</a>
<a
href="https://redirect.github.com/docker/login-action/pull/878">docker/login-action#878</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.57.0 to 0.62.1 in
<a
href="https://redirect.github.com/docker/login-action/pull/870">docker/login-action#870</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/login-action/pull/875">docker/login-action#875</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v3.4.0...v3.5.0">https://github.com/docker/login-action/compare/v3.4.0...v3.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/184bdaa0721073962dff0199f1fb9940f07167d1"><code>184bdaa</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/878">#878</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependenc...</li>
<li><a
href="https://github.com/docker/login-action/commit/5c6bc94683baa064818f51e7417087c2ac58b32c"><code>5c6bc94</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/caf405864315c6006c5581b540e5047cf728b4e7"><code>caf4058</code></a>
build(deps): bump the aws-sdk-dependencies group with 2 updates</li>
<li><a
href="https://github.com/docker/login-action/commit/ef38ec311a7df3f01475313e7c5bb584b74b112a"><code>ef38ec3</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/860">#860</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependenc...</li>
<li><a
href="https://github.com/docker/login-action/commit/d52e8ef81c0de894e9c95bed8de0ee5955ec7eb7"><code>d52e8ef</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/9644ab7025be3206ff4b12f1531a1b6919022b00"><code>9644ab7</code></a>
build(deps): bump the aws-sdk-dependencies group with 2 updates</li>
<li><a
href="https://github.com/docker/login-action/commit/7abd1d512621d8896b31f4ea992d207f15915ad6"><code>7abd1d5</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/875">#875</a>
from docker/dependabot/npm_and_yarn/form-data-2.5.5</li>
<li><a
href="https://github.com/docker/login-action/commit/1a81202c4fda440f3b33eca3381d5d39c7efe85e"><code>1a81202</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/876">#876</a>
from crazy-max/aws-public-dual-stack</li>
<li><a
href="https://github.com/docker/login-action/commit/d1ab30dc54161cbfd704562857677edf4dd7837a"><code>d1ab30d</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/f25ff28d1c8cd9a7c35896711238fed682755e1c"><code>f25ff28</code></a>
support dual-stack for aws public ecr</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/74a5d142397b4f367a81961eba4e8cd7edddf772...184bdaa0721073962dff0199f1fb9940f07167d1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/login-action&package-manager=github_actions&previous-version=3.4.0&new-version=3.5.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-08 12:50:21 +01:00
LukasandGitHub 40936efe8d feature: import and export bookmarks to clipboard (#4093)
# Description of Changes

- add **import** and **export buttons** to bookmark editor (bottom
right) to **copy and past bookmark data**
- the export reads the hidden `<input id="bookmarkData">` field and uses
`navigator.clipboard.writeText()` to copy it to the clipboard
- the import reads from `navigator.clipboard.readText()` and sets the
internal `bookmarks` variable, which is used to update the UI elements
- after successful import or export, the buttons flash in green to give
visual feedback to the user
- this provides non-technical users with an intuitive method to copy
bookmarks between files
- I have seen, that this is also possible with the pipeline tool, but
this requires multiple steps and familiarity with the pipeline:
    1. use `extract-bookmarks` to generate `bookmarks.json`
    2. open the file and copy the data
    3. use `edit-table-of-contents` with the copied data
    4. process the target file
- challenges:
- I used `navigator.clipboard` as opposed to `document.execCommand`. The
latter is used in `account.html`, `errorBanner.html` and
`errorBanner.js`, but is
[deprecated](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand).
- I used the bootstrap-style rendering for the title attribute tooltip
for visual consistency in the bookmark editor, where the tooltip hovers
centered above the originating element. However, in most other places
the title tooltip follows the cursor and is slightly visually different.
- in case you are testing this on a mobile device (EDIT: or non-locally
hosted), the copy-to-clipboard might fail when hosted without SSL
(mobile only works in secure environment)
- similarly, when not using normal user interaction (i.e.
`element.click()` via console) the copy-to-clipboard will throw an error
`Clipboard write was blocked due to lack of user activation.`

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] 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)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
<img width="600" alt="Bookmark editor with new Import/Export buttons in
the bottom right corner" title="Bookmark editor with new Import/Export
buttons in the bottom right corner"
src="https://github.com/user-attachments/assets/61b948a1-9f68-4793-9c86-a056bad6b7e1"
/>
<img width="300" alt="Bookmark editor with new Import/Export buttons
with low width layout" title="Bookmark editor with new Import/Export
buttons with low width layout"
src="https://github.com/user-attachments/assets/4fa7bc76-ca11-4268-b83a-8d1e612dc5b9"
/>


### Testing (if applicable)

- [x] 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.
2025-08-08 12:48:36 +01:00
LudyandGitHub b77d02e988 chore(templates): remove redundant fetch-utils.js script includes (#4092)
# Description of Changes

- **What was changed**: Removed all explicit `<script
th:src="@{'/js/fetch-utils.js'}"></script>` tags from various Thymeleaf
templates (`home.html`, `home-legacy.html`, `scanner-effect.html`,
etc.).
- **Why the change was made**: The `fetch-utils.js` script is already
included globally via `<th:block th:insert="~{fragments/common ::
head()}">` in `fragments/common.html` (line 156). Keeping redundant
includes leads to unnecessary script loading and potential duplication.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-08 12:30:30 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
d3c786d018 🌐 Sync Translations + Update README Progress Table (#4135)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation. Below are the details of the
changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files (`messages_*.properties`) to reflect changes
in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-08-08 12:21:29 +01:00
c4c9f3f303 Update CODEOWNERS (#4142)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-08 11:38:57 +01:00
LudyandGitHub 6cd64a22ba build(local): simplify writeVersion task with WriteProperties plugin and enable build caching (#4139)
# Description of Changes

- **What was changed**:  
- Replaced the custom `writeVersion` task in `build.gradle` with the
built-in `WriteProperties` plugin configuration.
- Updated `gradle.properties` to enable `org.gradle.caching`
(uncommented) for local development.
- **Why the change was made**:  
- To reduce boilerplate and leverage Gradle’s native property-writing
capabilities for maintaining the version file.
- To improve build performance by reusing outputs via the Gradle build
cache.

- **Scope**:  
- These updates only affect local development and do not change
production or CI script

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-08 10:36:30 +01:00
aec5a8ddc5 feat(common): add configurable maxDPI limit for PDF-to-image conversion (#4129)
# Description of Changes

- **What was changed:**  
Added a new `maxDPI` property under `system` in `ApplicationProperties`;
updated `PdfUtils` to retrieve and enforce this configurable limit
instead of a hard-coded constant; modified `ConverterWebController` and
the PDF-to-image template to expose the limit to users; added
`pdfToImage.dpi` entries across all translation files; updated
`settings.yml.template` and `DeveloperGuide.md` to document the new
setting.
- **Why the change was made:**  
To allow deployments to tune the maximum DPI for PDF-to-image
conversions based on available resources, preventing excessive memory
usage and crashes caused by arbitrarily high DPI values.

Closes #3985

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-07 13:57:47 +01:00
Anthony Stirling 0dd0e0c71e formatting and fixes 2025-08-06 16:35:09 +01:00
Anthony Stirling d1c8802890 cleanup and fixes 2025-08-06 16:21:39 +01:00
a 31c397d016 Merge branch 'org' of git@github.com:Stirling-Tools/Stirling-PDF.git into org 2025-08-06 15:53:55 +01:00
Anthony Stirling e418e06ace preauth 2025-08-06 15:53:39 +01:00
Anthony Stirling 9e78057a3e disable console 2025-08-06 15:06:19 +01:00
Anthony Stirling 4437e1cf30 fix removal for V1 2025-08-06 15:05:15 +01:00
Anthony StirlingandGitHub 3f81adaf56 Merge branch 'main' into org 2025-08-06 14:57:14 +01:00
Anthony Stirling cac817ed56 remove other roles 2025-08-06 14:43:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
63b64b5dc5 build(deps): bump io.swagger.core.v3:swagger-core-jakarta from 2.2.34 to 2.2.35 (#4117)
Bumps io.swagger.core.v3:swagger-core-jakarta from 2.2.34 to 2.2.35.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.swagger.core.v3:swagger-core-jakarta&package-manager=gradle&previous-version=2.2.34&new-version=2.2.35)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:18:25 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1d47f5e26a build(deps): bump docker/metadata-action from 5.7.0 to 5.8.0 (#4116)
Bumps
[docker/metadata-action](https://github.com/docker/metadata-action) from
5.7.0 to 5.8.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/metadata-action/releases">docker/metadata-action's
releases</a>.</em></p>
<blockquote>
<h2>v5.8.0</h2>
<ul>
<li>New <code>is_not_default_branch</code> global expression by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/metadata-action/pull/535">docker/metadata-action#535</a></li>
<li>Allow to match part of the git tag or value for semver/pep440 types
by <a href="https://github.com/crazy-max"><code>@​crazy-max</code></a>
in <a
href="https://redirect.github.com/docker/metadata-action/pull/536">docker/metadata-action#536</a>
<a
href="https://redirect.github.com/docker/metadata-action/pull/537">docker/metadata-action#537</a></li>
<li>Bump <code>@​actions/github</code> from 6.0.0 to 6.0.1 in <a
href="https://redirect.github.com/docker/metadata-action/pull/523">docker/metadata-action#523</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.56.0 to 0.62.1 in
<a
href="https://redirect.github.com/docker/metadata-action/pull/526">docker/metadata-action#526</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/metadata-action/pull/533">docker/metadata-action#533</a></li>
<li>Bump moment-timezone from 0.5.47 to 0.6.0 in <a
href="https://redirect.github.com/docker/metadata-action/pull/525">docker/metadata-action#525</a></li>
<li>Bump semver from 7.7.1 to 7.7.2 in <a
href="https://redirect.github.com/docker/metadata-action/pull/524">docker/metadata-action#524</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/metadata-action/compare/v5.7.0...v5.8.0">https://github.com/docker/metadata-action/compare/v5.7.0...v5.8.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/metadata-action/commit/c1e51972afc2121e065aed6d45c65596fe445f3f"><code>c1e5197</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/537">#537</a>
from crazy-max/pep440-match</li>
<li><a
href="https://github.com/docker/metadata-action/commit/89dd65a56942f24df76cdf7a4c23b89e9e0c64f9"><code>89dd65a</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/metadata-action/commit/699ee45cf1d1e4c00bb9ca9bacb9f983fc58fbf6"><code>699ee45</code></a>
allow to match part of the git tag or value for pep440 type</li>
<li><a
href="https://github.com/docker/metadata-action/commit/e0542a6360c9f152bbf3353bd9c94564a730f25f"><code>e0542a6</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/536">#536</a>
from crazy-max/semver-match</li>
<li><a
href="https://github.com/docker/metadata-action/commit/b7facdfcef4956d2d16250632ca1d9fb16ed637c"><code>b7facdf</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/metadata-action/commit/81c60dfb8b905e3a16457c3da8880fc62e9051b8"><code>81c60df</code></a>
allow to match part of the git tag or value for semver type</li>
<li><a
href="https://github.com/docker/metadata-action/commit/de1119515dcfb4b110f21f67dc739b3ba1472a84"><code>de11195</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/535">#535</a>
from crazy-max/not_def_branch</li>
<li><a
href="https://github.com/docker/metadata-action/commit/2f9c64b1b1b1f3dd8b9e5a74ae4db13087cb814e"><code>2f9c64b</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/metadata-action/issues/533">#533</a>
from docker/dependabot/npm_and_yarn/form-data-2.5.5</li>
<li><a
href="https://github.com/docker/metadata-action/commit/510f74697528050f83e777f81df8cfccb153ccd3"><code>510f746</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/metadata-action/commit/2bc3f4e0f13f667fe2ccb93725d524c114c6ce80"><code>2bc3f4e</code></a>
is_not_default_branch global expression</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/metadata-action/compare/902fa8ec7d6ecbf8d84d538b9b233a880e428804...c1e51972afc2121e065aed6d45c65596fe445f3f">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/metadata-action&package-manager=github_actions&previous-version=5.7.0&new-version=5.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:17:59 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a20ffd09a build(deps): bump commonmarkVersion from 0.25.0 to 0.25.1 (#4115)
Bumps `commonmarkVersion` from 0.25.0 to 0.25.1.
Updates `org.commonmark:commonmark` from 0.25.0 to 0.25.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/commonmark/commonmark-java/releases">org.commonmark:commonmark's
releases</a>.</em></p>
<blockquote>
<h2>commonmark-java 0.25.1</h2>
<h3>Fixed</h3>
<ul>
<li>footnotes: Fix parsing of footnote definitions containing multiple
paragraphs
separated by blank lines. Before it only worked if paragraphs were
separated
by lines of 4 spaces. (<a
href="https://redirect.github.com/commonmark/commonmark-java/issues/388">#388</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/commonmark/commonmark-java/blob/main/CHANGELOG.md">org.commonmark:commonmark's
changelog</a>.</em></p>
<blockquote>
<h2>[0.25.1] - 2025-08-01</h2>
<h3>Fixed</h3>
<ul>
<li>footnotes: Fix parsing of footnote definitions containing multiple
paragraphs
separated by blank lines. Before it only worked if paragraphs were
separated
by lines of 4 spaces. (<a
href="https://redirect.github.com/commonmark/commonmark-java/issues/388">#388</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/b703d6a3561a6eb00f6de8dfab85cf0301cb7a2d"><code>b703d6a</code></a>
[maven-release-plugin] prepare release commonmark-parent-0.25.1</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/eb93eca29e8ea0369186b6a22afd2b1f853df1f8"><code>eb93eca</code></a>
Merge pull request <a
href="https://redirect.github.com/commonmark/commonmark-java/issues/390">#390</a>
from commonmark/release-0.25.1</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/bd50c7d587c435c80eeb1f59d2c41fed3efefb33"><code>bd50c7d</code></a>
Prepare CHANGELOG for version 0.25.1</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/226e8e970f4525466b28ff4a37b131776204e8b8"><code>226e8e9</code></a>
Merge pull request <a
href="https://redirect.github.com/commonmark/commonmark-java/issues/389">#389</a>
from commonmark/issue-388-footnotes-multiple-paragraphs</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/3111fed6c73dc6e961c590c58973b44e12fe5464"><code>3111fed</code></a>
footnotes: Fix multiple paragraphs separated by blank lines</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/33737b7d5dd65153f2d022f7b5770f0fa0630296"><code>33737b7</code></a>
CHANGELOG: Add issue links</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/c577edfe08e523ab806a111f629a518c4de660be"><code>c577edf</code></a>
README: Bump version</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/8d5918d4efd3ad1dca4626af8a516872f7aa6121"><code>8d5918d</code></a>
[maven-release-plugin] prepare for next development iteration</li>
<li>See full diff in <a
href="https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.25.0...commonmark-parent-0.25.1">compare
view</a></li>
</ul>
</details>
<br />

Updates `org.commonmark:commonmark-ext-gfm-tables` from 0.25.0 to 0.25.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/commonmark/commonmark-java/releases">org.commonmark:commonmark-ext-gfm-tables's
releases</a>.</em></p>
<blockquote>
<h2>commonmark-java 0.25.1</h2>
<h3>Fixed</h3>
<ul>
<li>footnotes: Fix parsing of footnote definitions containing multiple
paragraphs
separated by blank lines. Before it only worked if paragraphs were
separated
by lines of 4 spaces. (<a
href="https://redirect.github.com/commonmark/commonmark-java/issues/388">#388</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/commonmark/commonmark-java/blob/main/CHANGELOG.md">org.commonmark:commonmark-ext-gfm-tables's
changelog</a>.</em></p>
<blockquote>
<h2>[0.25.1] - 2025-08-01</h2>
<h3>Fixed</h3>
<ul>
<li>footnotes: Fix parsing of footnote definitions containing multiple
paragraphs
separated by blank lines. Before it only worked if paragraphs were
separated
by lines of 4 spaces. (<a
href="https://redirect.github.com/commonmark/commonmark-java/issues/388">#388</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/b703d6a3561a6eb00f6de8dfab85cf0301cb7a2d"><code>b703d6a</code></a>
[maven-release-plugin] prepare release commonmark-parent-0.25.1</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/eb93eca29e8ea0369186b6a22afd2b1f853df1f8"><code>eb93eca</code></a>
Merge pull request <a
href="https://redirect.github.com/commonmark/commonmark-java/issues/390">#390</a>
from commonmark/release-0.25.1</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/bd50c7d587c435c80eeb1f59d2c41fed3efefb33"><code>bd50c7d</code></a>
Prepare CHANGELOG for version 0.25.1</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/226e8e970f4525466b28ff4a37b131776204e8b8"><code>226e8e9</code></a>
Merge pull request <a
href="https://redirect.github.com/commonmark/commonmark-java/issues/389">#389</a>
from commonmark/issue-388-footnotes-multiple-paragraphs</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/3111fed6c73dc6e961c590c58973b44e12fe5464"><code>3111fed</code></a>
footnotes: Fix multiple paragraphs separated by blank lines</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/33737b7d5dd65153f2d022f7b5770f0fa0630296"><code>33737b7</code></a>
CHANGELOG: Add issue links</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/c577edfe08e523ab806a111f629a518c4de660be"><code>c577edf</code></a>
README: Bump version</li>
<li><a
href="https://github.com/commonmark/commonmark-java/commit/8d5918d4efd3ad1dca4626af8a516872f7aa6121"><code>8d5918d</code></a>
[maven-release-plugin] prepare for next development iteration</li>
<li>See full diff in <a
href="https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.25.0...commonmark-parent-0.25.1">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:16:32 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47a49c5353 build(deps): bump org.eclipse.angus:angus-mail from 2.0.3 to 2.0.4 (#4114)
Bumps
[org.eclipse.angus:angus-mail](https://github.com/eclipse-ee4j/angus-mail)
from 2.0.3 to 2.0.4.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/37c1c6ca1e7bdc1c7be6ed47eb6f1e388d3cbc79"><code>37c1c6c</code></a>
Prepare release org.eclipse.angus:all:2.0.4</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/a53d904ca190c7241ee923b14bae6e16d0726c35"><code>a53d904</code></a>
Update changes log (<a
href="https://redirect.github.com/eclipse-ee4j/angus-mail/issues/169">#169</a>)</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/5d0e7b3f5155130c55e6375ae3f5dc5d0a3564ab"><code>5d0e7b3</code></a>
Update changes log</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/84fe702f7a4837f325b878f17fee148a7e8ba951"><code>84fe702</code></a>
Fix issue299 (<a
href="https://redirect.github.com/eclipse-ee4j/angus-mail/issues/166">#166</a>)
(<a
href="https://redirect.github.com/eclipse-ee4j/angus-mail/issues/167">#167</a>)</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/a15041d503cefb380e3a5284eb7144eb92d82e93"><code>a15041d</code></a>
Update README.md</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/005bec40252bc732d2822cb9ab79902a4adc6f17"><code>005bec4</code></a>
Merge pull request <a
href="https://redirect.github.com/eclipse-ee4j/angus-mail/issues/138">#138</a>
from eclipse-ee4j/2.0.3-RELEASE</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/637b1913d26a0420a6b6bf1ed7ed0ebb868d8a57"><code>637b191</code></a>
Update TCK-Results.md</li>
<li><a
href="https://github.com/eclipse-ee4j/angus-mail/commit/2a375178f614071e872cd518d18ffa337af19029"><code>2a37517</code></a>
Prepare next development cycle for 2.0.4-SNAPSHOT</li>
<li>See full diff in <a
href="https://github.com/eclipse-ee4j/angus-mail/compare/2.0.3...2.0.4">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.eclipse.angus:angus-mail&package-manager=gradle&previous-version=2.0.3&new-version=2.0.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-05 16:16:10 +01:00
Anthony StirlingandGitHub 3da9ae46b6 Merge branch 'main' into org 2025-08-05 15:47:25 +01:00
Anthony Stirling 771ce3acfe formating and lang 2025-08-01 21:14:12 +01:00
Anthony Stirling a9def611f6 admin permission switch 2025-08-01 20:34:11 +01:00
Anthony Stirling b049638f49 debug TODO: TO REMOVE before merge 2025-08-01 15:19:22 +01:00
Anthony Stirling 5761bfbffa formatting 2025-08-01 14:48:37 +01:00
Anthony Stirling 3c6786f719 logging 2025-08-01 14:48:25 +01:00
a 7af18cb10c Merge branch 'org' of git@github.com:Stirling-Tools/Stirling-PDF.git into org 2025-08-01 14:46:46 +01:00
Anthony Stirling 42f5e68f48 h2 console stuff 2025-08-01 14:46:22 +01:00
Anthony StirlingandGitHub 92e7538182 Merge branch 'main' into org 2025-07-31 12:50:55 +01:00
Anthony Stirling 5e5f2dda83 org init 2025-07-31 12:16:01 +01:00
Anthony Stirling 0966b919cb remove file locks plus formatting 2025-07-29 19:00:48 +01:00
118 changed files with 6394 additions and 2221 deletions
+12 -1
View File
@@ -49,7 +49,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.26.0",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
"java.saveActions.cleanup": true,
"java.cleanup.actions": [
@@ -79,9 +79,17 @@
".venv*/",
".vscode/",
"bin/",
"app/core/bin/",
"app/common/bin/",
"app/proprietary/bin/",
"build/",
"app/core/build/",
"app/common/build/",
"app/proprietary/build/",
"configs/",
"app/core/configs/",
"customFiles/",
"app/core/customFiles/",
"docs/",
"exampleYmlFiles",
"gradle/",
@@ -93,6 +101,9 @@
".git-blame-ignore-revs",
".gitattributes",
".gitignore",
"app/core/.gitignore",
"app/common/.gitignore",
"app/proprietary/.gitignore",
".pre-commit-config.yaml"
],
"java.signatureHelp.enabled": true,
+1 -7
View File
@@ -31,18 +31,12 @@ indent_size = 2
# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards.
indent_size = 2
[*.yaml]
[*.{yml,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
+21 -2
View File
@@ -1,2 +1,21 @@
# All PRs to V1 must be approved by Frooodle
* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh @EthanHealy01
# All PRs must be approved by Frooodle or Ludy87
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
#V1 frontend
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
+3 -1
View File
@@ -26,4 +26,6 @@ project: &project
- gradlew
- gradlew.bat
- launch4jConfig.xml
- settings.gradle
- settings.gradle
- frontend/**
- docker/**
+5
View File
@@ -42,6 +42,7 @@
- name: "Front End"
color: "BBD2F1"
description: "Issues or pull requests related to front-end development"
from_name: "frontend"
- name: "github-actions"
description: "Pull requests that update GitHub Actions code"
color: "999999"
@@ -77,6 +78,7 @@
- name: "Translation"
color: "9FABF9"
from_name: "translation"
description: "Issues or pull requests related to translation"
- name: "upstream"
color: "DEDEDE"
- name: "v2"
@@ -178,3 +180,6 @@
- name: "pr-deployed"
color: "00FF00"
description: "Pull request has been deployed to a test environment"
- name: "codex"
color: "ededed"
description: "chatgpt AI generated code"
@@ -196,7 +196,7 @@ jobs:
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@9693b137b6566bb66055a713613bf4f0493701eb # v1.2.3
uses: actions/ai-inference@0cbed4a10641c75090de5968e66d70eb4660f751 # v1.2.7
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
+5 -5
View File
@@ -67,13 +67,13 @@ jobs:
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -88,7 +88,7 @@ jobs:
- name: Generate tags
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -134,7 +134,7 @@ jobs:
- name: Generate tags ultra-lite
id: meta2
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref != 'refs/heads/main'
with:
images: |
@@ -165,7 +165,7 @@ jobs:
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
+2 -1
View File
@@ -2,6 +2,7 @@
"editor.wordSegmenterLocales": "",
"editor.guides.bracketPairs": "active",
"editor.guides.bracketPairsHorizontal": "active",
"editor.defaultFormatter": "EditorConfig.EditorConfig",
"cSpell.enabled": false,
"[feature]": {
"editor.defaultFormatter": "alexkrechik.cucumberautocomplete"
@@ -40,7 +41,7 @@
"java.configuration.updateBuildConfiguration": "interactive",
"java.format.enabled": true,
"java.format.settings.profile": "GoogleStyle",
"java.format.settings.google.version": "1.27.0",
"java.format.settings.google.version": "1.28.0",
"java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting",
// (DE) Aktiviert Kommentare im Java-Format.
// (EN) Enables comments in Java formatting.
+37 -37
View File
@@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![63%](https://geps.dev/progress/63) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![63%](https://geps.dev/progress/63) |
| Basque (Euskara) (eu_ES) | ![37%](https://geps.dev/progress/37) |
| Bulgarian (Български) (bg_BG) | ![70%](https://geps.dev/progress/70) |
| Catalan (Català) (ca_CA) | ![69%](https://geps.dev/progress/69) |
| Croatian (Hrvatski) (hr_HR) | ![62%](https://geps.dev/progress/62) |
| Czech (Česky) (cs_CZ) | ![71%](https://geps.dev/progress/71) |
| Danish (Dansk) (da_DK) | ![63%](https://geps.dev/progress/63) |
| Dutch (Nederlands) (nl_NL) | ![61%](https://geps.dev/progress/61) |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![91%](https://geps.dev/progress/91) |
| German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) |
| Greek (Ελληνικά) (el_GR) | ![69%](https://geps.dev/progress/69) |
| Hindi (हिंदी) (hi_IN) | ![68%](https://geps.dev/progress/68) |
| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![63%](https://geps.dev/progress/63) |
| Irish (Gaeilge) (ga_IE) | ![70%](https://geps.dev/progress/70) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![95%](https://geps.dev/progress/95) |
| Korean (한국어) (ko_KR) | ![69%](https://geps.dev/progress/69) |
| Norwegian (Norsk) (no_NB) | ![67%](https://geps.dev/progress/67) |
| Persian (فارسی) (fa_IR) | ![66%](https://geps.dev/progress/66) |
| Polish (Polski) (pl_PL) | ![73%](https://geps.dev/progress/73) |
| Portuguese (Português) (pt_PT) | ![70%](https://geps.dev/progress/70) |
| Portuguese Brazilian (Português) (pt_BR) | ![77%](https://geps.dev/progress/77) |
| Romanian (Română) (ro_RO) | ![59%](https://geps.dev/progress/59) |
| Russian (Русский) (ru_RU) | ![90%](https://geps.dev/progress/90) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) |
| Simplified Chinese (简体中文) (zh_CN) | ![95%](https://geps.dev/progress/95) |
| Slovakian (Slovensky) (sk_SK) | ![53%](https://geps.dev/progress/53) |
| Slovenian (Slovenščina) (sl_SI) | ![73%](https://geps.dev/progress/73) |
| Spanish (Español) (es_ES) | ![75%](https://geps.dev/progress/75) |
| Swedish (Svenska) (sv_SE) | ![67%](https://geps.dev/progress/67) |
| Thai (ไทย) (th_TH) | ![60%](https://geps.dev/progress/60) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![66%](https://geps.dev/progress/66) |
| Traditional Chinese (繁體中文) (zh_TW) | ![99%](https://geps.dev/progress/99) |
| Turkish (Türkçe) (tr_TR) | ![82%](https://geps.dev/progress/82) |
| Ukrainian (Українська) (uk_UA) | ![72%](https://geps.dev/progress/72) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![58%](https://geps.dev/progress/58) |
| Malayalam (മലയാളം) (ml_IN) | ![75%](https://geps.dev/progress/75) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
+1 -1
View File
@@ -41,5 +41,5 @@ dependencies {
api 'org.snakeyaml:snakeyaml-engine:2.10'
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'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.4'
}
@@ -197,7 +197,7 @@ public class ApplicationProperties {
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath".length()))
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
.getInputStream();
}
try {
@@ -233,6 +233,7 @@ public class ApplicationProperties {
@JsonIgnore
public Resource getPrivateKey() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
@@ -311,6 +312,7 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Datasource datasource;
private Boolean disableSanitize;
private int maxDPI;
private Boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -10,21 +10,25 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public enum Role {
// Unlimited access
// System-wide administrator - can manage all organizations
SYSTEM_ADMIN(
"ROLE_SYSTEM_ADMIN",
Integer.MAX_VALUE,
Integer.MAX_VALUE,
"adminUserSettings.systemAdmin"),
// Organization administrator - can manage their organization and all its teams
ORG_ADMIN("ROLE_ORG_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.orgAdmin"),
// Team leader - can manage users in their specific team
TEAM_LEAD("ROLE_TEAM_LEAD", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.teamLead"),
// Legacy admin role - equivalent to SYSTEM_ADMIN for backward compatibility
ADMIN("ROLE_ADMIN", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.admin"),
// Unlimited access
// Regular user with unlimited access within their team/org
USER("ROLE_USER", Integer.MAX_VALUE, Integer.MAX_VALUE, "adminUserSettings.user"),
// 40 API calls Per Day, 40 web calls
LIMITED_API_USER("ROLE_LIMITED_API_USER", 40, 40, "adminUserSettings.apiUser"),
// 20 API calls Per Day, 20 web calls
EXTRA_LIMITED_API_USER("ROLE_EXTRA_LIMITED_API_USER", 20, 20, "adminUserSettings.extraApiUser"),
// 0 API calls per day and 20 web calls
WEB_ONLY_USER("ROLE_WEB_ONLY_USER", 0, 20, "adminUserSettings.webOnlyUser"),
INTERNAL_API_USER(
"STIRLING-PDF-BACKEND-API-USER",
Integer.MAX_VALUE,
@@ -63,4 +67,35 @@ public enum Role {
}
throw new IllegalArgumentException("No Role defined for id: " + roleId);
}
/** Checks if this role can manage users across all organizations */
public boolean isSystemAdmin() {
return this == SYSTEM_ADMIN || this == ADMIN; // ADMIN for backward compatibility
}
/** Checks if this role can manage an organization and all its teams */
public boolean isOrgAdmin() {
return isSystemAdmin() || this == ORG_ADMIN;
}
/** Checks if this role can manage a specific team */
public boolean isTeamLead() {
return isOrgAdmin() || this == TEAM_LEAD;
}
/** Gets the hierarchy level of this role (higher number = more permissions) */
public int getHierarchyLevel() {
return switch (this) {
case SYSTEM_ADMIN, ADMIN -> 4;
case ORG_ADMIN -> 3;
case TEAM_LEAD -> 2;
case USER -> 1;
default -> 0; // Limited users
};
}
/** Checks if this role has higher or equal authority than another role */
public boolean hasAuthorityOver(Role otherRole) {
return this.getHierarchyLevel() >= otherRole.getHierarchyLevel();
}
}
@@ -0,0 +1,652 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.regex.Pattern;
import lombok.Data;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
@UtilityClass
public class EmlParser {
private static volatile Boolean jakartaMailAvailable = null;
private static volatile Method mimeUtilityDecodeTextMethod = null;
private static volatile boolean mimeUtilityChecked = false;
private static final Pattern MIME_ENCODED_PATTERN =
Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
private static final String DISPOSITION_ATTACHMENT = "attachment";
private static final String TEXT_PLAIN = "text/plain";
private static final String TEXT_HTML = "text/html";
private static final String MULTIPART_PREFIX = "multipart/";
private static final String HEADER_CONTENT_TYPE = "content-type:";
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
private static final String HEADER_CONTENT_ID = "Content-ID";
private static final String HEADER_SUBJECT = "Subject:";
private static final String HEADER_FROM = "From:";
private static final String HEADER_TO = "To:";
private static final String HEADER_CC = "Cc:";
private static final String HEADER_BCC = "Bcc:";
private static final String HEADER_DATE = "Date:";
private static synchronized boolean isJakartaMailAvailable() {
if (jakartaMailAvailable == null) {
try {
Class.forName("jakarta.mail.internet.MimeMessage");
Class.forName("jakarta.mail.Session");
Class.forName("jakarta.mail.internet.MimeUtility");
Class.forName("jakarta.mail.internet.MimePart");
Class.forName("jakarta.mail.internet.MimeMultipart");
Class.forName("jakarta.mail.Multipart");
Class.forName("jakarta.mail.Part");
jakartaMailAvailable = true;
} catch (ClassNotFoundException e) {
jakartaMailAvailable = false;
}
}
return jakartaMailAvailable;
}
public static EmailContent extractEmailContent(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
if (isJakartaMailAvailable()) {
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
} else {
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
}
}
private static EmailContent extractEmailContentBasic(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
EmailContent content = new EmailContent();
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
if (!dateStr.isEmpty()) {
content.setDateString(dateStr);
}
String htmlBody = extractHtmlBody(emlContent);
if (htmlBody != null) {
content.setHtmlBody(htmlBody);
} else {
String textBody = extractTextBody(emlContent);
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
}
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
return content;
}
private static EmailContent extractEmailContentAdvanced(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
try {
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
Method getDefaultInstance =
sessionClass.getMethod("getDefaultInstance", Properties.class);
Object session = getDefaultInstance.invoke(null, new Properties());
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
Constructor<?> mimeMessageConstructor =
mimeMessageClass.getConstructor(constructorArgs);
Object message =
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
return extractFromMimeMessage(message, request, customHtmlSanitizer);
} catch (ReflectiveOperationException e) {
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
}
}
private static EmailContent extractFromMimeMessage(
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
EmailContent content = new EmailContent();
try {
Class<?> messageClass = message.getClass();
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(buildAddressString(fromAddresses));
extractRecipients(message, messageClass, content);
Method getSentDate = messageClass.getMethod("getSentDate");
content.setDate((Date) getSentDate.invoke(message));
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
} catch (ReflectiveOperationException | RuntimeException e) {
content.setSubject("Email Conversion");
content.setFrom("Unknown");
content.setTo("Unknown");
content.setCc("");
content.setBcc("");
content.setTextBody("Email content could not be parsed with advanced processing");
}
return content;
}
private static void extractRecipients(
Object message, Class<?> messageClass, EmailContent content) {
try {
Method getRecipients =
messageClass.getMethod(
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
Object toType = recipientTypeClass.getField("TO").get(null);
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
content.setTo(buildAddressString(toRecipients));
Object ccType = recipientTypeClass.getField("CC").get(null);
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
content.setCc(buildAddressString(ccRecipients));
Object bccType = recipientTypeClass.getField("BCC").get(null);
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
content.setBcc(buildAddressString(bccRecipients));
} catch (ReflectiveOperationException e) {
try {
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(buildAddressString(recipients));
content.setCc("");
content.setBcc("");
} catch (ReflectiveOperationException ex) {
content.setTo("");
content.setCc("");
content.setBcc("");
}
}
}
private static String buildAddressString(Object[] addresses) {
if (addresses == null || addresses.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < addresses.length; i++) {
if (i > 0) builder.append(", ");
builder.append(safeMimeDecode(addresses[i].toString()));
}
return builder.toString();
}
private static void processMessageContent(
Object message,
Object messageContent,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
try {
if (messageContent instanceof String stringContent) {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
} else {
content.setTextBody(stringContent);
}
} else {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(messageContent)) {
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
}
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setTextBody("Email content could not be parsed with advanced processing");
}
}
private static void processMultipart(
Object multipart,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
final int MAX_MULTIPART_DEPTH = 10;
if (depth > MAX_MULTIPART_DEPTH) {
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
return;
}
try {
Class<?> multipartClass = multipart.getClass();
Method getCount = multipartClass.getMethod("getCount");
int count = (Integer) getCount.invoke(multipart);
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
processPart(part, content, request, customHtmlSanitizer, depth + 1);
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
}
}
private static void processPart(
Object part,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
try {
Class<?> partClass = part.getClass();
Method isMimeType = partClass.getMethod("isMimeType", String.class);
Method getContent = partClass.getMethod("getContent");
Method getDisposition = partClass.getMethod("getDisposition");
Method getFileName = partClass.getMethod("getFileName");
Method getContentType = partClass.getMethod("getContentType");
Method getHeader = partClass.getMethod("getHeader", String.class);
Object disposition = getDisposition.invoke(part);
String filename = (String) getFileName.invoke(part);
String contentType = (String) getContentType.invoke(part);
String normalizedDisposition =
disposition != null ? ((String) disposition).toLowerCase() : null;
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
content.setTextBody(stringContent);
}
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
&& normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
String htmlBody =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(stringContent)
: stringContent;
content.setHtmlBody(htmlBody);
}
} else if ((normalizedDisposition != null
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|| (filename != null && !filename.trim().isEmpty())) {
processAttachment(
part, content, request, getHeader, getContent, filename, contentType);
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
Object multipartContent = getContent.invoke(part);
if (multipartContent != null) {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(multipartContent)) {
processMultipart(
multipartContent, content, request, customHtmlSanitizer, depth + 1);
}
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue processing other parts if one fails
}
}
private static void processAttachment(
Object part,
EmailContent content,
EmlToPdfRequest request,
Method getHeader,
Method getContent,
String filename,
String contentType) {
content.setAttachmentCount(content.getAttachmentCount() + 1);
if (filename != null && !filename.trim().isEmpty()) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(safeMimeDecode(filename));
attachment.setContentType(contentType);
try {
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
if (contentIdHeaders != null) {
for (String contentIdHeader : contentIdHeaders) {
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
attachment.setEmbedded(true);
String contentId = contentIdHeader.trim().replaceAll("[<>]", "");
attachment.setContentId(contentId);
break;
}
}
}
} catch (ReflectiveOperationException e) {
}
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
extractAttachmentData(part, attachment, getContent, request);
}
content.getAttachments().add(attachment);
}
}
private static void extractAttachmentData(
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
try {
Object attachmentContent = getContent.invoke(part);
byte[] attachmentData = null;
if (attachmentContent instanceof InputStream inputStream) {
try (InputStream stream = inputStream) {
attachmentData = stream.readAllBytes();
} catch (IOException e) {
if (attachment.isEmbedded()) {
attachmentData = new byte[0];
} else {
throw new RuntimeException(e);
}
}
} else if (attachmentContent instanceof byte[] byteArray) {
attachmentData = byteArray;
} else if (attachmentContent instanceof String stringContent) {
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
}
if (attachmentData != null) {
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
long maxSizeBytes = maxSizeMB * 1024 * 1024;
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
attachment.setData(attachmentData);
attachment.setSizeBytes(attachmentData.length);
} else {
attachment.setSizeBytes(attachmentData.length);
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue without attachment data
}
}
private static String extractBasicHeader(String emlContent, String headerName) {
try {
String[] lines = emlContent.split("\r?\n");
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
StringBuilder value =
new StringBuilder(line.substring(headerName.length()).trim());
for (int j = i + 1; j < lines.length; j++) {
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
value.append(" ").append(lines[j].trim());
} else {
break;
}
}
return safeMimeDecode(value.toString());
}
if (line.trim().isEmpty()) break;
}
} catch (RuntimeException e) {
// Ignore errors in header extraction
}
return "";
}
private static String extractHtmlBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (Exception e) {
return null;
}
}
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase();
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
if (bodyStart != -1) {
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
}
return null;
}
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (RuntimeException e) {
return null;
}
}
private static int findPartEnd(String content, int start) {
String[] lines = content.substring(start).split("\r?\n");
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.startsWith("--") && line.length() > 10) break;
result.append(line).append("\n");
}
return start + result.length();
}
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
List<EmailAttachment> attachments = new ArrayList<>();
try {
String[] lines = emlContent.split("\r?\n");
boolean inHeaders = true;
String currentContentType = "";
String currentDisposition = "";
String currentFilename = "";
String currentEncoding = "";
for (String line : lines) {
String lowerLine = line.toLowerCase().trim();
if (line.trim().isEmpty()) {
inHeaders = false;
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(currentFilename);
attachment.setContentType(currentContentType);
attachment.setTransferEncoding(currentEncoding);
attachments.add(attachment);
}
currentContentType = "";
currentDisposition = "";
currentFilename = "";
currentEncoding = "";
inHeaders = true;
continue;
}
if (!inHeaders) continue;
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
currentFilename = extractFilenameFromDisposition(currentDisposition);
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
currentEncoding =
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
}
}
} catch (RuntimeException e) {
// Continue with empty list
}
return attachments;
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty())
|| (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/"))
|| (contentType.toLowerCase().contains("application/") && !filename.isEmpty());
}
private static String extractFilenameFromDisposition(String disposition) {
if (disposition == null || !disposition.contains("filename=")) {
return "";
}
// Handle filename*= (RFC 2231 encoded filename)
if (disposition.toLowerCase().contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10;
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
String extendedFilename =
disposition.substring(filenameStarStart, filenameStarEnd).trim();
extendedFilename = extendedFilename.replaceAll("^\"|\"$", "");
if (extendedFilename.contains("'")) {
String[] parts = extendedFilename.split("'", 3);
if (parts.length == 3) {
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
}
}
}
// Handle regular filename=
int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9;
int filenameEnd = disposition.indexOf(";", filenameStart);
if (filenameEnd == -1) filenameEnd = disposition.length();
String filename = disposition.substring(filenameStart, filenameEnd).trim();
filename = filename.replaceAll("^\"|\"$", "");
return safeMimeDecode(filename);
}
public static String safeMimeDecode(String headerValue) {
if (headerValue == null || headerValue.trim().isEmpty()) {
return "";
}
if (!mimeUtilityChecked) {
synchronized (EmlParser.class) {
if (!mimeUtilityChecked) {
initializeMimeUtilityDecoding();
}
}
}
if (mimeUtilityDecodeTextMethod != null) {
try {
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
} catch (ReflectiveOperationException | RuntimeException e) {
// Fall through to custom implementation
}
}
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
}
private static void initializeMimeUtilityDecoding() {
try {
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
mimeUtilityDecodeTextMethod = null;
}
mimeUtilityChecked = true;
}
@Data
public static class EmailContent {
private String subject;
private String from;
private String to;
private String cc;
private String bcc;
private Date date;
private String dateString; // For basic parsing fallback
private String htmlBody;
private String textBody;
private int attachmentCount;
private List<EmailAttachment> attachments = new ArrayList<>();
public void setHtmlBody(String htmlBody) {
this.htmlBody = htmlBody != null ? htmlBody.replaceAll("\r", "") : null;
}
public void setTextBody(String textBody) {
this.textBody = textBody != null ? textBody.replaceAll("\r", "") : null;
}
}
@Data
public static class EmailAttachment {
private String filename;
private String contentType;
private byte[] data;
private boolean embedded;
private String embeddedFilename;
private long sizeBytes;
private String contentId;
private String disposition;
private String transferEncoding;
public void setData(byte[] data) {
this.data = data;
if (data != null) {
this.sizeBytes = data.length;
}
}
}
}
@@ -0,0 +1,601 @@
package stirling.software.common.util;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.experimental.UtilityClass;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
@UtilityClass
public class EmlProcessingUtils {
// Style constants
private static final int DEFAULT_FONT_SIZE = 12;
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
private static final String DEFAULT_ZOOM = "1.0";
private static final String DEFAULT_TEXT_COLOR = "#202124";
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private static final int EML_CHECK_LENGTH = 8192;
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MIME type detection
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
Map.of(
".png", "image/png",
".jpg", "image/jpeg",
".jpeg", "image/jpeg",
".gif", "image/gif",
".bmp", "image/bmp",
".webp", "image/webp",
".svg", "image/svg+xml",
".ico", "image/x-icon",
".tiff", "image/tiff",
".tif", "image/tiff");
public static void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) {
throw new IllegalArgumentException("EML file is empty or null");
}
if (isInvalidEmlFormat(emlBytes)) {
throw new IllegalArgumentException("Invalid EML file format");
}
}
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
try {
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
String content;
try {
content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8);
if (content.contains("\uFFFD")) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
} catch (Exception e) {
content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1);
}
String lowerContent = content.toLowerCase(Locale.ROOT);
boolean hasFrom =
lowerContent.contains("from:") || lowerContent.contains("return-path:");
boolean hasSubject = lowerContent.contains("subject:");
boolean hasMessageId = lowerContent.contains("message-id:");
boolean hasDate = lowerContent.contains("date:");
boolean hasTo =
lowerContent.contains("to:")
|| lowerContent.contains("cc:")
|| lowerContent.contains("bcc:");
boolean hasMimeStructure =
lowerContent.contains("multipart/")
|| lowerContent.contains("text/plain")
|| lowerContent.contains("text/html")
|| lowerContent.contains("boundary=");
int headerCount = 0;
if (hasFrom) headerCount++;
if (hasSubject) headerCount++;
if (hasMessageId) headerCount++;
if (hasDate) headerCount++;
if (hasTo) headerCount++;
return headerCount < MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure;
} catch (RuntimeException e) {
return false;
}
}
public static String generateEnhancedEmailHtml(
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
StringBuilder html = new StringBuilder();
html.append(
String.format(
"""
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<title>%s</title>
<style>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer)));
appendEnhancedStyles(html);
html.append(
"""
</style>
</head><body>
""");
html.append(
String.format(
"""
<div class="email-container">
<div class="email-header">
<h1>%s</h1>
<div class="email-meta">
<div><strong>From:</strong> %s</div>
<div><strong>To:</strong> %s</div>
""",
sanitizeText(content.getSubject(), customHtmlSanitizer),
sanitizeText(content.getFrom(), customHtmlSanitizer),
sanitizeText(content.getTo(), customHtmlSanitizer)));
if (content.getCc() != null && !content.getCc().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>CC:</strong> %s</div>\n",
sanitizeText(content.getCc(), customHtmlSanitizer)));
}
if (content.getBcc() != null && !content.getBcc().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>BCC:</strong> %s</div>\n",
sanitizeText(content.getBcc(), customHtmlSanitizer)));
}
if (content.getDate() != null) {
html.append(
String.format(
"<div><strong>Date:</strong> %s</div>\n",
PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append(
String.format(
"<div><strong>Date:</strong> %s</div>\n",
sanitizeText(content.getDateString(), customHtmlSanitizer)));
}
html.append("</div></div>\n");
html.append("<div class=\"email-body\">\n");
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
String processedHtml =
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
html.append(processedHtml);
} else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) {
html.append(
String.format(
"<div class=\"text-body\">%s</div>",
convertTextToHtml(content.getTextBody(), customHtmlSanitizer)));
} else {
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
}
html.append("</div>\n");
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
}
html.append("</div>\n</body></html>");
return html.toString();
}
public static String processEmailHtmlBody(
String htmlBody,
EmlParser.EmailContent emailContent,
CustomHtmlSanitizer customHtmlSanitizer) {
if (htmlBody == null) return "";
String processed =
customHtmlSanitizer != null ? customHtmlSanitizer.sanitize(htmlBody) : htmlBody;
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", "");
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", "");
if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
processed = PdfAttachmentHandler.processInlineImages(processed, emailContent);
}
return processed;
}
public static String convertTextToHtml(
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
if (textBody == null) return "";
String html =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(textBody)
: escapeHtml(textBody);
html = html.replace("\r\n", "\n").replace("\r", "\n");
html = html.replace("\n", "<br>\n");
html =
html.replaceAll(
"(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)",
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
html =
html.replaceAll(
"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})",
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
return html;
}
private static void appendEnhancedStyles(StringBuilder html) {
String css =
String.format(
"""
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.email-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.attachment-section {
margin-top: 15px;
padding: 10px;
background-color: %s;
border: 1px solid %s;
border-radius: 3px;
}
.attachment-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.attachment-inclusion-note, .attachment-info-note {
margin-top: 8px;
padding: 6px;
font-size: %dpx;
border-radius: 3px;
}
.attachment-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.attachment-link-container {
display: flex;
align-items: center;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 4px 0;
}
.attachment-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
}
""",
DEFAULT_FONT_FAMILY,
DEFAULT_FONT_SIZE,
DEFAULT_LINE_HEIGHT,
DEFAULT_TEXT_COLOR,
DEFAULT_BACKGROUND_COLOR,
DEFAULT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 4,
DEFAULT_FONT_SIZE - 1,
ATTACHMENT_BACKGROUND_COLOR,
ATTACHMENT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 1,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 3);
html.append(css);
}
private static void appendAttachmentsSection(
StringBuilder html,
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
html.append("<div class=\"attachment-section\">\n");
int displayedAttachmentCount =
content.getAttachmentCount() > 0
? content.getAttachmentCount()
: content.getAttachments().size();
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
if (!content.getAttachments().isEmpty()) {
for (int i = 0; i < content.getAttachments().size(); i++) {
EmlParser.EmailAttachment attachment = content.getAttachments().get(i);
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
String sizeStr = GeneralUtils.formatBytes(attachment.getSizeBytes());
String contentType =
attachment.getContentType() != null
&& !attachment.getContentType().isEmpty()
? ", " + escapeHtml(attachment.getContentType())
: "";
String attachmentId = "attachment_" + i;
html.append(
String.format(
"""
<div class="attachment-item" id="%s">
<span class="attachment-icon" data-filename="%s">@</span>
<span class="attachment-name">%s</span>
<span class="attachment-details">(%s%s)</span>
</div>
""",
attachmentId,
escapeHtml(embeddedFilename),
escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())),
sizeStr,
contentType));
}
}
if (request != null && request.isIncludeAttachments()) {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachments are embedded in the file.</em></p>
</div>
""");
} else {
html.append(
"""
<div class="attachment-info-note">
<p><em>Attachment information displayed - files not included in PDF.</em></p>
</div>
""");
}
html.append("</div>\n");
}
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
if (request != null) {
htmlRequest.setFileInput(request.getFileInput());
}
htmlRequest.setZoom(Float.parseFloat(DEFAULT_ZOOM));
return htmlRequest;
}
public static String detectMimeType(String filename, String existingMimeType) {
if (existingMimeType != null && !existingMimeType.isEmpty()) {
return existingMimeType;
}
if (filename != null) {
String lowerFilename = filename.toLowerCase();
for (Map.Entry<String, String> entry : EXTENSION_TO_MIME_TYPE.entrySet()) {
if (lowerFilename.endsWith(entry.getKey())) {
return entry.getValue();
}
}
}
return "image/png";
}
public static String decodeUrlEncoded(String encoded) {
try {
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
return encoded; // Return original if decoding fails
}
}
public static String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) {
return encodedText;
}
try {
StringBuilder result = new StringBuilder();
Pattern concatenatedPattern =
Pattern.compile(
"(=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)(\\s*=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)+");
Matcher concatenatedMatcher = concatenatedPattern.matcher(encodedText);
String processedText =
concatenatedMatcher.replaceAll(
match -> match.group().replaceAll("\\s+(?==\\?)", ""));
Pattern mimePattern = Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
Matcher matcher = mimePattern.matcher(processedText);
int lastEnd = 0;
while (matcher.find()) {
result.append(processedText, lastEnd, matcher.start());
String charset = matcher.group(1);
String encoding = matcher.group(2).toUpperCase();
String encodedValue = matcher.group(3);
try {
String decodedValue =
switch (encoding) {
case "B" -> {
String cleanBase64 = encodedValue.replaceAll("\\s", "");
byte[] decodedBytes = Base64.getDecoder().decode(cleanBase64);
Charset targetCharset;
try {
targetCharset = Charset.forName(charset);
} catch (Exception e) {
targetCharset = StandardCharsets.UTF_8;
}
yield new String(decodedBytes, targetCharset);
}
case "Q" -> decodeQuotedPrintable(encodedValue, charset);
default -> matcher.group(0); // Return original if unknown encoding
};
result.append(decodedValue);
} catch (RuntimeException e) {
result.append(matcher.group(0)); // Keep original on decode error
}
lastEnd = matcher.end();
}
result.append(processedText.substring(lastEnd));
return result.toString();
} catch (Exception e) {
return encodedText; // Return original on any parsing error
}
}
private static String decodeQuotedPrintable(String encodedText, String charset) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < encodedText.length(); i++) {
char c = encodedText.charAt(i);
switch (c) {
case '=' -> {
if (i + 2 < encodedText.length()) {
String hex = encodedText.substring(i + 1, i + 3);
try {
int value = Integer.parseInt(hex, 16);
result.append((char) value);
i += 2;
} catch (NumberFormatException e) {
result.append(c);
}
} else if (i + 1 == encodedText.length()
|| (i + 2 == encodedText.length()
&& encodedText.charAt(i + 1) == '\n')) {
if (i + 1 < encodedText.length() && encodedText.charAt(i + 1) == '\n') {
i++; // Skip the newline too
}
} else {
result.append(c);
}
}
case '_' -> result.append(' '); // Space encoding in Q encoding
default -> result.append(c);
}
}
byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1);
try {
Charset targetCharset = Charset.forName(charset);
return new String(bytes, targetCharset);
} catch (Exception e) {
try {
return new String(bytes, StandardCharsets.UTF_8);
} catch (Exception fallbackException) {
return new String(bytes, StandardCharsets.ISO_8859_1);
}
}
}
public static String escapeHtml(String text) {
if (text == null) return "";
return text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
if (customHtmlSanitizer != null) {
return customHtmlSanitizer.sanitize(text);
} else {
return escapeHtml(text);
}
}
public static String simplifyHtmlContent(String htmlContent) {
String simplified = htmlContent.replaceAll("(?i)<script[^>]*>.*?</script>", "");
simplified = simplified.replaceAll("(?i)<style[^>]*>.*?</style>", "");
return simplified;
}
}
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,11 @@ import java.awt.image.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.Iterator;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import org.springframework.web.multipart.MultipartFile;
@@ -115,7 +118,36 @@ public class ImageProcessingUtils {
public static BufferedImage loadImageWithExifOrientation(MultipartFile file)
throws IOException {
BufferedImage image = ImageIO.read(file.getInputStream());
BufferedImage image = null;
String filename = file.getOriginalFilename();
if (filename != null && filename.toLowerCase().endsWith(".psd")) {
// For PSD files, try explicit ImageReader
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("PSD");
if (readers.hasNext()) {
ImageReader reader = readers.next();
try (ImageInputStream iis = ImageIO.createImageInputStream(file.getInputStream())) {
reader.setInput(iis);
image = reader.read(0);
} finally {
reader.dispose();
}
}
if (image == null) {
throw new IOException(
"Unable to read image from file: "
+ filename
+ ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP compression");
}
} else {
// For non-PSD files, use standard ImageIO
image = ImageIO.read(file.getInputStream());
}
if (image == null) {
throw new IOException("Unable to read image from file: " + filename);
}
double orientation = extractImageOrientation(file.getInputStream());
return applyOrientation(image, orientation);
}
@@ -0,0 +1,680 @@
package stirling.software.common.util;
import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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.PDPage;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition;
import org.jetbrains.annotations.NotNull;
import org.springframework.web.multipart.MultipartFile;
import lombok.Data;
import lombok.Getter;
import lombok.experimental.UtilityClass;
import stirling.software.common.service.CustomPDFDocumentFactory;
@UtilityClass
public class PdfAttachmentHandler {
// Note: This class is designed for EML attachments, not general PDF attachments.
private static final String ATTACHMENT_MARKER = "@";
private static final float ATTACHMENT_ICON_WIDTH = 12f;
private static final float ATTACHMENT_ICON_HEIGHT = 14f;
private static final float ANNOTATION_X_OFFSET = 2f;
private static final float ANNOTATION_Y_OFFSET = 10f;
public static byte[] attachFilesToPdf(
byte[] pdfBytes,
List<EmlParser.EmailAttachment> attachments,
CustomPDFDocumentFactory pdfDocumentFactory)
throws IOException {
if (attachments == null || attachments.isEmpty()) {
return pdfBytes;
}
try (PDDocument document = pdfDocumentFactory.load(pdfBytes);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
List<MultipartFile> multipartAttachments = new ArrayList<>(attachments.size());
for (int i = 0; i < attachments.size(); i++) {
EmlParser.EmailAttachment attachment = attachments.get(i);
if (attachment.getData() != null && attachment.getData().length > 0) {
String embeddedFilename =
attachment.getFilename() != null
? attachment.getFilename()
: ("attachment_" + i);
attachment.setEmbeddedFilename(embeddedFilename);
multipartAttachments.add(createMultipartFile(attachment));
}
}
if (!multipartAttachments.isEmpty()) {
Map<Integer, String> indexToFilenameMap =
addAttachmentsToDocumentWithMapping(
document, multipartAttachments, attachments);
setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS);
addAttachmentAnnotationsToDocumentWithMapping(
document, attachments, indexToFilenameMap);
}
document.save(outputStream);
return outputStream.toByteArray();
} catch (RuntimeException e) {
throw new IOException(
"Invalid PDF structure or processing error: " + e.getMessage(), e);
} catch (Exception e) {
throw new IOException("Error attaching files to PDF: " + e.getMessage(), e);
}
}
private static MultipartFile createMultipartFile(EmlParser.EmailAttachment attachment) {
return new MultipartFile() {
@Override
public @NotNull String getName() {
return "attachment";
}
@Override
public String getOriginalFilename() {
return attachment.getFilename() != null
? attachment.getFilename()
: "attachment_" + System.currentTimeMillis();
}
@Override
public String getContentType() {
return attachment.getContentType() != null
? attachment.getContentType()
: "application/octet-stream";
}
@Override
public boolean isEmpty() {
return attachment.getData() == null || attachment.getData().length == 0;
}
@Override
public long getSize() {
return attachment.getData() != null ? attachment.getData().length : 0;
}
@Override
public byte @NotNull [] getBytes() {
return attachment.getData() != null ? attachment.getData() : new byte[0];
}
@Override
public @NotNull InputStream getInputStream() {
byte[] data = attachment.getData();
return new ByteArrayInputStream(data != null ? data : new byte[0]);
}
@Override
public void transferTo(@NotNull File dest) throws IOException, IllegalStateException {
try (FileOutputStream fos = new FileOutputStream(dest)) {
byte[] data = attachment.getData();
if (data != null) {
fos.write(data);
}
}
}
};
}
private static String ensureUniqueFilename(String filename, Set<String> existingNames) {
if (!existingNames.contains(filename)) {
return filename;
}
String baseName;
String extension = "";
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0) {
baseName = filename.substring(0, lastDot);
extension = filename.substring(lastDot);
} else {
baseName = filename;
}
int counter = 1;
String uniqueName;
do {
uniqueName = baseName + "_" + counter + extension;
counter++;
} while (existingNames.contains(uniqueName));
return uniqueName;
}
private static @NotNull PDRectangle calculateAnnotationRectangle(
PDPage page, float x, float y) {
PDRectangle cropBox = page.getCropBox();
// ISO 32000-1:2008 Section 8.3: PDF coordinate system transforms
int rotation = page.getRotation();
float pdfX = x;
float pdfY = cropBox.getHeight() - y;
switch (rotation) {
case 90 -> {
float temp = pdfX;
pdfX = pdfY;
pdfY = cropBox.getWidth() - temp;
}
case 180 -> {
pdfX = cropBox.getWidth() - pdfX;
pdfY = y;
}
case 270 -> {
float temp = pdfX;
pdfX = cropBox.getHeight() - pdfY;
pdfY = temp;
}
default -> {}
}
float iconHeight = ATTACHMENT_ICON_HEIGHT;
float paddingX = 2.0f;
float paddingY = 2.0f;
PDRectangle rect =
new PDRectangle(
pdfX + ANNOTATION_X_OFFSET + paddingX,
pdfY - iconHeight + ANNOTATION_Y_OFFSET + paddingY,
ATTACHMENT_ICON_WIDTH,
iconHeight);
PDRectangle mediaBox = page.getMediaBox();
if (rect.getLowerLeftX() < mediaBox.getLowerLeftX()
|| rect.getLowerLeftY() < mediaBox.getLowerLeftY()
|| rect.getUpperRightX() > mediaBox.getUpperRightX()
|| rect.getUpperRightY() > mediaBox.getUpperRightY()) {
float adjustedX =
Math.max(
mediaBox.getLowerLeftX(),
Math.min(
rect.getLowerLeftX(),
mediaBox.getUpperRightX() - rect.getWidth()));
float adjustedY =
Math.max(
mediaBox.getLowerLeftY(),
Math.min(
rect.getLowerLeftY(),
mediaBox.getUpperRightY() - rect.getHeight()));
rect = new PDRectangle(adjustedX, adjustedY, rect.getWidth(), rect.getHeight());
}
return rect;
}
public static String processInlineImages(
String htmlContent, EmlParser.EmailContent emailContent) {
if (htmlContent == null || emailContent == null) return htmlContent;
Map<String, EmlParser.EmailAttachment> contentIdMap = new HashMap<>();
for (EmlParser.EmailAttachment attachment : emailContent.getAttachments()) {
if (attachment.isEmbedded()
&& attachment.getContentId() != null
&& attachment.getData() != null) {
contentIdMap.put(attachment.getContentId(), attachment);
}
}
if (contentIdMap.isEmpty()) return htmlContent;
Pattern cidPattern =
Pattern.compile(
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
Pattern.CASE_INSENSITIVE);
Matcher matcher = cidPattern.matcher(htmlContent);
StringBuilder result = new StringBuilder();
while (matcher.find()) {
String contentId = matcher.group(1);
EmlParser.EmailAttachment attachment = contentIdMap.get(contentId);
if (attachment != null && attachment.getData() != null) {
String mimeType =
EmlProcessingUtils.detectMimeType(
attachment.getFilename(), attachment.getContentType());
String base64Data = Base64.getEncoder().encodeToString(attachment.getData());
String dataUri = "data:" + mimeType + ";base64," + base64Data;
String replacement =
matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri);
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
} else {
matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0)));
}
}
matcher.appendTail(result);
return result.toString();
}
public static String formatEmailDate(Date date) {
if (date == null) return "";
SimpleDateFormat formatter =
new SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH);
formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
return formatter.format(date);
}
@Data
public static class MarkerPosition {
private int pageIndex;
private float x;
private float y;
private String character;
private String filename;
public MarkerPosition(int pageIndex, float x, float y, String character, String filename) {
this.pageIndex = pageIndex;
this.x = x;
this.y = y;
this.character = character;
this.filename = filename;
}
}
public static class AttachmentMarkerPositionFinder extends PDFTextStripper {
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
private int currentPageIndex;
protected boolean sortByPosition;
private boolean isInAttachmentSection;
private boolean attachmentSectionFound;
private final StringBuilder currentText = new StringBuilder();
private static final Pattern ATTACHMENT_SECTION_PATTERN =
Pattern.compile("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE);
private static final Pattern FILENAME_PATTERN =
Pattern.compile("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)");
public AttachmentMarkerPositionFinder() {
super();
this.currentPageIndex = 0;
this.sortByPosition = false; // Disable sorting to preserve document order
this.isInAttachmentSection = false;
this.attachmentSectionFound = false;
}
@Override
public String getText(PDDocument document) throws IOException {
super.getText(document);
if (sortByPosition) {
positions.sort(
(a, b) -> {
int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex());
if (pageCompare != 0) return pageCompare;
return Float.compare(
b.getY(), a.getY()); // Descending Y per PDF coordinate system
});
}
return ""; // Return empty string as we only need positions
}
@Override
protected void startPage(PDPage page) throws IOException {
super.startPage(page);
}
@Override
protected void endPage(PDPage page) throws IOException {
currentPageIndex++;
super.endPage(page);
}
@Override
protected void writeString(String string, List<TextPosition> textPositions)
throws IOException {
String lowerString = string.toLowerCase();
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
isInAttachmentSection = true;
attachmentSectionFound = true;
}
if (isInAttachmentSection
&& (lowerString.contains("</body>")
|| lowerString.contains("</html>")
|| (attachmentSectionFound
&& lowerString.trim().isEmpty()
&& string.length() > 50))) {
isInAttachmentSection = false;
}
if (isInAttachmentSection) {
currentText.append(string);
for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) {
if (i < textPositions.size()) {
TextPosition textPosition = textPositions.get(i);
String filename = extractFilenameAfterMarker(string, i);
MarkerPosition position =
new MarkerPosition(
currentPageIndex,
textPosition.getXDirAdj(),
textPosition.getYDirAdj(),
ATTACHMENT_MARKER,
filename);
positions.add(position);
}
}
}
super.writeString(string, textPositions);
}
@Override
public void setSortByPosition(boolean sortByPosition) {
this.sortByPosition = sortByPosition;
}
private String extractFilenameAfterMarker(String text, int markerIndex) {
String afterMarker = text.substring(markerIndex + 1);
Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker);
if (matcher.find()) {
return matcher.group(1);
}
String[] parts = afterMarker.split("[\\s\\(\\)]+");
for (String part : parts) {
part = part.trim();
if (part.length() > 3 && part.contains(".")) {
return part;
}
}
return null;
}
}
private static Map<Integer, String> addAttachmentsToDocumentWithMapping(
PDDocument document,
List<MultipartFile> attachments,
List<EmlParser.EmailAttachment> originalAttachments)
throws IOException {
PDDocumentCatalog catalog = document.getDocumentCatalog();
if (catalog == null) {
throw new IOException("PDF document catalog is not accessible");
}
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);
}
Map<String, PDComplexFileSpecification> existingNames = embeddedFilesTree.getNames();
if (existingNames == null) {
existingNames = new HashMap<>();
}
Map<Integer, String> indexToFilenameMap = new HashMap<>();
for (int i = 0; i < attachments.size(); i++) {
MultipartFile attachment = attachments.get(i);
String filename = attachment.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
filename = "attachment_" + i;
}
String normalizedFilename =
isAscii(filename)
? filename
: java.text.Normalizer.normalize(
filename, java.text.Normalizer.Form.NFC);
String uniqueFilename =
ensureUniqueFilename(normalizedFilename, existingNames.keySet());
indexToFilenameMap.put(i, uniqueFilename);
PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, attachment.getInputStream());
embeddedFile.setSize((int) attachment.getSize());
GregorianCalendar currentTime = new GregorianCalendar();
embeddedFile.setCreationDate(currentTime);
embeddedFile.setModDate(currentTime);
String contentType = attachment.getContentType();
if (contentType != null && !contentType.trim().isEmpty()) {
embeddedFile.setSubtype(contentType);
}
PDComplexFileSpecification fileSpecification = new PDComplexFileSpecification();
fileSpecification.setFile(uniqueFilename);
fileSpecification.setFileUnicode(uniqueFilename);
fileSpecification.setEmbeddedFile(embeddedFile);
fileSpecification.setEmbeddedFileUnicode(embeddedFile);
existingNames.put(uniqueFilename, fileSpecification);
}
embeddedFilesTree.setNames(existingNames);
documentNames.setEmbeddedFiles(embeddedFilesTree);
catalog.setNames(documentNames);
return indexToFilenameMap;
}
private static void addAttachmentAnnotationsToDocumentWithMapping(
PDDocument document,
List<EmlParser.EmailAttachment> attachments,
Map<Integer, String> indexToFilenameMap)
throws IOException {
if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) {
return;
}
AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder();
finder.setSortByPosition(false); // Keep document order to maintain pairing
finder.getText(document);
List<MarkerPosition> markerPositions = finder.getPositions();
int annotationsToAdd = Math.min(markerPositions.size(), attachments.size());
for (int i = 0; i < annotationsToAdd; i++) {
MarkerPosition position = markerPositions.get(i);
String filenameNearMarker = position.getFilename();
EmlParser.EmailAttachment matchingAttachment =
findAttachmentByFilename(attachments, filenameNearMarker);
if (matchingAttachment != null) {
String embeddedFilename =
findEmbeddedFilenameForAttachment(matchingAttachment, indexToFilenameMap);
if (embeddedFilename != null) {
PDPage page = document.getPage(position.getPageIndex());
addAttachmentAnnotationToPageWithMapping(
document,
page,
matchingAttachment,
embeddedFilename,
position.getX(),
position.getY(),
i);
} else {
// No embedded filename found for attachment
}
} else {
// No matching attachment found for filename near marker
}
}
}
private static EmlParser.EmailAttachment findAttachmentByFilename(
List<EmlParser.EmailAttachment> attachments, String targetFilename) {
if (targetFilename == null || targetFilename.trim().isEmpty()) {
return null;
}
String normalizedTarget = normalizeFilename(targetFilename);
// First try exact match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.equals(normalizedTarget)) {
return attachment;
}
}
}
// Then try contains match
for (EmlParser.EmailAttachment attachment : attachments) {
if (attachment.getFilename() != null) {
String normalizedAttachment = normalizeFilename(attachment.getFilename());
if (normalizedAttachment.contains(normalizedTarget)
|| normalizedTarget.contains(normalizedAttachment)) {
return attachment;
}
}
}
return null;
}
private static String findEmbeddedFilenameForAttachment(
EmlParser.EmailAttachment attachment, Map<Integer, String> indexToFilenameMap) {
String attachmentFilename = attachment.getFilename();
if (attachmentFilename == null) {
return null;
}
for (Map.Entry<Integer, String> entry : indexToFilenameMap.entrySet()) {
String embeddedFilename = entry.getValue();
if (embeddedFilename != null
&& (embeddedFilename.equals(attachmentFilename)
|| embeddedFilename.contains(attachmentFilename)
|| attachmentFilename.contains(embeddedFilename))) {
return embeddedFilename;
}
}
return null;
}
private static String normalizeFilename(String filename) {
if (filename == null) return "";
return filename.toLowerCase()
.trim()
.replaceAll("\\s+", " ")
.replaceAll("[^a-zA-Z0-9._-]", "");
}
private static void addAttachmentAnnotationToPageWithMapping(
PDDocument document,
PDPage page,
EmlParser.EmailAttachment attachment,
String embeddedFilename,
float x,
float y,
int attachmentIndex)
throws IOException {
PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment();
PDRectangle rect = calculateAnnotationRectangle(page, x, y);
fileAnnotation.setRectangle(rect);
fileAnnotation.setPrinted(false);
fileAnnotation.setHidden(false);
fileAnnotation.setNoView(false);
fileAnnotation.setNoZoom(true);
fileAnnotation.setNoRotate(true);
try {
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
PDAppearanceStream normalAppearance = new PDAppearanceStream(document);
normalAppearance.setBBox(new PDRectangle(0, 0, rect.getWidth(), rect.getHeight()));
appearance.setNormalAppearance(normalAppearance);
fileAnnotation.setAppearance(appearance);
} catch (RuntimeException e) {
fileAnnotation.setAppearance(null);
}
PDEmbeddedFilesNameTreeNode efTree =
document.getDocumentCatalog().getNames().getEmbeddedFiles();
if (efTree != null) {
Map<String, PDComplexFileSpecification> efMap = efTree.getNames();
if (efMap != null) {
PDComplexFileSpecification fileSpec = efMap.get(embeddedFilename);
if (fileSpec != null) {
fileAnnotation.setFile(fileSpec);
} else {
// Could not find embedded file
}
}
}
fileAnnotation.setContents(
"Attachment " + (attachmentIndex + 1) + ": " + attachment.getFilename());
fileAnnotation.setAnnotationName(
"EmbeddedFile_" + attachmentIndex + "_" + embeddedFilename);
page.getAnnotations().add(fileAnnotation);
}
private static boolean isAscii(String str) {
if (str == null) return true;
for (int i = 0; i < str.length(); i++) {
if (str.charAt(i) > 127) {
return false;
}
}
return true;
}
}
@@ -35,6 +35,7 @@ import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
@Slf4j
@@ -145,13 +146,18 @@ public class PdfUtils {
throws IOException, Exception {
// Validate and limit DPI to prevent excessive memory usage
final int MAX_SAFE_DPI = 500; // Maximum safe DPI to prevent memory issues
if (DPI > MAX_SAFE_DPI) {
int maxSafeDpi = 500; // Default maximum safe DPI
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
maxSafeDpi = properties.getSystem().getMaxDPI();
}
if (DPI > maxSafeDpi) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dpiExceedsLimit",
"DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.",
DPI,
MAX_SAFE_DPI);
maxSafeDpi);
}
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
@@ -0,0 +1,59 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.StandardEnvironment;
import stirling.software.common.configuration.InstallationPathConfig;
class ApplicationPropertiesDynamicYamlPropertySourceTest {
@Test
void loads_yaml_into_environment() throws Exception {
// YAML-Config in Temp-Datei schreiben
String yaml =
""
+ "ui:\n"
+ " appName: \"My App\"\n"
+ "system:\n"
+ " enableAnalytics: true\n";
Path tmp = Files.createTempFile("spdf-settings-", ".yml");
Files.writeString(tmp, yaml);
// Pfad per statischem Mock liefern
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString());
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein
assertEquals("My App", env.getProperty("ui.appName"));
assertEquals("true", env.getProperty("system.enableAnalytics"));
}
}
@Test
void throws_when_settings_file_missing() throws Exception {
String missing = "/path/does/not/exist/spdf.yml";
try (MockedStatic<InstallationPathConfig> mocked =
Mockito.mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(missing);
ConfigurableEnvironment env = new StandardEnvironment();
ApplicationProperties props = new ApplicationProperties();
assertThrows(IOException.class, () -> props.dynamicYamlPropertySource(env));
}
}
}
@@ -0,0 +1,248 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties.Driver;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Security;
import stirling.software.common.model.exception.UnsupportedProviderException;
class ApplicationPropertiesLogicTest {
@Test
void system_isAnalyticsEnabled_null_false_true() {
ApplicationProperties.System sys = new ApplicationProperties.System();
sys.setEnableAnalytics(null);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.FALSE);
assertFalse(sys.isAnalyticsEnabled());
sys.setEnableAnalytics(Boolean.TRUE);
assertTrue(sys.isAnalyticsEnabled());
}
@Test
void tempFileManagement_defaults_and_overrides() {
ApplicationProperties.TempFileManagement tfm =
new ApplicationProperties.TempFileManagement();
String expectedBase =
java.lang.System.getProperty("java.io.tmpdir").replaceAll("/+$", "")
+ "/stirling-pdf";
assertEquals(expectedBase, tfm.getBaseTmpDir());
String expectedLibre = expectedBase + "/libreoffice";
assertEquals(expectedLibre, tfm.getLibreofficeDir());
tfm.setBaseTmpDir("/custom/base");
assertEquals("/custom/base", tfm.getBaseTmpDir());
tfm.setLibreofficeDir("/opt/libre");
assertEquals("/opt/libre", tfm.getLibreofficeDir());
}
@Test
void oauth2_scope_parsing_and_validity() {
Security.OAUTH2 oauth2 = new Security.OAUTH2();
oauth2.setIssuer("https://issuer");
oauth2.setClientId("client");
oauth2.setClientSecret("secret");
oauth2.setUseAsUsername("email");
oauth2.setScopes("openid, profile ,email");
assertTrue(oauth2.isSettingsValid());
}
@Test
void security_login_method_flags() {
Security sec = new Security();
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isUserPass());
assertTrue(sec.isOauth2Active());
assertTrue(sec.isSaml2Active());
sec.setLoginMethod(Security.LoginMethods.NORMAL.toString());
assertTrue(sec.isUserPass());
assertFalse(sec.isOauth2Active());
assertFalse(sec.isSaml2Active());
}
@Test
void security_isAltLogin_reflects_oauth2_or_saml2() {
Security sec = new Security();
assertFalse(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(false);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(false);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
sec.getOauth2().setEnabled(true);
sec.getSaml2().setEnabled(true);
assertTrue(sec.isAltLogin());
}
@Test
void oauth2_client_provider_mapping_and_unsupported() throws UnsupportedProviderException {
Security.OAUTH2.Client client = new Security.OAUTH2.Client();
assertNotNull(client.get("google"));
assertNotNull(client.get("github"));
assertNotNull(client.get("keycloak"));
UnsupportedProviderException ex =
assertThrows(UnsupportedProviderException.class, () -> client.get("unknown"));
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppName(" ");
ui.setHomeDescription("");
ui.setAppNameNavbar(null);
assertNull(ui.getAppName());
assertNull(ui.getHomeDescription());
assertNull(ui.getAppNameNavbar());
ui.setAppName("Stirling-PDF");
ui.setHomeDescription("Home");
ui.setAppNameNavbar("Nav");
assertEquals("Stirling-PDF", ui.getAppName());
assertEquals("Home", ui.getHomeDescription());
assertEquals("Nav", ui.getAppNameNavbar());
}
@Test
void driver_toString_contains_driver_name() {
assertTrue(Driver.H2.toString().contains("h2"));
assertTrue(Driver.POSTGRESQL.toString().contains("postgresql"));
}
@Test
void session_limits_and_timeouts_have_reasonable_defaults() {
ApplicationProperties.ProcessExecutor pe = new ApplicationProperties.ProcessExecutor();
ApplicationProperties.ProcessExecutor.SessionLimit s = pe.getSessionLimit();
assertEquals(2, s.getQpdfSessionLimit());
assertEquals(1, s.getTesseractSessionLimit());
assertEquals(1, s.getLibreOfficeSessionLimit());
assertEquals(1, s.getPdfToHtmlSessionLimit());
assertEquals(8, s.getPythonOpenCvSessionLimit());
assertEquals(16, s.getWeasyPrintSessionLimit());
assertEquals(1, s.getInstallAppSessionLimit());
assertEquals(1, s.getCalibreSessionLimit());
assertEquals(8, s.getGhostscriptSessionLimit());
assertEquals(2, s.getOcrMyPdfSessionLimit());
ApplicationProperties.ProcessExecutor.TimeoutMinutes t = pe.getTimeoutMinutes();
assertEquals(30, t.getTesseractTimeoutMinutes());
assertEquals(30, t.getQpdfTimeoutMinutes());
assertEquals(30, t.getLibreOfficeTimeoutMinutes());
assertEquals(20, t.getPdfToHtmlTimeoutMinutes());
assertEquals(30, t.getPythonOpenCvTimeoutMinutes());
assertEquals(30, t.getWeasyPrintTimeoutMinutes());
assertEquals(60, t.getInstallAppTimeoutMinutes());
assertEquals(30, t.getCalibreTimeoutMinutes());
assertEquals(30, t.getGhostscriptTimeoutMinutes());
assertEquals(30, t.getOcrMyPdfTimeoutMinutes());
}
@Deprecated
@Test
void enterprise_metadata_defaults() {
ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition();
ApplicationProperties.EnterpriseEdition.CustomMetadata eMeta = ee.getCustomMetadata();
eMeta.setCreator(" ");
eMeta.setProducer(null);
assertEquals("Stirling-PDF", eMeta.getCreator());
assertEquals("Stirling-PDF", eMeta.getProducer());
}
@Test
void premium_metadata_defaults() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("");
pMeta.setProducer("");
assertEquals("Stirling-PDF", pMeta.getCreator());
assertEquals("Stirling-PDF", pMeta.getProducer());
}
@Test
void premium_metadata_awesome() {
Premium.ProFeatures pf = new Premium.ProFeatures();
Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata();
pMeta.setCreator("Awesome PDF Tool");
pMeta.setProducer("Awesome PDF Tool");
assertEquals("Awesome PDF Tool", pMeta.getCreator());
assertEquals("Awesome PDF Tool", pMeta.getProducer());
}
@Test
void string_isValid_handles_null_empty_blank_and_trimmed() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
assertFalse(oauth2.isValid((String) null, "issuer"));
assertFalse(oauth2.isValid("", "issuer"));
assertFalse(oauth2.isValid(" ", "issuer"));
assertTrue(oauth2.isValid("x", "issuer"));
assertTrue(oauth2.isValid(" x ", "issuer")); // trimmt intern
}
@Test
void collection_isValid_handles_null_and_empty() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
Collection<String> nullColl = null;
Collection<String> empty = List.of();
assertFalse(oauth2.isValid(nullColl, "scopes"));
assertFalse(oauth2.isValid(empty, "scopes"));
}
@Test
void collection_isValid_true_when_non_empty_even_if_element_is_blank() {
ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2();
// Aktuelles Verhalten: prüft NUR !isEmpty(), nicht Inhalt
Collection<String> oneBlank = new ArrayList<>();
oneBlank.add(" ");
assertTrue(
oauth2.isValid(oneBlank, "scopes"),
"Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist");
}
}
@@ -0,0 +1,80 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
class ApplicationPropertiesSaml2HttpTest {
@Test
void idpMetadataUri_http_is_resolved_via_mockwebserver() throws Exception {
try (MockWebServer server = new MockWebServer()) {
server.enqueue(
new MockResponse()
.setResponseCode(200)
.addHeader("Content-Type", "application/xml")
.setBody("<EntityDescriptor/>"));
server.start();
String url = server.url("/meta").toString();
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri(url);
try (InputStream in = s.getIdpMetadataUri()) {
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(body.contains("EntityDescriptor"));
}
}
}
@Test
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
assertThrows(IOException.class, s::getIdpMetadataUri);
}
@Test
void spCert_else_branch_returns_FileSystemResource_for_filesystem_path() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
// temporäre Datei simuliert "Filesystem"-Pfad (-> else-Zweig)
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
Files.writeString(tmp, "CERT");
s.setSpCert(tmp.toString());
Resource r = s.getSpCert();
assertNotNull(r);
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
assertTrue(r.exists(), "Temp file should exist");
}
@Test
void idpCert_else_branch_returns_FileSystemResource_even_if_missing() {
var s = new ApplicationProperties.Security.SAML2();
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
String missing = "/this/path/does/not/exist/idp.crt";
s.setIdpCert(missing);
Resource r = s.getIdpCert();
assertNotNull(r);
assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path");
assertFalse(r.exists(), "Resource should not exist for missing file");
}
}
@@ -0,0 +1,55 @@
package stirling.software.common.model;
import static org.junit.jupiter.api.Assertions.*;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
class ApplicationPropertiesSaml2ResourceTest {
@Test
void idpMetadataUri_classpath_is_resolved() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("classpath:saml/dummy.txt");
try (InputStream in = s.getIdpMetadataUri()) {
assertNotNull(in, "Classpath InputStream should not be null");
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(txt.contains("ok"));
}
}
@Test
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setSpCert(null);
s.setIdpCert(null);
s.setPrivateKey(null);
assertNull(s.getSpCert());
assertNull(s.getIdpCert());
assertNull(s.getPrivateKey());
s.setSpCert("classpath:saml/dummy.txt");
s.setIdpCert("classpath:saml/dummy.txt");
s.setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSpCert();
Resource idp = s.getIdpCert();
Resource pk = s.getPrivateKey();
assertTrue(sp.exists());
assertTrue(idp.exists());
assertTrue(pk.exists());
Path tmp = Files.createTempFile("spdf-key-", ".pem");
Files.writeString(tmp, "KEY");
s.setPrivateKey(tmp.toString());
Resource pkFs = s.getPrivateKey();
assertNotNull(pkFs);
assertTrue(pkFs.exists());
}
}
@@ -0,0 +1 @@
ok
+1 -1
View File
@@ -91,7 +91,7 @@ dependencies {
// 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-psd:$imageioVersion"
// runtimeOnly "com.twelvemonkeys.imageio:imageio-sgi:$imageioVersion"
// runtimeOnly "com.twelvemonkeys.imageio:imageio-tga:$imageioVersion"
// runtimeOnly "com.twelvemonkeys.imageio:imageio-thumbsdb:$imageioVersion"
@@ -8,6 +8,8 @@ import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall;
@Controller
@@ -62,6 +64,13 @@ public class ConverterWebController {
@Hidden
public String pdfToimgForm(Model model) {
boolean isPython = CheckProgramInstall.isPythonAvailable();
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
model.addAttribute("maxDPI", properties.getSystem().getMaxDPI());
} else {
model.addAttribute("maxDPI", 500); // Default value if not set
}
model.addAttribute("isPython", isPython);
model.addAttribute("currentPage", "pdf-to-img");
return "convert/pdf-to-img";
@@ -1,6 +1,7 @@
multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
#logging.level.root=DEBUG
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.saml2=TRACE
#logging.level.org.springframework.security=DEBUG
@@ -34,6 +35,7 @@ spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.h2.console.path=/h2-console
spring.jpa.hibernate.ddl-auto=update
server.servlet.session.timeout:30m
# Change the default URL path for OpenAPI JSON
@@ -366,6 +366,38 @@ navbar.sections.popular=المفضل
settings.title=الإعدادات
settings.update=التحديث متاح
settings.updateAvailable={0} هو الإصدار المثبت حاليًا. إصدار جديد ({1}) متاح.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=إصدار التطبيق:
settings.downloadOption.title=تحديد خيار التنزيل (للتنزيلات ذات الملف الواحد غير المضغوط):
settings.downloadOption.1=فتح في نفس النافذة
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=هل يجب تعطيل/تمكين ال
adminUserSettings.usernameInfo=يمكن أن يحتوي اسم المستخدم فقط على أحرف وأرقام والرموز الخاصة التالية @._+- أو يجب أن يكون عنوان بريد إلكتروني صالح.
adminUserSettings.role=الدور
adminUserSettings.actions=الإجراءات
adminUserSettings.apiUser=مستخدم API محدود
adminUserSettings.extraApiUser=مستخدم API محدود إضافي
adminUserSettings.webOnlyUser=مستخدم الويب فقط
adminUserSettings.demoUser=مستخدم تجريبي (بدون إعدادات مخصصة)
adminUserSettings.internalApiUser=مستخدم API داخلي
adminUserSettings.forceChange=إجبار المستخدم على تغيير كلمة المرور عند تسجيل الدخول
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=نوع اللون
pdfToImage.color=اللون
pdfToImage.grey=تدرج الرمادي
pdfToImage.blackwhite=أبيض وأسود (قد يفقد البيانات!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=تحويل
pdfToImage.info=Python غير مثبت. مطلوب لتحويل WebP.
pdfToImage.placeholder=(مثال: 1,2,8 أو 4,7,12-16 أو 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populyar
settings.title=Parametrlər
settings.update=Yeniləmə mövcuddur
settings.updateAvailable={0} cari quraşdırılmış versiyadır. Yeni ({1}) versiyası mövcuddur.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Proqram Versiyası:
settings.downloadOption.title=Yükləmə versiyasını seçin (Tək fayllı zip olmayan yükləmələr üçün):
settings.downloadOption.1=Eyni pəncərədə açın
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=İstifadəçi aktivləşdirilməli/dea
adminUserSettings.usernameInfo=İstifadəçi adı sadəcə hərflərdən, rəqəmlərdən və @._+- xüsusi simvollarından ibarət ola bilər və ya düzgün email ünvanı olmalıdır.
adminUserSettings.role=Rol
adminUserSettings.actions=Fəaliyyətlər
adminUserSettings.apiUser=Məhdudlaşdırılmış API İstifadəçisi
adminUserSettings.extraApiUser=Əlavə Məhdudlaşdırılmış API İstifadəçisi
adminUserSettings.webOnlyUser=Yalnız Veb İstifadəçisi
adminUserSettings.demoUser=Demo İstifadəçisi (Fərdi parametrlər yoxdur)
adminUserSettings.internalApiUser=Daxili API İstifadəçisi
adminUserSettings.forceChange=İstifadəçini giriş zamanı parolu dəyişməyə məcbur et
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Rəng Tipi
pdfToImage.color=Rəng
pdfToImage.grey=Boz Tonlama
pdfToImage.blackwhite=Qara və Ağ (Data İtə Bilər)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Çevir
pdfToImage.info=Python Yüklü Deyil.WebP Çevirməsi Üçün Vacibdir
pdfToImage.placeholder=(məsələn, 1,2,8 və ya 4,7,12-16 və ya 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Популярни
settings.title=Настройки
settings.update=Налична актуализация
settings.updateAvailable={0} е текущата инсталирана версия. Налична е нова версия ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Версия на приложението:
settings.downloadOption.title=Изберете опция за изтегляне (за изтегляния на един файл без да е архивиран):
settings.downloadOption.1=Отваряне в същия прозорец
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Трябва ли потребител
adminUserSettings.usernameInfo=Потребителското име може да съдържа само букви, цифри и следните специални символи @._+- или трябва да е валиден имейл адрес.
adminUserSettings.role=Роля
adminUserSettings.actions=Действия
adminUserSettings.apiUser=Ограничен API потребител
adminUserSettings.extraApiUser=Допълнителен ограничен API потребител
adminUserSettings.webOnlyUser=Само за уеб-потребител
adminUserSettings.demoUser=Демо потребител (без персонализирани настройки)
adminUserSettings.internalApiUser=Вътрешен API потребител
adminUserSettings.forceChange=Принудете потребителя да промени потребителското име/парола при влизане
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Тип цвят
pdfToImage.color=Цвят
pdfToImage.grey=Скала на сивото
pdfToImage.blackwhite=Черно и бяло (може да загубите данни!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Преобразуване
pdfToImage.info=Python не е инсталиран. Изисква се за конвертиране на WebP.
pdfToImage.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=སྤྱི་མོས།
settings.title=སྒྲིག་འགོད།
settings.update=གསར་སྒྱུར་ཡོད།
settings.updateAvailable={0} ནི་ད་ལྟ་སྒྲིག་འཇུག་བྱས་པའི་པར་གཞི་ཡིན། པར་གཞི་གསར་པ་ ({1}) ཡོད།
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=མཉེན་ཆས་པར་གཞི།
settings.downloadOption.title=ཕབ་ལེན་གདམ་ག་འདེམས་རོགས། (ཡིག་ཆ་རྐྱང་པ་ zip མིན་པའི་ཕབ་ལེན་ཆེད།):
settings.downloadOption.1=སྒེའུ་ཁུང་གཅིག་པའི་ནང་ཁ་ཕྱེ།
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=སྤྱོད་མཁན་སྤ
adminUserSettings.usernameInfo=སྤྱོད་མཁན་མིང་ནང་ཡི་གེ་དང་ཨང་ཀི། དམིགས་བསལ་མཚོན་རྟགས་ @._+- ཡང་ན་གློག་འཕྲིན་ཁ་བྱང་ཚད་ལྡན་ཞིག་དགོས།
adminUserSettings.role=འགན་འཁུར།
adminUserSettings.actions=བྱ་སྤྱོད།
adminUserSettings.apiUser=ཚད་བཀག་ཅན་གྱི་ API སྤྱོད་མཁན།
adminUserSettings.extraApiUser=ཚད་བཀག་ཅན་གྱི་ API སྤྱོད་མཁན་འཕར་མ།
adminUserSettings.webOnlyUser=དྲ་ཚིགས་ཁོ་ནའི་སྤྱོད་མཁན།
adminUserSettings.demoUser=བརྟག་དཔྱད་སྤྱོད་མཁན། (རང་སྒྲིག་མེད་པ།)
adminUserSettings.internalApiUser=ནང་ཁུལ་ API སྤྱོད་མཁན།
adminUserSettings.forceChange=ནང་འཛུལ་སྐབས་གསང་ཚིག་བསྒྱུར་དགོས་པ་བཟོ་བ།
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=ཚོས་མདོག་གི་རིགས།
pdfToImage.color=ཚོས་མདོག
pdfToImage.grey=སྐྱ་མདོག
pdfToImage.blackwhite=དཀར་ནག (གནས་ཚུལ་བརླག་སྲིད།)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=བསྒྱུར་བ།
pdfToImage.info=Python སྒྲིག་འཇུག་བྱས་མི་འདུག WebP བསྒྱུར་བར་དགོས་མཁོ་ཡིན།
pdfToImage.placeholder=(དཔེར་ན། 1,2,8 ཡང་ན་ 4,7,12-16 ཡང་ན་ 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Opcions
settings.update=Actualització Disponible
settings.updateAvailable=La versió actual instal·lada és {0}. Una nova versió ({1}) està disponible.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Versió de l'App:
settings.downloadOption.title=Trieu l'opció de descàrrega (per a descàrregues d'un sol fitxer no comprimit):
settings.downloadOption.1=Obre en la mateixa finestra
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Vols deshabilitar/habilitar aquest usu
adminUserSettings.usernameInfo=El nom d'usuari només pot contenir lletres, números i els següents caràcters especials: @._+- o ha de ser una adreça de correu electrònic vàlida.
adminUserSettings.role=Rol
adminUserSettings.actions=Accions
adminUserSettings.apiUser=Usuari amb API limitada
adminUserSettings.extraApiUser=Usuari Addicional amb API limitada
adminUserSettings.webOnlyUser=Usuari només WEB
adminUserSettings.demoUser=Usuari de Demo (Sense configuracions personalitzades)
adminUserSettings.internalApiUser=Usuari d'API Interna
adminUserSettings.forceChange=Força l'usuari a canviar la contrasenya en iniciar sessió
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tipus de Color
pdfToImage.color=Color
pdfToImage.grey=Escala de Grisos
pdfToImage.blackwhite=Blanc i Negre (Pot perdre dades!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Converteix
pdfToImage.info=Python no està instal·lat. És necessari per a la conversió a WebP.
pdfToImage.placeholder=(p. ex. 1,2,8 o 4,7,12-16 o 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Oblíbené
settings.title=Nastavení
settings.update=K dispozici je aktualizace
settings.updateAvailable={0} je aktuálně nainstalovaná verze. Je k dispozici nová verze ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Verze aplikace:
settings.downloadOption.title=Vyberte možnost stahování (Pro stahování jednoho souboru bez zipu):
settings.downloadOption.1=Otevřít ve stejném okně
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Má být uživatel deaktivován/aktivo
adminUserSettings.usernameInfo=Uživatelské jméno může obsahovat pouze písmena, číslice a následující speciální znaky @._+- nebo musí být platná e-mailová adresa.
adminUserSettings.role=Role
adminUserSettings.actions=Akce
adminUserSettings.apiUser=Omezený API uživatel
adminUserSettings.extraApiUser=Další omezený API uživatel
adminUserSettings.webOnlyUser=Pouze webový uživatel
adminUserSettings.demoUser=Demo uživatel (Bez vlastních nastavení)
adminUserSettings.internalApiUser=Interní API uživatel
adminUserSettings.forceChange=Vynutit změnu hesla při přihlášení
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Typ barev
pdfToImage.color=Barevný
pdfToImage.grey=Stupně šedi
pdfToImage.blackwhite=Černobílý (Může dojít ke ztrátě dat!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Převést
pdfToImage.info=Python není nainstalován. Vyžadován pro konverzi do WebP.
pdfToImage.placeholder=(např. 1,2,8 nebo 4,7,12-16 nebo 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populære
settings.title=Indstillinger
settings.update=Opdatering tilgængelig
settings.updateAvailable={0} er den aktuelt installerede version. En ny version ({1}) er tilgængelig.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=App Version:
settings.downloadOption.title=Vælg download mulighed (For enkelt fil ikke-zip downloads):
settings.downloadOption.1=Åbn i samme vindue
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Skal brugeren deaktiveres/aktiveres?
adminUserSettings.usernameInfo=Brugernavn må kun indeholde bogstaver, tal og følgende specialtegn @._+- eller skal være en gyldig e-mailadresse.
adminUserSettings.role=Rolle
adminUserSettings.actions=Handlinger
adminUserSettings.apiUser=Begrænset API-bruger
adminUserSettings.extraApiUser=Yderligere Begrænset API-bruger
adminUserSettings.webOnlyUser=Kun Web-bruger
adminUserSettings.demoUser=Demo-bruger (Ingen brugerdefinerede indstillinger)
adminUserSettings.internalApiUser=Intern API-bruger
adminUserSettings.forceChange=Tving bruger til at ændre adgangskode ved login
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Farvetype
pdfToImage.color=Farve
pdfToImage.grey=Gråtone
pdfToImage.blackwhite=Sort og Hvid (Kan miste data!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konvertér
pdfToImage.info=Python er ikke installeret. Påkrævet for WebP-konvertering.
pdfToImage.placeholder=(f.eks. 1,2,8 eller 4,7,12-16 eller 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Beliebt
settings.title=Einstellungen
settings.update=Update verfügbar
settings.updateAvailable={0} ist die aktuelle installierte Version. Eine neue Version ({1}) ist verfügbar.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=App-Version:
settings.downloadOption.title=Download-Option wählen (für einzelne Dateien, die keine Zip-Downloads sind):
settings.downloadOption.1=Im selben Fenster öffnen
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Soll der Benutzer deaktiviert/aktivier
adminUserSettings.usernameInfo=Der Benutzername darf nur Buchstaben, Zahlen und die folgenden Sonderzeichen @._+- enthalten oder muss eine gültige E-Mail-Adresse sein.
adminUserSettings.role=Rolle
adminUserSettings.actions=Aktionen
adminUserSettings.apiUser=Eingeschränkter API-Benutzer
adminUserSettings.extraApiUser=Zusätzlicher eingeschränkter API-Benutzer
adminUserSettings.webOnlyUser=Nur Web-Benutzer
adminUserSettings.demoUser=Demo-Benutzer (Keine benutzerdefinierten Einstellungen)
adminUserSettings.internalApiUser=Interner API-Benutzer
adminUserSettings.forceChange=Benutzer dazu zwingen, Benutzernamen/Passwort bei der Anmeldung zu ändern
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Farbtyp
pdfToImage.color=Farbe
pdfToImage.grey=Graustufen
pdfToImage.blackwhite=Schwarzweiß (Datenverlust möglich!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Umwandeln
pdfToImage.info=Python ist nicht installiert. Erforderlich für die WebP-Konvertierung.
pdfToImage.placeholder=(z.B. 1,2,8 oder 4,7,12-16 oder 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Vorhandene Lesezeichen ersetzen (deaktiviere
editTableOfContents.editorTitle=Lesezeichen-Editor
editTableOfContents.editorDesc=Fügen unten Lesezeichen hinzu und ordne sie an. Klicke auf +, um das untergeordnete Lesezeichen hinzuzufügen.
editTableOfContents.addBookmark=Neues Lesezeichen hinzufügen
editTableOfContents.importBookmarksDefault=Importieren
editTableOfContents.importBookmarksFromJsonFile=JSON-Datei hochladen
editTableOfContents.importBookmarksFromClipboard=Aus Zwischenablage einfügen
editTableOfContents.exportBookmarksDefault=Exportieren
editTableOfContents.exportBookmarksAsJson=Als JSON herunterladen
editTableOfContents.exportBookmarksAsText=Als Text kopieren
editTableOfContents.desc.1=Mit diesem Werkzeug können Sie das Inhaltsverzeichnis (Lesezeichen) eines PDF-Dokuments hinzufügen oder bearbeiten.
editTableOfContents.desc.2=Sie können eine hierarchische Struktur erstellen, indem Sie untergeordnete Lesezeichen zu übergeordneten hinzufügen.
editTableOfContents.desc.3=Jedes Lesezeichen benötigt einen Titel und eine Seitenzahl.
@@ -366,6 +366,38 @@ navbar.sections.popular=Δημοφιλή
settings.title=Ρυθμίσεις
settings.update=Διαθέσιμη ενημέρωση
settings.updateAvailable={0} είναι η τρέχουσα εγκατεστημένη έκδοση. Μια νέα έκδοση ({1}) είναι διαθέσιμη.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Έκδοση εφαρμογής:
settings.downloadOption.title=Επιλογή λήψης (Για μεμονωμένη λήψη αρχείων χωρίς συμπίεση):
settings.downloadOption.1=Άνοιγμα στο ίδιο παράθυρο
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Θέλετε να απενεργοπο
adminUserSettings.usernameInfo=Το όνομα χρήστη μπορεί να περιέχει μόνο γράμματα, αριθμούς και τους ειδικούς χαρακτήρες @._+- ή πρέπει να είναι έγκυρη διεύθυνση email.
adminUserSettings.role=Ρόλος
adminUserSettings.actions=Ενέργειες
adminUserSettings.apiUser=Περιορισμένος χρήστης API
adminUserSettings.extraApiUser=Επιπλέον περιορισμένος χρήστης API
adminUserSettings.webOnlyUser=Χρήστης μόνο web
adminUserSettings.demoUser=Δοκιμαστικός χρήστης (Χωρίς προσαρμοσμένες ρυθμίσεις)
adminUserSettings.internalApiUser=Εσωτερικός χρήστης API
adminUserSettings.forceChange=Υποχρεωτική αλλαγή κωδικού κατά τη σύνδεση
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Τύπος χρώματος
pdfToImage.color=Έγχρωμο
pdfToImage.grey=Κλίμακα του γκρι
pdfToImage.blackwhite=Ασπρόμαυρο (Μπορεί να χαθούν δεδομένα!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Μετατροπή
pdfToImage.info=Η Python δεν είναι εγκατεστημένη. Απαιτείται για μετατροπή WebP.
pdfToImage.placeholder=(π.χ. 1,2,8 ή 4,7,12-16 ή 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Settings
settings.update=Update available
settings.updateAvailable={0} is the current installed version. A new version ({1}) is available.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=App Version:
settings.downloadOption.title=Choose download option (For single file non zip downloads):
settings.downloadOption.1=Open in same window
@@ -414,6 +446,9 @@ account.adminNotif=You have admin privileges. Access system settings and user ma
adminUserSettings.title=User Control Settings
adminUserSettings.header=Admin User Control Settings
adminUserSettings.systemAdmin=System Administrator
adminUserSettings.orgAdmin=Organisation Administrator
adminUserSettings.teamLead=Team Leader
adminUserSettings.admin=Admin
adminUserSettings.user=User
adminUserSettings.addUser=Add New User
@@ -423,9 +458,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address.
adminUserSettings.role=Role
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Limited API User
adminUserSettings.extraApiUser=Additional Limited API User
adminUserSettings.webOnlyUser=Web Only User
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.internalApiUser=Internal API User
adminUserSettings.forceChange=Force user to change password on login
@@ -569,12 +601,12 @@ rotate.tags=server side
home.imageToPdf.title=Image to PDF
home.imageToPdf.desc=Convert a image (PNG, JPEG, GIF) to PDF.
imageToPdf.tags=conversion,img,jpg,picture,photo
home.imageToPdf.desc=Convert a image (PNG, JPEG, GIF, PSD) to PDF.
imageToPdf.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfToImage.title=PDF to Image
home.pdfToImage.desc=Convert a PDF to a image. (PNG, JPEG, GIF)
pdfToImage.tags=conversion,img,jpg,picture,photo
home.pdfToImage.desc=Convert a PDF to a image. (PNG, JPEG, GIF, PSD)
pdfToImage.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfOrganiser.title=Organise
home.pdfOrganiser.desc=Remove/Rearrange pages in any order
@@ -1402,6 +1434,7 @@ pdfToImage.colorType=Colour type
pdfToImage.color=Colour
pdfToImage.grey=Greyscale
pdfToImage.blackwhite=Black and White (May lose data!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Convert
pdfToImage.info=Python is not installed. Required for WebP conversion.
pdfToImage.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1)
@@ -1859,6 +1892,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Settings
settings.update=Update available
settings.updateAvailable={0} is the current installed version. A new version ({1}) is available.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=App Version:
settings.downloadOption.title=Choose download option (For single file non zip downloads):
settings.downloadOption.1=Open in same window
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address.
adminUserSettings.role=Role
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Limited API User
adminUserSettings.extraApiUser=Additional Limited API User
adminUserSettings.webOnlyUser=Web Only User
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.internalApiUser=Internal API User
adminUserSettings.forceChange=Force user to change password on login
@@ -569,12 +598,12 @@ rotate.tags=server side
home.imageToPdf.title=Image to PDF
home.imageToPdf.desc=Convert a image (PNG, JPEG, GIF) to PDF.
imageToPdf.tags=conversion,img,jpg,picture,photo
home.imageToPdf.desc=Convert a image (PNG, JPEG, GIF, PSD) to PDF.
imageToPdf.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfToImage.title=PDF to Image
home.pdfToImage.desc=Convert a PDF to a image. (PNG, JPEG, GIF)
pdfToImage.tags=conversion,img,jpg,picture,photo
home.pdfToImage.desc=Convert a PDF to a image. (PNG, JPEG, GIF, PSD)
pdfToImage.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfOrganiser.title=Organize
home.pdfOrganiser.desc=Remove/Rearrange pages in any order
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Color type
pdfToImage.color=Color
pdfToImage.grey=Grayscale
pdfToImage.blackwhite=Black and White (May lose data!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Convert
pdfToImage.info=Python is not installed. Required for WebP conversion.
pdfToImage.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populares
settings.title=Configuración
settings.update=Actualización disponible
settings.updateAvailable={0} es la versión instalada. Hay disponible una versión nueva ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Versión de la aplicación:
settings.downloadOption.title=Elegir la opción de descarga (para descargas de un solo archivo sin ZIP):
settings.downloadOption.1=Abrir en la misma ventana
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=¿Se debe habilitar/deshabilitar el us
adminUserSettings.usernameInfo=El nombre de usuario solo puede contener letras, números y los siguientes caracteres especiales @._+- o debe ser una dirección de correo electrónico válida.
adminUserSettings.role=Rol
adminUserSettings.actions=Acciones
adminUserSettings.apiUser=Usuario limitado de API
adminUserSettings.extraApiUser=Otro usuario limitado de API
adminUserSettings.webOnlyUser=Usuario solo web
adminUserSettings.demoUser=Usuario Demo (Sin ajustes personalizados)
adminUserSettings.internalApiUser=Usuario interno de API
adminUserSettings.forceChange=Forzar usuario a cambiar usuario/contraseña en el acceso
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tipo de color
pdfToImage.color=Color
pdfToImage.grey=Escala de grises
pdfToImage.blackwhite=Blanco y Negro (¡Puede perder datos!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Convertir
pdfToImage.info=Python no está instalado. Se requiere para la conversión WebP.
pdfToImage.placeholder=(por ejemplo 1,2,8 o 4,7,12-16 o 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Ezarpenak
settings.update=Eguneratze eskuragarria
settings.updateAvailable={0} is the current installed version. A new version ({1}) is available.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Aplikazioaren bertsioa:
settings.downloadOption.title=Hautatu deskargatzeko aukera (fitxategi bakarra deskargatzeko ZIP gabe):
settings.downloadOption.1=Ireki leiho berean
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address.
adminUserSettings.role=Rol
adminUserSettings.actions=Ekintzak
adminUserSettings.apiUser=APIren erabiltzaile mugatua
adminUserSettings.extraApiUser=Additional Limited API User
adminUserSettings.webOnlyUser=Web-erabiltzailea bakarrik
adminUserSettings.demoUser=Demo User (No custom settings)
adminUserSettings.internalApiUser=Internal API User
adminUserSettings.forceChange=Force user to change password on login
@@ -570,11 +599,11 @@ rotate.tags=server side
home.imageToPdf.title=Irudia PDF bihurtu
home.imageToPdf.desc=Irudi bat(PNG, JPEG, GIF)PDF bihurtu
imageToPdf.tags=conversion,img,jpg,picture,photo
imageToPdf.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfToImage.title=PDFa irudi bihurtu
home.pdfToImage.desc=PDF bat irudi (PNG, JPEG, GIF) bihurtu
pdfToImage.tags=conversion,img,jpg,picture,photo
pdfToImage.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfOrganiser.title=Antolatzailea
home.pdfOrganiser.desc=Ezabatu/Berrantolatu orrialdeak edozein ordenatan
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Kolore-mota
pdfToImage.color=Kolorea
pdfToImage.grey=Gris-eskala
pdfToImage.blackwhite=Zuria eta Beltza (Datuak galdu ditzake!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Bihurtu
pdfToImage.info=Python is not installed. Required for WebP conversion.
pdfToImage.placeholder=(e.g. 1,2,8 or 4,7,12-16 or 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=محبوب
settings.title=تنظیمات
settings.update=به‌روزرسانی موجود است
settings.updateAvailable={0} نسخه نصب شده فعلی است. یک نسخه جدید ({1}) موجود است.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=نسخه برنامه:
settings.downloadOption.title=گزینه دانلود را انتخاب کنید (برای دانلود یک فایل غیر فشرده):
settings.downloadOption.1=باز کردن در همان پنجره
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=آیا باید وضعیت کاربر
adminUserSettings.usernameInfo=نام کاربری فقط می‌تواند شامل حروف، اعداد و کاراکترهای خاص @._+- باشد یا باید یک آدرس ایمیل معتبر باشد.
adminUserSettings.role=نقش
adminUserSettings.actions=اقدامات
adminUserSettings.apiUser=کاربر محدود API
adminUserSettings.extraApiUser=کاربر محدود اضافی API
adminUserSettings.webOnlyUser=فقط کاربر وب
adminUserSettings.demoUser=کاربر دمو (بدون تنظیمات سفارشی)
adminUserSettings.internalApiUser=کاربر داخلی API
adminUserSettings.forceChange=مجبور کردن کاربر به تغییر رمز عبور هنگام ورود
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=نوع رنگ
pdfToImage.color=رنگ
pdfToImage.grey=خاکستری
pdfToImage.blackwhite=سیاه و سفید (ممکن است اطلاعات از دست برود!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=تبدیل
pdfToImage.info=پایتون نصب نشده است. برای تبدیل WebP لازم است.
pdfToImage.placeholder=(مثال: 1,2,8 یا 4,7,12-16 یا 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populaire
settings.title=Paramètres
settings.update=Mise à jour disponible
settings.updateAvailable={0} est la version actuellement installée. Une nouvelle version ({1}) est disponible.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Version de l'application :
settings.downloadOption.title=Choisissez l'option de téléchargement (pour les téléchargements à fichier unique non ZIP) :
settings.downloadOption.1=Ouvrir dans la même fenêtre
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Voulez vous vraiment déactiver/réact
adminUserSettings.usernameInfo=Le nom d'utilisateur ne peut contenir que des lettres, des chiffres et les caractères spéciaux suivants @._+- ou doit être une adresse e-mail valide.
adminUserSettings.role=Rôle
adminUserSettings.actions=Actions
adminUserSettings.apiUser=Utilisateur API limité
adminUserSettings.extraApiUser=Utilisateur limité supplémentaire de l'API
adminUserSettings.webOnlyUser=Utilisateur Web uniquement
adminUserSettings.demoUser=Demo User (Paramètres par défaut)
adminUserSettings.internalApiUser=Utilisateur de l'API interne
adminUserSettings.forceChange=Forcer l'utilisateur à changer son nom d'utilisateur/mot de passe lors de la connexion
@@ -569,7 +598,7 @@ rotate.tags=pivoter,server side,rotate
home.imageToPdf.title=Image en PDF
home.imageToPdf.desc=Convertissez une image (PNG, JPEG, GIF) en PDF.
home.imageToPdf.desc=Convertissez une image (PNG, JPEG, GIF, PSD) en PDF.
imageToPdf.tags=pdf,conversion,img,jpg,image,photo
home.pdfToImage.title=PDF en image
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Type d'impression
pdfToImage.color=Couleur
pdfToImage.grey=Niveaux de gris
pdfToImage.blackwhite=Noir et blanc (peut engendrer une perte de données !)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Convertir
pdfToImage.info=Python n'est pas installé. Nécessaire pour la conversion WebP.
pdfToImage.placeholder=(par exemple : 1,2,8 ou 4,7,12-16 ou 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Remplacer les signets existants (décocher p
editTableOfContents.editorTitle=Éditeur de signets
editTableOfContents.editorDesc=Ajoutez et organisez les signets ci-dessous. Cliquez sur + pour ajouter des signets enfants.
editTableOfContents.addBookmark=Ajouter un nouveau signet
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=Cet outil vous permet d'ajouter ou de modifier la table des matières (signets) dans un document PDF.
editTableOfContents.desc.2=Vous pouvez créer une structure hiérarchique en ajoutant des signets enfants à des signets parents.
editTableOfContents.desc.3=Chaque signet nécessite un titre et un numéro de page cible.
@@ -366,6 +366,38 @@ navbar.sections.popular=Coitianta
settings.title=Socruithe
settings.update=Nuashonrú ar fáil
settings.updateAvailable=Is é {0} an leagan suiteáilte reatha. Tá leagan nua ({1}) ar fáil.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Leagan Aipe:
settings.downloadOption.title=Roghnaigh rogha íoslódála (Le haghaidh íoslódálacha comhad amháin seachas zip):
settings.downloadOption.1=Oscail sa bhfuinneog chéanna
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ar cheart an t-úsáideoir a dhíchuma
adminUserSettings.usernameInfo=Ní féidir ach litreacha, uimhreacha agus na carachtair speisialta seo a leanas @._+- a bheith san ainm úsáideora nó ní mór gur seoladh ríomhphoist bailí é.
adminUserSettings.role=Ról
adminUserSettings.actions=Gníomhartha
adminUserSettings.apiUser=Úsáideoir API Teoranta
adminUserSettings.extraApiUser=Úsáideoir API Teoranta breise
adminUserSettings.webOnlyUser=Úsáideoir Gréasáin Amháin
adminUserSettings.demoUser=Úsáideoir Taispeána (Gan socruithe saincheaptha)
adminUserSettings.internalApiUser=Úsáideoir API Inmheánach
adminUserSettings.forceChange=Cuir iallach ar an úsáideoir pasfhocal a athrú ar logáil isteach
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Cineál dath
pdfToImage.color=Dath
pdfToImage.grey=Scála Liath
pdfToImage.blackwhite=Dubh agus Bán (Dfhéadfadh sonraí a chailleadh!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Tiontaigh
pdfToImage.info=Níl Python suiteáilte. Ag teastáil le haghaidh comhshó WebP.
pdfToImage.placeholder=(m.sh. 1,2,8 nó 4,7,12-16 nó 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=लोकप्रिय
settings.title=सेटिंग्स
settings.update=अपडेट उपलब्ध है
settings.updateAvailable={0} वर्तमान स्थापित संस्करण है। एक नया संस्करण ({1}) उपलब्ध है।
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=ऐप संस्करण:
settings.downloadOption.title=डाउनलोड विकल्प चुनें (एकल फ़ाइल गैर-ज़िप डाउनलोड के लिए):
settings.downloadOption.1=उसी विंडो में खोलें
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=क्या उपयोगकर्
adminUserSettings.usernameInfo=उपयोगकर्ता नाम में केवल अक्षर, संख्याएं और निम्नलिखित विशेष वर्ण @._+- हो सकते हैं या एक वैध ईमेल पता होना चाहिए।
adminUserSettings.role=भूमिका
adminUserSettings.actions=कार्रवाइयां
adminUserSettings.apiUser=सीमित API उपयोगकर्ता
adminUserSettings.extraApiUser=अतिरिक्त सीमित API उपयोगकर्ता
adminUserSettings.webOnlyUser=केवल वेब उपयोगकर्ता
adminUserSettings.demoUser=डेमो उपयोगकर्ता (कोई कस्टम सेटिंग्स नहीं)
adminUserSettings.internalApiUser=आंतरिक API उपयोगकर्ता
adminUserSettings.forceChange=लॉगिन पर उपयोगकर्ता को पासवर्ड बदलने के लिए मजबूर करें
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=रंग प्रकार
pdfToImage.color=रंग
pdfToImage.grey=ग्रेस्केल
pdfToImage.blackwhite=काला और सफेद (डेटा खो सकता है!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=बदलें
pdfToImage.info=Python स्थापित नहीं है। WebP रूपांतरण के लिए आवश्यक है।
pdfToImage.placeholder=(जैसे 1,2,8 या 4,7,12-16 या 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popularno
settings.title=Postavke
settings.update=Dostupno ažuriranje
settings.updateAvailable={0} je trenutno instalirana verzija. Dostupna je nova verzija ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Verzija aplikacije:
settings.downloadOption.title=Odaberite opciju preuzimanja (Za preuzimanje pojedinačnih datoteka bez zip formata):
settings.downloadOption.1=Otvori u istom prozoru
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Treba li isključiti/uključiti ovog k
adminUserSettings.usernameInfo=Korisničko ime može sadržavati samo slova, brojke i sljedeće posebne znakove @._+- ili mora biti važeća adresa e-pošte.
adminUserSettings.role=Uloga
adminUserSettings.actions=Akcije
adminUserSettings.apiUser=Korisnik s ograničenim API pristupom
adminUserSettings.extraApiUser=Dodatni korisnik s ograničenim API pristupom
adminUserSettings.webOnlyUser=Web Korisnik
adminUserSettings.demoUser=Demo korisnik (Bez prilagođenih Postavki)
adminUserSettings.internalApiUser=Interni API Korisnik
adminUserSettings.forceChange=Prisiliti korisnika da promijeni lozinku prilikom prijave
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tip boje
pdfToImage.color=Boja
pdfToImage.grey=Sivi tonovi
pdfToImage.blackwhite=Crno-bijelo (mogu se izgubiti podaci!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Pretvori
pdfToImage.info=Python nije instaliran. Treba je za konverziju na WebP.
pdfToImage.placeholder=(t.j. 1,2,8 ili 4,7,12-16 ili 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Népszerű
settings.title=Beállítások
settings.update=Frissítés elérhető
settings.updateAvailable=A jelenlegi telepített verzió: {0}. Új verzió ({1}) érhető el.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Sürgős frissítés érhető el
update.updateAvailable=Frissítés érhető el
update.modalTitle=Frissítés érhető el
update.current=Jelenlegi verzió
update.latest=Legújabb verzió
update.latestStable=Legújabb stabil verzió
update.priority=Fontosság
update.recommendedAction=Ajánlott lépés
update.breakingChangesDetected=⚠️ Jelentős változások észlelve
update.breakingChangesMessage=Ez a frissítés jelentős változásokat tartalmaz. Kérjük, olvassa el az alábbi migrációs útmutatót.
update.migrationGuides=Migrációs útmutatók:
update.viewGuide=Útmutató megtekintése
update.loadingDetailedInfo=Részletes verzióinformációk betöltése folyamatban...
update.close=Bezárás
update.viewAllReleases=Összes kiadás megtekintése
update.downloadLatest=Legújabb verzió letöltése
update.availableUpdates=Elérhető frissítések:
update.unableToLoadDetails=Nem sikerült betölteni a részletes verzióinformációkat.
update.version=Verzió
# Update priority levels
update.priority.urgent=SÜRGETŐ
update.priority.normal=NORMÁL
update.priority.minor=KISEBB
update.priority.low=ALACSONY
# Breaking changes text
update.breakingChanges=Megszakító változások:
update.breakingChangesDefault=Ez a verzió megszakító változásokat tartalmaz
update.migrationGuide=Migrációs útmutató
settings.appVersion=Alkalmazás verziója:
settings.downloadOption.title=Letöltési beállítás (egyetlen fájl, nem tömörített letöltések esetén):
settings.downloadOption.1=Megnyitás ugyanabban az ablakban
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Biztosan módosítja a felhasználó
adminUserSettings.usernameInfo=A felhasználónév csak betűket, számokat és a következő speciális karaktereket tartalmazhatja: @._+- vagy érvényes e-mail címnek kell lennie.
adminUserSettings.role=Szerepkör
adminUserSettings.actions=Műveletek
adminUserSettings.apiUser=Korlátozott API felhasználó
adminUserSettings.extraApiUser=További korlátozott API felhasználó
adminUserSettings.webOnlyUser=Csak webes felhasználó
adminUserSettings.demoUser=Demo felhasználó (egyedi beállítások nélkül)
adminUserSettings.internalApiUser=Belső API felhasználó
adminUserSettings.forceChange=Jelszóváltoztatás kikényszerítése bejelentkezéskor
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Színtípus
pdfToImage.color=Színes
pdfToImage.grey=Szürkeárnyalatos
pdfToImage.blackwhite=Fekete-fehér (adatvesztéssel járhat!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konvertálás
pdfToImage.info=Python nincs telepítve. WebP konverzióhoz szükséges.
pdfToImage.placeholder=(pl. 1,2,8 vagy 4,7,12-16 vagy 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Meglévő könyvjelzők cseréje (törölje
editTableOfContents.editorTitle=Könyvjelző szerkesztő
editTableOfContents.editorDesc=Könyvjelzők hozzáadása és rendezése lent. Kattintson a + gombra gyermek könyvjelzők hozzáadásához.
editTableOfContents.addBookmark=Új könyvjelző hozzáadása
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=Ez az eszköz lehetővé teszi a tartalomjegyzék (könyvjelzők) hozzáadását vagy szerkesztését egy PDF dokumentumban.
editTableOfContents.desc.2=Hierarchikus struktúrákat hozhat létre, ha gyermek könyvjelzőket ad a szülő könyvjelzőkhöz.
editTableOfContents.desc.3=Minden könyvjelzőhöz szükséges egy cím és egy céloldalszám.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populer
settings.title=Pengaturan
settings.update=Pembaruan tersedia
settings.updateAvailable={0} adalah versi yang terpasang saat ini. Versi baru ({1}) tersedia.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Versi Aplikasi:
settings.downloadOption.title=Pilih opsi unduhan (Untuk unduhan berkas tunggal non zip):
settings.downloadOption.1=Buka di jendela yang sama
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Haruskah pengguna dinonaktifkan/diakti
adminUserSettings.usernameInfo=Nama pengguna hanya boleh mengandung huruf, angka, dan karakter khusus berikut @._+- atau harus berupa alamat email yang valid.
adminUserSettings.role=Peran
adminUserSettings.actions=Tindakan
adminUserSettings.apiUser=Pengguna API Terbatas
adminUserSettings.extraApiUser=Pengguna API Terbatas Tambahan
adminUserSettings.webOnlyUser=Pengguna Khusus Web
adminUserSettings.demoUser=Pengguna Demo (Tanpa pengaturan kustom)
adminUserSettings.internalApiUser=Pengguna API Internal
adminUserSettings.forceChange=Memaksa pengguna untuk mengubah nama pengguna/kata sandi saat masuk
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tipe warna
pdfToImage.color=Warna
pdfToImage.grey=Skala abu-abu
pdfToImage.blackwhite=Black and White (Bisa kehilangan data!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konversi
pdfToImage.info=Python tidak terinstal. Diperlukan untuk konversi WebP.
pdfToImage.placeholder=(misalnya 1,2,8 atau 4,7,12-16 atau 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popolare
settings.title=Impostazioni
settings.update=Aggiornamento disponibile
settings.updateAvailable={0} è la versione attualmente installata. Una nuova versione ({1}) è disponibile.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Aggiornamento disponibile
update.updateAvailable=Aggiornamento disponibile
update.modalTitle=Aggiornamento disponibile
update.current=Corrente
update.latest=Ultimo
update.latestStable=Ultima versione stabile
update.priority=Priorità
update.recommendedAction=Azione consigliata
update.breakingChangesDetected=⚠️ Rilevate modifiche sostanziali
update.breakingChangesMessage=Questo aggiornamento contiene modifiche sostanziali. Consulta le guide alla migrazione riportate di seguito.
update.migrationGuides=Guide alla migrazione:
update.viewGuide=Visualizza la guida
update.loadingDetailedInfo=Caricamento delle informazioni dettagliate sulla versione...
update.close=Chiudi
update.viewAllReleases=Visualizza tutte le versioni
update.downloadLatest=Scarica l'ultima
update.availableUpdates=Aggiornamenti disponibili:
update.unableToLoadDetails=Impossibile caricare informazioni dettagliate sulla versione.
update.version=Versione
# Update priority levels
update.priority.urgent=URGENTE
update.priority.normal=NORMALE
update.priority.minor=MINORE
update.priority.low=BASSA
# Breaking changes text
update.breakingChanges=Modifiche sostanziali:
update.breakingChangesDefault=Questa versione contiene modifiche sostanziali
update.migrationGuide=Guida alla migrazione
settings.appVersion=Versione App:
settings.downloadOption.title=Scegli opzione di download (Per file singoli non compressi):
settings.downloadOption.1=Apri in questa finestra
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=L'utente dovrebbe essere disabilitato/
adminUserSettings.usernameInfo=Il nome utente può contenere solo lettere, numeri e i seguenti caratteri speciali @._+- oppure deve essere un indirizzo email valido.
adminUserSettings.role=Ruolo
adminUserSettings.actions=Azioni
adminUserSettings.apiUser=Utente API limitato
adminUserSettings.extraApiUser=API utente limitato aggiuntivo
adminUserSettings.webOnlyUser=Utente solo Web
adminUserSettings.demoUser=Utente demo (nessuna impostazione personalizzata)
adminUserSettings.internalApiUser=API utente interna
adminUserSettings.forceChange=Forza l'utente a cambiare nome utente/password all'accesso
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tipo di colore
pdfToImage.color=A colori
pdfToImage.grey=Scala di grigi
pdfToImage.blackwhite=Bianco e Nero (potresti perdere dettagli!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Converti
pdfToImage.info=Python non è installato.È richiesto per la conversione WebP.
pdfToImage.placeholder=(es. 1,2,8 o 4,7,12-16 o 2n-1)
@@ -1663,7 +1693,7 @@ fileChooser.dragAndDrop=Trascina & Rilascia
fileChooser.dragAndDropPDF=Trascina & rilascia il file PDF
fileChooser.dragAndDropImage=Trascina & rilascia il file immagine
fileChooser.hoveredDragAndDrop=Trascina & rilascia i file qui
fileChooser.extractPDF=Estraendo...
fileChooser.extractPDF=Estrazione...
fileChooser.addAttachments=trascina & rilascia gli allegati qui
#release notes
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Sostituisci i segnalibri esistenti (deselezi
editTableOfContents.editorTitle=Editor segnalibri
editTableOfContents.editorDesc=Aggiungi e disponi i segnalibri qui sotto. Fai clic su + per aggiungere segnalibri secondari.
editTableOfContents.addBookmark=Aggiungi nuovo segnalibro
editTableOfContents.importBookmarksDefault=Importa
editTableOfContents.importBookmarksFromJsonFile=Carica file JSON
editTableOfContents.importBookmarksFromClipboard=Incolla dagli appunti
editTableOfContents.exportBookmarksDefault=Esporta
editTableOfContents.exportBookmarksAsJson=Scarica come JSON
editTableOfContents.exportBookmarksAsText=Copia come testo
editTableOfContents.desc.1=Questo strumento consente di aggiungere o modificare il sommario (segnalibri) in un documento PDF.
editTableOfContents.desc.2=È possibile creare una struttura gerarchica aggiungendo segnalibri secondari a quelli principali.
editTableOfContents.desc.3=Ogni segnalibro richiede un titolo e un numero di pagina di destinazione.
@@ -366,6 +366,38 @@ navbar.sections.popular=人気
settings.title=設定
settings.update=利用可能なアップデート
settings.updateAvailable=バージョン {0} がインストールされています。 新しいバージョン ({1}) が利用可能です。
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Appバージョン:
settings.downloadOption.title=ダウンロードオプション(zip以外の単一ファイル):
settings.downloadOption.1=同じウィンドウで開く
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=ユーザーを無効/有効にする
adminUserSettings.usernameInfo=ユーザー名には、文字、数字、および次の特殊文字 @._+- のみを含めることができます。または、有効な電子メール アドレスである必要があります。
adminUserSettings.role=ロール
adminUserSettings.actions=アクション
adminUserSettings.apiUser=限定されたAPIユーザー
adminUserSettings.extraApiUser=追加の制限付きAPIユーザー
adminUserSettings.webOnlyUser=ウェブ専用ユーザー
adminUserSettings.demoUser=デモユーザー (カスタム設定なし)
adminUserSettings.internalApiUser=内部APIユーザー
adminUserSettings.forceChange=ログイン時にユーザー名/パスワードを強制的に変更する
@@ -570,11 +599,11 @@ rotate.tags=server side
home.imageToPdf.title=画像をPDFに変換
home.imageToPdf.desc=画像 (PNG, JPEG, GIF) をPDFに変換します。
imageToPdf.tags=conversion,img,jpg,picture,photo
imageToPdf.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfToImage.title=PDFを画像に変換
home.pdfToImage.desc=PDFを画像 (PNG, JPEG, GIF) に変換します。
pdfToImage.tags=conversion,img,jpg,picture,photo
pdfToImage.tags=conversion,img,jpg,picture,photo,psd,photoshop
home.pdfOrganiser.title=整理
home.pdfOrganiser.desc=ページの削除/並べ替えします。
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=カラーモード
pdfToImage.color=カラー
pdfToImage.grey=グレースケール
pdfToImage.blackwhite=白黒(データが失われる可能性があります!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=変換
pdfToImage.info=Pythonがインストールされていません。WebPの変換に必要です。
pdfToImage.placeholder=(例:1,2,8、4,7,12-16、2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=既存のしおりを置き換える(既
editTableOfContents.editorTitle=しおりエディター
editTableOfContents.editorDesc=以下にしおりを追加して配置します。+をクリックして、子のしおりを追加します。
editTableOfContents.addBookmark=新しいしおりを追加
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=このツールを使用すると、PDFドキュメントに目次(しおり)を追加または編集できます。
editTableOfContents.desc.2=親しおりに子しおりを追加することで階層構造を作成できます。
editTableOfContents.desc.3=各しおりにはタイトルと対象のページ番号が必要です。
@@ -366,6 +366,38 @@ navbar.sections.popular=인기
settings.title=설정
settings.update=업데이트 가능
settings.updateAvailable={0}은(는) 현재 설치된 버전입니다. 새 버전({1})이 사용 가능합니다.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=앱 버전:
settings.downloadOption.title=다운로드 옵션 선택 (단일 파일 비압축 다운로드용):
settings.downloadOption.1=같은 창에서 열기
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=사용자를 비활성화/활성화하
adminUserSettings.usernameInfo=사용자 이름은 문자, 숫자 및 @._+- 특수문자만 포함하거나 유효한 이메일 주소여야 합니다.
adminUserSettings.role=역할
adminUserSettings.actions=작업
adminUserSettings.apiUser=제한된 API 사용자
adminUserSettings.extraApiUser=추가 제한된 API 사용자
adminUserSettings.webOnlyUser=웹 전용 사용자
adminUserSettings.demoUser=데모 사용자 (사용자 지정 설정 없음)
adminUserSettings.internalApiUser=내부 API 사용자
adminUserSettings.forceChange=로그인 시 사용자 비밀번호 변경 강제
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=색상 유형
pdfToImage.color=컬러
pdfToImage.grey=그레이스케일
pdfToImage.blackwhite=흑백 (데이터 손실 가능성 있음!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=변환
pdfToImage.info=WebP 변환에는 Python이 필요합니다. Python이 설치되지 않았습니다.
pdfToImage.placeholder=(예: 1,2,8 또는 4,7,12-16 또는 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=ജനപ്രിയം
settings.title=ക്രമീകരണങ്ങൾ
settings.update=അപ്ഡേറ്റ് ലഭ്യമാണ്
settings.updateAvailable={0} നിലവിൽ ഇൻസ്റ്റാൾ ചെയ്ത പതിപ്പാണ്. ഒരു പുതിയ പതിപ്പ് ({1}) ലഭ്യമാണ്.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=ആപ്പ് പതിപ്പ്:
settings.downloadOption.title=ഡൗൺലോഡ് ഓപ്ഷൻ തിരഞ്ഞെടുക്കുക (സിംഗിൾ ഫയൽ നോൺ-സിപ്പ് ഡൗൺലോഡുകൾക്ക്):
settings.downloadOption.1=ഒരേ വിൻഡോയിൽ തുറക്കുക
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=ഉപയോക്താവിനെ
adminUserSettings.usernameInfo=ഉപയോക്തൃനാമത്തിൽ അക്ഷരങ്ങൾ, അക്കങ്ങൾ, താഴെ പറയുന്ന പ്രത്യേക പ്രതീകങ്ങൾ @._+- എന്നിവ മാത്രമേ ഉണ്ടാകാവൂ അല്ലെങ്കിൽ സാധുവായ ഒരു ഇമെയിൽ വിലാസം ആയിരിക്കണം.
adminUserSettings.role=റോൾ
adminUserSettings.actions=പ്രവർത്തനങ്ങൾ
adminUserSettings.apiUser=പരിമിതമായ API ഉപയോക്താവ്
adminUserSettings.extraApiUser=അധിക പരിമിതമായ API ഉപയോക്താവ്
adminUserSettings.webOnlyUser=വെബ് മാത്രം ഉപയോക്താവ്
adminUserSettings.demoUser=ഡെമോ ഉപയോക്താവ് (ഇഷ്ടാനുസൃത ക്രമീകരണങ്ങളില്ല)
adminUserSettings.internalApiUser=ആന്തരിക API ഉപയോക്താവ്
adminUserSettings.forceChange=ലോഗിൻ ചെയ്യുമ്പോൾ പാസ്വേഡ് മാറ്റാൻ ഉപയോക്താവിനെ നിർബന്ധിക്കുക
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=നിറ തരം
pdfToImage.color=നിറം
pdfToImage.grey=ഗ്രേസ്കെയിൽ
pdfToImage.blackwhite=കറുപ്പും വെളുപ്പും (ഡാറ്റ നഷ്ടപ്പെട്ടേക്കാം!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=പരിവർത്തനം ചെയ്യുക
pdfToImage.info=പൈത്തൺ ഇൻസ്റ്റാൾ ചെയ്തിട്ടില്ല. WebP പരിവർത്തനത്തിന് ആവശ്യമാണ്.
pdfToImage.placeholder=(ഉദാ. 1,2,8 അല്ലെങ്കിൽ 4,7,12-16 അല്ലെങ്കിൽ 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Instellingen
settings.update=Update beschikbaar
settings.updateAvailable={0} is de huidig geïnstalleerde versie. Een nieuwe versie ({1}) is beschikbaar.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=App versie:
settings.downloadOption.title=Kies download optie (Voor enkelvoudige bestanddownloads zonder zip):
settings.downloadOption.1=Open in hetzelfde venster
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Gebruikersnaam kan alleen letters, nummers en de volgende speciale tekens @._+- bevatten of moet een geldig emailadres zijn.
adminUserSettings.role=Rol
adminUserSettings.actions=Acties
adminUserSettings.apiUser=Beperkte API gebruiker
adminUserSettings.extraApiUser=Extra beperkte API gebruiker
adminUserSettings.webOnlyUser=Alleen web gebruiker
adminUserSettings.demoUser=Demogebruiker (geen aangepaste instellingen)
adminUserSettings.internalApiUser=Interne API gebruiker
adminUserSettings.forceChange=Forceer gebruiker om gebruikersnaam/wachtwoord te wijzigen bij inloggen
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Kleurtype
pdfToImage.color=Kleur
pdfToImage.grey=Grijstinten
pdfToImage.blackwhite=Zwart en wit (kan data verliezen!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Omzetten
pdfToImage.info=Python is niet geïnstalleerd. Vereist voor WebP-conversie.
pdfToImage.placeholder=(bijv. 1,2,8 of 4,7,12-16 of 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populært
settings.title=Innstillinger
settings.update=Oppdatering tilgjengelig
settings.updateAvailable={0} er den nåværende installerte versjonen. En ny versjon ({1}) er tilgjengelig.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=App Versjon:
settings.downloadOption.title=Velg nedlastingsalternativ (For enkeltfil ikke-zip nedlastinger):
settings.downloadOption.1=Åpne i samme vindu
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Skal brukeren deaktiveres/aktiveres?
adminUserSettings.usernameInfo=Brukernavn kan bare inneholde bokstaver, tall og følgende spesialtegn @._+- eller må være en gyldig e-postadresse.
adminUserSettings.role=Rolle
adminUserSettings.actions=Handlinger
adminUserSettings.apiUser=Begrenset API Bruker
adminUserSettings.extraApiUser=Ekstra Begrenset API Bruker
adminUserSettings.webOnlyUser=Kun Web Bruker
adminUserSettings.demoUser=Demo Bruker (Ingen tilpassede innstillinger)
adminUserSettings.internalApiUser=Intern API Bruker
adminUserSettings.forceChange=Tving bruker til å endre passord ved innlogging
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Farge type
pdfToImage.color=Farge
pdfToImage.grey=Gråtone
pdfToImage.blackwhite=Svart-hvitt (kan miste data!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konverter
pdfToImage.info=Python is not installed. Required for WebP conversion.
pdfToImage.placeholder=(f.eks. 1,2,8 eller 4,7,12-16 eller 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popularne
settings.title=Ustawienia
settings.update=Dostępna aktualizacja
settings.updateAvailable=Wersja {0} jest obecenia zainstalowana, dostępna jest nowa wersja ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Wersja aplikacji:
settings.downloadOption.title=Wybierz opcję pobierania (w przypadku pobierania pojedynczych plików innych niż ZIP):
settings.downloadOption.1=Otwórz w tym samym oknie
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Czy użytkownik powinien zostać wył
adminUserSettings.usernameInfo=Niewłaściwa nazwa użytkownika - musi zawierać litery, cyfry i @._+- LUB być adresem email.
adminUserSettings.role=Rola
adminUserSettings.actions=Akcje
adminUserSettings.apiUser=Ograniczony Użytkownik API
adminUserSettings.extraApiUser=Dodatkowy ograniczony Użytkownik API
adminUserSettings.webOnlyUser=Użytkownik tylko WEB
adminUserSettings.demoUser=Użytkownik DEMO
adminUserSettings.internalApiUser=Wewnętrzny użytkownik API
adminUserSettings.forceChange=Wymuś zmianę hasło po zalogowaniu
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Rodzaj koloru
pdfToImage.color=Kolor
pdfToImage.grey=Odcień szarości
pdfToImage.blackwhite=Czarno-biały (może spowodować utratę danych!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konwertuj
pdfToImage.info=Python nie został zainstalowany. Jest wymagany do konwersji WebP.
pdfToImage.placeholder=(przykład 1,2,8 lub 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populares
settings.title=Configurações
settings.update=Atualização disponível
settings.updateAvailable={0} é a versão atualmente instalada. Uma nova versão ({1}) está disponível.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Versão do Aplicativo:
settings.downloadOption.title=Escolha a opção de download (para download de arquivo único, não compactados):
settings.downloadOption.1=Abrir na mesma janela
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=O usuário deve ser desabilitado/habil
adminUserSettings.usernameInfo=Nome de usuário só pode incluir letras, números e os seguintes caracteres especiais @._+- ou deve ser um e-mail válido.
adminUserSettings.role=Função
adminUserSettings.actions=Ações
adminUserSettings.apiUser=Usuário de API limitado
adminUserSettings.extraApiUser=Usuário de API limitado adicional
adminUserSettings.webOnlyUser=Usuário web apenas
adminUserSettings.demoUser=Usuário demo (Sem configurações personalizadas)
adminUserSettings.internalApiUser=Usuário de API interno
adminUserSettings.forceChange=Forçar usuário a trocar a senha ao iniciar sessão
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Cor de saída:
pdfToImage.color=Colorido
pdfToImage.grey=Escala de Cinza
pdfToImage.blackwhite=Preto e Branco (pode perder informações!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Converter
pdfToImage.info=Python não está instalado. Necessário para conversão WebP.
pdfToImage.placeholder=(por exemplo 1,2,8 ou 4,7,12-16 ou 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Definições
settings.update=Atualização disponível
settings.updateAvailable={0} é a versão atual instalada. Uma nova versão ({1}) está disponível.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Versão da Aplicação:
settings.downloadOption.title=Escolha a opção de download (Para downloads de ficheiro único não zipado):
settings.downloadOption.1=Abrir na mesma janela
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Deve o utilizador ser desativado/ativa
adminUserSettings.usernameInfo=O nome de utilizador só pode conter letras, números e os seguintes caracteres especiais @._+- ou deve ser um endereço de email válido.
adminUserSettings.role=Função
adminUserSettings.actions=Ações
adminUserSettings.apiUser=Utilizador API Limitado
adminUserSettings.extraApiUser=Utilizador API Limitado Adicional
adminUserSettings.webOnlyUser=Utilizador Apenas Web
adminUserSettings.demoUser=Utilizador Demo (Sem Definições Personalizadas)
adminUserSettings.internalApiUser=Utilizador API Interno
adminUserSettings.forceChange=Forçar utilizador a alterar palavra-passe no login
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tipo de cor
pdfToImage.color=Cor
pdfToImage.grey=Escala de Cinza
pdfToImage.blackwhite=Preto e Branco (Pode perder dados!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Converter
pdfToImage.info=Python não está instalado. Necessário para conversão WebP.
pdfToImage.placeholder=(ex. 1,2,8 ou 4,7,12-16 ou 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Setări
settings.update=Actualizare disponibilă
settings.updateAvailable={0} este versiunea instalată curent. O nouă versiune ({1}) este disponibilă.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Versiune aplicație:
settings.downloadOption.title=Alege opțiunea de descărcare (pentru descărcarea unui singur fișier non-zip):
settings.downloadOption.1=Deschide în aceeași fereastră
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ar trebui dezactivat/activat utilizato
adminUserSettings.usernameInfo=Numele de utilizator poate conține doar litere, numere și următoarele caractere speciale @._+- sau trebuie să fie o adresă de email validă.
adminUserSettings.role=Rol
adminUserSettings.actions=Acțiuni
adminUserSettings.apiUser=Utilizator API Limitat
adminUserSettings.extraApiUser=Utilizator API Limitat Suplimentar
adminUserSettings.webOnlyUser=Utilizator Doar Web
adminUserSettings.demoUser=Utilizator Demo (Fără setări personalizate)
adminUserSettings.internalApiUser=Utilizator API Intern
adminUserSettings.forceChange=Forțează utilizatorul să schimbe parola la conectare
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Tip culoare
pdfToImage.color=Culoare
pdfToImage.grey=Scală de gri
pdfToImage.blackwhite=Alb și negru (Poate pierde date!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Convertește
pdfToImage.info=Python nu este instalat. Necesar pentru conversia WebP.
pdfToImage.placeholder=(ex. 1,2,8 sau 4,7,12-16 sau 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Популярное
settings.title=Настройки
settings.update=Доступно обновление
settings.updateAvailable=Текущая установленная версия - {0}. Доступна новая версия ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Версия приложения:
settings.downloadOption.title=Выберите вариант загрузки (для одиночных файлов без архивации):
settings.downloadOption.1=Открыть в том же окне
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Отключить/включить п
adminUserSettings.usernameInfo=Имя пользователя может содержать только буквы, цифры и следующие специальные символы @._+- или должно быть действительным адресом электронной почты.
adminUserSettings.role=Роль
adminUserSettings.actions=Действия
adminUserSettings.apiUser=Ограниченный пользователь API
adminUserSettings.extraApiUser=Дополнительный ограниченный пользователь API
adminUserSettings.webOnlyUser=Только веб-пользователь
adminUserSettings.demoUser=Демо-пользователь (без настраиваемых параметров)
adminUserSettings.internalApiUser=Внутренний пользователь API
adminUserSettings.forceChange=Требовать смену пароля при входе
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Тип цвета
pdfToImage.color=Цветной
pdfToImage.grey=Оттенки серого
pdfToImage.blackwhite=Черно-белый (возможна потеря данных!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Преобразовать
pdfToImage.info=Python не установлен. Требуется для конвертации в WebP.
pdfToImage.placeholder=(например, 1,2,8 или 4,7,12-16 или 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Заменить существующие з
editTableOfContents.editorTitle=Редактор закладок
editTableOfContents.editorDesc=Добавьте и упорядочьте закладки ниже. Нажмите «+», чтобы добавить дочерние закладки.
editTableOfContents.addBookmark=Добавить новую закладку
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=Этот инструмент позволяет вам добавлять или редактировать оглавление (закладки) в PDF-документе.
editTableOfContents.desc.2=Вы можете создать иерархическую структуру, добавив дочерние закладки к родительским.
editTableOfContents.desc.3=Для каждой закладки требуется название и номер целевой страницы.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Nastavenia
settings.update=Dostupná aktualizácia
settings.updateAvailable={0} je aktuálne nainštalovaná verzia. Nová verzia ({1}) je dostupná.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Verzia aplikácie:
settings.downloadOption.title=Vyberte možnosť sťahovania (Pre jednotlivé neskomprimované súbory):
settings.downloadOption.1=Otvoriť v rovnakom okne
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Používateľské meno musí obsahovať iba písmená a čísla, žiadne medzery alebo špeciálne znaky.
adminUserSettings.role=Rola
adminUserSettings.actions=Akcie
adminUserSettings.apiUser=Obmedzený API používateľ
adminUserSettings.extraApiUser=Ďalší obmedzený API používateľ
adminUserSettings.webOnlyUser=Používateľ iba pre web
adminUserSettings.demoUser=Demo používateľ (Bez vlastných nastavení)
adminUserSettings.internalApiUser=Interný API používateľ
adminUserSettings.forceChange=Donútiť používateľa zmeniť heslo pri prihlásení
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Typ farby
pdfToImage.color=Farba
pdfToImage.grey=Odtiene šedej
pdfToImage.blackwhite=Čierno-biele (Môže stratiť údaje!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konvertovať
pdfToImage.info=Python is not installed. Required for WebP conversion.
pdfToImage.placeholder=(napr. 1,2,8 alebo 4,7,12-16 alebo 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Priljubljeno
settings.title=Nastavitve
settings.update=Na voljo je posodobitev
settings.updateAvailable={0} je trenutno nameščena različica. Na voljo je nova različica ({1}).
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Različica aplikacije:
settings.downloadOption.title=Izberi možnost prenosa (za prenose ene datoteke brez zip):
settings.downloadOption.1=Odpri v istem oknu
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ali naj bo uporabnik onemogočen/omogo
adminUserSettings.usernameInfo=Uporabniško ime lahko vsebuje samo črke, številke in naslednje posebne znake @._+- ali mora biti veljaven e-poštni naslov.
adminUserSettings.role=Vloga
adminUserSettings.actions=Dejanja
adminUserSettings.apiUser=Omejen uporabnik API-ja
adminUserSettings.extraApiUser=Dodatni omejeni uporabnik API-ja
adminUserSettings.webOnlyUser=Samo spletni uporabnik
adminUserSettings.demoUser=Demo uporabnik (brez nastavitev po meri)
adminUserSettings.internalApiUser=Notranji uporabnik API-ja
adminUserSettings.forceChange=Prisili uporabnika, da spremeni geslo ob prijavi
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Vrsta barve
pdfToImage.color=Barva
pdfToImage.grey=Sivine
pdfToImage.blackwhite=Črno-belo (Lahko izgubite podatke!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Pretvori
pdfToImage.info=Python ni nameščen. Zahtevano za pretvorbo WebP.
pdfToImage.placeholder=(npr. 1,2,8 ali 4,7,12-16 ali 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popularno
settings.title=Podešavanja
settings.update=Dostupno ažuriranje
settings.updateAvailable={0} je trenutno instalirana verzija. Nova verzija ({1}) je dostupna.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Verzija aplikacije:
settings.downloadOption.title=Odaberi opciju preuzimanja (Za preuzimanje pojedinačnih fajlova bez zip formata):
settings.downloadOption.1=Otvori u istom prozoru
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Da onemogućim/omogućim korisnika?
adminUserSettings.usernameInfo=Korisničko ime može sadržati samo slova, brojeve i specijalne karaktere @._+- ili mora biti validna email adresa.
adminUserSettings.role=Uloga
adminUserSettings.actions=Akcije
adminUserSettings.apiUser=Korisnik s ograničenim API pristupom
adminUserSettings.extraApiUser=Dodatni ograničeni API korisnik
adminUserSettings.webOnlyUser=Korisnik samo za web
adminUserSettings.demoUser=Demo korisnik (Bez prilagođenih podešavanja)
adminUserSettings.internalApiUser=Interni API korisnik
adminUserSettings.forceChange=Prisili korisnika da promeni korisničko ime/lozinku pri prijavi
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Režim boja:
pdfToImage.color=Kolor
pdfToImage.grey=Monohromatski
pdfToImage.blackwhite=Crno-belo (Može izgubiti detalje!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konvertuj
pdfToImage.info=Python nije instaliran. Neophodan je za WebP konverziju.
pdfToImage.placeholder=(npr. 1,2,8 ili 4,7,12-16 ili 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Zameni postojeće obeleživače (isključi d
editTableOfContents.editorTitle=Editor obeleživača
editTableOfContents.editorDesc=Dodaj i rasporedi obeleživače ispod. Klikni + za dodavanje podređenih obeleživača.
editTableOfContents.addBookmark=Dodaj novi obeleživač
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=Ovaj alat omogućava dodavanje ili izmenu sadržaja (obeleživača) u PDF dokumentu.
editTableOfContents.desc.2=Moguće je kreirati hijerarhijsku strukturu dodavanjem podređenih obeleživača nadređenim obeleživačima.
editTableOfContents.desc.3=Svaki obeleživač zahteva naslov i broj ciljne strane.
@@ -366,6 +366,38 @@ navbar.sections.popular=Populära
settings.title=Inställningar
settings.update=Uppdatering tillgänglig
settings.updateAvailable={0} är den aktuella installerade versionen. En ny version ({1}) finns tillgänglig.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Appversion:
settings.downloadOption.title=Välj nedladdningsalternativ (för nedladdning av en fil utan zip):
settings.downloadOption.1=Öppnas i samma fönster
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Ska användaren inaktiveras/aktiveras?
adminUserSettings.usernameInfo=Användarnamn kan endast innehålla bokstäver, siffror och följande specialtecken @._+- eller måste vara en giltig e-postadress.
adminUserSettings.role=Roll
adminUserSettings.actions=Åtgärder
adminUserSettings.apiUser=Begränsad API-användare
adminUserSettings.extraApiUser=Ytterligare begränsad API-användare
adminUserSettings.webOnlyUser=Endast webbanvändare
adminUserSettings.demoUser=Demoanvändare (Inga anpassade inställningar)
adminUserSettings.internalApiUser=Intern API-användare
adminUserSettings.forceChange=Tvinga användare att ändra lösenord vid inloggning
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Färgtyp
pdfToImage.color=Färg
pdfToImage.grey=Gråskala
pdfToImage.blackwhite=Svartvitt (kan förlora data!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Konvertera
pdfToImage.info=Python är inte installerat. Krävs för WebP-konvertering.
pdfToImage.placeholder=(t.ex. 1,2,8 eller 4,7,12-16 eller 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=ยอดนิยม
settings.title=การตั้งค่า
settings.update=มีการอัปเดต
settings.updateAvailable={0} คือเวอร์ชันที่ติดตั้งในปัจจุบัน มีเวอร์ชันใหม่ ({1}) พร้อมให้บริการ
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=เวอร์ชันแอป:
settings.downloadOption.title=เลือกตัวเลือกการดาวน์โหลด (สำหรับการดาวน์โหลดไฟล์เดียวที่ไม่ใช่ zip):
settings.downloadOption.1=เปิดในหน้าต่างเดียวกัน
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=ผู้ใช้นี้ควร
adminUserSettings.usernameInfo=ชื่อผู้ใช้สามารถประกอบด้วยตัวอักษร ตัวเลข และอักขระพิเศษต่อไปนี้ @._+- หรือจะต้องเป็นที่อยู่อีเมลที่ถูกต้อง
adminUserSettings.role=บทบาท
adminUserSettings.actions=การดำเนินการ
adminUserSettings.apiUser=ผู้ใช้ API จำกัด
adminUserSettings.extraApiUser=ผู้ใช้ API เพิ่มเติม
adminUserSettings.webOnlyUser=ผู้ใช้เว็บเท่านั้น
adminUserSettings.demoUser=ผู้ใช้ทดลอง (ไม่มีการตั้งค่าปรับแต่ง)
adminUserSettings.internalApiUser=ผู้ใช้ API ภายใน
adminUserSettings.forceChange=บังคับให้ผู้ใช้เปลี่ยนรหัสผ่านในการเข้าสู่ระบบ
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=ประเภทสี
pdfToImage.color=สี
pdfToImage.grey=ระดับสีเทา
pdfToImage.blackwhite=ขาวดำ (อาจสูญเสียข้อมูล!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=แปลง
pdfToImage.info=Python ไม่มีการติดตั้ง จำเป็นสำหรับการแปลง WebP
pdfToImage.placeholder=(เช่น 1,2,8 หรือ 4,7,12-16 หรือ 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Ayarlar
settings.update=Güncelleme mevcut
settings.updateAvailable={0} mevcut kurulu sürümdür. Yeni bir sürüm ({1}) mevcuttur.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Uygulama Sürümü:
settings.downloadOption.title=İndirme seçeneği seçin (Zip olmayan tek dosya indirmeler için):
settings.downloadOption.1=Aynı pencerede aç
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Kullanıcı devre dışı bırakılmal
adminUserSettings.usernameInfo=Kullanıcı adı yalnızca harf, rakam ve aşağıdaki özel karakterleri @._+- içerebilir veya geçerli bir e-posta adresi olmalıdır.
adminUserSettings.role=Rol
adminUserSettings.actions=Eylemler
adminUserSettings.apiUser=Sınırlı API Kullanıcısı
adminUserSettings.extraApiUser=Ek Sınırlı API Kullanıcısı
adminUserSettings.webOnlyUser=Sadece Web Kullanıcısı
adminUserSettings.demoUser=Demo Kullanıcısı (Özel ayar yok)
adminUserSettings.internalApiUser=Dahili API Kullanıcısı
adminUserSettings.forceChange=Kullanıcının girişte kullanıcı adı/şifre değiştirmesini zorla
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Renk türü
pdfToImage.color=Renk
pdfToImage.grey=Gri tonlama
pdfToImage.blackwhite=Siyah ve Beyaz (Veri kaybolabilir!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Dönüştür
pdfToImage.info=Python kurulu değil. WebP dönüşümü için gereklidir.
pdfToImage.placeholder=(örneğin 1,2,8 veya 4,7,12-16 ya da 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Mevcut yer işaretlerini değiştir (var ola
editTableOfContents.editorTitle=Yer İşareti Düzenleyici
editTableOfContents.editorDesc=Aşağıdan yer işaretleri ekleyin ve düzenleyin. Alt yer işareti eklemek için + simgesine tıklayın.
editTableOfContents.addBookmark=Yeni Yer İşareti Ekle
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=Bu araç, bir PDF belgesine içindekiler tablosu (yer işaretleri) eklemenizi veya mevcut olanları düzenlemenizi sağlar.
editTableOfContents.desc.2=Alt yer işaretleri ekleyerek hiyerarşik bir yapı oluşturabilirsiniz.
editTableOfContents.desc.3=Her yer işareti bir başlık ve hedef sayfa numarası gerektirir.
@@ -366,6 +366,38 @@ navbar.sections.popular=Популярне
settings.title=Налаштування
settings.update=Доступне оновлення
settings.updateAvailable=Зараз встановлена версія {0}. Нова версія ({1}) доступна.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Версія додатку:
settings.downloadOption.title=Виберіть варіант завантаження (для завантаження одного файлу без zip):
settings.downloadOption.1=Відкрити в тому ж вікні
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Чи потрібно вимкнути
adminUserSettings.usernameInfo=Ім’я користувача може містити лише літери, цифри та наступні спеціальні символи @._+- або має бути дійсною електронною адресою.
adminUserSettings.role=Роль
adminUserSettings.actions=Дії
adminUserSettings.apiUser=Обмежений користувач API
adminUserSettings.extraApiUser=Додатковий обмежений користувач API
adminUserSettings.webOnlyUser=Тільки веб-користувач
adminUserSettings.demoUser=Демо-користувач (без налаштованих параметрів)
adminUserSettings.internalApiUser=Внутрішній користувач API
adminUserSettings.forceChange=Примусити користувача змінити пароль при вході в систему
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Тип кольору
pdfToImage.color=Колір
pdfToImage.grey=Відтінки сірого
pdfToImage.blackwhite=Чорно-білий (може втратити дані!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Конвертувати
pdfToImage.info=Python не встановлено. Необхідно для конвертації WebP.
pdfToImage.placeholder=(наприклад 1,2,8 або 4,7,12-16 або 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=Popular
settings.title=Cài đặt
settings.update=Có bản cập nhật
settings.updateAvailable={0} là phiên bản hiện tại đã cài đặt. Một phiên bản mới ({1}) đã có sẵn.
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=Phiên bản ứng dụng:
settings.downloadOption.title=Chọn tùy chọn tải xuống (Đối với tải xuống tệp đơn không nén):
settings.downloadOption.1=Mở trong cùng cửa sổ
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=Should the user be disabled/enabled?
adminUserSettings.usernameInfo=Tên người dùng chỉ có thể chứa chữ cái, số và các ký tự đặc biệt sau @._+- hoặc phải là một địa chỉ email hợp lệ.
adminUserSettings.role=Vai trò
adminUserSettings.actions=Hành động
adminUserSettings.apiUser=Người dùng API giới hạn
adminUserSettings.extraApiUser=Người dùng API giới hạn bổ sung
adminUserSettings.webOnlyUser=Chỉ người dùng web
adminUserSettings.demoUser=Người dùng demo (Không có cài đặt tùy chỉnh)
adminUserSettings.internalApiUser=Người dùng API nội bộ
adminUserSettings.forceChange=Buộc người dùng thay đổi mật khẩu khi đăng nhập
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=Loại màu
pdfToImage.color=Màu
pdfToImage.grey=Thang độ xám
pdfToImage.blackwhite=Đen trắng (Có thể mất dữ liệu!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=Chuyển đổi
pdfToImage.info=Python is not installed. Required for WebP conversion.
pdfToImage.placeholder=(ví dụ: 1,2,8 hoặc 4,7,12-16 hoặc 2n-1)
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=Replace existing bookmarks (uncheck to appen
editTableOfContents.editorTitle=Bookmark Editor
editTableOfContents.editorDesc=Add and arrange bookmarks below. Click + to add child bookmarks.
editTableOfContents.addBookmark=Add New Bookmark
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=This tool allows you to add or edit the table of contents (bookmarks) in a PDF document.
editTableOfContents.desc.2=You can create a hierarchical structure by adding child bookmarks to parent bookmarks.
editTableOfContents.desc.3=Each bookmark requires a title and target page number.
@@ -366,6 +366,38 @@ navbar.sections.popular=热门
settings.title=设置
settings.update=有可用的更新
settings.updateAvailable=当前版本为 {0},新版本 ({1}) 可用。
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=应用程序版本:
settings.downloadOption.title=选择下载选项(单个文件非压缩文件):
settings.downloadOption.1=在同一窗口打开
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=是否应禁用/启用该用户?
adminUserSettings.usernameInfo=用户名只能包含字母、数字和以下特殊字符@._+-,或者必须是有效的电子邮件地址。
adminUserSettings.role=角色
adminUserSettings.actions=操作
adminUserSettings.apiUser=受限制的 API 用户
adminUserSettings.extraApiUser=额外受限制的 API 用户
adminUserSettings.webOnlyUser=仅限 Web 用户
adminUserSettings.demoUser=演示用户(无自定义设置)
adminUserSettings.internalApiUser=内部 API 用户
adminUserSettings.forceChange=强制用户在登录时更改用户名/密码
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=颜色类型
pdfToImage.color=颜色
pdfToImage.grey=灰度
pdfToImage.blackwhite=黑白(可能会丢失数据!)。
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=转换
pdfToImage.info=WebP 转换需要安装 Python
pdfToImage.placeholder=(例如:1,2,8 或 4,7,12-16 或 2n-1
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=替换现有书签(取消勾选则追加
editTableOfContents.editorTitle=书签编辑器
editTableOfContents.editorDesc=在下方添加并排列书签,点击 + 可添加子书签
editTableOfContents.addBookmark=添加书签
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=此工具可用于在 PDF 文档中添加或编辑目录(书签)
editTableOfContents.desc.2=您可以通过为父书签添加子书签来构建层级结构
editTableOfContents.desc.3=每个书签需填写标题和目标页码
@@ -366,6 +366,38 @@ navbar.sections.popular=熱門功能
settings.title=設定
settings.update=有更新可用
settings.updateAvailable=目前安裝的版本是 {0}。有新版本({1})可供使用。
# Update modal and notification strings
update.urgentUpdateAvailable=🚨 Update Available
update.updateAvailable=Update Available
update.modalTitle=Update Available
update.current=Current
update.latest=Latest
update.latestStable=Latest Stable
update.priority=Priority
update.recommendedAction=Recommended Action
update.breakingChangesDetected=⚠️ Breaking Changes Detected
update.breakingChangesMessage=This update contains breaking changes. Please review the migration guides below.
update.migrationGuides=Migration Guides:
update.viewGuide=View Guide
update.loadingDetailedInfo=Loading detailed version information...
update.close=Close
update.viewAllReleases=View All Releases
update.downloadLatest=Download Latest
update.availableUpdates=Available Updates:
update.unableToLoadDetails=Unable to load detailed version information.
update.version=Version
# Update priority levels
update.priority.urgent=URGENT
update.priority.normal=NORMAL
update.priority.minor=MINOR
update.priority.low=LOW
# Breaking changes text
update.breakingChanges=Breaking Changes:
update.breakingChangesDefault=This version contains breaking changes
update.migrationGuide=Migration Guide
settings.appVersion=應用程式版本:
settings.downloadOption.title=選擇下載選項(適用於單一檔案非壓縮下載):
settings.downloadOption.1=在同一視窗中開啟
@@ -423,9 +455,6 @@ adminUserSettings.confirmChangeUserStatus=是否要停用/啟用此使用者?
adminUserSettings.usernameInfo=使用者名稱只能包含字母、數字和以下特殊字元 @._+- 或必須是有效的電子郵件地址。
adminUserSettings.role=角色
adminUserSettings.actions=操作
adminUserSettings.apiUser=受限制的 API 使用者
adminUserSettings.extraApiUser=額外受限制的 API 使用者
adminUserSettings.webOnlyUser=僅網頁版使用者
adminUserSettings.demoUser=示範使用者(無自訂設定)
adminUserSettings.internalApiUser=內部 API 使用者
adminUserSettings.forceChange=強制使用者在登入時變更密碼
@@ -1402,6 +1431,7 @@ pdfToImage.colorType=顏色類型
pdfToImage.color=顏色
pdfToImage.grey=灰度
pdfToImage.blackwhite=黑白(可能會遺失資料!)
pdfToImage.dpi=DPI (The server limit is {0} dpi)
pdfToImage.submit=轉換
pdfToImage.info=尚未安裝 Python。需要安裝 Python 才能進行 WebP 轉換。
pdfToImage.placeholder=(例如 1,2,8 或 4,7,12-16 或 2n-1
@@ -1859,6 +1889,12 @@ editTableOfContents.replaceExisting=取代現有書籤 (取消勾選以附加到
editTableOfContents.editorTitle=書籤編輯器
editTableOfContents.editorDesc=在下方新增和排列書籤。點選 + 新增子書籤。
editTableOfContents.addBookmark=新增書籤
editTableOfContents.importBookmarksDefault=Import
editTableOfContents.importBookmarksFromJsonFile=Upload JSON file
editTableOfContents.importBookmarksFromClipboard=Paste from clipboard
editTableOfContents.exportBookmarksDefault=Export
editTableOfContents.exportBookmarksAsJson=Download as JSON
editTableOfContents.exportBookmarksAsText=Copy as text
editTableOfContents.desc.1=此工具可讓您在 PDF 文件中新增或編輯目錄 (書籤)。
editTableOfContents.desc.2=您可以透過將子書籤新增至父書籤來建立階層式結構。
editTableOfContents.desc.3=每個書籤都需要標題和目標頁碼。
@@ -108,6 +108,7 @@ system:
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
html:
urlSecurity:
enabled: true # Enable URL security restrictions for HTML processing
@@ -336,6 +336,12 @@
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-psd",
"moduleVersion": "3.12.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.12.0",
@@ -623,21 +629,21 @@
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.34",
"moduleVersion": "2.2.35",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
"moduleVersion": "2.2.34",
"moduleVersion": "2.2.35",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
"moduleVersion": "2.2.34",
"moduleVersion": "2.2.35",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -980,13 +986,13 @@
},
{
"moduleName": "org.commonmark:commonmark",
"moduleVersion": "0.25.0",
"moduleVersion": "0.25.1",
"moduleLicense": "BSD-2-Clause",
"moduleLicenseUrl": "https://opensource.org/licenses/BSD-2-Clause"
},
{
"moduleName": "org.commonmark:commonmark-ext-gfm-tables",
"moduleVersion": "0.25.0",
"moduleVersion": "0.25.1",
"moduleLicense": "BSD-2-Clause",
"moduleLicenseUrl": "https://opensource.org/licenses/BSD-2-Clause"
},
@@ -156,7 +156,7 @@
.bookmark-actions {
margin-top: 20px;
display: flex;
justify-content: flex-start;
justify-content: space-between;
}
/* Collapse/expand icons */
@@ -274,3 +274,25 @@
--bg-empty: var(--md-sys-color-surface-container-low, #24282e);
--border-empty: var(--md-sys-color-outline, #495057);
}
.success-flash {
position: relative;
}
.success-flash::after {
content: "✓";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-weight: bold;
font-size: 1.2em;
color: white;
opacity: 0;
animation: fadeOut 1s ease-in-out;
}
@keyframes fadeOut {
0% { opacity: 1; }
100% { opacity: 0; }
}
@@ -16,21 +16,96 @@ function compareVersions(version1, version2) {
return 0;
}
async function getLatestReleaseVersion() {
const url = "https://api.github.com/repos/Stirling-Tools/Stirling-PDF/releases/latest";
function getDownloadUrl() {
// Only show download for non-Docker installations
if (machineType === 'Docker' || machineType === 'Kubernetes') {
return null;
}
const baseUrl = 'https://files.stirlingpdf.com/';
// Determine file based on machine type and security
if (machineType === 'Server-jar') {
return baseUrl + (activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
if (machineType.startsWith('Client-')) {
const os = machineType.replace('Client-', ''); // win, mac, unix
const type = activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
return baseUrl + os + type + '.jar';
} else if (os === 'win') {
return baseUrl + os + '-installer.exe';
} else if (os === 'mac') {
return baseUrl + os + '-installer.dmg';
}
}
return null;
}
// Function to get translated priority text
function getTranslatedPriority(priority) {
switch(priority?.toLowerCase()) {
case 'urgent': return updatePriorityUrgent;
case 'normal': return updatePriorityNormal;
case 'minor': return updatePriorityMinor;
case 'low': return updatePriorityLow;
default: return priority?.toUpperCase() || 'NORMAL';
}
}
async function getUpdateSummary() {
// Map Java License enum to API types
let type = 'normal';
if (licenseType === 'PRO') {
type = 'pro';
} else if (licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `https://supabase.stirling.com/functions/v1/updates?from=${currentVersion}&type=${type}&login=${activeSecurity}&summary=true`;
console.log("Fetching update summary from:", url);
try {
const response = await fetch(url);
console.log("Response status:", response.status);
if (response.status === 200) {
const data = await response.json();
return data.tag_name ? data.tag_name.substring(1) : "";
return data;
} else {
// If the status is not 200, try to get the version from build.gradle
return await getCurrentVersionFromBypass();
console.error("Failed to fetch update summary from Supabase:", response.status);
return null;
}
} catch (error) {
console.error("Failed to fetch latest version from GitHub:", error);
// If an error occurs, try to get the version from build.gradle
return await getCurrentVersionFromBypass();
console.error("Failed to fetch update summary from Supabase:", error);
return null;
}
}
async function getFullUpdateInfo() {
// Map Java License enum to API types
let type = 'normal';
if (licenseType === 'PRO') {
type = 'pro';
} else if (licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `https://supabase.stirling.com/functions/v1/updates?from=${currentVersion}&type=${type}&login=${activeSecurity}&summary=false`;
console.log("Fetching full update info from:", url);
try {
const response = await fetch(url);
console.log("Full update response status:", response.status);
if (response.status === 200) {
const data = await response.json();
return data;
} else {
console.error("Failed to fetch full update info from Supabase:", response.status);
return null;
}
} catch (error) {
console.error("Failed to fetch full update info from Supabase:", error);
return null;
}
}
@@ -60,6 +135,7 @@ async function checkForUpdate() {
var updateLinkLegacy = document.getElementById("update-link-legacy") || null;
if (updateBtn !== null) {
updateBtn.style.display = "none";
updateBtn.classList.remove("btn-danger", "btn-warning", "btn-outline-primary");
}
if (updateLink !== null) {
updateLink.style.display = "none";
@@ -71,19 +147,47 @@ async function checkForUpdate() {
}
}
const latestVersion = await getLatestReleaseVersion();
console.log("latestVersion=" + latestVersion);
const updateSummary = await getUpdateSummary();
if (!updateSummary) {
console.log("No update summary available");
return;
}
console.log("updateSummary=", updateSummary);
console.log("currentVersion=" + currentVersion);
console.log("compareVersions(latestVersion, currentVersion) > 0)=" + compareVersions(latestVersion, currentVersion));
if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) {
console.log("latestVersion=" + updateSummary.latest_version);
if (updateSummary.latest_version && compareVersions(updateSummary.latest_version, currentVersion) > 0) {
const priority = updateSummary.max_priority || 'normal';
if (updateBtn != null) {
document.getElementById("update-btn").style.display = "block";
// Style button based on priority
if (priority === 'urgent') {
updateBtn.classList.add("btn-danger");
updateBtn.innerHTML = urgentUpdateAvailable;
} else if (priority === 'normal') {
updateBtn.classList.add("btn-warning");
updateBtn.innerHTML = updateAvailableText;
} else {
updateBtn.classList.add("btn-outline-primary");
updateBtn.innerHTML = updateAvailableText;
}
// Store summary for initial display
updateBtn.setAttribute('data-update-summary', JSON.stringify(updateSummary));
updateBtn.style.display = "block";
// Add click handler for update details modal
updateBtn.onclick = function(e) {
e.preventDefault();
showUpdateModal();
};
}
if (updateLink !== null) {
document.getElementById("update-link").style.display = "flex";
}
if (updateLinkLegacy !== null) {
document.getElementById("app-update").innerHTML = updateAvailable.replace("{0}", '<b>' + currentVersion + '</b>').replace("{1}", '<b>' + latestVersion + '</b>');
document.getElementById("app-update").innerHTML = updateAvailable.replace("{0}", '<b>' + currentVersion + '</b>').replace("{1}", '<b>' + updateSummary.latest_version + '</b>');
if (updateLinkLegacy.classList.contains("visually-hidden")) {
updateLinkLegacy.classList.remove("visually-hidden");
}
@@ -99,6 +203,188 @@ async function checkForUpdate() {
}
}
async function showUpdateModal() {
// Close settings modal if open
const settingsModal = bootstrap.Modal.getInstance(document.getElementById('settingsModal'));
if (settingsModal) {
settingsModal.hide();
}
// Get summary data from button
const updateBtn = document.getElementById("update-btn");
const summaryData = JSON.parse(updateBtn.getAttribute('data-update-summary'));
// Utility function to escape HTML special characters
function escapeHtml(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/\//g, '&#x2F;');
}
// Create initial modal with loading state
const initialModalHtml = `
<div class="modal fade" id="updateModal" tabindex="-1" role="dialog" aria-labelledby="updateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable" role="document" style="max-height: 80vh;">
<div class="modal-content" style="max-height: 80vh;">
<div class="modal-header">
<h5 class="modal-title" id="updateModalLabel">${updateModalTitle}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body" id="updateModalBody" style="max-height: 60vh; overflow-y: auto;">
<div class="update-summary mb-4">
<div class="row mb-3">
<div class="${summaryData.latest_stable_version ? 'col-4' : 'col-6'} text-center">
<small class="text-muted">${updateCurrent}</small><br>
<strong>${escapeHtml(currentVersion)}</strong>
</div>
<div class="${summaryData.latest_stable_version ? 'col-4' : 'col-6'} text-center">
<small class="text-muted">${updateLatest}</small><br>
<strong class="text-primary">${escapeHtml(summaryData.latest_version)}</strong>
</div>
${summaryData.latest_stable_version ? `
<div class="col-4 text-center">
<small class="text-muted">${updateLatestStable}</small><br>
<strong class="text-success">${escapeHtml(summaryData.latest_stable_version)}</strong>
</div>
` : ''}
</div>
<div class="alert ${summaryData.max_priority === 'urgent' ? 'alert-danger' : 'alert-warning'}" role="alert">
<strong>${updatePriority}:</strong> ${getTranslatedPriority(summaryData.max_priority)}
${summaryData.recommended_action ? `<br><strong>${updateRecommendedAction}:</strong> ${escapeHtml(summaryData.recommended_action)}` : ''}
</div>
</div>
${summaryData.any_breaking ? `
<div class="alert alert-warning" role="alert">
<h6><strong>${updateBreakingChangesDetected}</strong></h6>
<p>${updateBreakingChangesMessage}</p>
</div>
` : ''}
${summaryData.migration_guides && summaryData.migration_guides.length > 0 ? `
<div class="migration-guides mb-4">
<h6>${updateMigrationGuides}</h6>
<ul class="list-group">
${summaryData.migration_guides.map(guide => `
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>${updateVersion} ${escapeHtml(guide.version)}:</strong> ${escapeHtml(guide.notes)}
</div>
<a href="${escapeHtml(guide.url)}" target="_blank" class="btn btn-sm btn-outline-primary">${updateViewGuide}</a>
</li>
`).join('')}
</ul>
</div>
` : ''}
<div class="text-center">
<div class="spinner-border text-primary" role="status" id="loadingSpinner">
<span class="visually-hidden">${updateLoadingDetailedInfo}</span>
</div>
<p class="mt-2">${updateLoadingDetailedInfo}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${updateClose}</button>
<a href="https://github.com/Stirling-Tools/Stirling-PDF/releases" target="_blank" class="btn btn-outline-primary">${updateViewAllReleases}</a>
${getDownloadUrl() ? `<a href="${escapeHtml(getDownloadUrl())}" class="btn btn-success" target="_blank">${updateDownloadLatest}</a>` : ''}
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('updateModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML('beforeend', initialModalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('updateModal'));
modal.show();
// Fetch full update info
const fullUpdateInfo = await getFullUpdateInfo();
// Update modal with full information
const modalBody = document.getElementById('updateModalBody');
if (fullUpdateInfo && fullUpdateInfo.new_versions) {
const storedMode = localStorage.getItem("dark-mode");
const isDarkMode = storedMode === "on" ||
(storedMode === null && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
const darkClasses = isDarkMode ? {
accordionItem: 'bg-dark border-secondary text-light',
accordionButton: 'bg-dark text-light border-secondary',
accordionBody: 'bg-dark text-light'
} : {
accordionItem: '',
accordionButton: '',
accordionBody: ''
};
const detailedVersionsHtml = `
<div class="detailed-versions mt-4">
<h6>${updateAvailableUpdates}</h6>
<div class="accordion" id="versionsAccordion">
${fullUpdateInfo.new_versions.map((version, index) => `
<div class="accordion-item" style="border-color: var(--md-sys-color-outline);">
<h2 class="accordion-header" id="heading${index}">
<button class="accordion-button ${index === 0 ? '' : 'collapsed'}" style="color: var(--md-sys-color-on-surface); background-color:
var(--md-sys-color-surface);" type="button" data-bs-toggle="collapse"
data-bs-target="#collapse${index}" aria-expanded="${index === 0 ? 'true' : 'false'}" aria-controls="collapse${index}">
<div class="d-flex justify-content-between w-100 me-3">
<span><strong>${updateVersion} ${version.version}</strong></span>
<span class="badge ${version.priority === 'urgent' ? 'bg-danger' : version.priority === 'normal' ? 'bg-warning' : 'bg-secondary'}">${getTranslatedPriority(version.priority)}</span>
</div>
</button>
</h2>
<div id="collapse${index}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
aria-labelledby="heading${index}" data-bs-parent="#versionsAccordion">
<div class="accordion-body" style="color: var(--md-sys-color-on-surface); background-color:
var(--md-sys-color-surface-bright);">
<h6>${version.announcement.title}</h6>
<p>${version.announcement.message}</p>
${version.compatibility.breaking_changes ? `
<div class="alert alert-warning alert-sm" role="alert">
<small><strong> ${updateBreakingChanges}</strong> ${version.compatibility.breaking_description || updateBreakingChangesDefault}</small>
${version.compatibility.migration_guide_url ? `<br><a href="${version.compatibility.migration_guide_url}" target="_blank" class="btn btn-sm btn-outline-warning mt-1">${updateMigrationGuide}</a>` : ''}
</div>
` : ''}
</div>
</div>
</div>
`).join('')}
</div>
</div>
`;
// Remove loading spinner and add detailed info
const spinner = document.getElementById('loadingSpinner');
if (spinner) {
spinner.parentElement.remove();
}
modalBody.insertAdjacentHTML('beforeend', detailedVersionsHtml);
} else {
// Remove loading spinner if failed to load
const spinner = document.getElementById('loadingSpinner');
if (spinner) {
spinner.parentElement.innerHTML = `<p class="text-muted">${updateUnableToLoadDetails}</p>`;
}
}
}
document.addEventListener("DOMContentLoaded", (event) => {
checkForUpdate();
});
@@ -1,88 +1,117 @@
document.addEventListener('DOMContentLoaded', function() {
const bookmarksContainer = document.getElementById('bookmarks-container');
const addBookmarkBtn = document.getElementById('addBookmarkBtn');
const bookmarkDataInput = document.getElementById('bookmarkData');
document.addEventListener("DOMContentLoaded", function () {
const bookmarksContainer = document.getElementById("bookmarks-container");
const errorMessageContainer = document.getElementById("error-message-container");
const addBookmarkBtn = document.getElementById("addBookmarkBtn");
const bookmarkDataInput = document.getElementById("bookmarkData");
let bookmarks = [];
let counter = 0; // Used for generating unique IDs
// Add event listener to the file input to extract existing bookmarks
document.getElementById('fileInput-input').addEventListener('change', async function(e) {
if (!e.target.files || e.target.files.length === 0) {
// callback function on file input change to extract bookmarks from PDF
async function getBookmarkDataFromPdf(event) {
if (!event.target.files || event.target.files.length === 0) {
return;
}
// Reset bookmarks initially
bookmarks = [];
updateBookmarksUI();
const formData = new FormData();
formData.append('file', e.target.files[0]);
// Show loading indicator
showLoadingIndicator();
formData.append("file", event.target.files[0]);
try {
// Call the API to extract bookmarks using fetchWithCsrf for CSRF protection
const response = await fetchWithCsrf('/api/v1/general/extract-bookmarks', {
method: 'POST',
body: formData
const response = await fetchWithCsrf("/api/v1/general/extract-bookmarks", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to extract bookmarks: ${response.status} ${response.statusText}`);
throw new Error(`Failed to fetch API: ${response.status} ${response.statusText}`);
}
const extractedBookmarks = await response.json();
return extractedBookmarks;
} catch (error) {
throw new Error("Error extracting bookmark data:", error);
}
}
// callback function on file input change to extract bookmarks from JSON
async function getBookmarkDataFromJson(event) {
if (!event.target.files || event.target.files.length === 0) {
return;
}
const file = event.target.files[0];
try {
const fileText = await file.text();
const jsonData = JSON.parse(fileText);
return jsonData;
} catch (error) {
throw new Error(`Error extracting bookmark data: error while reading or parsing JSON file: ${error.message}`);
}
}
// display new bookmark data given by a callback function that loads or fetches the data
async function loadBookmarks(getBookmarkDataCallback) {
// reset bookmarks
bookmarks = [];
updateBookmarksUI();
showLoadingIndicator();
try {
// Get new bookmarks from the callback
const newBookmarks = await getBookmarkDataCallback();
// Convert extracted bookmarks to our format with IDs
if (extractedBookmarks && extractedBookmarks.length > 0) {
bookmarks = extractedBookmarks.map(convertExtractedBookmark);
} else {
showEmptyState();
if (newBookmarks && newBookmarks.length > 0) {
bookmarks = newBookmarks.map(convertExtractedBookmark);
}
} catch (error) {
// Show error message
showErrorMessage('Failed to extract bookmarks. You can still create new ones.');
// Add a default bookmark if no bookmarks and error
if (bookmarks.length === 0) {
showEmptyState();
}
bookmarks = [];
throw new Error(`Error loading bookmarks: ${error}`);
} finally {
// Remove loading indicator
removeLoadingIndicator();
// Update the UI
updateBookmarksUI();
}
}
// Add event listener to the file input to extract existing bookmarks
document.getElementById("fileInput-input").addEventListener("change", async function (event) {
try {
await loadBookmarks(async function () {
return getBookmarkDataFromPdf(event);
});
} catch {
showErrorMessage("Failed to extract bookmarks. You can still create new ones.");
}
});
function showLoadingIndicator() {
const loadingEl = document.createElement('div');
loadingEl.className = 'alert alert-info';
loadingEl.textContent = 'Loading bookmarks from PDF...';
loadingEl.id = 'loading-bookmarks';
bookmarksContainer.innerHTML = '';
const loadingEl = document.createElement("div");
loadingEl.className = "alert alert-info";
loadingEl.textContent = "Loading bookmarks from PDF...";
loadingEl.id = "loading-bookmarks";
errorMessageContainer.innerHTML = "";
bookmarksContainer.innerHTML = "";
bookmarksContainer.appendChild(loadingEl);
}
function removeLoadingIndicator() {
const loadingEl = document.getElementById('loading-bookmarks');
const loadingEl = document.getElementById("loading-bookmarks");
if (loadingEl) {
loadingEl.remove();
}
}
function showErrorMessage(message) {
const errorEl = document.createElement('div');
errorEl.className = 'alert alert-danger';
const errorEl = document.createElement("div");
errorEl.className = "alert alert-danger";
errorEl.textContent = message;
bookmarksContainer.appendChild(errorEl);
errorMessageContainer.appendChild(errorEl);
}
function showEmptyState() {
const emptyStateEl = document.createElement('div');
emptyStateEl.className = 'empty-bookmarks mb-3';
const emptyStateEl = document.createElement("div");
emptyStateEl.className = "empty-bookmarks mb-3";
emptyStateEl.innerHTML = `
<span class="material-symbols-rounded mb-2" style="font-size: 48px;">bookmark_add</span>
<h5>No bookmarks found</h5>
@@ -93,8 +122,8 @@ document.addEventListener('DOMContentLoaded', function() {
`;
// Add event listener to the "Add First Bookmark" button
emptyStateEl.querySelector('.btn-add-first-bookmark').addEventListener('click', function() {
addBookmark(null, 'New Bookmark', 1);
emptyStateEl.querySelector(".btn-add-first-bookmark").addEventListener("click", function () {
addBookmark(null, "New Bookmark", 1);
emptyStateEl.remove();
});
@@ -106,15 +135,15 @@ document.addEventListener('DOMContentLoaded', function() {
counter++;
const result = {
id: Date.now() + counter, // Generate a unique ID
title: bookmark.title || 'Untitled Bookmark',
title: bookmark.title || "Untitled Bookmark",
pageNumber: bookmark.pageNumber || 1,
children: [],
expanded: false // All bookmarks start collapsed for better visibility
expanded: false, // All bookmarks start collapsed for better visibility
};
// Convert children recursively
if (bookmark.children && bookmark.children.length > 0) {
result.children = bookmark.children.map(child => {
result.children = bookmark.children.map((child) => {
return convertExtractedBookmark(child);
});
}
@@ -123,24 +152,24 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Add bookmark button click handler
addBookmarkBtn.addEventListener('click', function(e) {
addBookmarkBtn.addEventListener("click", function (e) {
e.preventDefault();
addBookmark();
});
// Add form submit handler to update JSON data
document.getElementById('editTocForm').addEventListener('submit', function() {
document.getElementById("editTocForm").addEventListener("submit", function () {
updateBookmarkData();
});
function addBookmark(parent = null, title = '', pageNumber = 1) {
function addBookmark(parent = null, title = "", pageNumber = 1) {
counter++;
const newBookmark = {
id: Date.now() + counter,
title: title || 'New Bookmark',
title: title || "New Bookmark",
pageNumber: pageNumber || 1,
children: [],
expanded: false // New bookmarks start collapsed
expanded: false, // New bookmarks start collapsed
};
if (parent === null) {
@@ -162,13 +191,13 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => {
const newElement = document.querySelector(`[data-id="${newBookmark.id}"]`);
if (newElement) {
const titleInput = newElement.querySelector('.bookmark-title');
const titleInput = newElement.querySelector(".bookmark-title");
if (titleInput) {
titleInput.focus();
titleInput.select();
}
// Scroll to the new element
newElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
newElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
}
@@ -203,7 +232,7 @@ document.addEventListener('DOMContentLoaded', function() {
function removeBookmark(id) {
// Remove from top level
const index = bookmarks.findIndex(b => b.id === id);
const index = bookmarks.findIndex((b) => b.id === id);
if (index !== -1) {
bookmarks.splice(index, 1);
updateBookmarksUI();
@@ -213,7 +242,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Remove from children
function removeFromChildren(bookmarkArray, id) {
for (const bookmark of bookmarkArray) {
const childIndex = bookmark.children.findIndex(b => b.id === id);
const childIndex = bookmark.children.findIndex((b) => b.id === id);
if (childIndex !== -1) {
bookmark.children.splice(childIndex, 1);
return true;
@@ -253,7 +282,7 @@ document.addEventListener('DOMContentLoaded', function() {
return {
title: bookmark.title,
pageNumber: bookmark.pageNumber,
children: bookmark.children.map(cleanBookmark)
children: bookmark.children.map(cleanBookmark),
};
}
@@ -263,22 +292,22 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Only clear the container if there are no error messages or loading indicators
if (!document.querySelector('#bookmarks-container .alert')) {
bookmarksContainer.innerHTML = '';
if (!document.querySelector("#bookmarks-container .alert")) {
bookmarksContainer.innerHTML = "";
}
// Check if there are bookmarks to display
if (bookmarks.length === 0 && !document.querySelector('.empty-bookmarks')) {
if (bookmarks.length === 0 && !document.querySelector(".empty-bookmarks")) {
showEmptyState();
} else {
// Remove empty state if it exists and there are bookmarks
const emptyState = document.querySelector('.empty-bookmarks');
const emptyState = document.querySelector(".empty-bookmarks");
if (emptyState && bookmarks.length > 0) {
emptyState.remove();
}
// Create bookmark elements
bookmarks.forEach(bookmark => {
bookmarks.forEach((bookmark) => {
const bookmarkElement = createBookmarkElement(bookmark);
bookmarksContainer.appendChild(bookmarkElement);
});
@@ -287,15 +316,15 @@ document.addEventListener('DOMContentLoaded', function() {
updateBookmarkData();
// Initialize tooltips for dynamically added elements
if (typeof $ !== 'undefined') {
if (typeof $ !== "undefined") {
$('[data-bs-toggle="tooltip"]').tooltip();
}
}
// Create the main bookmark element with collapsible interface
function createBookmarkElement(bookmark, level = 0) {
const bookmarkEl = document.createElement('div');
bookmarkEl.className = 'bookmark-item';
const bookmarkEl = document.createElement("div");
bookmarkEl.className = "bookmark-item";
bookmarkEl.dataset.id = bookmark.id;
bookmarkEl.dataset.level = level;
@@ -304,10 +333,10 @@ document.addEventListener('DOMContentLoaded', function() {
bookmarkEl.appendChild(header);
// Create the content (collapsible part)
const content = document.createElement('div');
content.className = 'bookmark-content';
const content = document.createElement("div");
content.className = "bookmark-content";
if (!bookmark.expanded) {
content.style.display = 'none';
content.style.display = "none";
}
// Main input row
@@ -328,48 +357,48 @@ document.addEventListener('DOMContentLoaded', function() {
// Create the header that's always visible
function createBookmarkHeader(bookmark, level) {
const header = document.createElement('div');
header.className = 'bookmark-header';
const header = document.createElement("div");
header.className = "bookmark-header";
if (!bookmark.expanded) {
header.classList.add('collapsed');
header.classList.add("collapsed");
}
// Left side of header with expand/collapse and info
const headerLeft = document.createElement('div');
headerLeft.className = 'd-flex align-items-center';
const headerLeft = document.createElement("div");
headerLeft.className = "d-flex align-items-center";
// Toggle expand/collapse icon with child count
const toggleContainer = document.createElement('div');
toggleContainer.className = 'd-flex align-items-center';
toggleContainer.style.marginRight = '8px';
const toggleContainer = document.createElement("div");
toggleContainer.className = "d-flex align-items-center";
toggleContainer.style.marginRight = "8px";
// Only show toggle if has children
if (bookmark.children && bookmark.children.length > 0) {
// Create toggle icon
const toggleIcon = document.createElement('span');
toggleIcon.className = 'material-symbols-rounded toggle-icon me-1';
toggleIcon.textContent = 'expand_more';
toggleIcon.style.cursor = 'pointer';
const toggleIcon = document.createElement("span");
toggleIcon.className = "material-symbols-rounded toggle-icon me-1";
toggleIcon.textContent = "expand_more";
toggleIcon.style.cursor = "pointer";
toggleContainer.appendChild(toggleIcon);
// Add child count indicator
const childCount = document.createElement('span');
childCount.className = 'badge rounded-pill';
const childCount = document.createElement("span");
childCount.className = "badge rounded-pill";
// Use theme-appropriate badge color
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
childCount.classList.add(isDarkMode ? 'bg-info' : 'bg-secondary');
childCount.style.fontSize = '0.7rem';
childCount.style.padding = '0.2em 0.5em';
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
childCount.classList.add(isDarkMode ? "bg-info" : "bg-secondary");
childCount.style.fontSize = "0.7rem";
childCount.style.padding = "0.2em 0.5em";
childCount.textContent = bookmark.children.length;
childCount.setAttribute('data-bs-toggle', 'tooltip');
childCount.setAttribute('data-bs-placement', 'top');
childCount.title = `${bookmark.children.length} child bookmark${bookmark.children.length > 1 ? 's' : ''}`;
childCount.setAttribute("data-bs-toggle", "tooltip");
childCount.setAttribute("data-bs-placement", "top");
childCount.title = `${bookmark.children.length} child bookmark${bookmark.children.length > 1 ? "s" : ""}`;
toggleContainer.appendChild(childCount);
} else {
// Add spacer if no children
const spacer = document.createElement('span');
spacer.style.width = '24px';
spacer.style.display = 'inline-block';
const spacer = document.createElement("span");
spacer.style.width = "24px";
spacer.style.display = "inline-block";
toggleContainer.appendChild(spacer);
}
@@ -378,65 +407,68 @@ document.addEventListener('DOMContentLoaded', function() {
// Level indicator for nested items
if (level > 0) {
// Add relationship indicator visual line
const relationshipIndicator = document.createElement('div');
relationshipIndicator.className = 'bookmark-relationship-indicator';
const relationshipIndicator = document.createElement("div");
relationshipIndicator.className = "bookmark-relationship-indicator";
const line = document.createElement('div');
line.className = 'relationship-line';
const line = document.createElement("div");
line.className = "relationship-line";
relationshipIndicator.appendChild(line);
const arrow = document.createElement('div');
arrow.className = 'relationship-arrow';
const arrow = document.createElement("div");
arrow.className = "relationship-arrow";
relationshipIndicator.appendChild(arrow);
header.appendChild(relationshipIndicator);
// Text indicator
const levelIndicator = document.createElement('span');
levelIndicator.className = 'bookmark-level-indicator';
const levelIndicator = document.createElement("span");
levelIndicator.className = "bookmark-level-indicator";
levelIndicator.textContent = `Child`;
headerLeft.appendChild(levelIndicator);
}
// Title preview
const titlePreview = document.createElement('span');
titlePreview.className = 'bookmark-title-preview';
const titlePreview = document.createElement("span");
titlePreview.className = "bookmark-title-preview";
titlePreview.textContent = bookmark.title;
headerLeft.appendChild(titlePreview);
// Page number preview
const pagePreview = document.createElement('span');
pagePreview.className = 'bookmark-page-preview';
const pagePreview = document.createElement("span");
pagePreview.className = "bookmark-page-preview";
pagePreview.textContent = `Page ${bookmark.pageNumber}`;
headerLeft.appendChild(pagePreview);
// Right side of header with action buttons
const headerRight = document.createElement('div');
headerRight.className = 'bookmark-actions-header';
const headerRight = document.createElement("div");
headerRight.className = "bookmark-actions-header";
// Quick add buttons with clear visual distinction - using Stirling-PDF's tooltip system
const quickAddChildButton = createButton('subdirectory_arrow_right', 'btn-add-child', 'Add child bookmark', function(e) {
const quickAddChildButton = createButton("subdirectory_arrow_right", "btn-add-child", "Add child bookmark", function (e) {
e.preventDefault();
e.stopPropagation();
addBookmark(bookmark.id);
});
const quickAddSiblingButton = createButton('add', 'btn-add-sibling', 'Add sibling bookmark', function(e) {
const quickAddSiblingButton = createButton("add", "btn-add-sibling", "Add sibling bookmark", function (e) {
e.preventDefault();
e.stopPropagation();
// Find parent of current bookmark
const parentId = findParentBookmark(bookmarks, bookmark.id);
addBookmark(parentId, '', bookmark.pageNumber); // Same level as current bookmark
addBookmark(parentId, "", bookmark.pageNumber); // Same level as current bookmark
});
// Quick remove button
const quickRemoveButton = createButton('delete', 'btn-outline-danger', 'Remove bookmark', function(e) {
const quickRemoveButton = createButton("delete", "btn-outline-danger", "Remove bookmark", function (e) {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to remove this bookmark' +
(bookmark.children.length > 0 ? ' and all its children?' : '?'))) {
if (
confirm(
"Are you sure you want to remove this bookmark" + (bookmark.children.length > 0 ? " and all its children?" : "?")
)
) {
removeBookmark(bookmark.id);
}
});
@@ -450,9 +482,9 @@ document.addEventListener('DOMContentLoaded', function() {
header.appendChild(headerRight);
// Add click handler for expansion toggle
header.addEventListener('click', function(e) {
header.addEventListener("click", function (e) {
// Only toggle if not clicking on buttons
if (!e.target.closest('button')) {
if (!e.target.closest("button")) {
toggleBookmarkExpanded(bookmark.id);
}
});
@@ -461,8 +493,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
function createInputRow(bookmark) {
const row = document.createElement('div');
row.className = 'row';
const row = document.createElement("div");
row.className = "row";
// Title input
row.appendChild(createTitleInputElement(bookmark));
@@ -474,26 +506,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
function createTitleInputElement(bookmark) {
const titleCol = document.createElement('div');
titleCol.className = 'col-md-8';
const titleCol = document.createElement("div");
titleCol.className = "col-md-8";
const titleGroup = document.createElement('div');
titleGroup.className = 'mb-3';
const titleGroup = document.createElement("div");
titleGroup.className = "mb-3";
const titleLabel = document.createElement('label');
titleLabel.textContent = 'Title';
titleLabel.className = 'form-label';
const titleLabel = document.createElement("label");
titleLabel.textContent = "Title";
titleLabel.className = "form-label";
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control bookmark-title';
const titleInput = document.createElement("input");
titleInput.type = "text";
titleInput.className = "form-control bookmark-title";
titleInput.value = bookmark.title;
titleInput.addEventListener('input', function() {
titleInput.addEventListener("input", function () {
bookmark.title = this.value;
updateBookmarkData();
// Also update the preview in the header
const header = titleInput.closest('.bookmark-item').querySelector('.bookmark-title-preview');
const header = titleInput.closest(".bookmark-item").querySelector(".bookmark-title-preview");
if (header) {
header.textContent = this.value;
}
@@ -507,27 +539,27 @@ document.addEventListener('DOMContentLoaded', function() {
}
function createPageInputElement(bookmark) {
const pageCol = document.createElement('div');
pageCol.className = 'col-md-4';
const pageCol = document.createElement("div");
pageCol.className = "col-md-4";
const pageGroup = document.createElement('div');
pageGroup.className = 'mb-3';
const pageGroup = document.createElement("div");
pageGroup.className = "mb-3";
const pageLabel = document.createElement('label');
pageLabel.textContent = 'Page';
pageLabel.className = 'form-label';
const pageLabel = document.createElement("label");
pageLabel.textContent = "Page";
pageLabel.className = "form-label";
const pageInput = document.createElement('input');
pageInput.type = 'number';
pageInput.className = 'form-control bookmark-page';
const pageInput = document.createElement("input");
pageInput.type = "number";
pageInput.className = "form-control bookmark-page";
pageInput.value = bookmark.pageNumber;
pageInput.min = 1;
pageInput.addEventListener('input', function() {
pageInput.addEventListener("input", function () {
bookmark.pageNumber = parseInt(this.value) || 1;
updateBookmarkData();
// Also update the preview in the header
const header = pageInput.closest('.bookmark-item').querySelector('.bookmark-page-preview');
const header = pageInput.closest(".bookmark-item").querySelector(".bookmark-page-preview");
if (header) {
header.textContent = `Page ${bookmark.pageNumber}`;
}
@@ -541,25 +573,25 @@ document.addEventListener('DOMContentLoaded', function() {
}
function createButton(icon, className, title, clickHandler) {
const button = document.createElement('button');
button.type = 'button';
const button = document.createElement("button");
button.type = "button";
button.className = `btn ${className} btn-bookmark-action`;
button.innerHTML = `<span class="material-symbols-rounded">${icon}</span>`;
// Use Bootstrap tooltips
button.setAttribute('data-bs-toggle', 'tooltip');
button.setAttribute('data-bs-placement', 'top');
button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-bs-placement", "top");
button.title = title;
button.addEventListener('click', clickHandler);
button.addEventListener("click", clickHandler);
return button;
}
function createChildrenContainer(bookmark, level) {
const childrenContainer = document.createElement('div');
childrenContainer.className = 'bookmark-children';
const childrenContainer = document.createElement("div");
childrenContainer.className = "bookmark-children";
bookmark.children.forEach(child => {
bookmark.children.forEach((child) => {
childrenContainer.appendChild(createBookmarkElement(child, level + 1));
});
@@ -568,24 +600,24 @@ document.addEventListener('DOMContentLoaded', function() {
// Update the add bookmark button appearance with clear visual cue
addBookmarkBtn.innerHTML = '<span class="material-symbols-rounded">add</span> Add Top-level Bookmark';
addBookmarkBtn.className = 'btn btn-primary btn-add-bookmark top-level';
addBookmarkBtn.className = "btn btn-primary btn-add-bookmark top-level";
// Use Bootstrap tooltips
addBookmarkBtn.setAttribute('data-bs-toggle', 'tooltip');
addBookmarkBtn.setAttribute('data-bs-placement', 'top');
addBookmarkBtn.title = 'Add a new top-level bookmark';
addBookmarkBtn.setAttribute("data-bs-toggle", "tooltip");
addBookmarkBtn.setAttribute("data-bs-placement", "top");
addBookmarkBtn.title = "Add a new top-level bookmark";
// Add icon to empty state button as well
const updateEmptyStateButton = function() {
const emptyStateBtn = document.querySelector('.btn-add-first-bookmark');
const updateEmptyStateButton = function () {
const emptyStateBtn = document.querySelector(".btn-add-first-bookmark");
if (emptyStateBtn) {
emptyStateBtn.innerHTML = '<span class="material-symbols-rounded">add</span> Add First Bookmark';
emptyStateBtn.setAttribute('data-bs-toggle', 'tooltip');
emptyStateBtn.setAttribute('data-bs-placement', 'top');
emptyStateBtn.title = 'Add first bookmark';
emptyStateBtn.setAttribute("data-bs-toggle", "tooltip");
emptyStateBtn.setAttribute("data-bs-placement", "top");
emptyStateBtn.title = "Add first bookmark";
// Initialize tooltips for the empty state button
if (typeof $ !== 'undefined') {
if (typeof $ !== "undefined") {
$('[data-bs-toggle="tooltip"]').tooltip();
}
}
@@ -597,14 +629,147 @@ document.addEventListener('DOMContentLoaded', function() {
updateEmptyStateButton();
}
// Add bookmarks Import/Export functionality
// Import/Export button references
const importDefaultBtn = document.getElementById("importDefaultBtn");
const exportDefaultBtn = document.getElementById("exportDefaultBtn");
const importUploadJsonFileInput = document.getElementById("importUploadJsonFileInput");
const importPasteFromClipboardBtn = document.getElementById("importPasteFromClipboardBtn");
const exportDownloadJsonFileBtn = document.getElementById("exportDownloadJsonFileBtn");
const exportCopyToClipboardBtn = document.getElementById("exportCopyToClipboardBtn");
// display import/export from/to clipboard buttons if supported
if (navigator.clipboard && navigator.clipboard.readText) {
importPasteFromClipboardBtn.parentElement.classList.remove("d-none");
}
if (navigator.clipboard && navigator.clipboard.writeText) {
exportCopyToClipboardBtn.parentElement.classList.remove("d-none");
}
function flashButtonSuccess(button) {
const originalClass = button.className;
button.classList.remove("btn-outline-primary");
button.classList.add("btn-success", "success-flash");
setTimeout(() => {
button.className = originalClass;
}, 1000);
}
// Import handlers
async function handleJsonFileInputChange(event) {
try {
await loadBookmarks(async function () {
return getBookmarkDataFromJson(event);
});
flashButtonSuccess(importDefaultBtn);
} catch (error) {
console.error(`Failed to import bookmarks from JSON file: ${error.message}`);
}
}
async function importBookmarksFromClipboard() {
console.log("Importing bookmarks from clipboard...");
try {
await loadBookmarks(async function () {
const clipboardText = await navigator.clipboard.readText();
if (!clipboardText) return [];
return JSON.parse(clipboardText);
});
flashButtonSuccess(importDefaultBtn);
} catch (error) {
console.error(`Failed to import bookmarks from clipboard: ${error.message}`);
}
}
async function handleBookmarksPasteFromClipboard(event) {
// do not override normal paste behavior on input fields
if (event.target.tagName.toLowerCase() === "input") return;
try {
await loadBookmarks(async function () {
const clipboardText = event.clipboardData?.getData("text/plain");
if (!clipboardText) return [];
return JSON.parse(clipboardText);
});
flashButtonSuccess(importDefaultBtn);
} catch (error) {
console.error(`Failed to import bookmarks from clipboard (ctrl-v): ${error.message}`);
}
}
// Export handlers
async function exportBookmarksToJson() {
console.log("Exporting bookmarks to JSON...");
try {
const bookmarkData = bookmarkDataInput.value;
const blob = new Blob([bookmarkData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "bookmarks.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
flashButtonSuccess(exportDefaultBtn);
} catch (error) {
console.error(`Failed to export bookmarks to JSON: ${error.message}`);
}
}
async function exportBookmarksToClipboard() {
const bookmarkData = bookmarkDataInput.value;
try {
await navigator.clipboard.writeText(bookmarkData);
flashButtonSuccess(exportDefaultBtn);
} catch (error) {
console.error(`Failed to export bookmarks to clipboard: ${error.message}`);
}
}
async function handleBookmarksCopyToClipboard(event) {
// do not override normal copy behavior on input fields
if (event.target.tagName.toLowerCase() === "input") return;
const bookmarkData = bookmarkDataInput.value;
try {
event.clipboardData.setData("text/plain", bookmarkData);
event.preventDefault();
flashButtonSuccess(exportDefaultBtn);
} catch (error) {
console.error(`Failed to export bookmarks to clipboard (ctrl-c): ${error.message}`);
}
}
// register event listeners for import/export functions
importUploadJsonFileInput.addEventListener("change", handleJsonFileInputChange);
importPasteFromClipboardBtn.addEventListener("click", importBookmarksFromClipboard);
exportDownloadJsonFileBtn.addEventListener("click", exportBookmarksToJson);
exportCopyToClipboardBtn.addEventListener("click", exportBookmarksToClipboard);
document.body.addEventListener("copy", handleBookmarksCopyToClipboard);
document.body.addEventListener("paste", handleBookmarksPasteFromClipboard);
// set default actions
// importDefaultBtn is already handled by being a label for the file input
exportDefaultBtn.addEventListener("click", exportBookmarksToJson);
// Listen for theme changes to update badge colors
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'data-bs-theme') {
const isDarkMode = document.documentElement.getAttribute('data-bs-theme') === 'dark';
document.querySelectorAll('.badge').forEach(badge => {
badge.classList.remove('bg-secondary', 'bg-info');
badge.classList.add(isDarkMode ? 'bg-info' : 'bg-secondary');
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.attributeName === "data-bs-theme") {
const isDarkMode = document.documentElement.getAttribute("data-bs-theme") === "dark";
document.querySelectorAll(".badge").forEach((badge) => {
badge.classList.remove("bg-secondary", "bg-info");
badge.classList.add(isDarkMode ? "bg-info" : "bg-secondary");
});
}
});
@@ -613,26 +778,26 @@ document.addEventListener('DOMContentLoaded', function() {
observer.observe(document.documentElement, { attributes: true });
// Add visual enhancement to clearly show the top-level/child relationship
document.addEventListener('mouseover', function(e) {
document.addEventListener("mouseover", function (e) {
// When hovering over add buttons, highlight their relationship targets
const button = e.target.closest('.btn-add-child, .btn-add-sibling');
const button = e.target.closest(".btn-add-child, .btn-add-sibling");
if (button) {
if (button.classList.contains('btn-add-child')) {
if (button.classList.contains("btn-add-child")) {
// Highlight parent-child relationship
const bookmarkItem = button.closest('.bookmark-item');
const bookmarkItem = button.closest(".bookmark-item");
if (bookmarkItem) {
bookmarkItem.style.boxShadow = '0 0 0 2px var(--btn-add-child-border, #198754)';
bookmarkItem.style.boxShadow = "0 0 0 2px var(--btn-add-child-border, #198754)";
}
} else if (button.classList.contains('btn-add-sibling')) {
} else if (button.classList.contains("btn-add-sibling")) {
// Highlight sibling relationship
const bookmarkItem = button.closest('.bookmark-item');
const bookmarkItem = button.closest(".bookmark-item");
if (bookmarkItem) {
// Find siblings
const parent = bookmarkItem.parentElement;
const siblings = parent.querySelectorAll(':scope > .bookmark-item');
siblings.forEach(sibling => {
const siblings = parent.querySelectorAll(":scope > .bookmark-item");
siblings.forEach((sibling) => {
if (sibling !== bookmarkItem) {
sibling.style.boxShadow = '0 0 0 2px var(--btn-add-sibling-border, #0d6efd)';
sibling.style.boxShadow = "0 0 0 2px var(--btn-add-sibling-border, #0d6efd)";
}
});
}
@@ -640,13 +805,13 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
document.addEventListener('mouseout', function(e) {
document.addEventListener("mouseout", function (e) {
// Remove highlights when not hovering
const button = e.target.closest('.btn-add-child, .btn-add-sibling');
const button = e.target.closest(".btn-add-child, .btn-add-sibling");
if (button) {
// Remove all highlights
document.querySelectorAll('.bookmark-item').forEach(item => {
item.style.boxShadow = '';
document.querySelectorAll(".bookmark-item").forEach((item) => {
item.style.boxShadow = "";
});
}
});
@@ -32,7 +32,7 @@
</div>
<!-- Admin Settings Banner (for admins only) -->
<div th:if="${role == 'ROLE_ADMIN'}" class="data-panel data-mb-3" style="background-color: var(--md-sys-color-secondary-container);">
<div th:if="${isSystemAdmin}" class="data-panel data-mb-3" style="background-color: var(--md-sys-color-secondary-container);">
<div class="data-body" style="display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; background-color: var(--md-sys-color-secondary-container);">
<div style="display: flex; align-items: center; gap: 1rem;">
<span class="material-symbols-rounded" style="font-size: 2rem; color: var(--md-sys-color-secondary);">
@@ -22,7 +22,7 @@
<form id="imageToPDFForm" method="post" enctype="multipart/form-data"
th:action="@{'/api/v1/convert/img/pdf'}">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='image/*', inputText=#{imgPrompt})}">
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='image/*,.psd', inputText=#{imgPrompt})}">
</div>
<div class="mb-3">
<label for="fitOption" th:text="#{imageToPDF.selectLabel}">Fit Options</label>
@@ -4,6 +4,21 @@
<head>
<th:block th:insert="~{fragments/common :: head(title=#{pdfToImage.title}, header=#{pdfToImage.header})}"></th:block>
<script th:inline="javascript">
document.addEventListener("DOMContentLoaded", function () {
const maxDPI = /*[[${maxDPI}]]*/ 500; // Maximum DPI for PDF to image conversion
const maxDPILabelRaw = /*[[#{pdfToImage.dpi}]]*/ "DPI (The server limit is {0} dpi)";
const maxDPILabel = maxDPILabelRaw.replace("{0}", maxDPI); // Replace with actual value from properties
const dpilabel = document.querySelector('label[for="dpi"]');
if (dpilabel) {
dpilabel.textContent = maxDPILabel;
}
const maxDPIInput = document.getElementById("dpi");
if (maxDPIInput) {
maxDPIInput.setAttribute("max", maxDPI); // Set the maximum value for DPI input
}
});
</script>
</head>
<body>
@@ -1,75 +1,169 @@
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}"
<html th:lang="${#locale.language}"
th:dir="#{language.direction}"
th:data-language="${#locale.toString()}"
xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{editTableOfContents.title}, header=#{editTableOfContents.header})}">
</th:block>
<link rel="stylesheet" th:href="@{'/css/edit-table-of-contents.css'}">
</head>
<head>
<th:block
th:insert="~{fragments/common :: head(title=#{editTableOfContents.title}, header=#{editTableOfContents.header})}">
</th:block>
<link rel="stylesheet"
th:href="@{'/css/edit-table-of-contents.css'}">
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon edit">bookmark_add</span>
<span class="tool-header-text" th:text="#{editTableOfContents.header}"></span>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon edit">bookmark_add</span>
<span class="tool-header-text"
th:text="#{editTableOfContents.header}"></span>
</div>
<form th:action="@{'/api/v1/general/edit-table-of-contents'}"
method="post"
enctype="multipart/form-data"
id="editTocForm">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}">
</div>
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="replaceExisting"
name="replaceExisting"
checked>
<label class="form-check-label"
for="replaceExisting"
th:text="#{editTableOfContents.replaceExisting}"></label>
<input type="hidden"
name="replaceExisting"
value="false" />
</div>
<div class="bookmark-editor">
<h5 th:text="#{editTableOfContents.editorTitle}"></h5>
<p th:text="#{editTableOfContents.editorDesc}"></p>
<div id="error-message-container">
<!-- Error messages will be added here dynamically -->
</div>
<div id="bookmarks-container">
<!-- Bookmarks will be added here dynamically -->
</div>
<div class="bookmark-actions">
<button type="button"
id="addBookmarkBtn"
class="btn btn-outline-primary"
th:text="#{editTableOfContents.addBookmark}"></button>
<div class="d-flex flex-wrap justify-content-end gap-2">
<!-- Import Split Button -->
<div class="btn-group">
<label
id="importDefaultBtn"
for="importUploadJsonFileInput"
class="btn btn-outline-primary"
style="border-top-left-radius: 1.25rem !important; border-bottom-left-radius: 1.25rem !important;"
th:text="#{editTableOfContents.importBookmarksDefault}">
</label>
<button type="button"
class="btn btn-outline-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle Import Options</span>
</button>
<ul class="dropdown-menu">
<li><label class="dropdown-item"
id="importUploadJsonFileBtn"
for="importUploadJsonFileInput"
style="cursor: pointer;"
th:text="#{editTableOfContents.importBookmarksFromJsonFile}"></label></li>
<li class="d-none"><a class="dropdown-item"
href="#bookmarks-container"
id="importPasteFromClipboardBtn"
th:text="#{editTableOfContents.importBookmarksFromClipboard}"></a></li>
</ul>
</div>
<!-- Export Split Button -->
<div class="btn-group">
<button type="button"
id="exportDefaultBtn"
class="btn btn-outline-primary"
th:text="#{editTableOfContents.exportBookmarksDefault}"></button>
<button type="button"
class="btn btn-outline-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle Export Options</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item"
href="#bookmarks-container"
id="exportDownloadJsonFileBtn"
th:text="#{editTableOfContents.exportBookmarksAsJson}"></a></li>
<li class="d-none"><a class="dropdown-item"
href="#bookmarks-container"
id="exportCopyToClipboardBtn"
th:text="#{editTableOfContents.exportBookmarksAsText}"></a></li>
</ul>
</div>
</div>
</div>
<!-- Hidden field to store JSON data -->
<input type="hidden"
id="bookmarkData"
name="bookmarkData"
value="[]">
</div>
<p>
<a class="btn btn-outline-primary"
data-bs-toggle="collapse"
href="#info"
role="button"
aria-expanded="false"
aria-controls="info"
th:text="#{info}"></a>
</p>
<div class="collapse"
id="info">
<p th:text="#{editTableOfContents.desc.1}"></p>
<p th:text="#{editTableOfContents.desc.2}"></p>
<p th:text="#{editTableOfContents.desc.3}"></p>
</div>
<br>
<button type="submit"
id="submitBtn"
class="btn btn-primary"
th:text="#{editTableOfContents.submit}"></button>
</form>
<!-- Hidden file input for JSON import (outside of form)-->
<input type="file"
id="importUploadJsonFileInput"
accept="application/json"
style="display: none;">
</div>
<form th:action="@{'/api/v1/general/edit-table-of-contents'}" method="post" enctype="multipart/form-data" id="editTocForm">
<div
th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="replaceExisting" name="replaceExisting" checked>
<label class="form-check-label" for="replaceExisting"
th:text="#{editTableOfContents.replaceExisting}"></label>
<input type="hidden" name="replaceExisting" value="false" />
</div>
<div class="bookmark-editor">
<h5 th:text="#{editTableOfContents.editorTitle}"></h5>
<p th:text="#{editTableOfContents.editorDesc}"></p>
<div id="bookmarks-container">
<!-- Bookmarks will be added here dynamically -->
</div>
<div class="bookmark-actions">
<button type="button" id="addBookmarkBtn" class="btn btn-outline-primary" th:text="#{editTableOfContents.addBookmark}"></button>
</div>
<!-- Hidden field to store JSON data -->
<input type="hidden" id="bookmarkData" name="bookmarkData" value="[]">
</div>
<p>
<a class="btn btn-outline-primary" data-bs-toggle="collapse" href="#info" role="button"
aria-expanded="false" aria-controls="info" th:text="#{info}"></a>
</p>
<div class="collapse" id="info">
<p th:text="#{editTableOfContents.desc.1}"></p>
<p th:text="#{editTableOfContents.desc.2}"></p>
<p th:text="#{editTableOfContents.desc.3}"></p>
</div>
<br>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{editTableOfContents.submit}"></button>
</form>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<script th:src="@{'/js/pages/edit-table-of-contents.js'}"></script>
<script>
<script th:src="@{'/js/pages/edit-table-of-contents.js'}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap tooltips
if (typeof $ !== 'undefined') {
@@ -77,6 +171,6 @@
}
});
</script>
</body>
</body>
</html>
@@ -11,9 +11,44 @@
</script>
<script th:inline="javascript">
const currentVersion = /*[[${@appVersion}]]*/ '';
const licenseType = /*[[${@license}]]*/ '';
const machineType = /*[[${@machineType}]]*/ '';
const activeSecurity = /*[[${@activeSecurity}]]*/ false;
const noFavourites = /*[[#{noFavourites}]]*/ '';
console.log(noFavourites);
const updateAvailable = /*[[#{settings.updateAvailable}]]*/ '';
// Update notification i18n constants
const urgentUpdateAvailable = /*[[#{update.urgentUpdateAvailable}]]*/ '🚨 Update Available';
const updateAvailableText = /*[[#{update.updateAvailable}]]*/ 'Update Available';
const updateModalTitle = /*[[#{update.modalTitle}]]*/ 'Update Available';
const updateCurrent = /*[[#{update.current}]]*/ 'Current';
const updateLatest = /*[[#{update.latest}]]*/ 'Latest';
const updateLatestStable = /*[[#{update.latestStable}]]*/ 'Latest Stable';
const updatePriority = /*[[#{update.priority}]]*/ 'Priority';
const updateRecommendedAction = /*[[#{update.recommendedAction}]]*/ 'Recommended Action';
const updateBreakingChangesDetected = /*[[#{update.breakingChangesDetected}]]*/ '⚠️ Breaking Changes Detected';
const updateBreakingChangesMessage = /*[[#{update.breakingChangesMessage}]]*/ 'This update contains breaking changes. Please review the migration guides below.';
const updateMigrationGuides = /*[[#{update.migrationGuides}]]*/ 'Migration Guides:';
const updateViewGuide = /*[[#{update.viewGuide}]]*/ 'View Guide';
const updateLoadingDetailedInfo = /*[[#{update.loadingDetailedInfo}]]*/ 'Loading detailed version information...';
const updateClose = /*[[#{update.close}]]*/ 'Close';
const updateViewAllReleases = /*[[#{update.viewAllReleases}]]*/ 'View All Releases';
const updateDownloadLatest = /*[[#{update.downloadLatest}]]*/ 'Download Latest';
const updateAvailableUpdates = /*[[#{update.availableUpdates}]]*/ 'Available Updates:';
const updateUnableToLoadDetails = /*[[#{update.unableToLoadDetails}]]*/ 'Unable to load detailed version information.';
const updateVersion = /*[[#{update.version}]]*/ 'Version';
// Update priority levels
const updatePriorityUrgent = /*[[#{update.priority.urgent}]]*/ 'URGENT';
const updatePriorityNormal = /*[[#{update.priority.normal}]]*/ 'NORMAL';
const updatePriorityMinor = /*[[#{update.priority.minor}]]*/ 'MINOR';
const updatePriorityLow = /*[[#{update.priority.low}]]*/ 'LOW';
// Breaking changes text
const updateBreakingChanges = /*[[#{update.breakingChanges}]]*/ 'Breaking Changes:';
const updateBreakingChangesDefault = /*[[#{update.breakingChangesDefault}]]*/ 'This version contains breaking changes';
const updateMigrationGuide = /*[[#{update.migrationGuide}]]*/ 'Migration Guide';
</script>
<script th:src="@{'/js/homecard.js'}"></script>
<script th:src="@{'/js/githubVersion.js'}"></script>
@@ -413,9 +413,6 @@
</div>
</div>
<script th:src="@{'/js/fetch-utils.js'}"></script>
<script th:inline="javascript">
/*<![CDATA[*/
@@ -208,7 +208,6 @@
color: gold;
}
</style>
<script th:src="@{'/js/fetch-utils.js'}"></script>
<script th:inline="javascript">
/*<![CDATA[*/
window.analyticsPromptBoolean = /*[[${@analyticsPrompt}]]*/ false;
@@ -98,7 +98,6 @@
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<script th:src="@{'/js/fetch-utils.js'}"></script>
<script th:inline="javascript">
// Show/hide advanced settings
document.getElementById('advancedSettingsToggle').addEventListener('change', function() {
@@ -39,7 +39,6 @@
<!-- Button to download the JSON -->
<a href="#" id="downloadJS" class="btn btn-primary mt-3" style="display: none;" th:text="#{showJS.downloadJS}">Download JSON</a>
</div>
<script th:src="@{'/js/fetch-utils.js'}"></script>
<script>
document.querySelector('#pdfInfoForm').addEventListener('submit', function(event){
event.preventDefault();
@@ -192,7 +192,6 @@
</div>
</div>
</div>
<script th:src="@{'/js/fetch-utils.js'}"></script>
<script th:src="@{'/js/pipeline.js'}"></script>
</div>
</div>
@@ -106,7 +106,6 @@
<!-- Button to download the JSON -->
<a href="#" id="downloadJson" class="btn btn-primary mt-3" style="display: none;" th:text="#{getPdfInfo.downloadJson}">Download JSON</a>
</div>
<script th:src="@{'/js/fetch-utils.js'}"></script>
<script th:inline="javascript">
// Pre-load message translations
const getPdfInfoSummary = /*[[#{getPdfInfo.summary}]]*/ "PDF Summary";
+1 -1
View File
@@ -41,7 +41,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
api 'org.springframework.boot:spring-boot-starter-mail'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.34'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.35'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
@@ -33,7 +33,7 @@ public class AdminJobController {
* @return Job statistics
*/
@GetMapping("/api/v1/admin/job/stats")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
public ResponseEntity<JobStats> getJobStats() {
JobStats stats = taskManager.getJobStats();
log.info(
@@ -49,7 +49,7 @@ public class AdminJobController {
* @return Queue statistics
*/
@GetMapping("/api/v1/admin/job/queue/stats")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
public ResponseEntity<?> getQueueStats() {
Map<String, Object> queueStats = jobQueue.getQueueStats();
log.info("Admin requested queue stats: {} queued jobs", queueStats.get("queuedJobs"));
@@ -62,7 +62,7 @@ public class AdminJobController {
* @return A response indicating how many jobs were cleaned up
*/
@PostMapping("/api/v1/admin/job/cleanup")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
public ResponseEntity<?> cleanupOldJobs() {
int beforeCount = taskManager.getJobStats().getTotalJobs();
taskManager.cleanupOldJobs();
@@ -48,7 +48,7 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
@Slf4j
@Controller
@RequestMapping("/audit")
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@RequiredArgsConstructor
@EnterpriseEndpoint
public class AuditDashboardController {
@@ -0,0 +1,45 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.util.HashSet;
import java.util.Set;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "organizations")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class Organization implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "org_id")
private Long id;
@Column(name = "name", unique = true, nullable = false)
private String name;
@Column(name = "description")
private String description;
@OneToMany(mappedBy = "organization", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Team> teams = new HashSet<>();
public void addTeam(Team team) {
teams.add(team);
team.setOrganization(this);
}
public void removeTeam(Team team) {
teams.remove(team);
team.setOrganization(null);
}
}
@@ -11,7 +11,7 @@ import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "teams")
@Table(name = "teams", uniqueConstraints = @UniqueConstraint(columnNames = {"name", "org_id"}))
@NoArgsConstructor
@Getter
@Setter
@@ -26,9 +26,15 @@ public class Team implements Serializable {
@Column(name = "team_id")
private Long id;
@Column(name = "name", unique = true, nullable = false)
@Column(name = "name", nullable = false)
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(
name = "org_id",
nullable = true) // Nullable for backward compatibility during migration
private Organization organization;
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<User> users = new HashSet<>();
@@ -0,0 +1,15 @@
package stirling.software.proprietary.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrganizationWithTeamCountDTO {
private Long id;
private String name;
private String description;
private Long teamCount;
}
@@ -15,9 +15,12 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@@ -28,6 +31,8 @@ public class InitialSecuritySetup {
private final UserService userService;
private final TeamService teamService;
private final OrganizationService organizationService;
private final TeamRepository teamRepository;
private final ApplicationProperties applicationProperties;
private final DatabaseServiceInterface databaseService;
@@ -44,6 +49,9 @@ public class InitialSecuritySetup {
}
userService.migrateOauth2ToSSO();
migrateAdminRolesToSystemAdmin();
migrateDeprecatedRolesToUser();
assignTeamsToDefaultOrganizationIfMissing();
assignUsersToDefaultTeamIfMissing();
initializeInternalApiUser();
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
@@ -52,6 +60,49 @@ public class InitialSecuritySetup {
}
}
private void assignTeamsToDefaultOrganizationIfMissing() {
// Find teams without organizations (legacy teams from before org feature)
List<Team> teamsWithoutOrg = teamRepository.findTeamsWithoutOrganization();
if (teamsWithoutOrg.isEmpty()) {
log.debug("No teams without organizations found - migration not needed");
return;
}
log.info(
"Found {} teams without organizations. Starting migration...",
teamsWithoutOrg.size());
// Ensure default organizations exist
Organization defaultOrg = organizationService.getOrCreateDefaultOrganization();
Organization internalOrg = organizationService.getOrCreateInternalOrganization();
int migratedCount = 0;
for (Team team : teamsWithoutOrg) {
try {
// Assign internal team to internal org, all others to default org
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
team.setOrganization(internalOrg);
log.debug("Assigned team '{}' to internal organization", team.getName());
} else {
team.setOrganization(defaultOrg);
log.debug("Assigned team '{}' to default organization", team.getName());
}
teamRepository.save(team);
migratedCount++;
} catch (Exception e) {
log.error(
"Failed to migrate team '{}' to organization: {}",
team.getName(),
e.getMessage());
}
}
if (migratedCount > 0) {
log.info("Successfully migrated {} teams to organizations", migratedCount);
}
}
private void assignUsersToDefaultTeamIfMissing() {
Team defaultTeam = teamService.getOrCreateDefaultTeam();
Team internalTeam = teamService.getOrCreateInternalTeam();
@@ -86,7 +137,7 @@ public class InitialSecuritySetup {
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
initialUsername, initialPassword, team, Role.SYSTEM_ADMIN.getRoleId(), false);
log.info("Admin user created: {}", initialUsername);
} else {
createDefaultAdminUser();
@@ -100,7 +151,7 @@ public class InitialSecuritySetup {
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
defaultUsername, defaultPassword, team, Role.SYSTEM_ADMIN.getRoleId(), true);
log.info("Default admin user created: {}", defaultUsername);
}
}
@@ -134,4 +185,89 @@ public class InitialSecuritySetup {
}
userService.syncCustomApiUser(applicationProperties.getSecurity().getCustomGlobalAPIKey());
}
private void migrateAdminRolesToSystemAdmin() {
List<User> adminUsers = userService.findByRole(Role.ADMIN.getRoleId());
if (adminUsers.isEmpty()) {
log.debug("No ROLE_ADMIN users found - migration not needed");
return;
}
log.info("Found {} ROLE_ADMIN users. Converting to SYSTEM_ADMIN...", adminUsers.size());
int migratedCount = 0;
for (User user : adminUsers) {
try {
user.setUserRole(Role.SYSTEM_ADMIN);
userService.saveUser(user);
log.debug(
"Converted user '{}' from ROLE_ADMIN to SYSTEM_ADMIN", user.getUsername());
migratedCount++;
} catch (Exception e) {
log.error(
"Failed to migrate user '{}' from ROLE_ADMIN to SYSTEM_ADMIN: {}",
user.getUsername(),
e.getMessage());
}
}
if (migratedCount > 0) {
log.info(
"Successfully migrated {} users from ROLE_ADMIN to SYSTEM_ADMIN",
migratedCount);
}
}
private void migrateDeprecatedRolesToUser() {
String[] deprecatedRoles = {
"ROLE_WEB_ONLY_USER", "ROLE_EXTRA_LIMITED_API_USER", "ROLE_LIMITED_API_USER"
};
int totalMigrated = 0;
for (String deprecatedRole : deprecatedRoles) {
List<User> usersWithDeprecatedRole = userService.findByRole(deprecatedRole);
if (!usersWithDeprecatedRole.isEmpty()) {
log.info(
"Found {} users with role {}. Converting to USER...",
usersWithDeprecatedRole.size(),
deprecatedRole);
int migratedCount = 0;
for (User user : usersWithDeprecatedRole) {
try {
user.setUserRole(Role.USER);
userService.saveUser(user);
log.debug(
"Converted user '{}' from {} to USER",
user.getUsername(),
deprecatedRole);
migratedCount++;
} catch (Exception e) {
log.error(
"Failed to migrate user '{}' from {} to USER: {}",
user.getUsername(),
deprecatedRole,
e.getMessage());
}
}
if (migratedCount > 0) {
log.info(
"Successfully migrated {} users from {} to USER",
migratedCount,
deprecatedRole);
totalMigrated += migratedCount;
}
}
}
if (totalMigrated == 0) {
log.debug("No users with deprecated roles found - migration not needed");
} else {
log.info("Total users migrated from deprecated roles to USER: {}", totalMigrated);
}
}
}
@@ -7,6 +7,7 @@ import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -203,7 +204,7 @@ public class AccountWebController {
return "login";
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@GetMapping("/usage")
public String showUsage() {
if (!runningEE) {
@@ -212,13 +213,26 @@ public class AccountWebController {
return "usage";
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@GetMapping("/adminSettings")
public String showAddUserForm(
HttpServletRequest request, Model model, Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Filter role details to only show SYSTEM_ADMIN, USER, and DEMO_USER in UI
Map<String, String> filteredRoleDetails = new LinkedHashMap<>();
String[] allowedRoles = {
Role.SYSTEM_ADMIN.getRoleId(), Role.USER.getRoleId(), Role.DEMO_USER.getRoleId()
};
for (String roleId : allowedRoles) {
if (roleDetails.containsKey(roleId)) {
filteredRoleDetails.put(roleId, roleDetails.get(roleId));
}
}
roleDetails = filteredRoleDetails;
// Map to store session information and user activity status
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
@@ -426,6 +440,7 @@ public class AccountWebController {
model.addAttribute("username", username);
model.addAttribute("messageType", messageType);
model.addAttribute("role", user.get().getRolesAsString());
model.addAttribute("isSystemAdmin", user.get().isSystemAdmin());
model.addAttribute("settings", settingsJson);
model.addAttribute("changeCredsFlag", user.get().isFirstLogin());
model.addAttribute("currentPage", "account");
@@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.env.Environment;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@@ -74,6 +75,7 @@ public class SecurityConfiguration {
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final Environment environment;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -91,7 +93,8 @@ public class SecurityConfiguration {
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) {
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
Environment environment) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -106,6 +109,7 @@ public class SecurityConfiguration {
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.environment = environment;
}
@Bean
@@ -129,33 +133,49 @@ public class SecurityConfiguration {
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
csrf -> {
var csrfConfig =
csrf.ignoringRequestMatchers(
request -> {
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
});
// Only ignore CSRF for H2 console if H2 console is enabled
if (isH2ConsoleEnabled()) {
csrfConfig = csrfConfig.ignoringRequestMatchers("/h2-console/**");
}
csrfConfig
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler);
});
}
// Allow H2 console frames only if H2 console is enabled
if (isH2ConsoleEnabled()) {
http.headers(
headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin()));
}
http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement(
@@ -215,6 +235,9 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
|| trimmedUri.startsWith("/css/")
|| (isH2ConsoleEnabled()
&& trimmedUri.startsWith(
"/h2-console/"))
|| trimmedUri.startsWith("/fonts/")
|| trimmedUri.startsWith("/js/")
|| trimmedUri.startsWith(
@@ -323,4 +346,8 @@ public class SecurityConfiguration {
public PersistentTokenRepository persistentTokenRepository() {
return new JPATokenRepositoryImpl(persistentLoginRepository);
}
private boolean isH2ConsoleEnabled() {
return environment.getProperty("spring.h2.console.enabled", Boolean.class, false);
}
}
@@ -43,7 +43,7 @@ import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequ
@Tag(name = "Admin Settings", description = "Admin-only Settings Management APIs")
@RequestMapping("/api/v1/admin/settings")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@Slf4j
public class AdminSettingsController {
@@ -33,7 +33,7 @@ import stirling.software.proprietary.security.service.DatabaseService;
@Slf4j
@Controller
@RequestMapping("/api/v1/database")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PreAuthorize("@roleBasedAuthorizationService.canManageAllUsers()")
@Conditional(H2SQLCondition.class)
@Tag(name = "Database", description = "Database APIs for backup, import, and management")
@RequiredArgsConstructor
@@ -0,0 +1,216 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/org-admin")
@Tag(name = "Organization Admin", description = "Organization Admin Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize(
"@roleBasedAuthorizationService.canManageOrgUsers() or @roleBasedAuthorizationService.canManageOrgTeams()")
public class OrgAdminController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final RoleBasedAuthorizationService authorizationService;
/** Get all teams in the org admin's organization */
@GetMapping("/teams")
@PreAuthorize("@roleBasedAuthorizationService.canManageOrgTeams()")
public ResponseEntity<List<Team>> getOrganizationTeams() {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest().build();
}
List<Team> teams =
teamRepository.findByOrganizationId(currentUser.getOrganization().getId());
return ResponseEntity.ok(teams);
}
/** Get all users in the org admin's organization */
@GetMapping("/users")
public ResponseEntity<List<User>> getOrganizationUsers() {
if (!authorizationService.canManageOrgUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest().build();
}
// Get all users in teams belonging to this organization
List<Team> orgTeams =
teamRepository.findByOrganizationId(currentUser.getOrganization().getId());
List<User> orgUsers =
orgTeams.stream().flatMap(team -> team.getUsers().stream()).distinct().toList();
return ResponseEntity.ok(orgUsers);
}
/** Assign a user to a team within the organization */
@PostMapping("/assign-user-to-team")
@Transactional
public ResponseEntity<?> assignUserToTeam(
@RequestParam("userId") Long userId, @RequestParam("teamId") Long teamId) {
if (!authorizationService.canManageOrgUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage organization users");
}
Optional<User> userOpt = userRepository.findById(userId);
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (userOpt.isEmpty() || teamOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
Team team = teamOpt.get();
if (!authorizationService.canAddUserToTeam(userId, team)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to add user to this team");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
return ResponseEntity.ok().body("User assigned to team successfully");
}
/** Promote a user to team lead */
@PostMapping("/promote-to-team-lead")
@Transactional
public ResponseEntity<?> promoteToTeamLead(@RequestParam("userId") Long userId) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
if (!authorizationService.canAssignRole(Role.TEAM_LEAD)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to assign team lead role");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// User must be in a team to become a team lead
if (user.getTeam() == null) {
return ResponseEntity.badRequest()
.body("User must be assigned to a team before becoming a team lead");
}
user.setUserRole(Role.TEAM_LEAD);
userRepository.save(user);
return ResponseEntity.ok().body("User promoted to team lead successfully");
}
/** Demote a team lead to regular user */
@PostMapping("/demote-from-team-lead")
@Transactional
public ResponseEntity<?> demoteFromTeamLead(@RequestParam("userId") Long userId) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setUserRole(Role.USER);
userRepository.save(user);
return ResponseEntity.ok().body("User demoted from team lead successfully");
}
/** Create a new team in the organization */
@PostMapping("/create-team")
@Transactional
public ResponseEntity<?> createTeam(@RequestParam("teamName") String teamName) {
if (!authorizationService.canManageOrgTeams()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to create teams");
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getOrganization() == null) {
return ResponseEntity.badRequest()
.body("Org admin must be assigned to an organization");
}
Organization organization = currentUser.getOrganization();
// Check if team name already exists in the organization
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(
teamName, organization.getId())) {
return ResponseEntity.badRequest()
.body("Team with name '" + teamName + "' already exists in this organization");
}
Team newTeam = new Team();
newTeam.setName(teamName);
newTeam.setOrganization(organization);
Team savedTeam = teamRepository.save(newTeam);
return ResponseEntity.ok(savedTeam);
}
/** Remove a user from the organization (removes from their team) */
@PostMapping("/remove-user")
@Transactional
public ResponseEntity<?> removeUserFromOrganization(@RequestParam("userId") Long userId) {
if (!authorizationService.canRemoveUserFromTeam(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to remove this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setTeam(null);
user.setUserRole(Role.USER); // Reset to basic user role
userRepository.save(user);
return ResponseEntity.ok().body("User removed from organization successfully");
}
}
@@ -0,0 +1,99 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.dto.OrganizationWithTeamCountDTO;
import stirling.software.proprietary.security.repository.OrganizationRepository;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/organizations")
@RequiredArgsConstructor
@PreAuthorize("@roleBasedAuthorizationService.canManageOrganizations()")
public class OrganizationController {
private final OrganizationRepository organizationRepository;
private final OrganizationService organizationService;
private final RoleBasedAuthorizationService authorizationService;
@GetMapping
public ResponseEntity<List<OrganizationWithTeamCountDTO>> getAllOrganizations() {
List<OrganizationWithTeamCountDTO> organizations =
organizationRepository.findAllOrganizationsWithTeamCount();
return ResponseEntity.ok(organizations);
}
@GetMapping("/{id}")
@PreAuthorize(
"@roleBasedAuthorizationService.canViewOrganization(@organizationRepository.findById(#id).orElse(null))")
public ResponseEntity<Organization> getOrganization(@PathVariable Long id) {
Optional<Organization> organizationOpt = organizationRepository.findById(id);
if (organizationOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
Organization organization = organizationOpt.get();
return ResponseEntity.ok(organization);
}
@PostMapping
public ResponseEntity<?> createOrganization(@RequestBody Organization organization) {
if (organizationRepository.existsByNameIgnoreCase(organization.getName())) {
return ResponseEntity.badRequest()
.body("Organization with name '" + organization.getName() + "' already exists");
}
Organization savedOrganization = organizationRepository.save(organization);
return ResponseEntity.status(HttpStatus.CREATED).body(savedOrganization);
}
@PutMapping("/{id}")
public ResponseEntity<?> updateOrganization(
@PathVariable Long id, @RequestBody Organization organization) {
Optional<Organization> existingOrganization = organizationRepository.findById(id);
if (existingOrganization.isEmpty()) {
return ResponseEntity.notFound().build();
}
if (organizationRepository.existsByNameIgnoreCase(organization.getName())
&& !existingOrganization.get().getName().equalsIgnoreCase(organization.getName())) {
return ResponseEntity.badRequest()
.body("Organization with name '" + organization.getName() + "' already exists");
}
organization.setId(id);
Organization savedOrganization = organizationRepository.save(organization);
return ResponseEntity.ok(savedOrganization);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteOrganization(@PathVariable Long id) {
Optional<Organization> organization = organizationRepository.findById(id);
if (organization.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Prevent deletion of default organizations
if (OrganizationService.DEFAULT_ORG_NAME.equals(organization.get().getName())
|| OrganizationService.INTERNAL_ORG_NAME.equals(organization.get().getName())) {
return ResponseEntity.badRequest().body("Cannot delete system organizations");
}
if (!organization.get().getTeams().isEmpty()) {
return ResponseEntity.badRequest()
.body("Cannot delete organization with existing teams");
}
organizationRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}
@@ -14,11 +14,16 @@ import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Organization;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.OrganizationRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.OrganizationService;
import stirling.software.proprietary.security.service.OrganizationValidationService;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
import stirling.software.proprietary.security.service.TeamService;
@Controller
@@ -27,35 +32,50 @@ import stirling.software.proprietary.security.service.TeamService;
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize("@roleBasedAuthorizationService.canManageOrgTeams()")
public class TeamController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final OrganizationRepository organizationRepository;
private final OrganizationService organizationService;
private final OrganizationValidationService organizationValidationService;
private final RoleBasedAuthorizationService authorizationService;
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/create")
public RedirectView createTeam(@RequestParam("name") String name) {
if (teamRepository.existsByNameIgnoreCase(name)) {
public RedirectView createTeam(
@RequestParam("name") String name,
@RequestParam("organizationId") Long organizationId) {
Organization organization = organizationService.getOrCreateDefaultOrganization();
if (organizationId != null) {
organization = organizationRepository.findById(organizationId).orElse(organization);
}
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(name, organization.getId())) {
return new RedirectView("/teams?messageType=teamExists");
}
Team team = new Team();
team.setName(name);
team.setOrganization(organization);
teamRepository.save(team);
return new RedirectView("/teams?messageType=teamCreated");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/rename")
@PreAuthorize(
"@roleBasedAuthorizationService.canManageTeam(@teamRepository.findById(#teamId).orElse(null))")
public RedirectView renameTeam(
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
Optional<Team> existing = teamRepository.findById(teamId);
if (existing.isEmpty()) {
return new RedirectView("/teams?messageType=teamNotFound");
}
if (teamRepository.existsByNameIgnoreCase(newName)) {
Team team = existing.get();
if (teamRepository.existsByNameIgnoreCaseAndOrganizationId(
newName, team.getOrganization().getId())) {
return new RedirectView("/teams?messageType=teamNameExists");
}
Team team = existing.get();
// Prevent renaming the Internal team
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
@@ -67,9 +87,10 @@ public class TeamController {
return new RedirectView("/teams?messageType=teamRenamed");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/delete")
@Transactional
@PreAuthorize(
"@roleBasedAuthorizationService.canManageTeam(@teamRepository.findById(#teamId).orElse(null))")
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
Optional<Team> teamOpt = teamRepository.findById(teamId);
if (teamOpt.isEmpty()) {
@@ -92,9 +113,10 @@ public class TeamController {
return new RedirectView("/teams?messageType=teamDeleted");
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/addUser")
@Transactional
@PreAuthorize(
"@roleBasedAuthorizationService.canAddUserToTeam(#userId, @teamRepository.findById(#teamId).orElse(null))")
public RedirectView addUserToTeam(
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
@@ -121,6 +143,13 @@ public class TeamController {
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
}
// Ensure user and team are in the same organization (or user has no org yet)
if (user.getOrganization() != null
&& !organizationValidationService.isTeamInOrganization(
team, user.getOrganization())) {
return new RedirectView("/teams/" + teamId + "?error=userNotInSameOrganization");
}
// Assign user to team
user.setTeam(team);
userRepository.save(user);
@@ -0,0 +1,169 @@
package stirling.software.proprietary.security.controller.api;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.config.PremiumEndpoint;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.RoleBasedAuthorizationService;
@RestController
@RequestMapping("/api/v1/team-lead")
@Tag(name = "Team Lead", description = "Team Lead Management APIs")
@Slf4j
@RequiredArgsConstructor
@PremiumEndpoint
@PreAuthorize("@roleBasedAuthorizationService.canManageTeamUsers()")
public class TeamLeadController {
private final TeamRepository teamRepository;
private final UserRepository userRepository;
private final RoleBasedAuthorizationService authorizationService;
/** Get team members that the current team lead can manage */
@GetMapping("/my-team-members")
public ResponseEntity<List<User>> getMyTeamMembers() {
if (!authorizationService.canManageTeamUsers()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().build();
}
List<User> teamMembers = userRepository.findByTeam(currentUser.getTeam());
return ResponseEntity.ok(teamMembers);
}
/** Add a user to the team lead's team */
@PostMapping("/add-member")
@Transactional
public ResponseEntity<?> addMemberToMyTeam(@RequestParam("userId") Long userId) {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().body("Team lead must be assigned to a team");
}
if (!authorizationService.canAddUserToTeam(userId, currentUser.getTeam())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to add users to this team");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
// Check if user is already in a team
if (user.getTeam() != null) {
return ResponseEntity.badRequest()
.body("User is already assigned to team: " + user.getTeam().getName());
}
// Assign user to team
user.setTeam(currentUser.getTeam());
userRepository.save(user);
return ResponseEntity.ok().body("User added to team successfully");
}
/** Remove a user from the team lead's team */
@PostMapping("/remove-member")
@Transactional
public ResponseEntity<?> removeMemberFromMyTeam(@RequestParam("userId") Long userId) {
if (!authorizationService.canRemoveUserFromTeam(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to remove this user");
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
User currentUser = authorizationService.getCurrentUser();
// Prevent team leads from removing themselves
if (currentUser != null && currentUser.getId().equals(userId)) {
return ResponseEntity.badRequest()
.body("Team leads cannot remove themselves from the team");
}
// Remove user from team
user.setTeam(null);
userRepository.save(user);
return ResponseEntity.ok().body("User removed from team successfully");
}
/** Update a team member's role (team leads can only assign USER role) */
@PostMapping("/update-member-role")
@Transactional
public ResponseEntity<?> updateMemberRole(
@RequestParam("userId") Long userId, @RequestParam("role") String roleString) {
if (!authorizationService.canManageUser(userId)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Not authorized to manage this user");
}
try {
Role newRole = Role.fromString(roleString);
// Team leads can only assign USER role
if (!authorizationService.canAssignRole(newRole)) {
return ResponseEntity.badRequest()
.body("Not authorized to assign role: " + newRole.getRoleName());
}
Optional<User> userOpt = userRepository.findById(userId);
if (userOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
User user = userOpt.get();
user.setUserRole(newRole);
userRepository.save(user);
return ResponseEntity.ok().body("User role updated successfully");
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body("Invalid role: " + roleString);
}
}
/** Get team information for the current team lead */
@GetMapping("/my-team")
public ResponseEntity<Team> getMyTeam() {
User currentUser = authorizationService.getCurrentUser();
if (currentUser == null || currentUser.getTeam() == null) {
return ResponseEntity.badRequest().build();
}
if (!authorizationService.canManageTeam(currentUser.getTeam())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(currentUser.getTeam());
}
}

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