mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4430763c95 | ||
|
|
0d2608bdbc | ||
|
|
9fd8fd89ed | ||
|
|
cf47378b82 | ||
|
|
fd9ba085f6 | ||
|
|
e42a124a49 | ||
|
|
3a2370ea1f | ||
|
|
38f0381dec | ||
|
|
d5f58b6d45 | ||
|
|
319df45235 | ||
|
|
e7db714091 | ||
|
|
c6b4a2b141 | ||
|
|
7459463a3c | ||
|
|
c9bf436895 | ||
|
|
f8dbf171e1 | ||
|
|
e59c717dc0 | ||
|
|
f2bffe2dc6 | ||
|
|
5d827df08c | ||
|
|
bdb3c887f3 | ||
|
|
f902e8aca9 | ||
|
|
65a3eeca76 | ||
|
|
f72538d30f | ||
|
|
88c5fb46ad | ||
|
|
8e2f9546a5 | ||
|
|
f2f4bd5230 | ||
|
|
f3cc30d0c2 | ||
|
|
ba7c75aff4 | ||
|
|
a53d73ef51 | ||
|
|
c2a63cf425 | ||
|
|
c3456adc2b | ||
|
|
179b569769 | ||
|
|
341adaa07d | ||
|
|
feebfe82fa |
@@ -5,6 +5,7 @@ frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
frontend/src-tauri/target
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
|
||||
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path):
|
||||
"""
|
||||
Identifies duplicate keys in a .properties file.
|
||||
:param file_path: Path to the .properties file.
|
||||
:return: List of tuples (key, first_occurrence_line, duplicate_line).
|
||||
"""
|
||||
keys = {}
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, start=1):
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not stripped_line or stripped_line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Split the line into key and value
|
||||
if "=" in stripped_line:
|
||||
key, _ = stripped_line.split("=", 1)
|
||||
key = key.strip()
|
||||
|
||||
# Check if the key already exists
|
||||
if key in keys:
|
||||
duplicates.append((key, keys[key], line_number))
|
||||
else:
|
||||
keys[key] = line_number
|
||||
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for properties files (e.g., 200 KB)
|
||||
MAX_FILE_SIZE = 200 * 1024
|
||||
|
||||
|
||||
def parse_properties_file(file_path):
|
||||
"""
|
||||
Parses a .properties file and returns a structured list of its contents.
|
||||
:param file_path: Path to the .properties file.
|
||||
:return: List of dictionaries representing each line in the file.
|
||||
"""
|
||||
properties_list = []
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
for line_number, line in enumerate(file, start=1):
|
||||
stripped_line = line.strip()
|
||||
|
||||
# Handle empty lines
|
||||
if not stripped_line:
|
||||
properties_list.append(
|
||||
{"line_number": line_number, "type": "empty", "content": ""}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle comments
|
||||
if stripped_line.startswith("#"):
|
||||
properties_list.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"type": "comment",
|
||||
"content": stripped_line,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle key-value pairs
|
||||
match = re.match(r"^([^=]+)=(.*)$", line)
|
||||
if match:
|
||||
key, value = match.groups()
|
||||
properties_list.append(
|
||||
{
|
||||
"line_number": line_number,
|
||||
"type": "entry",
|
||||
"key": key.strip(),
|
||||
"value": value.strip(),
|
||||
}
|
||||
)
|
||||
|
||||
return properties_list
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the file in their original format.
|
||||
:param file_path: Path to the .properties file.
|
||||
:param updated_properties: List of updated properties to write.
|
||||
"""
|
||||
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
|
||||
|
||||
# Sort lines by their numbers and retain comments and empty lines
|
||||
all_lines = sorted(set(updated_lines.keys()))
|
||||
|
||||
original_format = []
|
||||
for line in all_lines:
|
||||
if line in updated_lines:
|
||||
entry = updated_lines[line]
|
||||
else:
|
||||
entry = None
|
||||
ref_entry = updated_lines[line]
|
||||
if ref_entry["type"] in ["comment", "empty"]:
|
||||
original_format.append(ref_entry)
|
||||
elif entry is None:
|
||||
# Add missing entries from the reference file
|
||||
original_format.append(ref_entry)
|
||||
elif entry["type"] == "entry":
|
||||
# Replace entries with those from the current JSON
|
||||
original_format.append(entry)
|
||||
|
||||
# Write the updated content back to the file
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
for entry in original_format:
|
||||
if entry["type"] == "comment":
|
||||
file.write(f"{entry['content']}\n")
|
||||
elif entry["type"] == "empty":
|
||||
file.write(f"{entry['content']}\n")
|
||||
elif entry["type"] == "entry":
|
||||
file.write(f"{entry['key']}={entry['value']}\n")
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference .properties file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_properties_file(reference_file)
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".properties")
|
||||
or not basename_current_file.startswith("messages_")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_properties_file(os.path.join(branch, file_path))
|
||||
updated_properties = []
|
||||
for ref_entry in reference_properties:
|
||||
ref_entry_copy = copy.deepcopy(ref_entry)
|
||||
for current_entry in current_properties:
|
||||
if current_entry["type"] == "entry":
|
||||
if ref_entry_copy["type"] != "entry":
|
||||
continue
|
||||
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
|
||||
ref_entry_copy["value"] = current_entry["value"]
|
||||
updated_properties.append(ref_entry_copy)
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_properties(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
return file.read().splitlines()
|
||||
return [""]
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = reference_file.split("/")[0]
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_lines = read_properties(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
|
||||
file_arr = file_list
|
||||
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
base_dir = os.path.abspath(
|
||||
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
|
||||
)
|
||||
|
||||
for file_path in file_arr:
|
||||
file_normpath = os.path.normpath(file_path)
|
||||
absolute_path = os.path.abspath(file_normpath)
|
||||
# Verify that file is within the expected directory
|
||||
if not absolute_path.startswith(base_dir):
|
||||
raise ValueError(f"Unsafe file found: {file_normpath}")
|
||||
# Verify file size before processing
|
||||
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
|
||||
raise ValueError(
|
||||
f"The file {file_normpath} is too large and could pose a security risk."
|
||||
)
|
||||
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
or (
|
||||
# only local windows command
|
||||
not file_normpath.startswith(
|
||||
os.path.join(
|
||||
"", "app", "core", "src", "main", "resources", "messages_"
|
||||
)
|
||||
)
|
||||
and not file_normpath.startswith(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_",
|
||||
)
|
||||
)
|
||||
)
|
||||
or not file_normpath.endswith(".properties")
|
||||
or not basename_current_file.startswith("messages_")
|
||||
):
|
||||
continue
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
|
||||
current_lines = read_properties(os.path.join(branch, file_path))
|
||||
reference_line_count = len(reference_lines)
|
||||
current_line_count = len(current_lines)
|
||||
|
||||
if reference_line_count != current_line_count:
|
||||
report.append("")
|
||||
report.append("1. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
has_differences = True
|
||||
if reference_line_count > current_line_count:
|
||||
report.append(
|
||||
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
|
||||
)
|
||||
elif reference_line_count < current_line_count:
|
||||
report.append(
|
||||
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
|
||||
)
|
||||
else:
|
||||
report.append("1. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
# Check for missing or extra keys
|
||||
current_keys = []
|
||||
reference_keys = []
|
||||
for line in current_lines:
|
||||
if not line.startswith("#") and line != "" and "=" in line:
|
||||
key, _ = line.split("=", 1)
|
||||
current_keys.append(key)
|
||||
for line in reference_lines:
|
||||
if not line.startswith("#") and line != "" and "=" in line:
|
||||
key, _ = line.split("=", 1)
|
||||
reference_keys.append(key)
|
||||
|
||||
current_keys_set = set(current_keys)
|
||||
reference_keys_set = set(reference_keys)
|
||||
missing_keys = current_keys_set.difference(reference_keys_set)
|
||||
extra_keys = reference_keys_set.difference(current_keys_set)
|
||||
missing_keys_list = list(missing_keys)
|
||||
extra_keys_list = list(extra_keys)
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
has_differences = True
|
||||
missing_keys_str = "`, `".join(missing_keys_list)
|
||||
extra_keys_str = "`, `".join(extra_keys_list)
|
||||
report.append("2. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
if missing_keys_list:
|
||||
spaces_keys_list = []
|
||||
for key in missing_keys_list:
|
||||
if " " in key:
|
||||
spaces_keys_list.append(key)
|
||||
if spaces_keys_list:
|
||||
spaces_keys_str = "`, `".join(spaces_keys_list)
|
||||
report.append(
|
||||
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
|
||||
)
|
||||
report.append(
|
||||
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
if find_duplicate_keys(os.path.join(branch, file_normpath)):
|
||||
has_differences = True
|
||||
output = "\n".join(
|
||||
[
|
||||
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
|
||||
for key, first, duplicate in find_duplicate_keys(
|
||||
os.path.join(branch, file_normpath)
|
||||
)
|
||||
]
|
||||
)
|
||||
report.append("3. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
report.append(" - duplicate entries were found:")
|
||||
report.append(output)
|
||||
else:
|
||||
report.append("3. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
report.append("")
|
||||
report.append("---")
|
||||
report.append("")
|
||||
if has_differences:
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"Thanks @{actor} for your help in keeping the translations up to date."
|
||||
)
|
||||
|
||||
if not only_reference_file:
|
||||
print("\n".join(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
help="Actor from PR.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-file",
|
||||
required=True,
|
||||
help="Path to the reference file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--branch",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Branch name.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check-file",
|
||||
type=str,
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
required=False,
|
||||
help="List of changed files, separated by spaces.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Sanitize --actor input to avoid injection attacks
|
||||
if args.actor:
|
||||
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
|
||||
|
||||
# Sanitize --branch input to avoid injection attacks
|
||||
if args.branch:
|
||||
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
|
||||
|
||||
file_list = args.files
|
||||
if file_list is None:
|
||||
if args.check_file:
|
||||
file_list = [args.check_file]
|
||||
else:
|
||||
file_list = glob.glob(
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"app",
|
||||
"core",
|
||||
"src",
|
||||
"main",
|
||||
"resources",
|
||||
"messages_*.properties",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
|
||||
Description: This script processes TOML translation files for localization checks. It compares translation files in a branch with
|
||||
a reference file to ensure consistency. The script performs two main checks:
|
||||
1. Verifies that the number of translation keys in the translation files matches the reference file.
|
||||
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
|
||||
@@ -9,10 +9,10 @@ The script also provides functionality to update the translation files to match
|
||||
adjusting the format.
|
||||
|
||||
Usage:
|
||||
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
|
||||
"""
|
||||
# Sample for Windows:
|
||||
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
|
||||
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
|
||||
|
||||
import copy
|
||||
import glob
|
||||
@@ -20,12 +20,14 @@ import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
import tomllib # Python 3.11+ (stdlib)
|
||||
import tomli_w # For writing TOML files
|
||||
|
||||
|
||||
def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
"""
|
||||
Identifies duplicate keys in a JSON file (including nested keys).
|
||||
:param file_path: Path to the JSON file.
|
||||
Identifies duplicate keys in a TOML file (including nested keys).
|
||||
:param file_path: Path to the TOML file.
|
||||
:param keys: Dictionary to track keys (used for recursion).
|
||||
:param prefix: Prefix for nested keys.
|
||||
:return: List of tuples (key, first_occurrence_path, duplicate_path).
|
||||
@@ -35,8 +37,9 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
# Load TOML file
|
||||
with open(file_path, 'rb') as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
for key, value in obj.items():
|
||||
@@ -54,18 +57,18 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for JSON files (e.g., 500 KB)
|
||||
# Maximum size for TOML files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
def parse_toml_file(file_path):
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
Parses a TOML translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the TOML file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
with open(file_path, 'rb') as file:
|
||||
data = tomllib.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
@@ -99,38 +102,37 @@ def unflatten_dict(d, sep="."):
|
||||
return result
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
def write_toml_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the JSON file.
|
||||
:param file_path: Path to the JSON file.
|
||||
Writes updated properties back to the TOML file.
|
||||
:param file_path: Path to the TOML file.
|
||||
:param updated_properties: Dictionary of updated properties to write.
|
||||
"""
|
||||
nested_data = unflatten_dict(updated_properties)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
|
||||
json.dump(nested_data, file, ensure_ascii=False, indent=2)
|
||||
file.write("\n") # Add trailing newline
|
||||
with open(file_path, "wb") as file:
|
||||
tomli_w.dump(nested_data, file)
|
||||
|
||||
|
||||
def update_missing_keys(reference_file, file_list, branch=""):
|
||||
"""
|
||||
Updates missing keys in the translation files based on the reference file.
|
||||
:param reference_file: Path to the reference JSON file.
|
||||
:param reference_file: Path to the reference TOML file.
|
||||
:param file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_json_file(reference_file)
|
||||
reference_properties = parse_toml_file(reference_file)
|
||||
|
||||
for file_path in file_list:
|
||||
basename_current_file = os.path.basename(os.path.join(branch, file_path))
|
||||
if (
|
||||
basename_current_file == os.path.basename(reference_file)
|
||||
or not file_path.endswith(".json")
|
||||
or not file_path.endswith(".toml")
|
||||
or not os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_json_file(os.path.join(branch, file_path))
|
||||
current_properties = parse_toml_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
@@ -141,16 +143,16 @@ def update_missing_keys(reference_file, file_list, branch=""):
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
write_json_file(os.path.join(branch, file_path), updated_properties)
|
||||
write_toml_file(os.path.join(branch, file_path), updated_properties)
|
||||
|
||||
|
||||
def check_for_missing_keys(reference_file, file_list, branch):
|
||||
update_missing_keys(reference_file, file_list, branch)
|
||||
|
||||
|
||||
def read_json_keys(file_path):
|
||||
def read_toml_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_json_file(file_path)
|
||||
return parse_toml_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -160,7 +162,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_keys = read_json_keys(reference_file)
|
||||
reference_keys = read_toml_keys(reference_file)
|
||||
has_differences = False
|
||||
|
||||
only_reference_file = True
|
||||
@@ -197,12 +199,12 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
|
||||
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
|
||||
continue
|
||||
|
||||
only_reference_file = False
|
||||
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
|
||||
current_keys = read_json_keys(os.path.join(branch, file_path))
|
||||
current_keys = read_toml_keys(os.path.join(branch, file_path))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
@@ -272,7 +274,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append("## ❌ Overall Check Status: **_Failed_**")
|
||||
report.append("")
|
||||
report.append(
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
|
||||
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
|
||||
)
|
||||
else:
|
||||
report.append("## ✅ Overall Check Status: **_Success_**")
|
||||
@@ -286,7 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Find missing keys")
|
||||
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
|
||||
parser.add_argument(
|
||||
"--actor",
|
||||
required=False,
|
||||
@@ -337,9 +339,9 @@ if __name__ == "__main__":
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
"translation.json",
|
||||
"translation.toml",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
|
||||
build-args: VERSION_TAG=alpha
|
||||
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
|
||||
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/backend/${{ matrix.docker-rev }}
|
||||
file: ./${{ matrix.docker-rev }}
|
||||
push: false
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
name: Check Properties Files on PR
|
||||
name: Check TOML Translation Files on PR
|
||||
|
||||
# This workflow validates TOML translation files
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -73,22 +68,22 @@ jobs:
|
||||
run: |
|
||||
echo "Fetching PR changed files..."
|
||||
echo "Getting list of changed files from PR..."
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for properties files, handle case where no matches are found
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No properties files changed in this PR"
|
||||
echo "Workflow will exit early as no relevant files to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found $(wc -l < changed_files.txt) matching properties files"
|
||||
# Check if PR number exists
|
||||
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
|
||||
echo "Error: PR number is empty"
|
||||
exit 1
|
||||
fi
|
||||
# Get changed files and filter for TOML translation files
|
||||
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
|
||||
# Check if any files were found
|
||||
if [ ! -s changed_files.txt ]; then
|
||||
echo "No TOML translation files changed in this PR"
|
||||
echo "Workflow will exit early as no relevant files to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Found $(wc -l < changed_files.txt) matching TOML files"
|
||||
|
||||
- name: Determine reference file test
|
||||
- name: Determine reference file
|
||||
id: determine-file
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
@@ -125,11 +120,11 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
|
||||
// Filter for relevant files based on the PR changes
|
||||
// Filter for relevant TOML files based on the PR changes
|
||||
const changedFiles = files
|
||||
.filter(file =>
|
||||
file.status !== "removed" &&
|
||||
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
|
||||
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
|
||||
)
|
||||
.map(file => file.filename);
|
||||
|
||||
@@ -169,16 +164,16 @@ jobs:
|
||||
|
||||
// Determine reference file
|
||||
let referenceFilePath;
|
||||
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
|
||||
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
|
||||
console.log("Using PR branch reference file.");
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: prRepoOwner,
|
||||
repo: prRepoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "frontend/public/locales/en-GB/translation.toml",
|
||||
ref: branch,
|
||||
});
|
||||
|
||||
referenceFilePath = "pr-branch-messages_en_GB.properties";
|
||||
referenceFilePath = "pr-branch-translation-en-GB.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
} else {
|
||||
@@ -186,11 +181,11 @@ jobs:
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: "app/core/src/main/resources/messages_en_GB.properties",
|
||||
path: "frontend/public/locales/en-GB/translation.toml",
|
||||
ref: "main",
|
||||
});
|
||||
|
||||
referenceFilePath = "main-branch-messages_en_GB.properties";
|
||||
referenceFilePath = "main-branch-translation-en-GB.toml";
|
||||
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
|
||||
fs.writeFileSync(referenceFilePath, content);
|
||||
}
|
||||
@@ -198,11 +193,20 @@ jobs:
|
||||
console.log(`Reference file path: ${referenceFilePath}`);
|
||||
core.exportVariable("REFERENCE_FILE", referenceFilePath);
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
pip install tomli-w
|
||||
|
||||
- name: Run Python script to check files
|
||||
id: run-check
|
||||
run: |
|
||||
echo "Running Python script to check files..."
|
||||
python .github/scripts/check_language_properties.py \
|
||||
echo "Running Python script to check TOML files..."
|
||||
python .github/scripts/check_language_toml.py \
|
||||
--actor ${{ github.event.pull_request.user.login }} \
|
||||
--reference-file "${REFERENCE_FILE}" \
|
||||
--branch "pr-branch" \
|
||||
@@ -213,7 +217,7 @@ jobs:
|
||||
id: capture-output
|
||||
run: |
|
||||
if [ -f result.txt ] && [ -s result.txt ]; then
|
||||
echo "Test, capturing output..."
|
||||
echo "Capturing output..."
|
||||
SCRIPT_OUTPUT=$(cat result.txt)
|
||||
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
|
||||
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
|
||||
@@ -227,7 +231,7 @@ jobs:
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
else
|
||||
echo "No update found."
|
||||
echo "No output found."
|
||||
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
|
||||
echo "FAIL_JOB=false" >> $GITHUB_ENV
|
||||
fi
|
||||
@@ -249,7 +253,7 @@ jobs:
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
|
||||
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
|
||||
|
||||
// Only update or create comments by the action user
|
||||
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
|
||||
@@ -260,7 +264,7 @@ jobs:
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
comment_id: comment.id,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Updated existing comment.");
|
||||
} else if (!comment) {
|
||||
@@ -269,7 +273,7 @@ jobs:
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
|
||||
});
|
||||
console.log("Created new comment.");
|
||||
} else {
|
||||
@@ -287,6 +291,6 @@ jobs:
|
||||
run: |
|
||||
echo "Cleaning up temporary files..."
|
||||
rm -rf pr-branch
|
||||
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
|
||||
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
|
||||
echo "Cleanup complete."
|
||||
continue-on-error: true # Ensure cleanup runs even if previous steps fail
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- V2-master
|
||||
- alljavadocker
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
@@ -93,10 +94,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Generate tags for latest (V2-demo branch - test)
|
||||
- name: Generate tags for latest (alljavadocker branch - test)
|
||||
id: meta-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -110,7 +111,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -149,10 +150,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
|
||||
type=raw,value=latest-fat
|
||||
|
||||
- name: Generate tags for latest-fat (V2-demo branch - test)
|
||||
- name: Generate tags for latest-fat (alljavadocker branch - test)
|
||||
id: meta-fat-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -166,7 +167,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -203,10 +204,10 @@ jobs:
|
||||
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
|
||||
type=raw,value=latest-ultra-lite
|
||||
|
||||
- name: Generate tags for ultra-lite (V2-demo branch - test)
|
||||
- name: Generate tags for ultra-lite (alljavadocker branch - test)
|
||||
id: meta-lite-test
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
||||
if: github.ref == 'refs/heads/V2-demo'
|
||||
if: github.ref == 'refs/heads/alljavadocker'
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stirling-tools/stirling-pdf-test
|
||||
@@ -220,7 +221,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile.unified-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
if: github.ref != 'refs/heads/main'
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.ultra-lite
|
||||
file: ./docker/embedded/Dockerfile.ultra-lite
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -183,7 +183,7 @@ jobs:
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: .
|
||||
file: ./Dockerfile.fat
|
||||
file: ./docker/embedded/Dockerfile.fat
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
name: Sync Files
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "app/core/src/main/resources/messages_*.properties"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
# cancel in-progress jobs if a new job is triggered
|
||||
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
|
||||
# or a pull request is updated.
|
||||
# It helps to save resources and time by ensuring that only the latest commit is built and tested
|
||||
# This is particularly useful for long-running jobs that may take a while to complete.
|
||||
# The `group` is set to a combination of the workflow name, event name, and branch name.
|
||||
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
|
||||
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Prevents sdist builds → no tar extraction
|
||||
PIP_ONLY_BINARY: ":all:"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation property files
|
||||
run: |
|
||||
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add app/core/src/main/resources/messages_*.properties
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
# Wheels-only + Hash-Pinning
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
git add README.md scripts/ignore_translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync_readme
|
||||
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
|
||||
body: |
|
||||
### 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
|
||||
draft: false
|
||||
delete-branch: true
|
||||
labels: github-actions
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
app/core/src/main/resources/messages_*.properties
|
||||
@@ -1,15 +1,15 @@
|
||||
name: Sync Files V2
|
||||
name: Sync Files (TOML)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- main
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "frontend/public/locales/*/translation.json"
|
||||
- "frontend/public/locales/*/translation.toml"
|
||||
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
|
||||
- "scripts/ignore_translation.toml"
|
||||
|
||||
@@ -52,21 +52,25 @@ jobs:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation JSON files
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
pip install tomli-w
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/public/locales/*/translation.json
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install README dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v2.py
|
||||
python scripts/counter_translation_v3.py
|
||||
|
||||
- name: Run git add
|
||||
run: |
|
||||
@@ -82,21 +86,22 @@ jobs:
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: sync_readme_v2
|
||||
base: V2
|
||||
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
|
||||
branch: sync_readme_v3
|
||||
base: main
|
||||
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
|
||||
body: |
|
||||
### Description of Changes
|
||||
|
||||
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
|
||||
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 (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
|
||||
- Updated translation files (`frontend/public/locales/*/translation.toml`) to reflect changes in the reference file `en-GB/translation.toml`.
|
||||
- Ensured consistency and synchronization across all supported language files.
|
||||
- Highlighted any missing or incomplete translations.
|
||||
- **Format**: TOML
|
||||
|
||||
#### **2. Update README.md**
|
||||
- Generated the translation progress table in `README.md`.
|
||||
- Generated the translation progress table in `README.md` using `counter_translation_v3.py`.
|
||||
- Added a summary of the current translation status for all supported languages.
|
||||
- Included up-to-date statistics on translation coverage.
|
||||
|
||||
@@ -115,4 +120,5 @@ jobs:
|
||||
sign-commits: true
|
||||
add-paths: |
|
||||
README.md
|
||||
frontend/public/locales/*/translation.json
|
||||
frontend/public/locales/*/translation.toml
|
||||
scripts/ignore_translation.toml
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
file: ./docker/embedded/Dockerfile
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
|
||||
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
|
||||
|
||||
+3
-3
@@ -202,10 +202,10 @@ const [ToolName] = (props: BaseToolProps) => {
|
||||
## 5. Add Translations
|
||||
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
|
||||
|
||||
**File to update:** `frontend/public/locales/en-GB/translation.json`
|
||||
**File to update:** `frontend/public/locales/en-GB/translation.toml`
|
||||
|
||||
**Required Translation Keys**:
|
||||
```json
|
||||
```toml
|
||||
{
|
||||
"home": {
|
||||
"[toolName]": {
|
||||
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
|
||||
```
|
||||
|
||||
**Translation Notes:**
|
||||
- **Only update `en-GB/translation.json`** - other locale files are managed separately
|
||||
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
|
||||
- Use descriptive keys that match your component's `t()` calls
|
||||
- Include tooltip translations if you created tooltip hooks
|
||||
- Add `options.*` keys if your tool has settings with descriptions
|
||||
|
||||
@@ -1,173 +1,69 @@
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80"></p>
|
||||
<h1 align="center">Stirling-PDF</h1>
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
|
||||
</p>
|
||||
|
||||
[](https://hub.docker.com/r/frooodle/s-pdf)
|
||||
[](https://discord.gg/HYmhKj45pU)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
|
||||
[](https://github.com/Stirling-Tools/stirling-pdf)
|
||||
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
|
||||
|
||||
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling-pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling PDF - Open source locally hosted web PDF editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
[](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
|
||||
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
|
||||
|
||||
[Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
|
||||
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
|
||||
</a>
|
||||
<a href="https://discord.gg/HYmhKj45pU">
|
||||
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
|
||||
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
|
||||
</a>
|
||||
<a href="https://github.com/Stirling-Tools/stirling-pdf">
|
||||
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
|
||||

|
||||
|
||||
Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
|
||||
## Key Capabilities
|
||||
|
||||
All documentation available at [https://docs.stirlingpdf.com/](https://docs.stirlingpdf.com/)
|
||||
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API.
|
||||
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
|
||||
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
|
||||
- **Enterprise‑grade** - SSO, auditing, and flexible on‑prem deployments.
|
||||
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
|
||||
- **Global UI** - Interface available in 40+ languages.
|
||||
|
||||

|
||||
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
|
||||
|
||||
## Features
|
||||
## Quick Start
|
||||
|
||||
- 50+ PDF Operations
|
||||
- Parallel file processing and downloads
|
||||
- Dark mode support
|
||||
- Custom download options
|
||||
- Custom 'Pipelines' to run multiple features in a automated queue
|
||||
- API for integration with external scripts
|
||||
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
|
||||
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
|
||||
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
|
||||
```bash
|
||||
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
|
||||
```
|
||||
|
||||
## PDF Features
|
||||
Then open: http://localhost:8080
|
||||
|
||||
### Page Operations
|
||||
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
|
||||
|
||||
- View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
|
||||
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
|
||||
- Merge multiple PDFs into a single resultant file
|
||||
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
|
||||
- Reorganize PDF pages into different orders
|
||||
- Rotate PDFs in 90-degree increments
|
||||
- Remove pages
|
||||
- Multi-page layout (format PDFs into a multi-paged page)
|
||||
- Scale page contents size by set percentage
|
||||
- Adjust contrast
|
||||
- Crop PDF
|
||||
- Auto-split PDF (with physically scanned page dividers)
|
||||
- Extract page(s)
|
||||
- Convert PDF to a single page
|
||||
- Overlay PDFs on top of each other
|
||||
- PDF to a single page
|
||||
- Split PDF by sections
|
||||
## Resources
|
||||
|
||||
### Conversion Operations
|
||||
- [**Documentation**](https://docs.stirlingpdf.com)
|
||||
- [**Homepage**](https://stirling.com)
|
||||
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
|
||||
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
|
||||
|
||||
- Convert PDFs to and from images
|
||||
- Convert any common file to PDF (using LibreOffice)
|
||||
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
|
||||
- Convert HTML to PDF
|
||||
- Convert PDF to XML
|
||||
- Convert PDF to CSV
|
||||
- URL to PDF
|
||||
- Markdown to PDF
|
||||
## Support
|
||||
|
||||
### Security & Permissions
|
||||
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
|
||||
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
|
||||
- Add and remove passwords
|
||||
- Change/set PDF permissions
|
||||
- Add watermark(s)
|
||||
- Certify/sign PDFs
|
||||
- Sanitize PDFs
|
||||
- Auto-redact text
|
||||
## Contributing
|
||||
|
||||
### Other Operations
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
- Add/generate/write signatures
|
||||
- Split by Size or PDF
|
||||
- Repair PDFs
|
||||
- Detect and remove blank pages
|
||||
- Compare two PDFs and show differences in text
|
||||
- Add images to PDFs
|
||||
- Compress PDFs to decrease their filesize (using qpdf)
|
||||
- Extract images from PDF
|
||||
- Remove images from PDF
|
||||
- Extract images from scans
|
||||
- Remove annotations
|
||||
- Add page numbers
|
||||
- Auto-rename files by detecting PDF header text
|
||||
- OCR on PDF (using Tesseract OCR)
|
||||
- PDF/A conversion (using LibreOffice)
|
||||
- Edit metadata
|
||||
- Flatten PDFs
|
||||
- Get all information on a PDF to view or export as JSON
|
||||
- Show/detect embedded JavaScript
|
||||
For development setup, see the [Developer Guide](DeveloperGuide.md).
|
||||
|
||||
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
|
||||
|
||||
## License
|
||||
|
||||
# 📖 Get Started
|
||||
|
||||
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
|
||||
|
||||
- Installation guides for all platforms
|
||||
- Configuration options
|
||||
- Feature documentation
|
||||
- API reference
|
||||
- Security setup
|
||||
- Enterprise features
|
||||
|
||||
|
||||
## Supported Languages
|
||||
|
||||
Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
Stirling PDF offers an Enterprise edition of its software. This is the same great software but with added features, support and comforts.
|
||||
Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
|
||||
|
||||
|
||||
## 🤝 Looking to contribute?
|
||||
|
||||
Join our community:
|
||||
- [Contribution Guidelines](CONTRIBUTING.md)
|
||||
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
|
||||
- [Developer Guide](devGuide/DeveloperGuide.md)
|
||||
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
|
||||
- [Discord Community](https://discord.gg/HYmhKj45pU)
|
||||
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -112,7 +112,6 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Security {
|
||||
private Boolean enableLogin;
|
||||
private Boolean csrfDisabled;
|
||||
private InitialLogin initialLogin = new InitialLogin();
|
||||
private OAUTH2 oauth2 = new OAUTH2();
|
||||
private SAML2 saml2 = new SAML2();
|
||||
|
||||
@@ -254,10 +254,7 @@ public class PostHogService {
|
||||
properties,
|
||||
"security_enableLogin",
|
||||
applicationProperties.getSecurity().getEnableLogin());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_csrfDisabled",
|
||||
applicationProperties.getSecurity().getCsrfDisabled());
|
||||
addIfNotEmpty(properties, "security_csrfDisabled", true);
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"security_loginAttemptCount",
|
||||
|
||||
@@ -39,6 +39,7 @@ public class RequestUriUtils {
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/manifest.json")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
|
||||
@@ -34,7 +34,6 @@ public class InitialSetup {
|
||||
public void init() throws IOException {
|
||||
initUUIDKey();
|
||||
initSecretKey();
|
||||
initEnableCSRFSecurity();
|
||||
initLegalUrls();
|
||||
initSetAppVersion();
|
||||
GeneralUtils.extractPipeline();
|
||||
@@ -59,19 +58,6 @@ public class InitialSetup {
|
||||
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
|
||||
}
|
||||
}
|
||||
|
||||
public void initEnableCSRFSecurity() throws IOException {
|
||||
if (GeneralUtils.isVersionHigher(
|
||||
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
|
||||
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
|
||||
if (!csrf) {
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
|
||||
applicationProperties.getSecurity().setCsrfDisabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void initLegalUrls() throws IOException {
|
||||
// Initialize Terms and Conditions
|
||||
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
|
||||
@@ -95,7 +81,7 @@ public class InitialSetup {
|
||||
isNewServer =
|
||||
existingVersion == null
|
||||
|| existingVersion.isEmpty()
|
||||
|| existingVersion.equals("0.0.0");
|
||||
|| "0.0.0".equals(existingVersion);
|
||||
|
||||
String appVersion = "0.0.0";
|
||||
Resource resource = new ClassPathResource("version.properties");
|
||||
|
||||
@@ -124,7 +124,6 @@ public class SettingsController {
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
settings.put("enableLogin", security.getEnableLogin());
|
||||
settings.put("csrfDisabled", security.getCsrfDisabled());
|
||||
settings.put("loginMethod", security.getLoginMethod());
|
||||
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
||||
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
||||
@@ -159,12 +158,6 @@ public class SettingsController {
|
||||
.getSecurity()
|
||||
.setEnableLogin((Boolean) settings.get("enableLogin"));
|
||||
}
|
||||
if (settings.containsKey("csrfDisabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
|
||||
}
|
||||
if (settings.containsKey("loginMethod")) {
|
||||
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
|
||||
applicationProperties
|
||||
|
||||
-2
@@ -31,12 +31,10 @@ import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.JobOwnershipService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.security.config.PremiumEndpoint;
|
||||
|
||||
@Slf4j
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@PremiumEndpoint
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
+75
-7
@@ -1,20 +1,88 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@Controller
|
||||
public class ReactRoutingController {
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
|
||||
public String forwardRootPaths() {
|
||||
return "forward:/index.html";
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
|
||||
private String cachedIndexHtml;
|
||||
private boolean indexHtmlExists = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// Only cache if index.html exists (production builds)
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
if (resource.exists()) {
|
||||
try {
|
||||
this.cachedIndexHtml = processIndexHtml();
|
||||
this.indexHtmlExists = true;
|
||||
} catch (IOException e) {
|
||||
// Failed to cache, will process on each request
|
||||
this.indexHtmlExists = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String processIndexHtml() throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
||||
// Replace %BASE_URL% with the actual context path for base href
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
|
||||
html = html.replace("</head>", contextPathScript + "</head>");
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public String forwardNestedPaths() {
|
||||
return "forward:/index.html";
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||
}
|
||||
// Fallback: process on each request (dev mode or cache failed)
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
|
||||
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
|
||||
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
/**
|
||||
* Unified signature image controller that works for both authenticated and unauthenticated users.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures -
|
||||
* PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated
|
||||
* signature management (save/delete), see proprietary SignatureController.
|
||||
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
|
||||
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
|
||||
* authenticated signature management (save/delete), see proprietary SignatureController.
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
|
||||
@@ -179,7 +179,7 @@ public class SharedSignatureService {
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
// Store reference to image file
|
||||
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
|
||||
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
||||
}
|
||||
|
||||
log.info("Saved signature {} for user {}", request.getId(), username);
|
||||
@@ -207,7 +207,7 @@ public class SharedSignatureService {
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("personal");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
@@ -238,7 +238,7 @@ public class SharedSignatureService {
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("shared");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
security:
|
||||
enableLogin: true # set to 'true' to enable login
|
||||
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
|
||||
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
|
||||
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
|
||||
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
|
||||
|
||||
+98
-5
@@ -1,7 +1,12 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -18,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -38,6 +44,7 @@ public class SignatureController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
private final UserService userService;
|
||||
private static final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
|
||||
/**
|
||||
* Save a new signature for the authenticated user. Enforces storage limits and authentication
|
||||
@@ -84,19 +91,105 @@ public class SignatureController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature owned by the authenticated user. Users can only delete their own personal
|
||||
* signatures, not shared ones.
|
||||
* Update a signature label. Users can update labels for their own personal signatures and for
|
||||
* shared signatures.
|
||||
*/
|
||||
@PostMapping("/{signatureId}/label")
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
public ResponseEntity<Void> updateSignatureLabel(
|
||||
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
String newLabel = body.get("label");
|
||||
|
||||
if (newLabel == null || newLabel.trim().isEmpty()) {
|
||||
log.warn("Invalid label update request");
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
signatureService.updateSignatureLabel(username, signatureId, newLabel);
|
||||
log.info("User {} updated label for signature {}", username, signatureId);
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to update signature label: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature owned by the authenticated user. Users can delete their own personal
|
||||
* signatures. Admins can also delete shared signatures.
|
||||
*/
|
||||
@DeleteMapping("/{signatureId}")
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
signatureService.deleteSignature(username, signatureId);
|
||||
log.info("User {} deleted signature {}", username, signatureId);
|
||||
return ResponseEntity.noContent().build();
|
||||
boolean isAdmin = userService.isCurrentUserAdmin();
|
||||
|
||||
// Validate filename to prevent path traversal
|
||||
if (signatureId.contains("..")
|
||||
|| signatureId.contains("/")
|
||||
|| signatureId.contains("\\")) {
|
||||
log.warn("Invalid signature ID: {}", signatureId);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
// Try to delete from personal folder first
|
||||
try {
|
||||
signatureService.deleteSignature(username, signatureId);
|
||||
log.info("User {} deleted personal signature {}", username, signatureId);
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IOException e) {
|
||||
// If not found in personal folder, check if it's in shared folder
|
||||
if (isAdmin) {
|
||||
// Admin can delete from shared folder
|
||||
if (deleteFromSharedFolder(signatureId)) {
|
||||
log.info("Admin {} deleted shared signature {}", username, signatureId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
// If not admin or not found in shared folder either, return 404
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature from the shared (ALL_USERS) folder. Only admins should call this method.
|
||||
*/
|
||||
private boolean deleteFromSharedFolder(String signatureId) throws IOException {
|
||||
String signatureBasePath = InstallationPathConfig.getSignaturesPath();
|
||||
Path sharedFolder = Paths.get(signatureBasePath, ALL_USERS_FOLDER);
|
||||
boolean deleted = false;
|
||||
|
||||
if (Files.exists(sharedFolder)) {
|
||||
try (Stream<Path> stream = Files.list(sharedFolder)) {
|
||||
List<Path> matchingFiles =
|
||||
stream.filter(
|
||||
path ->
|
||||
path.getFileName()
|
||||
.toString()
|
||||
.startsWith(signatureId + "."))
|
||||
.toList();
|
||||
for (Path file : matchingFiles) {
|
||||
Files.delete(file);
|
||||
deleted = true;
|
||||
log.info("Deleted shared signature file: {}", file);
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete metadata file if it exists
|
||||
Path metadataPath = sharedFolder.resolve(signatureId + ".json");
|
||||
if (Files.exists(metadataPath)) {
|
||||
Files.delete(metadataPath);
|
||||
log.info("Deleted shared signature metadata: {}", metadataPath);
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-50
@@ -1,7 +1,6 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
@@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
@@ -198,9 +194,7 @@ public class SecurityConfiguration {
|
||||
http.cors(cors -> cors.disable());
|
||||
}
|
||||
|
||||
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
|
||||
http.csrf(CsrfConfigurer::disable);
|
||||
}
|
||||
http.csrf(CsrfConfigurer::disable);
|
||||
|
||||
if (loginEnabledValue) {
|
||||
boolean v2Enabled = appConfig.v2Enabled();
|
||||
@@ -210,48 +204,6 @@ public class SecurityConfiguration {
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
|
||||
if (!securityProperties.getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
||||
CsrfTokenRequestAttributeHandler requestHandler =
|
||||
new CsrfTokenRequestAttributeHandler();
|
||||
requestHandler.setCsrfRequestAttributeName(null);
|
||||
http.csrf(
|
||||
csrf ->
|
||||
csrf.ignoringRequestMatchers(
|
||||
request -> {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// Ignore CSRF for auth endpoints
|
||||
if (uri.startsWith("/api/v1/auth/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
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));
|
||||
}
|
||||
|
||||
http.sessionManagement(
|
||||
sessionManagement -> {
|
||||
if (v2Enabled) {
|
||||
@@ -324,10 +276,16 @@ public class SecurityConfiguration {
|
||||
.authenticated());
|
||||
// Handle User/Password Logins
|
||||
if (securityProperties.isUserPass()) {
|
||||
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
|
||||
// We configure form login to handle Spring Security redirects,
|
||||
// but use /perform_login as the processing URL so /login remains a React route
|
||||
http.formLogin(
|
||||
formLogin ->
|
||||
formLogin
|
||||
.loginPage("/login")
|
||||
.loginPage("/login") // Redirect here when unauthenticated
|
||||
.loginProcessingUrl(
|
||||
"/perform_login") // Process form posts here (not
|
||||
// /login)
|
||||
.successHandler(
|
||||
new CustomAuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
|
||||
+6
-1
@@ -283,7 +283,12 @@ public class AdminLicenseController {
|
||||
// Prevent path traversal and enforce single filename component
|
||||
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "Filename must not contain path separators or '..'"));
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Filename must not contain path separators or '..'"));
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
|
||||
+13
@@ -56,6 +56,19 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
List<User> findAllSsoUsers();
|
||||
|
||||
/**
|
||||
* Finds SSO users who have never created a session (pending activation) and are not yet
|
||||
* grandfathered.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT u FROM User u "
|
||||
+ "LEFT JOIN SessionEntity s ON u.username = s.principalName "
|
||||
+ "WHERE (u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) "
|
||||
+ "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) "
|
||||
+ "AND s.sessionId IS NULL")
|
||||
List<User> findPendingSsoUsersWithoutSession();
|
||||
|
||||
/**
|
||||
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
|
||||
*/
|
||||
|
||||
+1
-5
@@ -105,22 +105,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Validating JWT token");
|
||||
jwtService.validateToken(jwtToken);
|
||||
log.debug("JWT token validated successfully");
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
log.debug("JWT validation failed: {}", e.getMessage());
|
||||
handleAuthenticationFailure(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
log.debug("JWT token username: {}", tokenUsername);
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
log.debug("Authentication successful for user: {}", tokenUsername);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
|
||||
+8
@@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CustomOAuth2AuthenticationSuccessHandler
|
||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||
@@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
|
||||
// User is not grandfathered and no paid license - block OAuth login
|
||||
log.warn(
|
||||
"OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)",
|
||||
username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||
// No existing user and no paid license -> block auto creation
|
||||
log.warn(
|
||||
"OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)",
|
||||
username);
|
||||
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
|
||||
+20
-4
@@ -67,10 +67,15 @@ public class OAuth2Configuration {
|
||||
keycloakClientRegistration().ifPresent(registrations::add);
|
||||
|
||||
if (registrations.isEmpty()) {
|
||||
log.error("No OAuth2 provider registered");
|
||||
log.error("No OAuth2 provider registered - check your OAuth2 configuration");
|
||||
throw new NoProviderFoundException("At least one OAuth2 provider must be configured.");
|
||||
}
|
||||
|
||||
log.info(
|
||||
"OAuth2 ClientRegistrationRepository created with {} provider(s): {}",
|
||||
registrations.size(),
|
||||
registrations.stream().map(ClientRegistration::getRegistrationId).toList());
|
||||
|
||||
return new InMemoryClientRegistrationRepository(registrations);
|
||||
}
|
||||
|
||||
@@ -165,7 +170,6 @@ public class OAuth2Configuration {
|
||||
githubClient.getUseAsUsername());
|
||||
|
||||
boolean isValid = validateProvider(github);
|
||||
log.info("Initialised GitHub OAuth2 provider");
|
||||
|
||||
return isValid
|
||||
? Optional.of(
|
||||
@@ -208,7 +212,19 @@ public class OAuth2Configuration {
|
||||
null,
|
||||
null);
|
||||
|
||||
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
|
||||
boolean isValid =
|
||||
!isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider);
|
||||
if (isValid) {
|
||||
log.info(
|
||||
"Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'",
|
||||
name,
|
||||
oauth.getIssuer(),
|
||||
REDIRECT_URI_PATH + name);
|
||||
} else {
|
||||
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
|
||||
}
|
||||
|
||||
return isValid
|
||||
? Optional.of(
|
||||
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
|
||||
.registrationId(name)
|
||||
@@ -217,7 +233,7 @@ public class OAuth2Configuration {
|
||||
.scope(oidcProvider.getScopes())
|
||||
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
|
||||
.clientName(clientName)
|
||||
.redirectUri(REDIRECT_URI_PATH + "oidc")
|
||||
.redirectUri(REDIRECT_URI_PATH + name)
|
||||
.authorizationGrantType(AUTHORIZATION_CODE)
|
||||
.build())
|
||||
: Optional.empty();
|
||||
|
||||
+11
-5
@@ -67,19 +67,25 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
boolean userExists = userService.usernameExistsIgnoreCase(username);
|
||||
|
||||
// Check if user is eligible for SAML (grandfathered or system has paid license)
|
||||
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
|
||||
if (userExists) {
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
|
||||
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
|
||||
// User is not grandfathered and no paid license - block SAML login
|
||||
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
|
||||
// User is not grandfathered and no ENTERPRISE license - block SAML login
|
||||
log.warn(
|
||||
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
|
||||
username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
}
|
||||
} else if (!licenseSettingsService.isOAuthEligible(null)) {
|
||||
// No existing user and no paid license -> block auto creation
|
||||
} else if (!licenseSettingsService.isSamlEligible(null)) {
|
||||
// No existing user and no ENTERPRISE license -> block auto creation
|
||||
log.warn(
|
||||
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
|
||||
username);
|
||||
response.sendRedirect(
|
||||
request.getContextPath() + "/logout?saml2RequiresLicense=true");
|
||||
return;
|
||||
|
||||
+1
-5
@@ -50,7 +50,6 @@ public class JwtService implements JwtServiceInterface {
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
log.info("JwtService initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -256,11 +255,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
log.debug("JWT token extracted from Authorization header");
|
||||
return token;
|
||||
}
|
||||
|
||||
log.debug("No JWT token found in Authorization header");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -283,10 +280,9 @@ public class JwtService implements JwtServiceInterface {
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
.get("kid");
|
||||
log.debug("Extracted key ID from token: {}", keyId);
|
||||
return keyId;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -55,7 +55,6 @@ public class KeyPairCleanupService {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Removing keys older than retention period");
|
||||
removeKeys(eligibleKeys);
|
||||
keyPersistenceService.refreshActiveKeyPair();
|
||||
}
|
||||
|
||||
+26
@@ -778,4 +778,30 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grandfathers SSO users who have never created a session (invited/pending accounts). These
|
||||
* users would otherwise be blocked when SSO requires a paid license despite existing before the
|
||||
* policy change.
|
||||
*
|
||||
* @return Number of pending users updated
|
||||
*/
|
||||
@Transactional
|
||||
public int grandfatherPendingSsoUsersWithoutSession() {
|
||||
List<User> pendingUsers = userRepository.findPendingSsoUsersWithoutSession();
|
||||
int updated = 0;
|
||||
|
||||
for (User user : pendingUsers) {
|
||||
if (!user.isOauthGrandfathered()) {
|
||||
user.setOauthGrandfathered(true);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
userRepository.saveAll(pendingUsers);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
+100
-9
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -13,6 +14,8 @@ import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@@ -31,6 +34,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// Storage limits per user
|
||||
private static final int MAX_SIGNATURES_PER_USER = 20;
|
||||
@@ -88,6 +92,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
response.setCreatedAt(timestamp);
|
||||
response.setUpdatedAt(timestamp);
|
||||
|
||||
// Copy text signature properties if present
|
||||
if ("text".equals(request.getType())) {
|
||||
response.setSignerName(request.getSignerName());
|
||||
response.setFontFamily(request.getFontFamily());
|
||||
response.setFontSize(request.getFontSize());
|
||||
response.setTextColor(request.getTextColor());
|
||||
}
|
||||
|
||||
// Extract and save image data
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
@@ -133,6 +145,19 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
||||
}
|
||||
|
||||
// Save metadata JSON file
|
||||
String metadataFileName = request.getId() + ".json";
|
||||
Path metadataPath = targetFolder.resolve(metadataFileName);
|
||||
verifyPathWithinDirectory(metadataPath, targetFolder);
|
||||
|
||||
String metadataJson = objectMapper.writeValueAsString(response);
|
||||
Files.writeString(
|
||||
metadataPath,
|
||||
metadataJson,
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
|
||||
return response;
|
||||
}
|
||||
@@ -179,6 +204,13 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
log.info("Deleted signature file: {}", file);
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete metadata file if it exists
|
||||
Path metadataPath = personalFolder.resolve(signatureId + ".json");
|
||||
if (Files.exists(metadataPath)) {
|
||||
Files.delete(metadataPath);
|
||||
log.info("Deleted signature metadata: {}", metadataPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
@@ -186,6 +218,50 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a signature label. */
|
||||
public void updateSignatureLabel(String username, String signatureId, String newLabel)
|
||||
throws IOException {
|
||||
validateFileName(signatureId);
|
||||
|
||||
// Try personal folder first
|
||||
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||
Path metadataPath = personalFolder.resolve(signatureId + ".json");
|
||||
|
||||
if (Files.exists(metadataPath)) {
|
||||
updateMetadataLabel(metadataPath, newLabel);
|
||||
log.info("Updated label for personal signature {} (user: {})", signatureId, username);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not found in personal, try shared folder
|
||||
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||
Path sharedMetadataPath = sharedFolder.resolve(signatureId + ".json");
|
||||
|
||||
if (Files.exists(sharedMetadataPath)) {
|
||||
updateMetadataLabel(sharedMetadataPath, newLabel);
|
||||
log.info("Updated label for shared signature {}", signatureId);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Signature metadata not found");
|
||||
}
|
||||
|
||||
private void updateMetadataLabel(Path metadataPath, String newLabel) throws IOException {
|
||||
String metadataJson = Files.readString(metadataPath, StandardCharsets.UTF_8);
|
||||
SavedSignatureResponse sig =
|
||||
objectMapper.readValue(metadataJson, SavedSignatureResponse.class);
|
||||
sig.setLabel(newLabel);
|
||||
sig.setUpdatedAt(System.currentTimeMillis());
|
||||
|
||||
String updatedJson = objectMapper.writeValueAsString(sig);
|
||||
Files.writeString(
|
||||
metadataPath,
|
||||
updatedJson,
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
|
||||
@@ -245,16 +321,31 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
String fileName = path.getFileName().toString();
|
||||
String id = fileName.substring(0, fileName.lastIndexOf('.'));
|
||||
|
||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id);
|
||||
sig.setType("image");
|
||||
sig.setScope(scope);
|
||||
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
|
||||
// Try to load metadata from JSON file
|
||||
Path metadataPath = folder.resolve(id + ".json");
|
||||
SavedSignatureResponse sig;
|
||||
|
||||
// Set unified URL path (works for both personal and shared)
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
if (Files.exists(metadataPath)) {
|
||||
// Load from metadata file
|
||||
String metadataJson =
|
||||
Files.readString(
|
||||
metadataPath, StandardCharsets.UTF_8);
|
||||
sig =
|
||||
objectMapper.readValue(
|
||||
metadataJson, SavedSignatureResponse.class);
|
||||
} else {
|
||||
// Fallback for old signatures without metadata
|
||||
sig = new SavedSignatureResponse();
|
||||
sig.setId(id);
|
||||
sig.setLabel(id);
|
||||
sig.setType("image");
|
||||
sig.setScope(scope);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
}
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
|
||||
+94
-10
@@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.UserLicenseSettings;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -192,10 +193,18 @@ public class UserLicenseSettingsService {
|
||||
+ "They will retain OAuth access even without a paid license. "
|
||||
+ "New users will require a paid license for OAuth.",
|
||||
updated);
|
||||
} else if (grandfatheredCount > 0) {
|
||||
log.debug(
|
||||
"OAuth grandfathering already completed: {} users grandfathered",
|
||||
grandfatheredCount);
|
||||
}
|
||||
|
||||
// Grandfather pending users (invited but never logged in)
|
||||
// The query filters to non-grandfathered users only, so this is idempotent
|
||||
if (grandfatheredCount > 0 || oauthUsersCount > 0) {
|
||||
int pendingUpdated = userService.grandfatherPendingSsoUsersWithoutSession();
|
||||
if (pendingUpdated > 0) {
|
||||
log.warn(
|
||||
"OAuth GRANDFATHERING: Marked {} pending SSO users (no prior sessions) as"
|
||||
+ " grandfathered.",
|
||||
pendingUpdated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,17 +344,76 @@ public class UserLicenseSettingsService {
|
||||
* @param user The user to check
|
||||
* @return true if the user can use OAuth/SAML
|
||||
*/
|
||||
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
|
||||
public boolean isOAuthEligible(User user) {
|
||||
String username = (user != null) ? user.getUsername() : "<new user>";
|
||||
log.info("OAuth eligibility check for user: {}", username);
|
||||
|
||||
// Grandfathered users always have OAuth access
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.debug("User {} is grandfathered for OAuth", user.getUsername());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Users can use OAuth/SAML only if system has ENTERPRISE license
|
||||
boolean hasEnterpriseLicense = hasEnterpriseLicense();
|
||||
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
|
||||
return hasEnterpriseLicense;
|
||||
// todo: remove
|
||||
if (user != null) {
|
||||
log.info(
|
||||
"User {} is NOT grandfathered (isOauthGrandfathered={})",
|
||||
username,
|
||||
user.isOauthGrandfathered());
|
||||
} else {
|
||||
log.info("New user attempting OAuth login - checking license requirement");
|
||||
}
|
||||
|
||||
// Users can use OAuth with SERVER or ENTERPRISE license
|
||||
boolean hasPaid = hasPaidLicense();
|
||||
log.info(
|
||||
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
|
||||
hasPaid,
|
||||
username,
|
||||
hasPaid);
|
||||
return hasPaid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user is eligible to use SAML authentication.
|
||||
*
|
||||
* <p>A user is eligible if:
|
||||
*
|
||||
* <ul>
|
||||
* <li>They are grandfathered for OAuth (existing user before policy change), OR
|
||||
* <li>The system has an ENTERPRISE license (SAML is enterprise-only)
|
||||
* </ul>
|
||||
*
|
||||
* @param user The user to check
|
||||
* @return true if the user can use SAML
|
||||
*/
|
||||
public boolean isSamlEligible(User user) {
|
||||
String username = (user != null) ? user.getUsername() : "<new user>";
|
||||
log.info("SAML2 eligibility check for user: {}", username);
|
||||
|
||||
// Grandfathered users always have SAML access
|
||||
if (user != null && user.isOauthGrandfathered()) {
|
||||
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user != null) {
|
||||
log.info(
|
||||
"User {} is NOT grandfathered (isOauthGrandfathered={})",
|
||||
username,
|
||||
user.isOauthGrandfathered());
|
||||
} else {
|
||||
log.info("New user attempting SAML2 login - checking license requirement");
|
||||
}
|
||||
|
||||
// Users can use SAML only with ENTERPRISE license
|
||||
boolean hasEnterprise = hasEnterpriseLicense();
|
||||
log.info(
|
||||
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
|
||||
hasEnterprise,
|
||||
username,
|
||||
hasEnterprise);
|
||||
return hasEnterprise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,8 +555,12 @@ public class UserLicenseSettingsService {
|
||||
if (checker == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
License license = checker.getPremiumLicenseEnabledResult();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE);
|
||||
log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid);
|
||||
|
||||
return hasPaid;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,7 +574,19 @@ public class UserLicenseSettingsService {
|
||||
if (checker == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
License license = checker.getPremiumLicenseEnabledResult();
|
||||
log.info(
|
||||
"License check result: type={}, requiresEnterprise=true, hasEnterprise={}",
|
||||
license,
|
||||
(license == License.ENTERPRISE));
|
||||
|
||||
if (license != License.ENTERPRISE) {
|
||||
log.warn(
|
||||
"SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.",
|
||||
license);
|
||||
}
|
||||
|
||||
return license == License.ENTERPRISE;
|
||||
}
|
||||
}
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Unit tests for OAuth2Configuration redirect URI logic.
|
||||
*
|
||||
* <p>These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment
|
||||
* MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing
|
||||
* InvalidClientRegistrationIdException when custom provider names were used.
|
||||
*
|
||||
* <p>Note: These are conceptual tests documenting the expected behavior. Full integration testing
|
||||
* with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2.
|
||||
* Valid OIDC configuration responses 3. Network mocking infrastructure
|
||||
*/
|
||||
class OAuth2ConfigurationTest {
|
||||
|
||||
/**
|
||||
* Tests the redirect URI pattern for OIDC provider configurations.
|
||||
*
|
||||
* <p>Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the
|
||||
* registration ID. For example: - Provider name: "authentik" → Redirect URI:
|
||||
* "/login/oauth2/code/authentik" - Provider name: "mycompany" → Redirect URI:
|
||||
* "/login/oauth2/code/mycompany" - Provider name: "oidc" → Redirect URI:
|
||||
* "/login/oauth2/code/oidc"
|
||||
*
|
||||
* <p>Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for
|
||||
* a registration with ID 'oidc' when the provider redirected back. This caused
|
||||
* InvalidClientRegistrationIdException when custom provider names were used.
|
||||
*/
|
||||
@Test
|
||||
void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() {
|
||||
// Verify the redirect URI pattern constant
|
||||
String redirectUriBase = "{baseUrl}/login/oauth2/code/";
|
||||
|
||||
// Test cases: provider name → expected redirect URI
|
||||
String[][] testCases = {
|
||||
{"authentik", redirectUriBase + "authentik"},
|
||||
{"mycompany", redirectUriBase + "mycompany"},
|
||||
{"oidc", redirectUriBase + "oidc"},
|
||||
{"okta", redirectUriBase + "okta"},
|
||||
{"auth0", redirectUriBase + "auth0"}
|
||||
};
|
||||
|
||||
for (String[] testCase : testCases) {
|
||||
String providerName = testCase[0];
|
||||
String expectedRedirectUri = testCase[1];
|
||||
|
||||
// The fix ensures: .redirectUri(REDIRECT_URI_PATH + name)
|
||||
// instead of: .redirectUri(REDIRECT_URI_PATH + "oidc")
|
||||
String actualRedirectUri = redirectUriBase + providerName;
|
||||
|
||||
assertEquals(
|
||||
expectedRedirectUri,
|
||||
actualRedirectUri,
|
||||
String.format(
|
||||
"Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'",
|
||||
providerName));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents the critical fix for OAuth2 redirect URI mismatch.
|
||||
*
|
||||
* <p>This test validates the logic that was changed in OAuth2Configuration.java line 220:
|
||||
*
|
||||
* <pre>
|
||||
* // BEFORE (bug):
|
||||
* .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc"
|
||||
*
|
||||
* // AFTER (fix):
|
||||
* .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name
|
||||
* </pre>
|
||||
*/
|
||||
@Test
|
||||
void testCriticalFix_redirectUriMatchesRegistrationId() {
|
||||
// The redirect URI path segment extraction by Spring Security
|
||||
String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123";
|
||||
|
||||
// Spring extracts the path segment between "code/" and "?"
|
||||
String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl);
|
||||
|
||||
// The extracted ID MUST match an actual registration ID
|
||||
assertEquals("authentik", extractedRegistrationId);
|
||||
|
||||
// If we had used hardcoded "oidc", the callback would be:
|
||||
String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123";
|
||||
String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl);
|
||||
|
||||
// This would look for registration with ID "oidc" but we registered "authentik"
|
||||
assertEquals("oidc", buggyExtractedId);
|
||||
|
||||
// The mismatch: registrationId="authentik", but Spring looks for "oidc"
|
||||
// Result: InvalidClientRegistrationIdException
|
||||
assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed");
|
||||
}
|
||||
|
||||
/** Helper method simulating Spring's extraction of registration ID from callback URL */
|
||||
private String extractRegistrationIdFromCallback(String callbackUrl) {
|
||||
// Simplified version of what Spring Security does
|
||||
// Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path
|
||||
String path = callbackUrl.split("\\?")[0];
|
||||
String[] parts = path.split("/");
|
||||
return parts[parts.length - 1]; // Last path segment
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the frontend-backend flow for custom provider names.
|
||||
*
|
||||
* <p>Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend:
|
||||
* ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to
|
||||
* "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with
|
||||
* "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for
|
||||
* OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring
|
||||
* Security redirects to provider with redirect_uri containing "authentik" 8. Provider:
|
||||
* Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security
|
||||
* extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID
|
||||
* "authentik" ✅ SUCCESS
|
||||
*
|
||||
* <p>If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
|
||||
* "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
|
||||
* Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
|
||||
* InvalidClientRegistrationIdException
|
||||
*/
|
||||
@Test
|
||||
void testEndToEndFlow_registrationIdConsistency() {
|
||||
String providerName = "authentik";
|
||||
|
||||
// Step 2: Registration ID
|
||||
String registrationId = providerName;
|
||||
assertEquals("authentik", registrationId);
|
||||
|
||||
// Step 3: Redirect URI (MUST use same name)
|
||||
String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
|
||||
assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
|
||||
|
||||
// Step 4: Provider list endpoint
|
||||
String authorizationPath = "/oauth2/authorization/" + providerName;
|
||||
assertEquals("/oauth2/authorization/authentik", authorizationPath);
|
||||
|
||||
// Step 5: Frontend extracts provider ID
|
||||
String frontendProviderId =
|
||||
authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
|
||||
assertEquals("authentik", frontendProviderId);
|
||||
|
||||
// Step 6-8: OAuth flow (external)
|
||||
|
||||
// Step 9: Callback URL from provider
|
||||
String callbackUrl =
|
||||
"http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
|
||||
String extractedId = extractRegistrationIdFromCallback(callbackUrl);
|
||||
|
||||
// Step 10: Registration lookup
|
||||
assertEquals(
|
||||
registrationId,
|
||||
extractedId,
|
||||
"Registration ID from callback MUST match original registration ID");
|
||||
}
|
||||
}
|
||||
+287
@@ -2,6 +2,9 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -198,4 +201,288 @@ class UserLicenseSettingsServiceTest {
|
||||
|
||||
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_runsOnlyWhenNoneGrandfathered() {
|
||||
// With grandfatheredCount == 0, should run grandfathering for all users
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
when(userService.grandfatherAllOAuthUsers()).thenReturn(10);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_skipsMainButRunsPendingWhenSomeAlreadyGrandfathered() {
|
||||
// V2→V2.1 upgrade: some users already grandfathered, but pending users need to be checked
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(4L);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(2);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_stillChecksPendingWhenAllUsersGrandfathered() {
|
||||
// All active users grandfathered, but still check for pending users
|
||||
when(userService.countOAuthUsers()).thenReturn(10L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(10L);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_skipsWhenNoOAuthUsers() {
|
||||
when(userService.countOAuthUsers()).thenReturn(0L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, never()).grandfatherAllOAuthUsers();
|
||||
verify(userService, never()).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
@Test
|
||||
void grandfatherExistingOAuthUsers_grandfathersPendingUsersOnFirstRun() {
|
||||
// Pending users (invited but never logged in) should be grandfathered
|
||||
// during the initial grandfathering run (when grandfatheredCount == 0)
|
||||
when(userService.countOAuthUsers()).thenReturn(5L);
|
||||
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
|
||||
when(userService.grandfatherAllOAuthUsers()).thenReturn(5);
|
||||
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(3);
|
||||
|
||||
service.grandfatherExistingOAuthUsers();
|
||||
|
||||
verify(userService, times(1)).grandfatherAllOAuthUsers();
|
||||
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
|
||||
}
|
||||
|
||||
// ===== OAuth Eligibility Tests =====
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_grandfatheredUser_returnsTrue() {
|
||||
// Grandfathered user should be eligible regardless of license
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("grandfathered-user");
|
||||
user.setOauthGrandfathered(true);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
boolean result = service.isOAuthEligible(user);
|
||||
|
||||
assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
|
||||
// Non-grandfathered user with SERVER license should be eligible
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
|
||||
boolean result = service.isOAuthEligible(user);
|
||||
|
||||
assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
|
||||
// Non-grandfathered user with ENTERPRISE license should be eligible
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
|
||||
boolean result = service.isOAuthEligible(user);
|
||||
|
||||
assertEquals(
|
||||
true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
|
||||
// Non-grandfathered user without license should NOT be eligible
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
boolean result = service.isOAuthEligible(user);
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
result,
|
||||
"Non-grandfathered user without paid license should NOT be eligible");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
|
||||
// New user (null) with SERVER license should be eligible for auto-creation
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
|
||||
boolean result = service.isOAuthEligible(null);
|
||||
|
||||
assertEquals(
|
||||
true, result, "New user with SERVER license should be eligible for auto-creation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
|
||||
// New user (null) without license should NOT be eligible
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
boolean result = service.isOAuthEligible(null);
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
result,
|
||||
"New user without paid license should NOT be eligible for auto-creation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
|
||||
// If LicenseKeyChecker is unavailable, OAuth should be blocked
|
||||
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
|
||||
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
boolean result = service.isOAuthEligible(user);
|
||||
|
||||
assertEquals(
|
||||
false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
|
||||
}
|
||||
|
||||
// ===== SAML Eligibility Tests =====
|
||||
|
||||
@Test
|
||||
void isSamlEligible_grandfatheredUser_returnsTrue() {
|
||||
// Grandfathered user should be eligible for SAML regardless of license
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("grandfathered-user");
|
||||
user.setOauthGrandfathered(true);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
boolean result = service.isSamlEligible(user);
|
||||
|
||||
assertEquals(true, result, "Grandfathered user should be eligible for SAML");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
|
||||
// Non-grandfathered user with ENTERPRISE license should be eligible
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
|
||||
boolean result = service.isSamlEligible(user);
|
||||
|
||||
assertEquals(
|
||||
true,
|
||||
result,
|
||||
"Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
|
||||
// Non-grandfathered user with SERVER license should NOT be eligible for SAML
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
|
||||
boolean result = service.isSamlEligible(user);
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
result,
|
||||
"Non-grandfathered user with SERVER license should NOT be eligible for SAML");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
|
||||
// Non-grandfathered user without license should NOT be eligible
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
|
||||
|
||||
boolean result = service.isSamlEligible(user);
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
result,
|
||||
"Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
|
||||
// New user (null) with ENTERPRISE license should be eligible for auto-creation
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
|
||||
|
||||
boolean result = service.isSamlEligible(null);
|
||||
|
||||
assertEquals(
|
||||
true,
|
||||
result,
|
||||
"New user with ENTERPRISE license should be eligible for SAML auto-creation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSamlEligible_newUserWithServerLicense_returnsFalse() {
|
||||
// New user (null) with SERVER license should NOT be eligible for SAML
|
||||
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
|
||||
|
||||
boolean result = service.isSamlEligible(null);
|
||||
|
||||
assertEquals(
|
||||
false,
|
||||
result,
|
||||
"New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
|
||||
// If LicenseKeyChecker is unavailable, SAML should be blocked
|
||||
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
|
||||
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
new stirling.software.proprietary.security.model.User();
|
||||
user.setUsername("test-user");
|
||||
user.setOauthGrandfathered(false);
|
||||
|
||||
boolean result = service.isSamlEligible(user);
|
||||
|
||||
assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.0.2'
|
||||
version = '2.1.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -8,36 +8,33 @@
|
||||
|
||||
Fork Stirling-PDF and create a new branch out of `main`.
|
||||
|
||||
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
|
||||
## Frontend Translation Files (TOML Format)
|
||||
|
||||
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
|
||||
### Add Language Directory and Translation File
|
||||
|
||||
1. Create a new language directory in `frontend/public/locales/`
|
||||
- Use hyphenated format: `pl-PL` (not underscore)
|
||||
|
||||
For example, to add Polish, you would add:
|
||||
2. Copy the reference translation file:
|
||||
- Source: `frontend/public/locales/en-GB/translation.toml`
|
||||
- Destination: `frontend/public/locales/pl-PL/translation.toml`
|
||||
|
||||
```html
|
||||
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
|
||||
```
|
||||
3. Translate all entries in the TOML file
|
||||
- Keep the TOML structure intact
|
||||
- Preserve all placeholders like `{n}`, `{total}`, `{filename}`, `{{variable}}`
|
||||
- See `scripts/translations/README.md` for translation tools and workflows
|
||||
|
||||
The `data-bs-language-code` is the code used to reference the file in the next step.
|
||||
4. Update the language selector in the frontend to include your new language
|
||||
|
||||
### Add Language Property File
|
||||
|
||||
Start by copying the existing English property file:
|
||||
|
||||
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
|
||||
|
||||
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
|
||||
|
||||
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
|
||||
|
||||
If you do not have a Java IDE, I am happy to verify that the changes work once you raise the PR (but I won't be able to verify the translations themselves).
|
||||
Then make a Pull Request (PR) into `main` for others to use!
|
||||
|
||||
## Handling Untranslatable Strings
|
||||
|
||||
Sometimes, certain strings in the properties file may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
|
||||
Sometimes, certain strings may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
|
||||
|
||||
For example, if the English string `error=Error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
|
||||
For example, if the English string `error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
|
||||
|
||||
**Note**: Use underscores in `ignore_translation.toml` even though frontend uses hyphens (e.g., `pl_PL` not `pl-PL`)
|
||||
|
||||
```toml
|
||||
[pl_PL]
|
||||
@@ -50,27 +47,27 @@ ignore = [
|
||||
## Add New Translation Tags
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you add any new translation tags, they must first be added to the `messages_en_GB.properties` file. This ensures consistency across all language files.
|
||||
> If you add any new translation tags, they must first be added to the `en-GB/translation.toml` file. This ensures consistency across all language files.
|
||||
|
||||
- New translation tags **must be added** to the `messages_en_GB.properties` file to maintain a reference for other languages.
|
||||
- After adding the new tags to `messages_en_GB.properties`, add and translate them in the respective language file (e.g., `messages_pl_PL.properties`).
|
||||
- New translation tags **must be added** to `frontend/public/locales/en-GB/translation.toml` to maintain a reference for other languages.
|
||||
- After adding the new tags to `en-GB/translation.toml`, add and translate them in the respective language file (e.g., `pl-PL/translation.toml`).
|
||||
- Use the scripts in `scripts/translations/` to validate and manage translations (see `scripts/translations/README.md`)
|
||||
|
||||
Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate.
|
||||
|
||||
### Use this code to perform a local check
|
||||
### Validation Commands
|
||||
|
||||
#### Windows command
|
||||
|
||||
```powershell
|
||||
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
|
||||
|
||||
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
|
||||
```
|
||||
|
||||
#### Linux command
|
||||
Use the translation scripts in `scripts/translations/` directory:
|
||||
|
||||
```bash
|
||||
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
|
||||
# Analyze translation progress
|
||||
python3 scripts/translations/translation_analyzer.py --language pl-PL
|
||||
|
||||
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
|
||||
# Validate TOML structure
|
||||
python3 scripts/translations/validate_json_structure.py --language pl-PL
|
||||
|
||||
# Validate placeholders
|
||||
python3 scripts/translations/validate_placeholders.py --language pl-PL
|
||||
```
|
||||
|
||||
See `scripts/translations/README.md` for complete documentation.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Stirling-PDF Dockerfile - Full version with embedded frontend
|
||||
# Single JAR contains both frontend and backend
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm --version \
|
||||
&& node --version \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy gradle files for dependency resolution
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build JAR with embedded frontend (includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF"
|
||||
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Full version"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, API, Spring Boot, React"
|
||||
|
||||
# Copy scripts and fonts
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
# Environment Variables
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# Install all dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
@@ -0,0 +1,142 @@
|
||||
# Stirling-PDF Dockerfile - Fat version with embedded frontend
|
||||
# Single JAR contains both frontend and backend with extra fonts for air-gapped environments
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm --version \
|
||||
&& node --version \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy gradle files for dependency resolution
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build JAR with embedded frontend (includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Fat"
|
||||
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Fat version with extra fonts for air-gapped environments"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, fat, air-gapped, API, Spring Boot, React"
|
||||
|
||||
# Copy scripts and fonts
|
||||
COPY scripts /scripts
|
||||
COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
# Environment Variables
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \
|
||||
UNO_PATH=/usr/lib/libreoffice/program \
|
||||
URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \
|
||||
PATH=$PATH:/opt/venv/bin \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf
|
||||
|
||||
# Install all dependencies plus extra fonts for air-gapped environments
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openssl \
|
||||
openssl-dev \
|
||||
openjdk21-jre \
|
||||
# Doc conversion
|
||||
gcompat \
|
||||
libc6-compat \
|
||||
libreoffice \
|
||||
ghostscript \
|
||||
fontforge \
|
||||
# pdftohtml
|
||||
poppler-utils \
|
||||
# OCR MY PDF
|
||||
unpaper \
|
||||
tesseract-ocr-data-eng \
|
||||
tesseract-ocr-data-chi_sim \
|
||||
tesseract-ocr-data-deu \
|
||||
tesseract-ocr-data-fra \
|
||||
tesseract-ocr-data-por \
|
||||
ocrmypdf \
|
||||
# Extra fonts for fat version
|
||||
font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine \
|
||||
# CV
|
||||
py3-opencv \
|
||||
python3 \
|
||||
py3-pip \
|
||||
py3-pillow@testing \
|
||||
py3-pdf2image@testing && \
|
||||
python3 -m venv /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip setuptools && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \
|
||||
ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \
|
||||
ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \
|
||||
mv /usr/share/tessdata /usr/share/tessdata-original && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
fc-cache -f -v && \
|
||||
chmod +x /scripts/* && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /pipeline /usr/share/fonts/opentype/noto /configs /customFiles /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"]
|
||||
@@ -0,0 +1,104 @@
|
||||
# Stirling-PDF Dockerfile - Ultra-lite version with embedded frontend
|
||||
# Single JAR contains both frontend and backend with minimal dependencies
|
||||
|
||||
# Stage 1: Build application with embedded frontend
|
||||
FROM gradle:8.14-jdk21 AS build
|
||||
|
||||
# Install Node.js and npm for frontend build
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm --version \
|
||||
&& node --version \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy gradle files for dependency resolution
|
||||
COPY build.gradle .
|
||||
COPY settings.gradle .
|
||||
COPY gradlew .
|
||||
COPY gradle gradle/
|
||||
COPY app/core/build.gradle core/.
|
||||
COPY app/common/build.gradle common/.
|
||||
COPY app/proprietary/build.gradle proprietary/.
|
||||
RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy entire project
|
||||
COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.22.1
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Ultra-Lite"
|
||||
LABEL org.opencontainers.image.description="Stirling-PDF with embedded frontend - Ultra-lite version with minimal dependencies"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL org.opencontainers.image.vendor="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.url="https://www.stirlingpdf.com"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com"
|
||||
LABEL maintainer="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.authors="Stirling-Tools"
|
||||
LABEL org.opencontainers.image.version="${VERSION_TAG}"
|
||||
LABEL org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spring Boot, React"
|
||||
|
||||
# Copy scripts
|
||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
COPY --from=build /app/build/libs/restart-helper.jar /restart-helper.jar
|
||||
|
||||
# Environment Variables
|
||||
ENV VERSION_TAG=$VERSION_TAG \
|
||||
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
ENDPOINTS_GROUPS_TO_REMOVE=CLI
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \
|
||||
echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \
|
||||
echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \
|
||||
apk upgrade --no-cache -a && \
|
||||
apk add --no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
tini \
|
||||
bash \
|
||||
curl \
|
||||
shadow \
|
||||
su-exec \
|
||||
openjdk21-jre && \
|
||||
mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \
|
||||
mkdir -p /usr/share/fonts/opentype/noto && \
|
||||
chmod +x /scripts/*.sh && \
|
||||
# User permissions
|
||||
addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \
|
||||
chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \
|
||||
chown stirlingpdfuser:stirlingpdfgroup /app.jar /restart-helper.jar
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
|
||||
# Set user and run command
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"]
|
||||
CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"]
|
||||
@@ -103,8 +103,8 @@ http {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache static assets (but not API endpoints)
|
||||
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ http {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache static assets (but not API endpoints)
|
||||
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
+4
-4
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="/modern-logo/favicon.ico" />
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
<title>Stirling PDF</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
<script type="module" src="src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+159
-141
@@ -11,25 +11,26 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
"@embedpdf/plugin-search": "^1.4.1",
|
||||
"@embedpdf/plugin-selection": "^1.4.1",
|
||||
"@embedpdf/plugin-spread": "^1.4.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.4.1",
|
||||
"@embedpdf/plugin-tiling": "^1.4.1",
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@embedpdf/core": "^1.5.0",
|
||||
"@embedpdf/engines": "^1.5.0",
|
||||
"@embedpdf/plugin-annotation": "^1.5.0",
|
||||
"@embedpdf/plugin-bookmark": "^1.5.0",
|
||||
"@embedpdf/plugin-export": "^1.5.0",
|
||||
"@embedpdf/plugin-history": "^1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.5.0",
|
||||
"@embedpdf/plugin-loader": "^1.5.0",
|
||||
"@embedpdf/plugin-pan": "^1.5.0",
|
||||
"@embedpdf/plugin-print": "^1.5.0",
|
||||
"@embedpdf/plugin-render": "^1.5.0",
|
||||
"@embedpdf/plugin-rotate": "^1.5.0",
|
||||
"@embedpdf/plugin-scroll": "^1.5.0",
|
||||
"@embedpdf/plugin-search": "^1.5.0",
|
||||
"@embedpdf/plugin-selection": "^1.5.0",
|
||||
"@embedpdf/plugin-spread": "^1.5.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.5.0",
|
||||
"@embedpdf/plugin-tiling": "^1.5.0",
|
||||
"@embedpdf/plugin-viewport": "^1.5.0",
|
||||
"@embedpdf/plugin-zoom": "^1.5.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -576,14 +577,14 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
|
||||
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.4.1",
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/engines": "1.5.0",
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -594,13 +595,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.4.1.tgz",
|
||||
"integrity": "sha512-yugIb5OwTI/1VnAaEvSYxAd2DvYBPkV/D7wytagyaOq98o3sqzcY2Q9zHt+LhnawA5KKG1e/FDPjCd4qm8gsvg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.5.0.tgz",
|
||||
"integrity": "sha512-/GzhjHFHWfOaX7vjgFJX/pyq668wYjoda1bZ9MpwF/EF000Wwy2Q0AOhprjldPFz8ASKjwKwqsXmaqrK99yOAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1",
|
||||
"@embedpdf/pdfium": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0",
|
||||
"@embedpdf/pdfium": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -611,31 +612,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.4.1.tgz",
|
||||
"integrity": "sha512-2nTg8Q1qpplBvspZJXMCZOA+/OILpfdNRPddlplxZXY/Upx0rzKXx/e6pXWW7AuOgtfGneT4h9tMs3A595/PdQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.5.0.tgz",
|
||||
"integrity": "sha512-x/1li3jdag+IzfZkcfRLKLqASLep4v6dgVi3z0JArwaicFra8k1IY2xaVTrwcZyx7pRb/rxvoO9yLHW0Y34NFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.4.1.tgz",
|
||||
"integrity": "sha512-BekKEK4UNCwzj7xOffKn6WpL0FQHxq+mTj2iGI3N7OwAX2J/BO2G+rDOB+lvojQG+Dkpg8uqm427ZKJDRyLgVQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.5.0.tgz",
|
||||
"integrity": "sha512-PI32t2U4ThZC907n2Iwr8E5WqmC574G83u3V9ysNFl29N9kasrY9RiLSzU4W/yQvXPjIbpQHBsbMKXLjCFBI9w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.4.1.tgz",
|
||||
"integrity": "sha512-d4HibNy6ecyDqx2Y2R8VjaqppSdjNofAJmU6VenOd88wn080sAUqvnkeVJ6ehJH5BoND4ymQrcAkcbVeYK0myA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.5.0.tgz",
|
||||
"integrity": "sha512-mxEPI6xYwOGaf9fYfoywuj6nwA10eHFPBuN066MzwphDk6DOHJGZ3Vq8zNQBXh20c/Lb25PL718D7MZWxZLUHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1",
|
||||
"@embedpdf/utils": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0",
|
||||
"@embedpdf/utils": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-history": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-selection": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-history": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-selection": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -643,15 +644,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-bookmark": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
|
||||
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.5.0.tgz",
|
||||
"integrity": "sha512-s3C9PtVesy5X8Ds/C9TEElFiqfKGRklG/uNPTROpNoolfpi0h7qX2xqqh/9+FzKH2nHjVcPB7Pp432v16h7eRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -659,15 +660,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
|
||||
"integrity": "sha512-g89fREFM/zkt2Ai2Q5dWwDkhXgC/JmVyUniaMgm1fTG/MZ0Z05E7f34DUzX/CKcJyVjxEgl6tojBTMeUbm15bA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.5.0.tgz",
|
||||
"integrity": "sha512-luk68mNW9l2X31qk4b02phKaqDl9aDXUAgHVz1EWrgwXQ3Oz9WEdu60utYARYDiepDo3Caadll8RwctYSf/anA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -676,16 +677,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
|
||||
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
|
||||
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -693,16 +694,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
|
||||
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
|
||||
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -711,16 +712,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-loader": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
|
||||
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
|
||||
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -729,17 +730,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.4.1.tgz",
|
||||
"integrity": "sha512-zmOZJ9dUqXiaV0F5GPf/5WTWf3jAEkiv153Tl3x8HT9Rfff+WQhV48NruCIBAy/T4jVt4aH7D1zt/B/ftvcdkA==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.5.0.tgz",
|
||||
"integrity": "sha512-EMQ08dHqLkZmFVuLOO6h3AAinFPQoA1r6OlL9z+p0sswq31JAgd4X7+xjYIpI01z/V3+cTzPHzp7qwob5E4tbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -747,17 +748,34 @@
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-print": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-1.5.0.tgz",
|
||||
"integrity": "sha512-rjorvNxAZfO9X4cFZVU9fHnldMWqMceJGmr3mH+yj7KdHePvNDDP+omyZyZKtxlUZENaeDI2h6k5z0GbhBz6sQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
"svelte": ">=5 <6",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
|
||||
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
|
||||
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -766,15 +784,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.4.1.tgz",
|
||||
"integrity": "sha512-hVzHkKwMNH3tUhxqJGsj5qTLpYZXbj6E74AEcG0w/fz5FrK7EnofPqt0gRfYmIzxnQGIh+39BRtcp8gmx8UNnw==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.5.0.tgz",
|
||||
"integrity": "sha512-5EmBCsq0VfrE3xWY6ofuVm8S6aK95EbAycRIk1wczcmTdvpsuXZ6P2ZaECUgYMcpZ6uAg4/kGf8X8VVZuCihSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -783,17 +801,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
|
||||
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
|
||||
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -802,16 +820,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.4.1.tgz",
|
||||
"integrity": "sha512-8JG4CbOcUsLuT0vHJJ4cECmu+Yn53EokWFUVXi2Mo/XvHjhrQuWmD7+y6s/qQPEpctFYWmUCXTDAX9ynPud+2Q==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.5.0.tgz",
|
||||
"integrity": "sha512-TB5b0H8Iobx/azVUBIlG2ClaKtf0y3/Xi3E/iB8BwvkIE2+g6EGfp8IMXIn8WDXST6bbvJEP31Ab0Ilp6SVkiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-loader": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-loader": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -820,18 +838,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
|
||||
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
|
||||
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -840,16 +858,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.4.1.tgz",
|
||||
"integrity": "sha512-l+SrDVGTiiItkt2cEtzv7V/X5HhmLbYHcQ8CFobGeIKdJtzKS1Nu/JSKqg7Ki7eCNgyPL1yMNfNE92bNKYVN4w==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.5.0.tgz",
|
||||
"integrity": "sha512-3EU5Cp+fPQSiMjvMR/P2kXxXry/RlnxHLs4JeskAaH95QcqWW3VD+DrHkWSiLFkdhI18rNNGNlMc5RvDGvbXGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-loader": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-loader": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -858,16 +876,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.4.1.tgz",
|
||||
"integrity": "sha512-bN3msjI0PovazgbPK3LyugYVTwIDo0RyBUhBaG42FgJxeY3hmFOWTPgfUH1QF7twHlySnksIvHRFYR3nViryVw==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.5.0.tgz",
|
||||
"integrity": "sha512-Z2qpyyr5s2M6460KDGu1Vk6rdbQFIoCpnyFAT6e7UaTIKkqJSNpmjqMsBU5PosYCFu/cClpHPvS7tg9/IKAk6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-render": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-render": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -876,18 +894,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.4.1.tgz",
|
||||
"integrity": "sha512-wgTfj5T8HV6KP61iiR63DVNrbVp8sPxTqa1Sm+2/D0jY+EPSSCmpt1/qYWiAXd1X+t78foOjCnfbo7fEMn5/pg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.5.0.tgz",
|
||||
"integrity": "sha512-0Vx9elHNpMM+zv8hEoZXBEm8Q0+4kU52LxOlTYRr1A5FskF836sUct6g1ngwK1bmfbAfpz+62PnYI2EeilDZig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-render": "1.4.1",
|
||||
"@embedpdf/plugin-scroll": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-render": "1.5.0",
|
||||
"@embedpdf/plugin-scroll": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -896,16 +914,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
|
||||
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
|
||||
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
"@embedpdf/models": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -914,19 +932,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.4.1.tgz",
|
||||
"integrity": "sha512-9HocmXnPZxqN06q7kyNAmLjgDHOEW8/8QfgNE3nMpRyNHIgnAjxvsWc9lApgp5ErDPG0cSDt0Cduil6nB3wSBQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.5.0.tgz",
|
||||
"integrity": "sha512-LiDkCd5/IXg2CRORl1Yikan2op+AYXSxhHzCFatyBdwzVj+n4y9I74OwCI62Mar8WDAIMyXZDCQxGPToSm+zDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1",
|
||||
"@embedpdf/models": "1.5.0",
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.4.1",
|
||||
"@embedpdf/plugin-scroll": "1.4.1",
|
||||
"@embedpdf/plugin-viewport": "1.4.1",
|
||||
"@embedpdf/core": "1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.5.0",
|
||||
"@embedpdf/plugin-scroll": "1.5.0",
|
||||
"@embedpdf/plugin-viewport": "1.5.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -935,9 +953,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.4.1.tgz",
|
||||
"integrity": "sha512-vvJ51Qsz3PyJWR2YvDMMpJXg4+YqdV7Vn2cusmW9sx+4EnAiBiw0HevEE+FepgFV8k+A0WbwXzmsujDIQJ7R4A==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.5.0.tgz",
|
||||
"integrity": "sha512-L6jsAPQPGM8ne+MMFAd5gqXb1RNEgNyh16VvVUVKcVnJlBhwil59nVeEQ0cwPhjF5qVeY6MQDIOjBzJqkgXOYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
|
||||
+20
-19
@@ -7,25 +7,26 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
"@embedpdf/plugin-loader": "^1.4.1",
|
||||
"@embedpdf/plugin-pan": "^1.4.1",
|
||||
"@embedpdf/plugin-render": "^1.4.1",
|
||||
"@embedpdf/plugin-rotate": "^1.4.1",
|
||||
"@embedpdf/plugin-scroll": "^1.4.1",
|
||||
"@embedpdf/plugin-search": "^1.4.1",
|
||||
"@embedpdf/plugin-selection": "^1.4.1",
|
||||
"@embedpdf/plugin-spread": "^1.4.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.4.1",
|
||||
"@embedpdf/plugin-tiling": "^1.4.1",
|
||||
"@embedpdf/plugin-viewport": "^1.4.1",
|
||||
"@embedpdf/plugin-zoom": "^1.4.1",
|
||||
"@embedpdf/core": "^1.5.0",
|
||||
"@embedpdf/engines": "^1.5.0",
|
||||
"@embedpdf/plugin-annotation": "^1.5.0",
|
||||
"@embedpdf/plugin-bookmark": "^1.5.0",
|
||||
"@embedpdf/plugin-export": "^1.5.0",
|
||||
"@embedpdf/plugin-history": "^1.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.5.0",
|
||||
"@embedpdf/plugin-loader": "^1.5.0",
|
||||
"@embedpdf/plugin-pan": "^1.5.0",
|
||||
"@embedpdf/plugin-print": "^1.5.0",
|
||||
"@embedpdf/plugin-render": "^1.5.0",
|
||||
"@embedpdf/plugin-rotate": "^1.5.0",
|
||||
"@embedpdf/plugin-scroll": "^1.5.0",
|
||||
"@embedpdf/plugin-search": "^1.5.0",
|
||||
"@embedpdf/plugin-selection": "^1.5.0",
|
||||
"@embedpdf/plugin-spread": "^1.5.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.5.0",
|
||||
"@embedpdf/plugin-tiling": "^1.5.0",
|
||||
"@embedpdf/plugin-viewport": "^1.5.0",
|
||||
"@embedpdf/plugin-zoom": "^1.5.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.9 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.5 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.4 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -163,6 +163,11 @@ unfavorite = "إزالة من المفضلة"
|
||||
fullscreen = "التبديل إلى وضع ملء الشاشة"
|
||||
sidebar = "التبديل إلى وضع الشريط الجانبي"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "لم يتم العثور على الخادم الخلفي"
|
||||
retry = "إعادة المحاولة"
|
||||
unreachable = "لا يمكن للتطبيق حالياً الاتصال بالخادم الخلفي. تحقق من حالة الخادم والاتصال بالشبكة، ثم حاول مرة أخرى."
|
||||
|
||||
[zipWarning]
|
||||
title = "ملف ZIP كبير"
|
||||
message = "هذا الملف ZIP يحتوي على {{count}} ملفات. هل تريد الاستخراج على أي حال؟"
|
||||
@@ -913,8 +918,8 @@ desc = "تراكب ملف PDF فوق آخر"
|
||||
title = "تراكب ملفات PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "محرر نصوص PDF"
|
||||
desc = "مراجعة وتحرير صادرات Stirling PDF بصيغة JSON مع تحرير نصوص مجمّعة وإعادة إنشاء PDF"
|
||||
title = "محرر نص PDF"
|
||||
desc = "حرّر النصوص والصور الموجودة داخل ملفات PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "نص,تعليق,تسمية"
|
||||
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "توقيع مرسوم"
|
||||
defaultImageLabel = "توقيع مرفوع"
|
||||
defaultTextLabel = "توقيع مكتوب"
|
||||
saveButton = "حفظ التوقيع"
|
||||
savePersonal = "حفظ شخصي"
|
||||
saveShared = "حفظ مشترك"
|
||||
saveUnavailable = "أنشئ توقيعاً أولاً لحفظه."
|
||||
noChanges = "التوقيع الحالي محفوظ بالفعل."
|
||||
tempStorageTitle = "تخزين مؤقت في المتصفح"
|
||||
tempStorageDescription = "يتم تخزين التواقيع في متصفحك فقط. ستُفقد إذا حذفت بيانات المتصفح أو بدّلت المتصفح."
|
||||
personalHeading = "تواقيع شخصية"
|
||||
sharedHeading = "تواقيع مشتركة"
|
||||
personalDescription = "أنت فقط من يمكنه رؤية هذه التواقيع."
|
||||
sharedDescription = "يمكن لجميع المستخدمين رؤية هذه التواقيع واستخدامها."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "رسم"
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "الرجاء تسجيل الدخول"
|
||||
ssoSignIn = "تسجيل الدخول عبر تسجيل الدخول الأحادي"
|
||||
oAuth2AutoCreateDisabled = "تم تعطيل الإنشاء التلقائي لمستخدم OAuth2"
|
||||
oAuth2AdminBlockedUser = "تم حظر تسجيل أو تسجيل دخول المستخدمين غير المسجلين حاليًا. يرجى الاتصال بالمسؤول."
|
||||
oAuth2RequiresLicense = "يتطلب تسجيل الدخول عبر OAuth/SSO ترخيصاً مدفوعاً (Server أو Enterprise). يرجى الاتصال بالمسؤول لترقية باقتك."
|
||||
saml2RequiresLicense = "يتطلب تسجيل الدخول عبر SAML ترخيصاً مدفوعاً (Server أو Enterprise). يرجى الاتصال بالمسؤول لترقية باقتك."
|
||||
maxUsersReached = "تم الوصول إلى الحد الأقصى لعدد المستخدمين ضمن ترخيصك الحالي. يرجى الاتصال بالمسؤول لترقية باقتك أو إضافة مقاعد إضافية."
|
||||
oauth2RequestNotFound = "لم يتم العثور على طلب التفويض"
|
||||
oauth2InvalidUserInfoResponse = "استجابة معلومات المستخدم غير صالحة"
|
||||
oauth2invalidRequest = "طلب غير صالح"
|
||||
@@ -3774,7 +3790,7 @@ version = "الإصدار الحالي"
|
||||
title = "توثيق API"
|
||||
header = "توثيق API"
|
||||
desc = "عرض واختبار نقاط نهاية Stirling PDF API"
|
||||
tags = "api,documentation,swagger,endpoints,development"
|
||||
tags = "api,توثيق,swagger,نقاط النهاية,تطوير"
|
||||
|
||||
[cookieBanner.popUp]
|
||||
title = "كيف نستخدم ملفات تعريف الارتباط"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "ملاءمة للعرض"
|
||||
actualSize = "الحجم الفعلي"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "لا يمكن معاينة الملف"
|
||||
dualPageView = "عرض صفحتين"
|
||||
firstPage = "الصفحة الأولى"
|
||||
lastPage = "الصفحة الأخيرة"
|
||||
previousPage = "الصفحة السابقة"
|
||||
nextPage = "الصفحة التالية"
|
||||
onlyPdfSupported = "عارض الملفات يدعم ملفات PDF فقط. يبدو أن هذا الملف بتنسيق مختلف."
|
||||
previousPage = "الصفحة السابقة"
|
||||
singlePageView = "عرض صفحة واحدة"
|
||||
unknownFile = "ملف غير معروف"
|
||||
zoomIn = "تكبير"
|
||||
zoomOut = "تصغير"
|
||||
singlePageView = "عرض صفحة واحدة"
|
||||
dualPageView = "عرض صفحتين"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "إغلاق الصفحات المحددة"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "تبديل الشريط الجانبي"
|
||||
exportSelected = "تصدير الصفحات المحددة"
|
||||
toggleAnnotations = "تبديل ظهور التعليقات التوضيحية"
|
||||
annotationMode = "تبديل وضع التعليقات"
|
||||
print = "طباعة PDF"
|
||||
draw = "رسم"
|
||||
save = "حفظ"
|
||||
saveChanges = "حفظ التغييرات"
|
||||
@@ -4497,6 +4517,7 @@ description = "عنوان URL أو اسم الملف الخاص بـ Impressum (
|
||||
title = "الممتاز والمؤسسي"
|
||||
description = "تهيئة مفتاح الترخيص للمزايا الممتازة أو المؤسسية."
|
||||
license = "تهيئة الترخيص"
|
||||
noInput = "يرجى تقديم مفتاح ترخيص أو ملف"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "هل لديك مفتاح ترخيص أو ملف شهادة؟"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "لا يمكن التراجع عن استبدال مفتاح الترخ
|
||||
line2 = "سيُفقد ترخيصك السابق نهائياً ما لم تكن قد احتفظت بنسخة احتياطية منه في مكان آخر."
|
||||
line3 = "مهم: احتفظ بمفاتيح الترخيص خاصة وآمنة. لا تشاركها علناً أبداً."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "مفتاح الترخيص"
|
||||
file = "ملف الشهادة"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "ملف شهادة الترخيص"
|
||||
description = "قم بتحميل ملف الترخيص .lic أو .cert من عمليات الشراء دون اتصال"
|
||||
choose = "اختر ملف الترخيص"
|
||||
selected = "المحدد: {{filename}} ({{size}})"
|
||||
successMessage = "تم تحميل ملف الترخيص وتفعيله بنجاح. لا يلزم إعادة التشغيل."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "الترخيص النشط"
|
||||
file = "المصدر: ملف الترخيص ({{path}})"
|
||||
key = "المصدر: مفتاح الترخيص"
|
||||
type = "النوع: {{type}}"
|
||||
noInput = "يرجى تقديم مفتاح ترخيص أو تحميل ملف شهادة"
|
||||
success = "نجاح"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "تمكين الميزات الممتازة"
|
||||
description = "تمكين التحقق من مفتاح الترخيص لميزات Pro/المؤسسة"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} محدد"
|
||||
download = "تنزيل"
|
||||
delete = "حذف"
|
||||
unsupported = "غير مدعوم"
|
||||
active = "نشط"
|
||||
addToUpload = "إضافة إلى الرفع"
|
||||
closeFile = "إغلاق الملف"
|
||||
deleteAll = "حذف الكل"
|
||||
loadingFiles = "جارٍ تحميل الملفات..."
|
||||
noFiles = "لا توجد ملفات متاحة"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "مطلوب عنوان بريد إلكتروني واحد على الأقل"
|
||||
submit = "إرسال الدعوات"
|
||||
success = "تمت دعوة المستخدم/المستخدمين بنجاح"
|
||||
partialSuccess = "فشلت بعض الدعوات"
|
||||
partialFailure = "فشل بعض الدعوات"
|
||||
allFailed = "فشلت دعوة المستخدمين"
|
||||
error = "فشل إرسال الدعوات"
|
||||
|
||||
@@ -5800,6 +5842,13 @@ submit = "تسجيل الدخول"
|
||||
signInWith = "تسجيل الدخول باستخدام"
|
||||
oauthPending = "جارٍ فتح المتصفح للمصادقة..."
|
||||
orContinueWith = "أو المتابعة بالبريد الإلكتروني"
|
||||
serverRequirement = "ملاحظة: يجب أن يكون تسجيل الدخول مفعّلاً على الخادم."
|
||||
showInstructions = "كيفية التمكين؟"
|
||||
hideInstructions = "إخفاء الإرشادات"
|
||||
instructions = "لتمكين تسجيل الدخول على خادم Stirling PDF الخاص بك:"
|
||||
instructionsEnvVar = "عيّن متغيّر البيئة:"
|
||||
instructionsOrYml = "أو في settings.yml:"
|
||||
instructionsRestart = "ثم أعد تشغيل الخادم لتصبح التغييرات نافذة."
|
||||
|
||||
[setup.login.username]
|
||||
label = "اسم المستخدم"
|
||||
|
||||
@@ -163,6 +163,11 @@ unfavorite = "Seçilmişlərdən çıxar"
|
||||
fullscreen = "Tam ekran rejiminə keç"
|
||||
sidebar = "Yan panel rejiminə keç"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "Backend tapılmadı"
|
||||
retry = "Yenidən cəhd et"
|
||||
unreachable = "Tətbiq hazırda backend-ə qoşula bilmir. Backend-in vəziyyətini və şəbəkə bağlantısını yoxlayın, sonra yenidən cəhd edin."
|
||||
|
||||
[zipWarning]
|
||||
title = "Böyük ZIP faylı"
|
||||
message = "Bu ZIP {{count}} fayl ehtiva edir. Yenə də çıxarılsın?"
|
||||
@@ -914,7 +919,7 @@ title = "Üst-Üstə Qoy"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF Mətn Redaktoru"
|
||||
desc = "Qruplaşdırılmış mətn redaktəsi və PDF yenidən yaradılması ilə Stirling PDF JSON ixraclarını nəzərdən keçirin və redaktə edin"
|
||||
desc = "PDF-lərin içindəki mövcud mətn və şəkilləri redaktə edin"
|
||||
|
||||
[home.addText]
|
||||
tags = "mətn,şərh,etiket"
|
||||
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Çəkilmiş imza"
|
||||
defaultImageLabel = "Yüklənmiş imza"
|
||||
defaultTextLabel = "Yazılmış imza"
|
||||
saveButton = "İmzanı saxla"
|
||||
savePersonal = "Şəxsi yadda saxla"
|
||||
saveShared = "Paylaşılanı yadda saxla"
|
||||
saveUnavailable = "Saxlamaq üçün əvvəlcə imza yaradın."
|
||||
noChanges = "Cari imza artıq saxlanıb."
|
||||
tempStorageTitle = "Müvəqqəti brauzer yaddaşı"
|
||||
tempStorageDescription = "İmzalar yalnız brauzerinizdə saxlanılır. Brauzer məlumatlarını təmizləsəniz və ya brauzer dəyişsəniz, itəcək."
|
||||
personalHeading = "Şəxsi imzalar"
|
||||
sharedHeading = "Paylaşılan imzalar"
|
||||
personalDescription = "Bu imzaları yalnız siz görə bilirsiniz."
|
||||
sharedDescription = "Bütün istifadəçilər bu imzaları görə və istifadə edə bilərlər."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Rəsm"
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "Zəhmət olmasa, daxil olun"
|
||||
ssoSignIn = "Single Sign-on vasitəsilə daxil olun"
|
||||
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Create İstifadəçisi Deaktivləşdirilmişdir"
|
||||
oAuth2AdminBlockedUser = "Qeydiyyatdan keçməmiş istifadəçilərin qeydiyyatı və daxil olması hal-hazırda bloklanmışdır. Zəhmət olmasa, administratorla əlaqə saxlayın."
|
||||
oAuth2RequiresLicense = "OAuth/SSO ilə giriş üçün ödənişli lisenziya (Server və ya Enterprise) tələb olunur. Planınızı yüksəltmək üçün administratorla əlaqə saxlayın."
|
||||
saml2RequiresLicense = "SAML ilə giriş üçün ödənişli lisenziya (Server və ya Enterprise) tələb olunur. Planınızı yüksəltmək üçün administratorla əlaqə saxlayın."
|
||||
maxUsersReached = "Mövcud lisenziyanız üçün maksimum istifadəçi sayına çatılıb. Planınızı yüksəltmək və ya əlavə yerlər əlavə etmək üçün administratorla əlaqə saxlayın."
|
||||
oauth2RequestNotFound = "Təsdiqlənmə sorğusu tapılmadı"
|
||||
oauth2InvalidUserInfoResponse = "Yanlış İstifadəçi Məlumatı Cavabı"
|
||||
oauth2invalidRequest = "Etibarsız Sorğu"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "Eninə sığdır"
|
||||
actualSize = "Həqiqi ölçü"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Faylın önizlənməsi mümkün deyil"
|
||||
dualPageView = "İki Səhifə Görünüşü"
|
||||
firstPage = "Birinci səhifə"
|
||||
lastPage = "Son səhifə"
|
||||
previousPage = "Əvvəlki səhifə"
|
||||
nextPage = "Növbəti səhifə"
|
||||
onlyPdfSupported = "Görüntüləyici yalnız PDF fayllarını dəstəkləyir. Bu fayl fərqli formatda görünür."
|
||||
previousPage = "Əvvəlki səhifə"
|
||||
singlePageView = "Tək Səhifə Görünüşü"
|
||||
unknownFile = "Naməlum fayl"
|
||||
zoomIn = "Böyüt"
|
||||
zoomOut = "Kiçilt"
|
||||
singlePageView = "Tək Səhifə Görünüşü"
|
||||
dualPageView = "İki Səhifə Görünüşü"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Seçilmiş faylları bağla"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "Yan paneli aç/bağla"
|
||||
exportSelected = "Seçilmiş səhifələri ixrac et"
|
||||
toggleAnnotations = "Annotasiyaların görünməsini dəyiş"
|
||||
annotationMode = "Annotasiya rejimini dəyiş"
|
||||
print = "PDF-i çap et"
|
||||
draw = "Rəsm çək"
|
||||
save = "Yadda saxla"
|
||||
saveChanges = "Dəyişiklikləri yadda saxla"
|
||||
@@ -4410,7 +4430,7 @@ description = "Daha geniş sistem müvəqqəti qovluğunu təmizləyib-təmizlə
|
||||
label = "Proses İcraedicisi Limitləri"
|
||||
description = "Hər icraedici üçün sessiya limitlərini və taym-outları konfiqurasiya edin"
|
||||
libreOffice = "LibreOffice"
|
||||
pdfToHtml = "PDF to HTML"
|
||||
pdfToHtml = "PDF-dən HTML-ə"
|
||||
qpdf = "QPDF"
|
||||
tesseract = "Tesseract OCR"
|
||||
pythonOpenCv = "Python OpenCV"
|
||||
@@ -4497,6 +4517,7 @@ description = "Impressum üçün URL və ya fayl adı (bəzi yurisdiksiyalarda t
|
||||
title = "Premium və Enterprise"
|
||||
description = "Premium və ya enterprise lisenziya açarınızı konfiqurasiya edin."
|
||||
license = "Lisenziya Konfiqurasiyası"
|
||||
noInput = "Zəhmət olmasa lisenziya açarı və ya fayl təqdim edin"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Lisenziya açarınız və ya sertifikat faylınız var?"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "Cari lisenziya açarının üzərinə yazmaq geri alına bilməz."
|
||||
line2 = "Ehtiyat nüsxəsi yoxdursa, əvvəlki lisenziyanız birdəfəlik itəcək."
|
||||
line3 = "Vacibdir: Lisenziya açarlarını məxfi və təhlükəsiz saxlayın. Heç vaxt onları ictimai paylaşmayın."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "Lisenziya açarı"
|
||||
file = "Sertifikat faylı"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "Lisenziya sertifikat faylı"
|
||||
description = "Oflayn alışdan əldə etdiyiniz .lic və ya .cert lisenziya faylını yükləyin"
|
||||
choose = "Lisenziya faylını seçin"
|
||||
selected = "Seçildi: {{filename}} ({{size}})"
|
||||
successMessage = "Lisenziya faylı uğurla yüklənib və aktivləşdirilib. Yenidən başlatmağa ehtiyac yoxdur."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Aktiv lisenziya"
|
||||
file = "Mənbə: Lisenziya faylı ({{path}})"
|
||||
key = "Mənbə: Lisenziya açarı"
|
||||
type = "Növ: {{type}}"
|
||||
noInput = "Zəhmət olmasa lisenziya açarı verin və ya sertifikat faylı yükləyin"
|
||||
success = "Uğurlu"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Premium Xüsusiyyətlərini aktiv et"
|
||||
description = "Pro/enterprise xüsusiyyətləri üçün lisenziya açarı yoxlamalarını aktiv et"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} seçildi"
|
||||
download = "Endir"
|
||||
delete = "Sil"
|
||||
unsupported = "Dəstəklənmir"
|
||||
active = "Aktiv"
|
||||
addToUpload = "Yükləməyə əlavə et"
|
||||
closeFile = "Faylı bağla"
|
||||
deleteAll = "Hamısını sil"
|
||||
loadingFiles = "Fayllar yüklənir..."
|
||||
noFiles = "Fayl mövcud deyil"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "Ən azı bir e-poçt ünvanı tələb olunur"
|
||||
submit = "Dəvətnamələri göndər"
|
||||
success = "istifadəçi(lər) uğurla dəvət olundu"
|
||||
partialSuccess = "Bəzi dəvətnamələr alınmadı"
|
||||
partialFailure = "Bəzi dəvətlər uğursuz oldu"
|
||||
allFailed = "İstifadəçiləri dəvət etmək alınmadı"
|
||||
error = "Dəvətnamələri göndərmək alınmadı"
|
||||
|
||||
@@ -5291,8 +5333,8 @@ emailDisabled = "E-poçt dəvətləri üçün ayarlarda SMTP konfiqurasiyası v
|
||||
[workspace.people.license]
|
||||
users = "istifadəçi"
|
||||
availableSlots = "Mövcud yerlər"
|
||||
grandfathered = "Grandfathered"
|
||||
grandfatheredShort = "{{count}} grandfathered"
|
||||
grandfathered = "Əvvəlki şərtlərlə"
|
||||
grandfatheredShort = "{{count}} əvvəlki şərtlərlə"
|
||||
fromLicense = "lisenziyadan"
|
||||
slotsAvailable = "{{count}} istifadəçi yeri mövcuddur"
|
||||
noSlotsAvailable = "Mövcud yer yoxdur"
|
||||
@@ -5800,6 +5842,13 @@ submit = "Daxil ol"
|
||||
signInWith = "Bununla daxil ol"
|
||||
oauthPending = "Təsdiqləmə üçün brauzer açılır..."
|
||||
orContinueWith = "Və ya e-poçt ilə davam edin"
|
||||
serverRequirement = "Qeyd: Serverdə giriş funksiyası aktiv olmalıdır."
|
||||
showInstructions = "Necə aktivləşdirmək olar?"
|
||||
hideInstructions = "Təlimatları gizlət"
|
||||
instructions = "Stirling PDF serverinizdə girişi aktivləşdirmək üçün:"
|
||||
instructionsEnvVar = "Mühit dəyişənini təyin edin:"
|
||||
instructionsOrYml = "Və ya settings.yml faylında:"
|
||||
instructionsRestart = "Dəyişikliklərin qüvvəyə minməsi üçün serveri yenidən başladın."
|
||||
|
||||
[setup.login.username]
|
||||
label = "İstifadəçi adı"
|
||||
|
||||
@@ -163,6 +163,11 @@ unfavorite = "Премахване от любими"
|
||||
fullscreen = "Превключване към режим на цял екран"
|
||||
sidebar = "Превключване към режим със странична лента"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "Бекендът не е намерен"
|
||||
retry = "Опитай отново"
|
||||
unreachable = "Приложението в момента не може да се свърже с бекенда. Проверете състоянието на бекенда и мрежовата свързаност, след което опитайте отново."
|
||||
|
||||
[zipWarning]
|
||||
title = "Голям ZIP файл"
|
||||
message = "Този ZIP съдържа {{count}} файла. Да се извлече въпреки това?"
|
||||
@@ -913,8 +918,8 @@ desc = "Наслагва PDF файлове върху друг PDF"
|
||||
title = "Наслагване PDF-и"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF текстов редактор"
|
||||
desc = "Преглеждайте и редактирайте JSON експорти на Stirling PDF с групово редактиране на текст и повторно генериране на PDF"
|
||||
title = "Редактор на текст в PDF"
|
||||
desc = "Редактирайте съществуващ текст и изображения в PDF файлове"
|
||||
|
||||
[home.addText]
|
||||
tags = "текст,анотация,етикет"
|
||||
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Нарисуван подпис"
|
||||
defaultImageLabel = "Качен подпис"
|
||||
defaultTextLabel = "Въведен подпис"
|
||||
saveButton = "Запази подписа"
|
||||
savePersonal = "Запази като личен"
|
||||
saveShared = "Запази като споделен"
|
||||
saveUnavailable = "Първо създайте подпис, за да го запазите."
|
||||
noChanges = "Текущият подпис вече е запазен."
|
||||
tempStorageTitle = "Временно съхранение в браузъра"
|
||||
tempStorageDescription = "Подписите се съхраняват само във вашия браузър. Ще бъдат загубени, ако изчистите данните на браузъра или смените браузър."
|
||||
personalHeading = "Лични подписи"
|
||||
sharedHeading = "Споделени подписи"
|
||||
personalDescription = "Само вие можете да виждате тези подписи."
|
||||
sharedDescription = "Всички потребители могат да виждат и използват тези подписи."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Рисунка"
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "Моля впишете се"
|
||||
ssoSignIn = "Влизане чрез еднократно влизане"
|
||||
oAuth2AutoCreateDisabled = "OAUTH2 Автоматично създаване на потребител е деактивирано"
|
||||
oAuth2AdminBlockedUser = "Регистрацията или влизането на нерегистрирани потребители в момента е блокирано. Моля, свържете се с администратора."
|
||||
oAuth2RequiresLicense = "Вход с OAuth/SSO изисква платен лиценз (Server или Enterprise). Моля, свържете се с администратора, за да надстроите плана си."
|
||||
saml2RequiresLicense = "Вход със SAML изисква платен лиценз (Server или Enterprise). Моля, свържете се с администратора, за да надстроите плана си."
|
||||
maxUsersReached = "Достигнат е максималният брой потребители за текущия ви лиценз. Моля, свържете се с администратора, за да надстроите плана си или да добавите още места."
|
||||
oauth2RequestNotFound = "Заявката за оторизация не е намерена"
|
||||
oauth2InvalidUserInfoResponse = "Невалидна информация за потребителя"
|
||||
oauth2invalidRequest = "Невалидна заявка"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "Побиране по ширина"
|
||||
actualSize = "Действителен размер"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Не може да се визуализира файлът"
|
||||
dualPageView = "Изглед: две страници"
|
||||
firstPage = "Първа страница"
|
||||
lastPage = "Последна страница"
|
||||
previousPage = "Предишна страница"
|
||||
nextPage = "Следваща страница"
|
||||
onlyPdfSupported = "Прегледачът поддържа само PDF файлове. Този файл изглежда е в друг формат."
|
||||
previousPage = "Предишна страница"
|
||||
singlePageView = "Изглед: една страница"
|
||||
unknownFile = "Непознат файл"
|
||||
zoomIn = "Увеличи"
|
||||
zoomOut = "Намали"
|
||||
singlePageView = "Изглед: една страница"
|
||||
dualPageView = "Изглед: две страници"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Затвори избраните файлове"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "Показване/скриване на страничната
|
||||
exportSelected = "Експорт на избраните страници"
|
||||
toggleAnnotations = "Показване/скриване на анотациите"
|
||||
annotationMode = "Превключи режим на анотации"
|
||||
print = "Печат на PDF"
|
||||
draw = "Рисуване"
|
||||
save = "Запази"
|
||||
saveChanges = "Запази промените"
|
||||
@@ -4497,6 +4517,7 @@ description = "URL или име на файл към импресум (задъ
|
||||
title = "Премиум и Enterprise"
|
||||
description = "Конфигурирайте вашия премиум или enterprise лицензионен ключ."
|
||||
license = "Конфигурация на лиценз"
|
||||
noInput = "Моля, предоставете лицензен ключ или файл"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Имате лицензен ключ или сертификат?"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "Презаписването на текущия лицензен кл
|
||||
line2 = "Предишният лиценз ще бъде окончателно загубен, освен ако не сте го архивирали другаде."
|
||||
line3 = "Важно: Пазете лицензните ключове поверителни и сигурни. Никога не ги споделяйте публично."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "Лицензен ключ"
|
||||
file = "Файл със сертификат"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "Файл с лицензен сертификат"
|
||||
description = "Качете вашия .lic или .cert лицензен файл от офлайн покупки"
|
||||
choose = "Изберете лицензен файл"
|
||||
selected = "Избрано: {{filename}} ({{size}})"
|
||||
successMessage = "Лицензният файл беше качен и активиран успешно. Не е необходимо рестартиране."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Активен лиценз"
|
||||
file = "Източник: Лицензен файл ({{path}})"
|
||||
key = "Източник: Лицензен ключ"
|
||||
type = "Тип: {{type}}"
|
||||
noInput = "Моля, предоставете лицензен ключ или качете файл със сертификат"
|
||||
success = "Успешно"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Активирай премиум функции"
|
||||
description = "Активира проверки на лицензионния ключ за pro/enterprise функции"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} избрани"
|
||||
download = "Изтегли"
|
||||
delete = "Изтрий"
|
||||
unsupported = "Неподдържано"
|
||||
active = "Активен"
|
||||
addToUpload = "Добави към качването"
|
||||
closeFile = "Затвори файла"
|
||||
deleteAll = "Изтрий всички"
|
||||
loadingFiles = "Зареждане на файлове..."
|
||||
noFiles = "Няма налични файлове"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "Изисква се поне един имейл адрес"
|
||||
submit = "Изпрати покани"
|
||||
success = "Потребител(и) поканени успешно"
|
||||
partialSuccess = "Някои покани не успяха"
|
||||
partialFailure = "Някои покани бяха неуспешни"
|
||||
allFailed = "Неуспешно канене на потребители"
|
||||
error = "Неуспешно изпращане на покани"
|
||||
|
||||
@@ -5800,6 +5842,13 @@ submit = "Вход"
|
||||
signInWith = "Вписване с"
|
||||
oauthPending = "Отваряне на браузър за удостоверяване..."
|
||||
orContinueWith = "Или продължете с имейл"
|
||||
serverRequirement = "Забележка: Сървърът трябва да има активиран вход."
|
||||
showInstructions = "Как да се активира?"
|
||||
hideInstructions = "Скрий инструкциите"
|
||||
instructions = "За да активирате вход на вашия Stirling PDF сървър:"
|
||||
instructionsEnvVar = "Задайте променливата на средата:"
|
||||
instructionsOrYml = "Или в settings.yml:"
|
||||
instructionsRestart = "След това рестартирайте сървъра, за да влязат промените в сила."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Потребителско име"
|
||||
|
||||
@@ -163,6 +163,11 @@ unfavorite = "Elimina dels preferits"
|
||||
fullscreen = "Canvia al mode de pantalla completa"
|
||||
sidebar = "Canvia al mode de barra lateral"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "Backend no trobat"
|
||||
retry = "Torneu-ho a intentar"
|
||||
unreachable = "L'aplicació no pot connectar-se al backend ara mateix. Verifiqueu l'estat del backend i la connectivitat de xarxa i torneu-ho a intentar."
|
||||
|
||||
[zipWarning]
|
||||
title = "Fitxer ZIP gran"
|
||||
message = "Aquest ZIP conté {{count}} fitxers. Vols extreure'l igualment?"
|
||||
@@ -914,7 +919,7 @@ title = "Superposar PDFs"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor de text PDF"
|
||||
desc = "Revisa i edita exportacions JSON de Stirling PDF amb edició de text agrupada i regeneració del PDF"
|
||||
desc = "Edita el text i les imatges existents dins dels PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,anotació,etiqueta"
|
||||
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Signatura dibuixada"
|
||||
defaultImageLabel = "Signatura pujada"
|
||||
defaultTextLabel = "Signatura teclejada"
|
||||
saveButton = "Desa la signatura"
|
||||
savePersonal = "Desa com a personal"
|
||||
saveShared = "Desa com a compartida"
|
||||
saveUnavailable = "Crea una signatura primer per poder-la desar."
|
||||
noChanges = "La signatura actual ja està desada."
|
||||
tempStorageTitle = "Emmagatzematge temporal del navegador"
|
||||
tempStorageDescription = "Les signatures només s'emmagatzemen al vostre navegador. Es perdran si netegeu les dades del navegador o canvieu de navegador."
|
||||
personalHeading = "Signatures personals"
|
||||
sharedHeading = "Signatures compartides"
|
||||
personalDescription = "Només vosaltres podeu veure aquestes signatures."
|
||||
sharedDescription = "Tots els usuaris poden veure i utilitzar aquestes signatures."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Dibuix"
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "Autenticat"
|
||||
ssoSignIn = "Inicia sessió mitjançant inici de sessió únic"
|
||||
oAuth2AutoCreateDisabled = "La creació automàtica d'usuaris OAUTH2 està desactivada"
|
||||
oAuth2AdminBlockedUser = "El registre o inici de sessió d'usuaris no registrats està actualment bloquejat. Si us plau, contacta amb l'administrador."
|
||||
oAuth2RequiresLicense = "L'inici de sessió OAuth/SSO requereix una llicència de pagament (Server o Enterprise). Poseu-vos en contacte amb l'administrador per actualitzar el vostre pla."
|
||||
saml2RequiresLicense = "L'inici de sessió SAML requereix una llicència de pagament (Server o Enterprise). Poseu-vos en contacte amb l'administrador per actualitzar el vostre pla."
|
||||
maxUsersReached = "S'ha assolit el nombre màxim d'usuaris de la vostra llicència actual. Poseu-vos en contacte amb l'administrador per actualitzar el vostre pla o afegir més places."
|
||||
oauth2RequestNotFound = "Sol·licitud d'autorització no trobada"
|
||||
oauth2InvalidUserInfoResponse = "Resposta d'informació d'usuari no vàlida"
|
||||
oauth2invalidRequest = "Sol·licitud no vàlida"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "Ajusta a l'amplada"
|
||||
actualSize = "Mida real"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "No es pot previsualitzar el fitxer"
|
||||
dualPageView = "Vista de dues pàgines"
|
||||
firstPage = "Primera pàgina"
|
||||
lastPage = "Última pàgina"
|
||||
previousPage = "Pàgina anterior"
|
||||
nextPage = "Pàgina següent"
|
||||
onlyPdfSupported = "El visualitzador només admet fitxers PDF. Aquest fitxer sembla ser d'un format diferent."
|
||||
previousPage = "Pàgina anterior"
|
||||
singlePageView = "Vista d'una sola pàgina"
|
||||
unknownFile = "Fitxer desconegut"
|
||||
zoomIn = "Amplia"
|
||||
zoomOut = "Redueix"
|
||||
singlePageView = "Vista d'una sola pàgina"
|
||||
dualPageView = "Vista de dues pàgines"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Tanca els fitxers seleccionats"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "Mostra/oculta la barra lateral"
|
||||
exportSelected = "Exporta les pàgines seleccionades"
|
||||
toggleAnnotations = "Mostra/oculta les anotacions"
|
||||
annotationMode = "Activa/desactiva el mode d'anotació"
|
||||
print = "Imprimeix el PDF"
|
||||
draw = "Dibuixa"
|
||||
save = "Desa"
|
||||
saveChanges = "Desa els canvis"
|
||||
@@ -3928,7 +3948,7 @@ files = "Fitxers"
|
||||
activity = "Registre"
|
||||
help = "Ajuda"
|
||||
account = "Compte"
|
||||
config = "Config"
|
||||
config = "Configuració"
|
||||
settings = "Ajustos"
|
||||
adminSettings = "Ajustos admin"
|
||||
allTools = "All Tools"
|
||||
@@ -4497,6 +4517,7 @@ description = "URL o nom de fitxer de l'impressum (requerit en algunes jurisdicc
|
||||
title = "Premium i Enterprise"
|
||||
description = "Configureu la clau de llicència Premium o Enterprise."
|
||||
license = "Configuració de llicència"
|
||||
noInput = "Proporcioneu una clau de llicència o un fitxer"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Tens una clau de llicència o un fitxer de certificat?"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "Sobreescriure la clau de llicència actual no es pot desfer."
|
||||
line2 = "La llicència anterior es perdrà permanentment si no en tens una còpia de seguretat."
|
||||
line3 = "Important: mantén les claus de llicència privades i segures. No les comparteixis mai públicament."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "Clau de llicència"
|
||||
file = "Fitxer de certificat"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "Fitxer de certificat de llicència"
|
||||
description = "Pugeu el vostre fitxer de llicència .lic o .cert de compres fora de línia"
|
||||
choose = "Trieu el fitxer de llicència"
|
||||
selected = "Seleccionat: {{filename}} ({{size}})"
|
||||
successMessage = "Fitxer de llicència pujat i activat correctament. No cal reiniciar."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Llicència activa"
|
||||
file = "Origen: Fitxer de llicència ({{path}})"
|
||||
key = "Origen: Clau de llicència"
|
||||
type = "Tipus: {{type}}"
|
||||
noInput = "Proporcioneu una clau de llicència o pugeu un fitxer de certificat"
|
||||
success = "Èxit"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Habilita les funcions Premium"
|
||||
description = "Habilita les comprovacions de clau per a funcions pro/enterprise"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} seleccionats"
|
||||
download = "Descarrega"
|
||||
delete = "Esborra"
|
||||
unsupported = "No compatible"
|
||||
active = "Actiu"
|
||||
addToUpload = "Afegeix a la pujada"
|
||||
closeFile = "Tanca el fitxer"
|
||||
deleteAll = "Suprimeix-ho tot"
|
||||
loadingFiles = "Carregant fitxers..."
|
||||
noFiles = "No hi ha fitxers disponibles"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "Cal almenys una adreça de correu"
|
||||
submit = "Envia invitacions"
|
||||
success = "usuari(s) convidat(s) correctament"
|
||||
partialSuccess = "Algunes invitacions han fallat"
|
||||
partialFailure = "Algunes invitacions han fallat"
|
||||
allFailed = "No s’ha pogut convidar els usuaris"
|
||||
error = "No s’han pogut enviar les invitacions"
|
||||
|
||||
@@ -5800,6 +5842,13 @@ submit = "Inicia sessió"
|
||||
signInWith = "Inicia sessió amb"
|
||||
oauthPending = "Obrint el navegador per autenticar-te..."
|
||||
orContinueWith = "O continua amb el correu electrònic"
|
||||
serverRequirement = "Nota: el servidor ha de tenir l'inici de sessió habilitat."
|
||||
showInstructions = "Com s'habilita?"
|
||||
hideInstructions = "Amagueu les instruccions"
|
||||
instructions = "Per habilitar l'inici de sessió al vostre servidor de Stirling PDF:"
|
||||
instructionsEnvVar = "Establiu la variable d'entorn:"
|
||||
instructionsOrYml = "O a settings.yml:"
|
||||
instructionsRestart = "A continuació, reinicieu el servidor perquè els canvis tinguin efecte."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Nom d'usuari"
|
||||
|
||||
@@ -163,6 +163,11 @@ unfavorite = "Odebrat z oblíbených"
|
||||
fullscreen = "Přepnout na režim na celou obrazovku"
|
||||
sidebar = "Přepnout na režim postranního panelu"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "Backend nebyl nalezen"
|
||||
retry = "Zkusit znovu"
|
||||
unreachable = "Aplikace se nyní nemůže připojit k backendu. Ověřte stav backendu a síťové připojení a poté to zkuste znovu."
|
||||
|
||||
[zipWarning]
|
||||
title = "Velký soubor ZIP"
|
||||
message = "Tento ZIP obsahuje {{count}} souborů. Přesto rozbalit?"
|
||||
@@ -347,7 +352,7 @@ teams = "Týmy"
|
||||
title = "Konfigurace"
|
||||
systemSettings = "Systémová nastavení"
|
||||
features = "Funkce"
|
||||
endpoints = "Endpoints"
|
||||
endpoints = "Koncové body"
|
||||
database = "Databáze"
|
||||
advanced = "Pokročilé"
|
||||
|
||||
@@ -914,7 +919,7 @@ title = "Překrýt PDF"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "Editor textu PDF"
|
||||
desc = "Prohlížejte a upravujte exporty JSON ze Stirling PDF se skupinovými úpravami textu a regenerací PDF"
|
||||
desc = "Upravujte existující text a obrázky v PDF"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,anotace,štítek"
|
||||
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Nakreslený podpis"
|
||||
defaultImageLabel = "Nahraný podpis"
|
||||
defaultTextLabel = "Napsaný podpis"
|
||||
saveButton = "Uložit podpis"
|
||||
savePersonal = "Uložit osobní"
|
||||
saveShared = "Uložit sdílené"
|
||||
saveUnavailable = "Nejprve vytvořte podpis, abyste jej mohli uložit."
|
||||
noChanges = "Aktuální podpis je již uložen."
|
||||
tempStorageTitle = "Dočasné úložiště prohlížeče"
|
||||
tempStorageDescription = "Podpisy jsou uloženy pouze ve vašem prohlížeči. Při vymazání dat prohlížeče nebo při přepnutí na jiný prohlížeč budou ztraceny."
|
||||
personalHeading = "Osobní podpisy"
|
||||
sharedHeading = "Sdílené podpisy"
|
||||
personalDescription = "Tyto podpisy vidíte pouze vy."
|
||||
sharedDescription = "Všichni uživatelé mohou tyto podpisy vidět a používat."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Kresba"
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "Prosím přihlaste se"
|
||||
ssoSignIn = "Přihlásit se přes Single Sign-on"
|
||||
oAuth2AutoCreateDisabled = "Automatické vytváření OAUTH2 uživatelů je zakázáno"
|
||||
oAuth2AdminBlockedUser = "Registrace nebo přihlášení neregistrovaných uživatelů je momentálně blokováno. Kontaktujte prosím správce."
|
||||
oAuth2RequiresLicense = "Přihlášení pomocí OAuth/SSO vyžaduje placenou licenci (Server nebo Enterprise). Kontaktujte prosím administrátora kvůli upgradu vašeho plánu."
|
||||
saml2RequiresLicense = "Přihlášení pomocí SAML vyžaduje placenou licenci (Server nebo Enterprise). Kontaktujte prosím administrátora kvůli upgradu vašeho plánu."
|
||||
maxUsersReached = "Byl dosažen maximální počet uživatelů pro vaši aktuální licenci. Kontaktujte prosím administrátora kvůli upgradu vašeho plánu nebo přidání dalších míst."
|
||||
oauth2RequestNotFound = "Požadavek na autorizaci nebyl nalezen"
|
||||
oauth2InvalidUserInfoResponse = "Neplatná odpověď s informacemi o uživateli"
|
||||
oauth2invalidRequest = "Neplatný požadavek"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "Přizpůsobit šířce"
|
||||
actualSize = "Skutečná velikost"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Nelze zobrazit náhled souboru"
|
||||
dualPageView = "Zobrazení dvou stránek"
|
||||
firstPage = "První stránka"
|
||||
lastPage = "Poslední stránka"
|
||||
previousPage = "Předchozí stránka"
|
||||
nextPage = "Další stránka"
|
||||
onlyPdfSupported = "Prohlížeč podporuje pouze soubory PDF. Tento soubor má zřejmě jiný formát."
|
||||
previousPage = "Předchozí stránka"
|
||||
singlePageView = "Zobrazení jedné stránky"
|
||||
unknownFile = "Neznámý soubor"
|
||||
zoomIn = "Přiblížit"
|
||||
zoomOut = "Oddálit"
|
||||
singlePageView = "Zobrazení jedné stránky"
|
||||
dualPageView = "Zobrazení dvou stránek"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Zavřít vybrané soubory"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "Přepnout postranní panel"
|
||||
exportSelected = "Exportovat vybrané stránky"
|
||||
toggleAnnotations = "Přepnout viditelnost anotací"
|
||||
annotationMode = "Přepnout režim anotací"
|
||||
print = "Tisk PDF"
|
||||
draw = "Kreslit"
|
||||
save = "Uložit"
|
||||
saveChanges = "Uložit změny"
|
||||
@@ -4497,6 +4517,7 @@ description = "URL nebo název souboru k Impressu (vyžadováno v některých ju
|
||||
title = "Premium a Enterprise"
|
||||
description = "Nakonfigurujte svůj prémiový nebo enterprise licenční klíč."
|
||||
license = "Konfigurace licence"
|
||||
noInput = "Zadejte licenční klíč nebo soubor"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Máte licenční klíč nebo certifikační soubor?"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "Přepsání aktuálního licenčního klíče nelze vrátit zpět."
|
||||
line2 = "Předchozí licence bude trvale ztracena, pokud ji nemáte zálohovanou jinde."
|
||||
line3 = "Důležité: Uchovávejte licenční klíče v soukromí a v bezpečí. Nikdy je nesdílejte veřejně."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "Licenční klíč"
|
||||
file = "Soubor certifikátu"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "Soubor licenčního certifikátu"
|
||||
description = "Nahrajte svůj licenční soubor .lic nebo .cert z offline nákupu"
|
||||
choose = "Vybrat licenční soubor"
|
||||
selected = "Vybráno: {{filename}} ({{size}})"
|
||||
successMessage = "Licenční soubor byl úspěšně nahrán a aktivován. Restart není vyžadován."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Aktivní licence"
|
||||
file = "Zdroj: Licenční soubor ({{path}})"
|
||||
key = "Zdroj: Licenční klíč"
|
||||
type = "Typ: {{type}}"
|
||||
noInput = "Zadejte licenční klíč nebo nahrajte soubor certifikátu"
|
||||
success = "Úspěch"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Povolit prémiové funkce"
|
||||
description = "Povolit kontrolu licenčního klíče pro pro/enterprise funkce"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} vybráno"
|
||||
download = "Stáhnout"
|
||||
delete = "Smazat"
|
||||
unsupported = "Nepodporováno"
|
||||
active = "Aktivní"
|
||||
addToUpload = "Přidat k nahrání"
|
||||
closeFile = "Zavřít soubor"
|
||||
deleteAll = "Smazat vše"
|
||||
loadingFiles = "Načítání souborů..."
|
||||
noFiles = "Nejsou k dispozici žádné soubory"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "uzivatel1@priklad.cz, uzivatel2@priklad.cz"
|
||||
emailsRequired = "Je vyžadována alespoň jedna e‑mailová adresa"
|
||||
submit = "Odeslat pozvánky"
|
||||
success = "uživatel(é) úspěšně pozváni"
|
||||
partialSuccess = "Některé pozvánky se nepodařilo odeslat"
|
||||
partialFailure = "Některá pozvání selhala"
|
||||
allFailed = "Nepodařilo se pozvat uživatele"
|
||||
error = "Nepodařilo se odeslat pozvánky"
|
||||
|
||||
@@ -5712,7 +5754,7 @@ title = "Graf využití endpointů"
|
||||
|
||||
[usage.table]
|
||||
title = "Podrobné statistiky"
|
||||
endpoint = "Endpoint"
|
||||
endpoint = "Koncový bod"
|
||||
visits = "Návštěvy"
|
||||
percentage = "Procenta"
|
||||
noData = "Žádná data nejsou k dispozici"
|
||||
@@ -5800,6 +5842,13 @@ submit = "Přihlásit se"
|
||||
signInWith = "Přihlásit se pomocí"
|
||||
oauthPending = "Otevírám prohlížeč pro ověření..."
|
||||
orContinueWith = "Nebo pokračovat e-mailem"
|
||||
serverRequirement = "Poznámka: Na serveru musí být povoleno přihlášení."
|
||||
showInstructions = "Jak povolit?"
|
||||
hideInstructions = "Skrýt pokyny"
|
||||
instructions = "Chcete-li povolit přihlášení na vašem serveru Stirling PDF:"
|
||||
instructionsEnvVar = "Nastavte proměnnou prostředí:"
|
||||
instructionsOrYml = "Nebo v settings.yml:"
|
||||
instructionsRestart = "Poté restartujte server, aby se změny projevily."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Uživatelské jméno"
|
||||
@@ -5843,7 +5892,7 @@ paragraph = "Odstavcová stránka"
|
||||
sparse = "Řídký text"
|
||||
|
||||
[pdfTextEditor.groupingMode]
|
||||
auto = "Auto"
|
||||
auto = "Automaticky"
|
||||
paragraph = "Odstavec"
|
||||
singleLine = "Jeden řádek"
|
||||
|
||||
@@ -5935,13 +5984,13 @@ warnings = "Varování"
|
||||
suggestions = "Poznámky"
|
||||
currentPageFonts = "Fonty na této stránce"
|
||||
allFonts = "Všechny fonty"
|
||||
fallback = "fallback"
|
||||
fallback = "náhradní"
|
||||
missing = "chybí"
|
||||
perfectMessage = "Všechny fonty lze reprodukovat dokonale."
|
||||
warningMessage = "Některé fonty se nemusí vykreslit správně."
|
||||
infoMessage = "K dispozici jsou informace o reprodukci fontů."
|
||||
perfect = "perfect"
|
||||
subset = "subset"
|
||||
perfect = "dokonalé"
|
||||
subset = "podmnožina"
|
||||
|
||||
[pdfTextEditor.errors]
|
||||
invalidJson = "Nelze přečíst soubor JSON. Ujistěte se, že byl vytvořen nástrojem PDF to JSON."
|
||||
|
||||
@@ -163,6 +163,11 @@ unfavorite = "Fjern fra favoritter"
|
||||
fullscreen = "Skift til fuldskærmstilstand"
|
||||
sidebar = "Skift til sidepanel-tilstand"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "Backend ikke fundet"
|
||||
retry = "Prøv igen"
|
||||
unreachable = "Programmet kan i øjeblikket ikke forbinde til backend. Kontroller backend-status og netværksforbindelse, og prøv igen."
|
||||
|
||||
[zipWarning]
|
||||
title = "Stor ZIP-fil"
|
||||
message = "Denne ZIP indeholder {{count}} filer. Udpak alligevel?"
|
||||
@@ -347,7 +352,7 @@ teams = "Teams"
|
||||
title = "Konfiguration"
|
||||
systemSettings = "Systemindstillinger"
|
||||
features = "Funktioner"
|
||||
endpoints = "Endpoints"
|
||||
endpoints = "Slutpunkter"
|
||||
database = "Database"
|
||||
advanced = "Avanceret"
|
||||
|
||||
@@ -359,7 +364,7 @@ connections = "Forbindelser"
|
||||
[settings.licensingAnalytics]
|
||||
title = "Licensering & Analytics"
|
||||
plan = "Plan"
|
||||
audit = "Audit"
|
||||
audit = "Revision"
|
||||
usageAnalytics = "Brugsanalyse"
|
||||
|
||||
[settings.policiesPrivacy]
|
||||
@@ -556,13 +561,13 @@ totalEndpoints = "Endpoints i alt"
|
||||
totalVisits = "Besøg i alt"
|
||||
showing = "Viser"
|
||||
selectedVisits = "Valgte besøg"
|
||||
endpoint = "Endpoint"
|
||||
endpoint = "Slutpunkt"
|
||||
visits = "Besøg"
|
||||
percentage = "Procent"
|
||||
loading = "Laster..."
|
||||
failedToLoad = "Kunne ikke indlæse endpoint-data. Prøv at opdatere."
|
||||
home = "Hjem"
|
||||
login = "Login"
|
||||
login = "Log ind"
|
||||
top = "Top"
|
||||
numberOfVisits = "Antal besøg"
|
||||
visitsTooltip = "Besøg: {0} ({1}% af totalen)"
|
||||
@@ -914,7 +919,7 @@ title = "Overlejr PDF'er"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-teksteditor"
|
||||
desc = "Gennemse og rediger Stirling PDF JSON-eksporter med grupperet tekstredigering og regenerering af PDF"
|
||||
desc = "Rediger eksisterende tekst og billeder i PDF'er"
|
||||
|
||||
[home.addText]
|
||||
tags = "tekst,annotering,etiket"
|
||||
@@ -1176,7 +1181,7 @@ selectFilesPlaceholder = "Vælg filer i hovedvisningen for at komme i gang"
|
||||
settings = "Indstillinger"
|
||||
conversionCompleted = "Konvertering fuldført"
|
||||
results = "Resultater"
|
||||
defaultFilename = "converted_file"
|
||||
defaultFilename = "konverteret_fil"
|
||||
conversionResults = "Konverteringsresultater"
|
||||
convertFrom = "Konvertér fra"
|
||||
convertTo = "Konvertér til"
|
||||
@@ -2262,8 +2267,16 @@ defaultCanvasLabel = "Tegnet signatur"
|
||||
defaultImageLabel = "Uploadet signatur"
|
||||
defaultTextLabel = "Indtastet signatur"
|
||||
saveButton = "Gem signatur"
|
||||
savePersonal = "Gem personlig"
|
||||
saveShared = "Gem delt"
|
||||
saveUnavailable = "Opret først en signatur for at gemme den."
|
||||
noChanges = "Nuværende signatur er allerede gemt."
|
||||
tempStorageTitle = "Midlertidig browserlagring"
|
||||
tempStorageDescription = "Signaturer gemmes kun i din browser. De går tabt, hvis du rydder browserdata eller skifter browser."
|
||||
personalHeading = "Personlige signaturer"
|
||||
sharedHeading = "Delte signaturer"
|
||||
personalDescription = "Kun du kan se disse signaturer."
|
||||
sharedDescription = "Alle brugere kan se og bruge disse signaturer."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Tegning"
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "Log venligst ind"
|
||||
ssoSignIn = "Log ind via Single Sign-on"
|
||||
oAuth2AutoCreateDisabled = "OAUTH2 Auto-Opret Bruger Deaktiveret"
|
||||
oAuth2AdminBlockedUser = "Registrering eller login af ikke-registrerede brugere er i øjeblikket blokeret. Kontakt venligst administratoren."
|
||||
oAuth2RequiresLicense = "OAuth/SSO-login kræver en betalt licens (Server eller Enterprise). Kontakt administratoren for at opgradere din plan."
|
||||
saml2RequiresLicense = "SAML-login kræver en betalt licens (Server eller Enterprise). Kontakt administratoren for at opgradere din plan."
|
||||
maxUsersReached = "Maksimalt antal brugere er nået for din nuværende licens. Kontakt administratoren for at opgradere din plan eller tilføje flere pladser."
|
||||
oauth2RequestNotFound = "Autorisationsanmodning ikke fundet"
|
||||
oauth2InvalidUserInfoResponse = "Ugyldigt Brugerinfo Svar"
|
||||
oauth2invalidRequest = "Ugyldig Anmodning"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "Tilpas til bredde"
|
||||
actualSize = "Faktisk størrelse"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Kan ikke forhåndsvise fil"
|
||||
dualPageView = "To-siders visning"
|
||||
firstPage = "Første side"
|
||||
lastPage = "Sidste side"
|
||||
previousPage = "Forrige side"
|
||||
nextPage = "Næste side"
|
||||
onlyPdfSupported = "Visningen understøtter kun PDF-filer. Denne fil ser ud til at være et andet format."
|
||||
previousPage = "Forrige side"
|
||||
singlePageView = "Enkelt-sides visning"
|
||||
unknownFile = "Ukendt fil"
|
||||
zoomIn = "Zoom ind"
|
||||
zoomOut = "Zoom ud"
|
||||
singlePageView = "Enkelt-sides visning"
|
||||
dualPageView = "To-siders visning"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Luk valgte filer"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "Skift sidepanel"
|
||||
exportSelected = "Eksporter valgte sider"
|
||||
toggleAnnotations = "Skift visning af annoteringer"
|
||||
annotationMode = "Skift annoteringstilstand"
|
||||
print = "Udskriv PDF"
|
||||
draw = "Tegn"
|
||||
save = "Gem"
|
||||
saveChanges = "Gem ændringer"
|
||||
@@ -4346,7 +4366,7 @@ features = "Funktionsflag"
|
||||
processing = "Behandling"
|
||||
|
||||
[admin.settings.advanced.endpoints]
|
||||
label = "Endpoints"
|
||||
label = "Slutpunkter"
|
||||
manage = "Administrer API-endpoints"
|
||||
description = "Endpointstyring konfigureres via YAML. Se dokumentationen for detaljer om aktivering/deaktivering af specifikke endpoints."
|
||||
|
||||
@@ -4497,6 +4517,7 @@ description = "URL eller filnavn til impressum (påkrævet i nogle jurisdiktione
|
||||
title = "Premium og Enterprise"
|
||||
description = "Konfigurer din premium- eller enterprise-licensnøgle."
|
||||
license = "Licenskonfiguration"
|
||||
noInput = "Angiv en licensnøgle eller fil"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Har du en licensnøgle eller en certifikatfil?"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "Overskrivning af din nuværende licensnøgle kan ikke fortrydes."
|
||||
line2 = "Din tidligere licens går permanent tabt, medmindre du har sikkerhedskopieret den andetsteds."
|
||||
line3 = "Vigtigt: Hold licensnøgler private og sikre. Del dem aldrig offentligt."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "Licensnøgle"
|
||||
file = "Certifikatfil"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "Licenscertifikatfil"
|
||||
description = "Upload din .lic- eller .cert-licensfil fra offlinekøb"
|
||||
choose = "Vælg licensfil"
|
||||
selected = "Valgt: {{filename}} ({{size}})"
|
||||
successMessage = "Licensfil uploadet og aktiveret. Genstart er ikke påkrævet."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Aktiv licens"
|
||||
file = "Kilde: Licensfil ({{path}})"
|
||||
key = "Kilde: Licensnøgle"
|
||||
type = "Type: {{type}}"
|
||||
noInput = "Angiv en licensnøgle eller upload en certifikatfil"
|
||||
success = "Succes"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Aktivér premium-funktioner"
|
||||
description = "Aktivér licensnøgletjek for pro-/enterprise-funktioner"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} valgt"
|
||||
download = "Download"
|
||||
delete = "Slet"
|
||||
unsupported = "Ikke understøttet"
|
||||
active = "Aktiv"
|
||||
addToUpload = "Føj til upload"
|
||||
closeFile = "Luk fil"
|
||||
deleteAll = "Slet alle"
|
||||
loadingFiles = "Indlæser filer..."
|
||||
noFiles = "Ingen filer tilgængelige"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "Mindst én e-mailadresse er påkrævet"
|
||||
submit = "Send invitationer"
|
||||
success = "Bruger(e) inviteret"
|
||||
partialSuccess = "Nogle invitationer mislykkedes"
|
||||
partialFailure = "Nogle invitationer mislykkedes"
|
||||
allFailed = "Kunne ikke invitere brugere"
|
||||
error = "Kunne ikke sende invitationer"
|
||||
|
||||
@@ -5291,8 +5333,8 @@ emailDisabled = "E-mailinvitationer kræver SMTP-konfiguration og mail.enableInv
|
||||
[workspace.people.license]
|
||||
users = "brugere"
|
||||
availableSlots = "Tilgængelige pladser"
|
||||
grandfathered = "Grandfathered"
|
||||
grandfatheredShort = "{{count}} grandfathered"
|
||||
grandfathered = "På gamle vilkår"
|
||||
grandfatheredShort = "{{count}} på gamle vilkår"
|
||||
fromLicense = "fra licens"
|
||||
slotsAvailable = "{{count}} ledig(e) brugerplads(er)"
|
||||
noSlotsAvailable = "Ingen pladser tilgængelige"
|
||||
@@ -5712,7 +5754,7 @@ title = "Diagram over endpoint-brug"
|
||||
|
||||
[usage.table]
|
||||
title = "Detaljeret statistik"
|
||||
endpoint = "Endpoint"
|
||||
endpoint = "Slutpunkt"
|
||||
visits = "Besøg"
|
||||
percentage = "Procent"
|
||||
noData = "Ingen data tilgængelige"
|
||||
@@ -5755,7 +5797,7 @@ label = "Vælg server"
|
||||
description = "Selvhostet server"
|
||||
|
||||
[setup.step3]
|
||||
label = "Login"
|
||||
label = "Log ind"
|
||||
description = "Indtast loginoplysninger"
|
||||
|
||||
[setup.mode.saas]
|
||||
@@ -5800,6 +5842,13 @@ submit = "Log ind"
|
||||
signInWith = "Log ind med"
|
||||
oauthPending = "Åbner browser for godkendelse..."
|
||||
orContinueWith = "Eller fortsæt med email"
|
||||
serverRequirement = "Bemærk: Serveren skal have login aktiveret."
|
||||
showInstructions = "Hvordan aktiveres det?"
|
||||
hideInstructions = "Skjul instruktioner"
|
||||
instructions = "Sådan aktiverer du login på din Stirling PDF-server:"
|
||||
instructionsEnvVar = "Sæt miljøvariablen:"
|
||||
instructionsOrYml = "Eller i settings.yml:"
|
||||
instructionsRestart = "Genstart derefter serveren, så ændringerne træder i kraft."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Brugernavn"
|
||||
|
||||
@@ -163,6 +163,11 @@ unfavorite = "Aus Favoriten entfernen"
|
||||
fullscreen = "In den Vollbildmodus wechseln"
|
||||
sidebar = "In den Seitenleistenmodus wechseln"
|
||||
|
||||
[backendStartup]
|
||||
notFoundTitle = "Backend nicht gefunden"
|
||||
retry = "Erneut versuchen"
|
||||
unreachable = "Die Anwendung kann derzeit keine Verbindung zum Backend herstellen. Überprüfen Sie den Backend-Status und die Netzwerkverbindung und versuchen Sie es dann erneut."
|
||||
|
||||
[zipWarning]
|
||||
title = "Große ZIP-Datei"
|
||||
message = "Dieses ZIP enthält {{count}} Dateien. Trotzdem extrahieren?"
|
||||
@@ -347,7 +352,7 @@ teams = "Teams"
|
||||
title = "Konfiguration"
|
||||
systemSettings = "Systemeinstellungen"
|
||||
features = "Funktionen"
|
||||
endpoints = "Endpoints"
|
||||
endpoints = "Endpunkte"
|
||||
database = "Datenbank"
|
||||
advanced = "Erweitert"
|
||||
|
||||
@@ -383,7 +388,7 @@ logout = "Abmelden"
|
||||
|
||||
[settings.connection.mode]
|
||||
saas = "Stirling Cloud"
|
||||
selfhosted = "Self-Hosted"
|
||||
selfhosted = "Selbst gehostet"
|
||||
|
||||
[settings.general]
|
||||
title = "Allgemein"
|
||||
@@ -612,7 +617,7 @@ desc = "Anzeigen, Kommentieren, Text oder Bilder hinzufügen"
|
||||
brandAlt = "Stirling PDF-Logo"
|
||||
openFiles = "Dateien öffnen"
|
||||
swipeHint = "Zum Wechseln der Ansicht nach links oder rechts wischen"
|
||||
tools = "Tools"
|
||||
tools = "Werkzeuge"
|
||||
toolsSlide = "Bereich für Toolauswahl"
|
||||
viewSwitcher = "Ansicht des Arbeitsbereichs wechseln"
|
||||
workbenchSlide = "Arbeitsbereichs-Panel"
|
||||
@@ -914,10 +919,10 @@ title = "PDFs überlagern"
|
||||
|
||||
[home.pdfTextEditor]
|
||||
title = "PDF-Texteditor"
|
||||
desc = "Stirling PDF JSON-Exporte prüfen und bearbeiten – mit gruppierter Textbearbeitung und PDF-Neuerzeugung"
|
||||
desc = "Vorhandenen Text und Bilder in PDFs bearbeiten"
|
||||
|
||||
[home.addText]
|
||||
tags = "text,annotation,label"
|
||||
tags = "text,anmerkung,beschriftung"
|
||||
title = "Text hinzufügen"
|
||||
desc = "Beliebigen Text überall in Ihrem PDF hinzufügen"
|
||||
|
||||
@@ -1216,7 +1221,7 @@ pdfaDigitalSignatureWarning = "Das PDF enthält eine digitale Signatur. Sie wird
|
||||
fileFormat = "Dateiformat"
|
||||
wordDoc = "Word-Dokument"
|
||||
wordDocExt = "Word-Dokument (.docx)"
|
||||
odtExt = "OpenDocument Text (.odt)"
|
||||
odtExt = "OpenDocument-Text (.odt)"
|
||||
pptExt = "PowerPoint (.pptx)"
|
||||
odpExt = "OpenDocument Präsentation (.odp)"
|
||||
txtExt = "Einfacher Text (.txt)"
|
||||
@@ -2262,12 +2267,20 @@ defaultCanvasLabel = "Gezeichnete Unterschrift"
|
||||
defaultImageLabel = "Hochgeladene Unterschrift"
|
||||
defaultTextLabel = "Getippte Unterschrift"
|
||||
saveButton = "Unterschrift speichern"
|
||||
savePersonal = "Persönlich speichern"
|
||||
saveShared = "Geteilt speichern"
|
||||
saveUnavailable = "Erstellen Sie zuerst eine Unterschrift, um sie zu speichern."
|
||||
noChanges = "Die aktuelle Unterschrift ist bereits gespeichert."
|
||||
tempStorageTitle = "Temporärer Browser-Speicher"
|
||||
tempStorageDescription = "Signaturen werden nur in Ihrem Browser gespeichert. Sie gehen verloren, wenn Sie Browserdaten löschen oder den Browser wechseln."
|
||||
personalHeading = "Persönliche Signaturen"
|
||||
sharedHeading = "Geteilte Signaturen"
|
||||
personalDescription = "Nur Sie können diese Signaturen sehen."
|
||||
sharedDescription = "Alle Benutzer können diese Signaturen sehen und verwenden."
|
||||
|
||||
[sign.saved.type]
|
||||
canvas = "Zeichnung"
|
||||
image = "Upload"
|
||||
image = "Hochladen"
|
||||
text = "Text"
|
||||
|
||||
[sign.saved.status]
|
||||
@@ -3441,6 +3454,9 @@ signinTitle = "Bitte melden Sie sich an."
|
||||
ssoSignIn = "Anmeldung per Single Sign-On"
|
||||
oAuth2AutoCreateDisabled = "OAUTH2 Benutzer automatisch erstellen deaktiviert"
|
||||
oAuth2AdminBlockedUser = "Die Registrierung bzw. das anmelden von nicht registrierten Benutzern ist derzeit gesperrt. Bitte wenden Sie sich an den Administrator."
|
||||
oAuth2RequiresLicense = "OAuth/SSO-Anmeldung erfordert eine kostenpflichtige Lizenz (Server oder Enterprise). Bitte wenden Sie sich an den Administrator, um Ihren Plan zu aktualisieren."
|
||||
saml2RequiresLicense = "SAML-Anmeldung erfordert eine kostenpflichtige Lizenz (Server oder Enterprise). Bitte wenden Sie sich an den Administrator, um Ihren Plan zu aktualisieren."
|
||||
maxUsersReached = "Die maximale Benutzeranzahl für Ihre aktuelle Lizenz wurde erreicht. Bitte wenden Sie sich an den Administrator, um Ihren Plan zu aktualisieren oder weitere Benutzerplätze hinzuzufügen."
|
||||
oauth2RequestNotFound = "Autorisierungsanfrage nicht gefunden"
|
||||
oauth2InvalidUserInfoResponse = "Ungültige Benutzerinformationsantwort"
|
||||
oauth2invalidRequest = "ungültige Anfrage"
|
||||
@@ -3849,14 +3865,17 @@ fitToWidth = "An Breite anpassen"
|
||||
actualSize = "Originalgröße"
|
||||
|
||||
[viewer]
|
||||
cannotPreviewFile = "Datei kann nicht in der Vorschau angezeigt werden"
|
||||
dualPageView = "Doppelseitenansicht"
|
||||
firstPage = "Erste Seite"
|
||||
lastPage = "Letzte Seite"
|
||||
previousPage = "Vorherige Seite"
|
||||
nextPage = "Nächste Seite"
|
||||
onlyPdfSupported = "Der Viewer unterstützt nur PDF-Dateien. Diese Datei scheint ein anderes Format zu haben."
|
||||
previousPage = "Vorherige Seite"
|
||||
singlePageView = "Einzelseitenansicht"
|
||||
unknownFile = "Unbekannte Datei"
|
||||
zoomIn = "Vergrößern"
|
||||
zoomOut = "Verkleinern"
|
||||
singlePageView = "Einzelseitenansicht"
|
||||
dualPageView = "Doppelseitenansicht"
|
||||
|
||||
[rightRail]
|
||||
closeSelected = "Ausgewählte Dateien schließen"
|
||||
@@ -3880,6 +3899,7 @@ toggleSidebar = "Seitenleiste umschalten"
|
||||
exportSelected = "Ausgewählte Seiten exportieren"
|
||||
toggleAnnotations = "Anmerkungen ein-/ausblenden"
|
||||
annotationMode = "Anmerkungsmodus umschalten"
|
||||
print = "PDF drucken"
|
||||
draw = "Zeichnen"
|
||||
save = "Speichern"
|
||||
saveChanges = "Änderungen speichern"
|
||||
@@ -3931,7 +3951,7 @@ account = "Konto"
|
||||
config = "Konfig"
|
||||
settings = "Optionen"
|
||||
adminSettings = "Admin Optionen"
|
||||
allTools = "Tools"
|
||||
allTools = "Werkzeuge"
|
||||
reader = "Reader"
|
||||
|
||||
[quickAccess.helpMenu]
|
||||
@@ -4497,6 +4517,7 @@ description = "URL oder Dateiname zum Impressum (in einigen Rechtsordnungen erfo
|
||||
title = "Premium & Enterprise"
|
||||
description = "Ihren Premium- oder Enterprise-Lizenzschlüssel konfigurieren."
|
||||
license = "Lizenzkonfiguration"
|
||||
noInput = "Bitte geben Sie einen Lizenzschlüssel oder eine Datei an"
|
||||
|
||||
[admin.settings.premium.licenseKey]
|
||||
toggle = "Lizenzschlüssel oder Zertifikatsdatei vorhanden?"
|
||||
@@ -4514,6 +4535,25 @@ line1 = "Das Überschreiben Ihres aktuellen Lizenzschlüssels kann nicht rückg
|
||||
line2 = "Ihre vorherige Lizenz geht dauerhaft verloren, sofern Sie sie nicht anderweitig gesichert haben."
|
||||
line3 = "Wichtig: Halten Sie Lizenzschlüssel privat und sicher. Geben Sie sie niemals öffentlich weiter."
|
||||
|
||||
[admin.settings.premium.inputMethod]
|
||||
text = "Lizenzschlüssel"
|
||||
file = "Zertifikatsdatei"
|
||||
|
||||
[admin.settings.premium.file]
|
||||
label = "Lizenz-Zertifikatsdatei"
|
||||
description = "Laden Sie Ihre .lic- oder .cert-Lizenzdatei aus Offline-Käufen hoch"
|
||||
choose = "Lizenzdatei auswählen"
|
||||
selected = "Ausgewählt: {{filename}} ({{size}})"
|
||||
successMessage = "Lizenzdatei erfolgreich hochgeladen und aktiviert. Kein Neustart erforderlich."
|
||||
|
||||
[admin.settings.premium.currentLicense]
|
||||
title = "Aktive Lizenz"
|
||||
file = "Quelle: Lizenzdatei ({{path}})"
|
||||
key = "Quelle: Lizenzschlüssel"
|
||||
type = "Typ: {{type}}"
|
||||
noInput = "Bitte geben Sie einen Lizenzschlüssel an oder laden Sie eine Zertifikatdatei hoch"
|
||||
success = "Erfolg"
|
||||
|
||||
[admin.settings.premium.enabled]
|
||||
label = "Premium-Funktionen aktivieren"
|
||||
description = "Lizenzschlüssel-Prüfungen für Pro-/Enterprise-Funktionen aktivieren"
|
||||
@@ -4647,7 +4687,9 @@ selectedCount = "{{count}} ausgewählt"
|
||||
download = "Herunterladen"
|
||||
delete = "Löschen"
|
||||
unsupported = "Nicht unterstützt"
|
||||
active = "Aktiv"
|
||||
addToUpload = "Zum Upload hinzufügen"
|
||||
closeFile = "Datei schließen"
|
||||
deleteAll = "Alle löschen"
|
||||
loadingFiles = "Dateien werden geladen..."
|
||||
noFiles = "Keine Dateien verfügbar"
|
||||
@@ -5248,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
|
||||
emailsRequired = "Mindestens eine E-Mail-Adresse ist erforderlich"
|
||||
submit = "Einladungen senden"
|
||||
success = "Benutzer erfolgreich eingeladen"
|
||||
partialSuccess = "Einige Einladungen sind fehlgeschlagen"
|
||||
partialFailure = "Einige Einladungen sind fehlgeschlagen"
|
||||
allFailed = "Benutzer konnten nicht eingeladen werden"
|
||||
error = "Einladungen konnten nicht gesendet werden"
|
||||
|
||||
@@ -5755,7 +5797,7 @@ label = "Server auswählen"
|
||||
description = "Self-Hosted-Server"
|
||||
|
||||
[setup.step3]
|
||||
label = "Login"
|
||||
label = "Anmeldung"
|
||||
description = "Anmeldedaten eingeben"
|
||||
|
||||
[setup.mode.saas]
|
||||
@@ -5796,10 +5838,17 @@ testFailed = "Verbindungstest fehlgeschlagen"
|
||||
title = "Anmelden"
|
||||
subtitle = "Geben Sie Ihre Anmeldedaten ein, um fortzufahren"
|
||||
connectingTo = "Verbinden mit:"
|
||||
submit = "Login"
|
||||
submit = "Anmelden"
|
||||
signInWith = "Anmelden mit"
|
||||
oauthPending = "Browser zur Authentifizierung wird geöffnet..."
|
||||
orContinueWith = "Oder mit E-Mail fortfahren"
|
||||
serverRequirement = "Hinweis: Auf dem Server muss die Anmeldung aktiviert sein."
|
||||
showInstructions = "Wie aktivieren?"
|
||||
hideInstructions = "Anleitung ausblenden"
|
||||
instructions = "So aktivieren Sie die Anmeldung auf Ihrem Stirling PDF-Server:"
|
||||
instructionsEnvVar = "Setzen Sie die Umgebungsvariable:"
|
||||
instructionsOrYml = "Oder in der settings.yml:"
|
||||
instructionsRestart = "Starten Sie anschließend Ihren Server neu, damit die Änderungen wirksam werden."
|
||||
|
||||
[setup.login.username]
|
||||
label = "Benutzername"
|
||||
@@ -5850,7 +5899,7 @@ singleLine = "Einzeilig"
|
||||
[pdfTextEditor.badges]
|
||||
unsaved = "Bearbeitet"
|
||||
modified = "Bearbeitet"
|
||||
earlyAccess = "Early Access"
|
||||
earlyAccess = "Früher Zugriff"
|
||||
|
||||
[pdfTextEditor.actions]
|
||||
reset = "Änderungen zurücksetzen"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user