mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32a0c04c4 | ||
|
|
b695e3900e | ||
|
|
2158ee4db6 | ||
|
|
3090a85726 | ||
|
|
d714a1617f | ||
|
|
2a29bda34f | ||
|
|
ab6edd3196 | ||
|
|
be7e79be55 | ||
|
|
c9e1b8eec5 | ||
|
|
eba93a3b6c | ||
|
|
03e81a0f16 | ||
|
|
f9ac1bd62e | ||
|
|
8aa6aff53a | ||
|
|
458bb641b5 | ||
|
|
247f82b5a7 | ||
|
|
06b4c147bd | ||
|
|
25154e4dbe | ||
|
|
989eea9e24 | ||
|
|
510e1c38eb | ||
|
|
ec05c5c049 | ||
|
|
d86a13cc89 | ||
|
|
85dedf4b28 | ||
|
|
0fa53185f2 | ||
|
|
3dd4a33595 | ||
|
|
fe2bfd8739 | ||
|
|
a15b0e33d5 | ||
|
|
0a242f6a57 | ||
|
|
3c46010155 | ||
|
|
02189a67bd | ||
|
|
d4985f57d4 | ||
|
|
4ab66fdf14 | ||
|
|
c19abe0da7 | ||
|
|
dd6b7968db | ||
|
|
2228ae7197 | ||
|
|
30987dcad2 | ||
|
|
43beadbdcb | ||
|
|
416d79aed3 | ||
|
|
abc0988fdf | ||
|
|
c7e0ea5b5b | ||
|
|
b35447934e | ||
|
|
d1e82eb8f1 | ||
|
|
7d44cc1a40 | ||
|
|
0bdc6466ca | ||
|
|
f2a6e95fcf | ||
|
|
0c08764669 | ||
|
|
9758e871d4 | ||
|
|
18fa16f08e | ||
|
|
233b710b78 | ||
|
|
d613a4659e | ||
|
|
03f484e0c0 | ||
|
|
fd52dc0226 | ||
|
|
21b1428ab5 | ||
|
|
1219cebd07 | ||
|
|
166f6d2d98 | ||
|
|
6441dc1d6f | ||
|
|
25bedf064f | ||
|
|
13eff6b333 | ||
|
|
46a4a978fc |
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Author: Ludy87
|
||||
Description: This script processes JSON 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.
|
||||
|
||||
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_json.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
|
||||
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import argparse
|
||||
import re
|
||||
import json
|
||||
|
||||
|
||||
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.
|
||||
: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).
|
||||
"""
|
||||
if keys is None:
|
||||
keys = {}
|
||||
|
||||
duplicates = []
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
def process_dict(obj, current_prefix=""):
|
||||
for key, value in obj.items():
|
||||
full_key = f"{current_prefix}.{key}" if current_prefix else key
|
||||
|
||||
if isinstance(value, dict):
|
||||
process_dict(value, full_key)
|
||||
else:
|
||||
if full_key in keys:
|
||||
duplicates.append((full_key, keys[full_key], full_key))
|
||||
else:
|
||||
keys[full_key] = full_key
|
||||
|
||||
process_dict(data, prefix)
|
||||
return duplicates
|
||||
|
||||
|
||||
# Maximum size for JSON files (e.g., 500 KB)
|
||||
MAX_FILE_SIZE = 500 * 1024
|
||||
|
||||
|
||||
def parse_json_file(file_path):
|
||||
"""
|
||||
Parses a JSON translation file and returns a flat dictionary of all keys.
|
||||
:param file_path: Path to the JSON file.
|
||||
:return: Dictionary with flattened keys.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
def flatten_dict(d, parent_key="", sep="."):
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v, new_key, sep=sep))
|
||||
else:
|
||||
items[new_key] = v
|
||||
return items
|
||||
|
||||
return flatten_dict(data)
|
||||
|
||||
|
||||
def unflatten_dict(d, sep="."):
|
||||
"""
|
||||
Converts a flat dictionary with dot notation keys back to nested dict.
|
||||
:param d: Flattened dictionary.
|
||||
:param sep: Separator used in keys.
|
||||
:return: Nested dictionary.
|
||||
"""
|
||||
result = {}
|
||||
for key, value in d.items():
|
||||
parts = key.split(sep)
|
||||
current = result
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
return result
|
||||
|
||||
|
||||
def write_json_file(file_path, updated_properties):
|
||||
"""
|
||||
Writes updated properties back to the JSON file.
|
||||
:param file_path: Path to the JSON 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
|
||||
|
||||
|
||||
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 file_list: List of translation files to update.
|
||||
:param branch: Branch where the files are located.
|
||||
"""
|
||||
reference_properties = parse_json_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 os.path.dirname(file_path).endswith("locales")
|
||||
):
|
||||
continue
|
||||
|
||||
current_properties = parse_json_file(os.path.join(branch, file_path))
|
||||
updated_properties = {}
|
||||
|
||||
for ref_key, ref_value in reference_properties.items():
|
||||
if ref_key in current_properties:
|
||||
# Keep the current translation
|
||||
updated_properties[ref_key] = current_properties[ref_key]
|
||||
else:
|
||||
# Add missing key with reference value
|
||||
updated_properties[ref_key] = ref_value
|
||||
|
||||
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_json_keys(file_path):
|
||||
if os.path.isfile(file_path) and os.path.exists(file_path):
|
||||
return parse_json_file(file_path)
|
||||
return {}
|
||||
|
||||
|
||||
def check_for_differences(reference_file, file_list, branch, actor):
|
||||
reference_branch = branch
|
||||
basename_reference_file = os.path.basename(reference_file)
|
||||
|
||||
report = []
|
||||
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
|
||||
reference_keys = read_json_keys(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(), "frontend", "public", "locales")
|
||||
)
|
||||
|
||||
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))
|
||||
locale_dir = os.path.basename(os.path.dirname(file_normpath))
|
||||
|
||||
if (
|
||||
basename_current_file == basename_reference_file
|
||||
and locale_dir == "en-GB"
|
||||
):
|
||||
continue
|
||||
|
||||
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
|
||||
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))
|
||||
reference_key_count = len(reference_keys)
|
||||
current_key_count = len(current_keys)
|
||||
|
||||
if reference_key_count != current_key_count:
|
||||
report.append("")
|
||||
report.append("1. **Test Status:** ❌ **_Failed_**")
|
||||
report.append(" - **Issue:**")
|
||||
has_differences = True
|
||||
if reference_key_count > current_key_count:
|
||||
report.append(
|
||||
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
|
||||
)
|
||||
elif reference_key_count < current_key_count:
|
||||
report.append(
|
||||
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
|
||||
)
|
||||
else:
|
||||
report.append("1. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
# Check for missing or extra keys
|
||||
current_keys_set = set(current_keys.keys())
|
||||
reference_keys_set = set(reference_keys.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:
|
||||
report.append(
|
||||
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_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 {first}, duplicate at `{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 [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
|
||||
)
|
||||
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(),
|
||||
"frontend",
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
"translation.json",
|
||||
)
|
||||
)
|
||||
update_missing_keys(args.reference_file, file_list)
|
||||
else:
|
||||
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
|
||||
@@ -3,7 +3,15 @@ name: Auto PR V2 Deployment
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: "PR number to deploy"
|
||||
required: true
|
||||
allow_fork:
|
||||
description: "Allow deploying fork PR?"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,102 +20,93 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
if: github.event.action != 'closed'
|
||||
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
|
||||
pr_number: ${{ github.event.number }}
|
||||
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
|
||||
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
|
||||
should_deploy: ${{ steps.decide.outputs.should_deploy }}
|
||||
is_fork: ${{ steps.resolve.outputs.is_fork }}
|
||||
allow_fork: ${{ steps.decide.outputs.allow_fork }}
|
||||
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
||||
pr_repository: ${{ steps.resolve.outputs.repository }}
|
||||
pr_ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check deployment conditions
|
||||
id: check-conditions
|
||||
- name: Resolve PR info
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
let prNumber = context.eventName === 'workflow_dispatch'
|
||||
? parseInt(process.env.INPUT_PR, 10)
|
||||
: context.payload.number;
|
||||
|
||||
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
core.setOutput('pr_number', String(prNumber));
|
||||
core.setOutput('repository', pr.head.repo.full_name);
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('is_fork', String(pr.head.repo.fork));
|
||||
core.setOutput('base_ref', pr.base.ref);
|
||||
core.setOutput('author', pr.user.login);
|
||||
core.setOutput('state', pr.state);
|
||||
|
||||
- name: Decide deploy
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
STATE: ${{ steps.resolve.outputs.state }}
|
||||
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
|
||||
# nur bei workflow_dispatch gesetzt:
|
||||
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
|
||||
# für Auto-PR-Logik:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
|
||||
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
|
||||
run: |
|
||||
echo "PR Title: $PR_TITLE"
|
||||
echo "PR Author: $PR_AUTHOR"
|
||||
echo "PR Branch: $PR_BRANCH"
|
||||
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
|
||||
|
||||
# Define authorized users
|
||||
authorized_users=(
|
||||
"Frooodle"
|
||||
"sf298"
|
||||
"Ludy87"
|
||||
"LaserKaspar"
|
||||
"sbplat"
|
||||
"reecebrowne"
|
||||
"DarioGii"
|
||||
"ConnorYoh"
|
||||
"EthanHealy01"
|
||||
"jbrunton96"
|
||||
)
|
||||
|
||||
# Check if author is in the authorized list
|
||||
is_authorized=false
|
||||
for user in "${authorized_users[@]}"; do
|
||||
if [[ "$PR_AUTHOR" == "$user" ]]; then
|
||||
is_authorized=true
|
||||
break
|
||||
set -e
|
||||
# Standard: nichts deployen
|
||||
should=false
|
||||
allow_fork="$(echo "${ALLOW_FORK_INPUT:-false}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
if [ "$STATE" != "open" ]; then
|
||||
echo "PR not open -> skip"
|
||||
else
|
||||
if [ "$IS_FORK" = "true" ] && [ "$allow_fork" != "true" ]; then
|
||||
echo "Fork PR and allow_fork=false -> skip"
|
||||
else
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If PR is targeting V2 and user is authorized, deploy unconditionally
|
||||
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
|
||||
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
|
||||
echo "✅ Deployment forced: PR targets V2 and author is authorized."
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise, continue with original keyword checks
|
||||
has_v2_keyword=false
|
||||
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
|
||||
has_v2_keyword=true
|
||||
fi
|
||||
|
||||
has_branch_keyword=false
|
||||
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
|
||||
has_branch_keyword=true
|
||||
fi
|
||||
|
||||
if [[ "$is_authorized" == "true" && ( "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ) ]]; then
|
||||
echo "✅ Deployment conditions met"
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "❌ Deployment conditions not met"
|
||||
echo " - Authorized user: $is_authorized"
|
||||
echo " - Has V2 keyword in title: $has_v2_keyword"
|
||||
echo " - Has V2/React keyword in branch: $has_branch_keyword"
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96")
|
||||
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
|
||||
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
|
||||
should=true
|
||||
else
|
||||
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
|
||||
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
|
||||
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
|
||||
should=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Get PR repository and ref
|
||||
id: get-pr-info
|
||||
if: steps.check-conditions.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
# For forks, use the full repository name, for internal PRs use the current repo
|
||||
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
||||
repository="${{ github.event.pull_request.head.repo.full_name }}"
|
||||
else
|
||||
repository="${{ github.repository }}"
|
||||
fi
|
||||
|
||||
echo "repository=$repository" >> $GITHUB_OUTPUT
|
||||
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
|
||||
echo "should_deploy=$should" >> $GITHUB_OUTPUT
|
||||
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
|
||||
|
||||
deploy-v2-pr:
|
||||
needs: check-pr
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.check-pr.outputs.should_deploy == 'true'
|
||||
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
|
||||
# Concurrency control - only one deployment per PR at a time
|
||||
concurrency:
|
||||
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
@@ -119,7 +118,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -154,13 +153,13 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
|
||||
const v2Comments = comments.filter(comment =>
|
||||
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
|
||||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
|
||||
comment.body.includes('❌ **V2 Auto-deployment failed**')
|
||||
);
|
||||
|
||||
|
||||
for (const comment of v2Comments) {
|
||||
console.log(`Deleting old V2 comment: ${comment.id}`);
|
||||
await github.rest.issues.deleteComment({
|
||||
@@ -177,7 +176,6 @@ jobs:
|
||||
issue_number: prNumber,
|
||||
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
|
||||
});
|
||||
|
||||
return newComment.id;
|
||||
|
||||
- name: Checkout PR
|
||||
@@ -188,7 +186,6 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # Fetch full history for commit hash detection
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
@@ -212,7 +209,7 @@ jobs:
|
||||
if [ -z "$FRONTEND_HASH" ]; then
|
||||
FRONTEND_HASH="no-frontend-changes"
|
||||
fi
|
||||
|
||||
|
||||
# Get last commit that touched backend code, docker/backend, or docker/compose
|
||||
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
|
||||
if [ -z "$BACKEND_HASH" ]; then
|
||||
@@ -321,7 +318,7 @@ jobs:
|
||||
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
|
||||
restart: on-failure:5
|
||||
|
||||
|
||||
stirling-pdf-v2-frontend:
|
||||
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
|
||||
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
|
||||
@@ -354,7 +351,7 @@ jobs:
|
||||
|
||||
# Clean up unused Docker resources to save space
|
||||
docker system prune -af --volumes || true
|
||||
|
||||
|
||||
# Clean up old backend/frontend images (older than 2 weeks)
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
ENDSSH
|
||||
@@ -492,7 +489,7 @@ jobs:
|
||||
|
||||
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
|
||||
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
|
||||
|
||||
|
||||
# Note: We don't remove the commit-based images since they can be reused across PRs
|
||||
# Only remove PR-specific containers and directories
|
||||
ENDSSH
|
||||
@@ -501,5 +498,4 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ../private.key
|
||||
continue-on-error: true
|
||||
|
||||
continue-on-error: true
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
branches:
|
||||
- V2
|
||||
paths:
|
||||
- ".github/workflows/frontend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/scripts/generate-licenses.js"
|
||||
@@ -28,37 +29,81 @@ jobs:
|
||||
repository-projects: write # Required for enabling automerge
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
node-version: '18'
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
run: npm ci --ignore-scripts --audit=false --fund=false
|
||||
|
||||
- name: Generate frontend license report
|
||||
- name: Generate frontend license report (internal PR)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
working-directory: frontend
|
||||
env:
|
||||
PR_IS_FORK: "false"
|
||||
run: npm run generate-licenses
|
||||
|
||||
- name: Generate frontend license report (fork PRs, pinned)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true"
|
||||
working-directory: frontend
|
||||
run: |
|
||||
mkdir -p src/assets
|
||||
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Postprocess with project script (BASE version)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
env:
|
||||
PR_IS_FORK: "true"
|
||||
run: |
|
||||
node base/frontend/scripts/generate-licenses.js \
|
||||
--input frontend/src/assets/3rdPartyLicenses.json
|
||||
|
||||
- name: Copy postprocessed artifacts back (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
mkdir -p frontend/src/assets
|
||||
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
|
||||
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
|
||||
fi
|
||||
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
|
||||
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
|
||||
fi
|
||||
|
||||
- name: Check for license warnings
|
||||
run: |
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
@@ -69,8 +114,8 @@ jobs:
|
||||
|
||||
# PR Event: Check licenses and comment on PR
|
||||
- name: Delete previous license check comments
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
@@ -101,9 +146,29 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summarize results (fork PRs)
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
|
||||
run: |
|
||||
{
|
||||
echo "## Frontend License Check"
|
||||
echo ""
|
||||
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
|
||||
echo "❌ **Failed** – incompatible or unknown licenses found."
|
||||
if [ -f "frontend/src/assets/license-warnings.json" ]; then
|
||||
echo ""
|
||||
echo "### Warnings"
|
||||
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
|
||||
fi
|
||||
else
|
||||
echo "✅ **Passed** – no license warnings detected."
|
||||
fi
|
||||
echo ""
|
||||
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment on PR - License Check Results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.setup-bot.outputs.token }}
|
||||
script: |
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Sync Files V2
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- V2
|
||||
- syncLangTest
|
||||
paths:
|
||||
- "build.gradle"
|
||||
- "README.md"
|
||||
- "frontend/public/locales/*/translation.json"
|
||||
- "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
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.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@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Sync translation JSON files
|
||||
run: |
|
||||
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
|
||||
|
||||
- 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"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
|
||||
- name: Sync README.md
|
||||
run: |
|
||||
python scripts/counter_translation_v2.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_v2
|
||||
base: V2
|
||||
title: ":globe_with_meridians: [V2] 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:
|
||||
|
||||
#### **1. Synchronization of Translation Files**
|
||||
- Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
|
||||
- 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
|
||||
frontend/public/locales/*/translation.json
|
||||
@@ -203,3 +203,10 @@ id_ed25519.pub
|
||||
|
||||
# node_modules
|
||||
node_modules/
|
||||
|
||||
# Translation temp files
|
||||
*_compact.json
|
||||
*compact*.json
|
||||
test_batch.json
|
||||
*.backup.*.json
|
||||
frontend/public/locales/*/translation.backup*.json
|
||||
|
||||
@@ -192,6 +192,11 @@ return useToolOperation({
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
|
||||
@@ -97,7 +97,6 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
|
||||
|
||||
|
||||
|
||||
|
||||
# 📖 Get Started
|
||||
|
||||
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
|
||||
@@ -116,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| 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) |  |
|
||||
| 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
|
||||
|
||||
@@ -258,12 +258,6 @@ public class AppConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Profile("default")
|
||||
public boolean googleDriveEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
@Profile("default")
|
||||
public String licenseType() {
|
||||
|
||||
@@ -530,7 +530,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@@ -549,26 +548,6 @@ public class ApplicationProperties {
|
||||
: producer;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GoogleDrive {
|
||||
private boolean enabled;
|
||||
private String clientId;
|
||||
private String apiKey;
|
||||
private String appId;
|
||||
|
||||
public String getClientId() {
|
||||
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId == null || appId.trim().isEmpty() ? "" : appId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -18,7 +18,9 @@ public class PDFFile {
|
||||
@Schema(description = "The input PDF file", format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(description = "File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
@Schema(
|
||||
description =
|
||||
"File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
private String fileId;
|
||||
|
||||
@AssertTrue(message = "Either fileInput or fileId must be provided")
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
public interface ServerCertificateServiceInterface {
|
||||
|
||||
boolean isEnabled();
|
||||
|
||||
boolean hasServerCertificate();
|
||||
|
||||
void initializeServerCertificate();
|
||||
|
||||
KeyStore getServerKeyStore() throws Exception;
|
||||
|
||||
String getServerCertificatePassword();
|
||||
|
||||
X509Certificate getServerCertificate() throws Exception;
|
||||
|
||||
byte[] getServerCertificatePublicKey() throws Exception;
|
||||
|
||||
void uploadServerCertificate(InputStream p12Stream, String password) throws Exception;
|
||||
|
||||
void deleteServerCertificate() throws Exception;
|
||||
|
||||
ServerCertificateInfo getServerCertificateInfo() throws Exception;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
class ServerCertificateInfo {
|
||||
private final boolean exists;
|
||||
private final String subject;
|
||||
private final String issuer;
|
||||
private final Date validFrom;
|
||||
private final Date validTo;
|
||||
}
|
||||
}
|
||||
-16
@@ -109,22 +109,6 @@ class ApplicationPropertiesLogicTest {
|
||||
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
|
||||
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
|
||||
|
||||
assertEquals("", gd.getClientId());
|
||||
assertEquals("", gd.getApiKey());
|
||||
assertEquals("", gd.getAppId());
|
||||
|
||||
gd.setClientId(" id ");
|
||||
gd.setApiKey(" key ");
|
||||
gd.setAppId(" app ");
|
||||
assertEquals(" id ", gd.getClientId());
|
||||
assertEquals(" key ", gd.getApiKey());
|
||||
assertEquals(" app ", gd.getAppId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ui_getters_return_null_for_blank() {
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
|
||||
@@ -237,6 +237,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("PageOps", "pdf-organizer");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
addEndpointToGroup("PageOps", "crop");
|
||||
addEndpointToGroup("PageOps", "extract-page");
|
||||
@@ -366,6 +367,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "cert-sign");
|
||||
addEndpointToGroup("Java", "remove-cert-sign");
|
||||
addEndpointToGroup("Java", "multi-page-layout");
|
||||
addEndpointToGroup("Java", "booklet-imposition");
|
||||
addEndpointToGroup("Java", "scale-pages");
|
||||
addEndpointToGroup("Java", "add-page-numbers");
|
||||
addEndpointToGroup("Java", "auto-rename");
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class BookletImpositionController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
"This operation combines page reordering for booklet printing with multi-page layout. "
|
||||
+ "It rearranges pages in the correct order for booklet printing and places multiple pages "
|
||||
+ "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> createBookletImposition(
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
int pagesPerSheet = request.getPagesPerSheet();
|
||||
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
|
||||
String spineLocation =
|
||||
request.getSpineLocation() != null ? request.getSpineLocation() : "LEFT";
|
||||
boolean addGutter = Boolean.TRUE.equals(request.getAddGutter());
|
||||
float gutterSize = request.getGutterSize();
|
||||
boolean doubleSided = Boolean.TRUE.equals(request.getDoubleSided());
|
||||
String duplexPass = request.getDuplexPass() != null ? request.getDuplexPass() : "BOTH";
|
||||
boolean flipOnShortEdge = Boolean.TRUE.equals(request.getFlipOnShortEdge());
|
||||
|
||||
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
|
||||
if (pagesPerSheet != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_booklet.pdf");
|
||||
}
|
||||
|
||||
private static int padToMultipleOf4(int n) {
|
||||
return (n + 3) / 4 * 4;
|
||||
}
|
||||
|
||||
private static class Side {
|
||||
final int left, right;
|
||||
final boolean isBack;
|
||||
|
||||
Side(int left, int right, boolean isBack) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.isBack = isBack;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Side> saddleStitchSides(
|
||||
int totalPagesOriginal,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge) {
|
||||
int N = padToMultipleOf4(totalPagesOriginal);
|
||||
List<Side> out = new ArrayList<>();
|
||||
int sheets = N / 4;
|
||||
|
||||
for (int s = 0; s < sheets; s++) {
|
||||
int a = N - 1 - (s * 2); // left, front
|
||||
int b = (s * 2); // right, front
|
||||
int c = (s * 2) + 1; // left, back
|
||||
int d = N - 2 - (s * 2); // right, back
|
||||
|
||||
// clamp to -1 (blank) if >= totalPagesOriginal
|
||||
a = (a < totalPagesOriginal) ? a : -1;
|
||||
b = (b < totalPagesOriginal) ? b : -1;
|
||||
c = (c < totalPagesOriginal) ? c : -1;
|
||||
d = (d < totalPagesOriginal) ? d : -1;
|
||||
|
||||
// Handle duplex pass selection
|
||||
boolean includeFront = "BOTH".equals(duplexPass) || "FIRST".equals(duplexPass);
|
||||
boolean includeBack = "BOTH".equals(duplexPass) || "SECOND".equals(duplexPass);
|
||||
|
||||
if (includeFront) {
|
||||
out.add(new Side(a, b, false)); // front side
|
||||
}
|
||||
|
||||
if (includeBack) {
|
||||
// For short-edge duplex, swap back-side left/right
|
||||
// Note: flipOnShortEdge is ignored in manual duplex mode since users physically
|
||||
// flip the stack
|
||||
if (doubleSided && flipOnShortEdge) {
|
||||
out.add(new Side(d, c, true)); // swapped back side (automatic duplex only)
|
||||
} else {
|
||||
out.add(new Side(c, d, true)); // normal back side
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private PDDocument createSaddleBooklet(
|
||||
PDDocument src,
|
||||
int totalPages,
|
||||
boolean addBorder,
|
||||
String spineLocation,
|
||||
boolean addGutter,
|
||||
float gutterSize,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge)
|
||||
throws IOException {
|
||||
|
||||
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
|
||||
|
||||
// Derive paper size from source document's first page CropBox
|
||||
PDRectangle srcBox = src.getPage(0).getCropBox();
|
||||
PDRectangle portraitPaper = new PDRectangle(srcBox.getWidth(), srcBox.getHeight());
|
||||
// Force landscape for booklet (Acrobat booklet uses landscape paper to fold to portrait)
|
||||
PDRectangle pageSize = new PDRectangle(portraitPaper.getHeight(), portraitPaper.getWidth());
|
||||
|
||||
// Validate and clamp gutter size
|
||||
if (gutterSize < 0) gutterSize = 0;
|
||||
if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f;
|
||||
|
||||
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
|
||||
|
||||
for (Side side : sides) {
|
||||
PDPage out = new PDPage(pageSize);
|
||||
dst.addPage(out);
|
||||
|
||||
float cellW = pageSize.getWidth() / 2f;
|
||||
float cellH = pageSize.getHeight();
|
||||
|
||||
// For RIGHT spine (RTL), swap left/right placements
|
||||
boolean rtl = "RIGHT".equalsIgnoreCase(spineLocation);
|
||||
int leftCol = rtl ? 1 : 0;
|
||||
int rightCol = rtl ? 0 : 1;
|
||||
|
||||
// Apply gutter margins with centered gap option
|
||||
float g = addGutter ? gutterSize : 0f;
|
||||
float leftCellX = leftCol * cellW + (g / 2f);
|
||||
float rightCellX = rightCol * cellW - (g / 2f);
|
||||
float leftCellW = cellW - (g / 2f);
|
||||
float rightCellW = cellW - (g / 2f);
|
||||
|
||||
// Create LayerUtility once per page for efficiency
|
||||
LayerUtility layerUtility = new LayerUtility(dst);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
dst, out, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
|
||||
if (addBorder) {
|
||||
cs.setLineWidth(1.5f);
|
||||
cs.setStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
// draw left cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.left,
|
||||
leftCellX,
|
||||
0f,
|
||||
leftCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
// draw right cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.right,
|
||||
rightCellX,
|
||||
0f,
|
||||
rightCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
private void drawCell(
|
||||
PDDocument src,
|
||||
PDDocument dst,
|
||||
PDPageContentStream cs,
|
||||
LayerUtility layerUtility,
|
||||
int pageIndex,
|
||||
float cellX,
|
||||
float cellY,
|
||||
float cellW,
|
||||
float cellH,
|
||||
boolean addBorder)
|
||||
throws IOException {
|
||||
|
||||
if (pageIndex < 0) {
|
||||
// Draw border for blank cell if needed
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
PDPage srcPage = src.getPage(pageIndex);
|
||||
PDRectangle r = srcPage.getCropBox(); // Use CropBox instead of MediaBox
|
||||
int rot = (srcPage.getRotation() + 360) % 360;
|
||||
|
||||
// Calculate scale factors, accounting for rotation
|
||||
float sx = cellW / r.getWidth();
|
||||
float sy = cellH / r.getHeight();
|
||||
float s = Math.min(sx, sy);
|
||||
|
||||
// If rotated 90/270 degrees, swap dimensions for fitting
|
||||
if (rot == 90 || rot == 270) {
|
||||
sx = cellW / r.getHeight();
|
||||
sy = cellH / r.getWidth();
|
||||
s = Math.min(sx, sy);
|
||||
}
|
||||
|
||||
float drawnW = (rot == 90 || rot == 270) ? r.getHeight() * s : r.getWidth() * s;
|
||||
float drawnH = (rot == 90 || rot == 270) ? r.getWidth() * s : r.getHeight() * s;
|
||||
|
||||
// Center in cell, accounting for CropBox offset
|
||||
float tx = cellX + (cellW - drawnW) / 2f - r.getLowerLeftX() * s;
|
||||
float ty = cellY + (cellH - drawnH) / 2f - r.getLowerLeftY() * s;
|
||||
|
||||
cs.saveGraphicsState();
|
||||
cs.transform(Matrix.getTranslateInstance(tx, ty));
|
||||
cs.transform(Matrix.getScaleInstance(s, s));
|
||||
|
||||
// Apply rotation if needed (rotate about origin), then translate to keep in cell
|
||||
switch (rot) {
|
||||
case 90:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0));
|
||||
// After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0,
|
||||
// r.getWidth()]
|
||||
cs.transform(Matrix.getTranslateInstance(0, -r.getWidth()));
|
||||
break;
|
||||
case 180:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0));
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight()));
|
||||
break;
|
||||
case 270:
|
||||
cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0));
|
||||
// After 270° CCW, the content spans x in [0, r.getHeight()] and y in
|
||||
// [-r.getWidth(), 0]
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0));
|
||||
break;
|
||||
default:
|
||||
// 0°: no-op
|
||||
}
|
||||
|
||||
// Reuse LayerUtility passed from caller
|
||||
PDFormXObject form = layerUtility.importPageAsForm(src, pageIndex);
|
||||
cs.drawForm(form);
|
||||
|
||||
cs.restoreGraphicsState();
|
||||
|
||||
// Draw border on top of form to ensure visibility
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
@@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -111,6 +114,32 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse client file IDs from JSON string
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
// Simple JSON array parsing - remove brackets and split by comma
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (inside.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
@@ -177,15 +206,48 @@ public class MergeController {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
for (MultipartFile multipartFile : files) {
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
GeneralUtils.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
}
|
||||
|
||||
if (!invalidIndexes.isEmpty()) {
|
||||
// Parse client file IDs (always present from frontend)
|
||||
String[] clientIds = parseClientFileIds(request.getClientFileIds());
|
||||
|
||||
// Map invalid indexes to client IDs
|
||||
List<String> errorFileIds = new ArrayList<>();
|
||||
for (Integer index : invalidIndexes) {
|
||||
if (index < clientIds.length) {
|
||||
errorFileIds.add(clientIds[index]);
|
||||
}
|
||||
}
|
||||
|
||||
String payload =
|
||||
String.format(
|
||||
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
|
||||
errorFileIds.toString());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(payload.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
|
||||
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
||||
|
||||
|
||||
+19
-8
@@ -10,21 +10,32 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@RequiredArgsConstructor
|
||||
@Hidden
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
@@ -58,6 +69,11 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Server certificate settings
|
||||
configData.put(
|
||||
"serverCertificateEnabled",
|
||||
serverCertificateService != null && serverCertificateService.isEnabled());
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
@@ -82,11 +98,6 @@ public class ConfigController {
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("GoogleDriveEnabled")) {
|
||||
configData.put(
|
||||
"GoogleDriveEnabled",
|
||||
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
|
||||
+45
-17
@@ -237,34 +237,54 @@ public class StampController {
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float x, y;
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
// Use override values if provided
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
|
||||
y =
|
||||
calculatePositionY(
|
||||
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
|
||||
}
|
||||
// Split the stampText into multiple lines
|
||||
String[] lines = stampText.split("\\\\n");
|
||||
String[] lines = stampText.split("\\r?\\n|\\\\n");
|
||||
|
||||
// Calculate dynamic line height based on font ascent and descent
|
||||
float ascent = font.getFontDescriptor().getAscent();
|
||||
float descent = font.getFontDescriptor().getDescent();
|
||||
float lineHeight = ((ascent - descent) / 1000) * fontSize;
|
||||
|
||||
// Compute a single pivot for the entire text block to avoid line-by-line wobble
|
||||
float capHeight = calculateTextCapHeight(font, fontSize);
|
||||
float blockHeight = Math.max(lineHeight, lineHeight * Math.max(1, lines.length));
|
||||
float maxWidth = 0f;
|
||||
for (String ln : lines) {
|
||||
maxWidth = Math.max(maxWidth, calculateTextWidth(ln, font, fontSize));
|
||||
}
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
// Use override values if provided
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
// Base positioning on the true multi-line block size
|
||||
x = calculatePositionX(pageSize, position, maxWidth, null, 0, null, margin);
|
||||
y = calculatePositionY(pageSize, position, blockHeight, margin);
|
||||
}
|
||||
|
||||
// After anchoring the block, draw from the top line downward
|
||||
float adjustedX = x;
|
||||
float adjustedY = y;
|
||||
float pivotX = adjustedX + maxWidth / 2f;
|
||||
float pivotY = adjustedY + blockHeight / 2f;
|
||||
|
||||
// Apply rotation about the block center at the graphics state level
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(pivotX, pivotY));
|
||||
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||
contentStream.transform(Matrix.getTranslateInstance(-pivotX, -pivotY));
|
||||
|
||||
contentStream.beginText();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
// Set the text matrix for each line with rotation
|
||||
contentStream.setTextMatrix(
|
||||
Matrix.getRotateInstance(Math.toRadians(rotation), x, y - (i * lineHeight)));
|
||||
// Start from top line: yTop = adjustedY + blockHeight - capHeight
|
||||
float yLine = adjustedY + blockHeight - capHeight - (i * lineHeight);
|
||||
contentStream.setTextMatrix(Matrix.getTranslateInstance(adjustedX, yLine));
|
||||
contentStream.showText(line);
|
||||
}
|
||||
contentStream.endText();
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
private void addImageStamp(
|
||||
@@ -308,9 +328,17 @@ public class StampController {
|
||||
}
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
// Rotate and scale about the center of the image
|
||||
float centerX = x + (desiredPhysicalWidth / 2f);
|
||||
float centerY = y + (desiredPhysicalHeight / 2f);
|
||||
contentStream.transform(Matrix.getTranslateInstance(centerX, centerY));
|
||||
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
|
||||
contentStream.drawImage(
|
||||
xobject,
|
||||
-desiredPhysicalWidth / 2f,
|
||||
-desiredPhysicalHeight / 2f,
|
||||
desiredPhysicalWidth,
|
||||
desiredPhysicalHeight);
|
||||
contentStream.restoreGraphicsState();
|
||||
}
|
||||
|
||||
|
||||
+31
-3
@@ -53,6 +53,7 @@ import org.bouncycastle.operator.InputDecryptorProvider;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
|
||||
import org.bouncycastle.pkcs.PKCSException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -68,13 +69,13 @@ import io.micrometer.common.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -82,7 +83,6 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class CertSignController {
|
||||
|
||||
static {
|
||||
@@ -102,6 +102,15 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public CertSignController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
private static void sign(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -177,6 +186,7 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@@ -195,6 +205,24 @@ public class CertSignController {
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
break;
|
||||
case "SERVER":
|
||||
if (serverCertificateService == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotAvailable",
|
||||
"Server certificate service is not available in this edition");
|
||||
}
|
||||
if (!serverCertificateService.isEnabled()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateDisabled",
|
||||
"Server certificate feature is disabled");
|
||||
}
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotFound", "No server certificate configured");
|
||||
}
|
||||
ks = serverCertificateService.getServerKeyStore();
|
||||
keystorePassword = serverCertificateService.getServerCertificatePassword();
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@@ -202,7 +230,7 @@ public class CertSignController {
|
||||
"certificate type: " + certType);
|
||||
}
|
||||
|
||||
CreateSignature createSignature = new CreateSignature(ks, password.toCharArray());
|
||||
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
sign(
|
||||
pdfDocumentFactory,
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BookletImpositionRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The number of pages per side for booklet printing (always 2 for proper booklet).",
|
||||
type = "number",
|
||||
defaultValue = "2",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"2"})
|
||||
private int pagesPerSheet = 2;
|
||||
|
||||
@Schema(description = "Boolean for if you wish to add border around the pages")
|
||||
private Boolean addBorder = false;
|
||||
|
||||
@Schema(
|
||||
description = "The spine location for the booklet.",
|
||||
type = "string",
|
||||
defaultValue = "LEFT",
|
||||
allowableValues = {"LEFT", "RIGHT"})
|
||||
private String spineLocation = "LEFT";
|
||||
|
||||
@Schema(description = "Add gutter margin (inner margin for binding)")
|
||||
private Boolean addGutter = false;
|
||||
|
||||
@Schema(
|
||||
description = "Gutter margin size in points (used when addGutter is true)",
|
||||
type = "number",
|
||||
defaultValue = "12")
|
||||
private float gutterSize = 12f;
|
||||
|
||||
@Schema(description = "Generate both front and back sides (double-sided printing)")
|
||||
private Boolean doubleSided = true;
|
||||
|
||||
@Schema(
|
||||
description = "For manual duplex: which pass to generate",
|
||||
type = "string",
|
||||
defaultValue = "BOTH",
|
||||
allowableValues = {"BOTH", "FIRST", "SECOND"})
|
||||
private String duplexPass = "BOTH";
|
||||
|
||||
@Schema(description = "Flip back sides for short-edge duplex printing (default is long-edge)")
|
||||
private Boolean flipOnShortEdge = false;
|
||||
}
|
||||
@@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean generateToc = false;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||
private String clientFileIds;
|
||||
}
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The type of the digital certificate",
|
||||
allowableValues = {"PEM", "PKCS12", "JKS"},
|
||||
allowableValues = {"PEM", "PKCS12", "JKS", "SERVER"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
|
||||
@@ -1916,6 +1916,7 @@ fileManager.storageError=Storage error occurred
|
||||
fileManager.storageLow=Storage is running low. Consider removing old files.
|
||||
fileManager.uploadError=Failed to upload some files.
|
||||
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
|
||||
fileManager.loadingFiles=Loading files...
|
||||
|
||||
# Page Editor
|
||||
pageEditor.title=Page Editor
|
||||
|
||||
@@ -76,11 +76,6 @@ premium:
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
googleDrive:
|
||||
enabled: false
|
||||
clientId: ''
|
||||
apiKey: ''
|
||||
appId: ''
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
enabled: true # Enable audit logging
|
||||
@@ -114,6 +109,11 @@ system:
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
validity: 365 # Certificate validity in days
|
||||
regenerateOnStartup: false # Generate new certificate on each startup
|
||||
html:
|
||||
urlSecurity:
|
||||
enabled: true # Enable URL security restrictions for HTML processing
|
||||
|
||||
@@ -422,10 +422,6 @@
|
||||
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
|
||||
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
|
||||
</div>
|
||||
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
|
||||
</div>
|
||||
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
|
||||
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-files flex-wrap"></div>
|
||||
@@ -443,16 +439,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
|
||||
|
||||
<div th:if="${@GoogleDriveEnabled == true}" >
|
||||
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
|
||||
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
|
||||
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
|
||||
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
|
||||
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
|
||||
</script>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.configuration;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ServerCertificateInitializer {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializeServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.initializeServerCertificate();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize server certificate", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
|
||||
|
||||
@Configuration
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@@ -55,19 +54,6 @@ public class EEAppConfig {
|
||||
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Primary
|
||||
public boolean googleDriveEnabled() {
|
||||
return runningProOrHigher()
|
||||
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveConfig")
|
||||
public GoogleDrive googleDriveConfig() {
|
||||
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
@SuppressWarnings("deprecation")
|
||||
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-certificate")
|
||||
@Slf4j
|
||||
@Tag(
|
||||
name = "Admin - Server Certificate",
|
||||
description = "Admin APIs for server certificate management")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class ServerCertificateController {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@GetMapping("/info")
|
||||
@Operation(
|
||||
summary = "Get server certificate information",
|
||||
description = "Returns information about the current server certificate")
|
||||
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
|
||||
getServerCertificateInfo() {
|
||||
try {
|
||||
ServerCertificateServiceInterface.ServerCertificateInfo info =
|
||||
serverCertificateService.getServerCertificateInfo();
|
||||
return ResponseEntity.ok(info);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate info", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(
|
||||
summary = "Upload server certificate",
|
||||
description =
|
||||
"Upload a new PKCS12 certificate file to be used as the server certificate")
|
||||
public ResponseEntity<String> uploadServerCertificate(
|
||||
@Parameter(description = "PKCS12 certificate file", required = true)
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(description = "Certificate password", required = true)
|
||||
@RequestParam("password")
|
||||
String password) {
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
|
||||
}
|
||||
|
||||
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
|
||||
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Only PKCS12 (.p12 or .pfx) files are supported");
|
||||
}
|
||||
|
||||
try {
|
||||
serverCertificateService.uploadServerCertificate(file.getInputStream(), password);
|
||||
return ResponseEntity.ok("Server certificate uploaded successfully");
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid certificate upload: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body("Invalid certificate or password.");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to upload server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Delete server certificate",
|
||||
description = "Delete the current server certificate")
|
||||
public ResponseEntity<String> deleteServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate();
|
||||
return ResponseEntity.ok("Server certificate deleted successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to delete server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
@Operation(
|
||||
summary = "Generate new server certificate",
|
||||
description = "Generate a new self-signed server certificate")
|
||||
public ResponseEntity<String> generateServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate(); // Remove existing if any
|
||||
serverCertificateService.initializeServerCertificate(); // Generate new
|
||||
return ResponseEntity.ok("New server certificate generated successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Failed to generate server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/certificate")
|
||||
@Operation(
|
||||
summary = "Download server certificate",
|
||||
description = "Download the server certificate in DER format for validation purposes")
|
||||
public ResponseEntity<byte[]> getServerCertificate() {
|
||||
try {
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"server-cert.cer\"")
|
||||
.contentType(MediaType.valueOf("application/pkix-cert"))
|
||||
.body(certificate);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/enabled")
|
||||
@Operation(
|
||||
summary = "Check if server certificate feature is enabled",
|
||||
description =
|
||||
"Returns whether the server certificate feature is enabled in configuration")
|
||||
public ResponseEntity<Boolean> isServerCertificateEnabled() {
|
||||
return ResponseEntity.ok(serverCertificateService.isEnabled());
|
||||
}
|
||||
}
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.*;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ServerCertificateService implements ServerCertificateServiceInterface {
|
||||
|
||||
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
|
||||
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
|
||||
|
||||
@Value("${system.serverCertificate.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
|
||||
private String organizationName;
|
||||
|
||||
@Value("${system.serverCertificate.validity:365}")
|
||||
private int validityDays;
|
||||
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
private Path getKeystorePath() {
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
return Files.exists(getKeystorePath());
|
||||
}
|
||||
|
||||
public void initializeServerCertificate() {
|
||||
if (!enabled) {
|
||||
log.debug("Server certificate feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
try {
|
||||
generateServerCertificate();
|
||||
log.info("Generated new server certificate at: {}", keystorePath);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
}
|
||||
} else {
|
||||
log.info("Server certificate already exists at: {}", keystorePath);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) {
|
||||
keyStore.load(fis, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
return keyStore;
|
||||
}
|
||||
|
||||
public String getServerCertificatePassword() {
|
||||
return DEFAULT_PASSWORD;
|
||||
}
|
||||
|
||||
public X509Certificate getServerCertificate() throws Exception {
|
||||
KeyStore keyStore = getServerKeyStore();
|
||||
return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS);
|
||||
}
|
||||
|
||||
public byte[] getServerCertificatePublicKey() throws Exception {
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return cert.getEncoded();
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
|
||||
// Find the first private key entry
|
||||
String alias = null;
|
||||
for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) {
|
||||
if (uploadedKeyStore.isKeyEntry(a)) {
|
||||
alias = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alias == null) {
|
||||
throw new IllegalArgumentException("No private key found in uploaded certificate");
|
||||
}
|
||||
|
||||
// Create new keystore with our standard alias and password
|
||||
KeyStore newKeyStore = KeyStore.getInstance("PKCS12");
|
||||
newKeyStore.load(null, null);
|
||||
|
||||
PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray());
|
||||
Certificate[] chain = uploadedKeyStore.getCertificateChain(alias);
|
||||
|
||||
newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain);
|
||||
|
||||
// Save to server keystore location
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
|
||||
log.info("Server certificate updated from uploaded file");
|
||||
}
|
||||
|
||||
public void deleteServerCertificate() throws Exception {
|
||||
Path keystorePath = getKeystorePath();
|
||||
if (Files.exists(keystorePath)) {
|
||||
Files.delete(keystorePath);
|
||||
log.info("Server certificate deleted");
|
||||
}
|
||||
}
|
||||
|
||||
public ServerCertificateInfo getServerCertificateInfo() throws Exception {
|
||||
if (!hasServerCertificate()) {
|
||||
return new ServerCertificateInfo(false, null, null, null, null);
|
||||
}
|
||||
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return new ServerCertificateInfo(
|
||||
true,
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
cert.getNotBefore(),
|
||||
cert.getNotAfter());
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
KeyPair keyPair = keyPairGenerator.generateKeyPair();
|
||||
|
||||
// Certificate details
|
||||
X500Name subject =
|
||||
new X500Name(
|
||||
"CN=" + organizationName + " Server, O=" + organizationName + ", C=US");
|
||||
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
|
||||
Date notBefore = new Date();
|
||||
Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000));
|
||||
|
||||
// Build certificate
|
||||
JcaX509v3CertificateBuilder certBuilder =
|
||||
new JcaX509v3CertificateBuilder(
|
||||
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||
|
||||
// Add PDF-specific certificate extensions for optimal PDF signing compatibility
|
||||
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||
|
||||
// 1) End-entity certificate, not a CA (critical)
|
||||
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
|
||||
// 2) Key usage for PDF digital signatures (critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.keyUsage,
|
||||
true,
|
||||
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// 3) Extended key usage for document signing (non-critical, widely accepted)
|
||||
certBuilder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
|
||||
|
||||
// 4) Subject Key Identifier for chain building (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.subjectKeyIdentifier,
|
||||
false,
|
||||
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// 5) Authority Key Identifier for self-signed cert (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.authorityKeyIdentifier,
|
||||
false,
|
||||
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Sign certificate
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||
.setProvider("BC")
|
||||
.build(keyPair.getPrivate());
|
||||
|
||||
X509CertificateHolder certHolder = certBuilder.build(signer);
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
|
||||
|
||||
// Create keystore
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry(
|
||||
KEYSTORE_ALIAS,
|
||||
keyPair.getPrivate(),
|
||||
DEFAULT_PASSWORD.toCharArray(),
|
||||
new Certificate[] {cert});
|
||||
|
||||
// Save keystore
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
keyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
+239
-247
@@ -8,14 +8,15 @@ Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This
|
||||
|
||||
Stirling-PDF is built using:
|
||||
|
||||
- Spring Boot + Thymeleaf
|
||||
- PDFBox
|
||||
- LibreOffice
|
||||
- qpdf
|
||||
- HTML, CSS, JavaScript
|
||||
- Spring Boot (Backend API)
|
||||
- React + TypeScript + Vite (Frontend V2)
|
||||
- Mantine UI + TailwindCSS (UI Framework)
|
||||
- PDFBox (PDF manipulation)
|
||||
- LibreOffice (Document conversion)
|
||||
- qpdf (PDF processing)
|
||||
- PDF.js (Client-side PDF rendering)
|
||||
- Embedded-PDF (PDF viewer component)
|
||||
- Docker
|
||||
- PDF.js
|
||||
- PDF-LIB.js
|
||||
- Lombok
|
||||
|
||||
## 3. Development Environment Setup
|
||||
@@ -24,8 +25,9 @@ Stirling-PDF is built using:
|
||||
|
||||
- Docker
|
||||
- Git
|
||||
- Java JDK 17 or later
|
||||
- Java JDK 17 or later (JDK 21 recommended)
|
||||
- Gradle 7.0 or later (Included within the repo)
|
||||
- Node.js 18+ and npm (for frontend development)
|
||||
|
||||
### Setup Steps
|
||||
|
||||
@@ -38,8 +40,8 @@ Stirling-PDF is built using:
|
||||
|
||||
2. Install Docker and JDK17 if not already installed.
|
||||
|
||||
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
|
||||
1. Only VSCode
|
||||
3. Install a recommended IDE:
|
||||
- **VSCode** (recommended for frontend)
|
||||
1. Open VS Code.
|
||||
2. When prompted, install the recommended extensions.
|
||||
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
|
||||
@@ -49,13 +51,15 @@ Stirling-PDF is built using:
|
||||
```
|
||||
|
||||
4. Install the required extensions from the list.
|
||||
- **IntelliJ IDEA** (recommended for backend)
|
||||
- **Eclipse** (alternative for backend)
|
||||
|
||||
4. Lombok Setup
|
||||
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
|
||||
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
|
||||
|
||||
5. Add environment variable
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
|
||||
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
|
||||
|
||||
## 4. Project Structure
|
||||
|
||||
@@ -68,10 +72,21 @@ Stirling-PDF/
|
||||
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
|
||||
├── docs/ # Documentation files
|
||||
├── exampleYmlFiles/ # Example YAML configuration files
|
||||
├── frontend/ # React frontend application (V2)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── tools/ # PDF tool implementations
|
||||
│ │ ├── contexts/ # React contexts (FileContext, etc.)
|
||||
│ │ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── services/ # API and processing services
|
||||
│ │ └── i18n.ts # Internationalization config
|
||||
│ ├── public/
|
||||
│ │ └── locales/ # Translation JSON files
|
||||
│ └── package.json
|
||||
├── images/ # Image assets
|
||||
├── pipeline/ # Pipeline-related files (generated at runtime)
|
||||
├── scripts/ # Utility scripts
|
||||
├── src/ # Source code
|
||||
├── src/ # Backend source code
|
||||
│ ├── main/
|
||||
│ │ ├── java/
|
||||
│ │ │ └── stirling/
|
||||
@@ -79,16 +94,14 @@ Stirling-PDF/
|
||||
│ │ │ └── SPDF/
|
||||
│ │ │ ├── config/
|
||||
│ │ │ ├── controller/
|
||||
│ │ │ │ ├── api/ # REST API endpoints
|
||||
│ │ │ │ └── web/ # Web controllers
|
||||
│ │ │ ├── model/
|
||||
│ │ │ ├── repository/
|
||||
│ │ │ ├── service/
|
||||
│ │ │ └── utils/
|
||||
│ │ └── resources/
|
||||
│ │ ├── static/
|
||||
│ │ │ ├── css/
|
||||
│ │ │ ├── js/
|
||||
│ │ │ └── pdfjs/
|
||||
│ │ └── templates/
|
||||
│ │ └── static/ # Legacy static assets
|
||||
│ └── test/
|
||||
│ └── java/
|
||||
│ └── stirling/
|
||||
@@ -141,7 +154,7 @@ services:
|
||||
- ./stirling/latest/config:/configs:rw
|
||||
- ./stirling/latest/logs:/logs:rw
|
||||
environment:
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
DOCKER_ENABLE_SECURITY: "true"
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
PUID: 1002
|
||||
PGID: 1002
|
||||
@@ -170,7 +183,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
1. Set the security environment variable:
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
|
||||
export DOCKER_ENABLE_SECURITY=true # or false to disable login and security features for builds
|
||||
```
|
||||
|
||||
2. Build the project with Gradle:
|
||||
@@ -196,7 +209,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
|
||||
For the fat version (with login and security features enabled):
|
||||
|
||||
```bash
|
||||
export DISABLE_ADDITIONAL_FEATURES=false
|
||||
export DOCKER_ENABLE_SECURITY=true
|
||||
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
|
||||
```
|
||||
|
||||
@@ -224,38 +237,50 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
|
||||
|
||||
### Full Testing with Docker
|
||||
|
||||
1. Build and run the Docker container per the above instructions:
|
||||
1. Build and run the Docker container per the above instructions
|
||||
|
||||
2. Access the application at `http://localhost:8080` and manually test all features developed.
|
||||
|
||||
### Local Testing (Java and UI Components)
|
||||
### Local Testing (Frontend and Backend)
|
||||
|
||||
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
|
||||
For quick iterations and development, you can run the frontend and backend separately:
|
||||
|
||||
- Java backend logic
|
||||
- RESTful API endpoints
|
||||
- JavaScript functionality
|
||||
- User interface components and styling
|
||||
- Thymeleaf templates
|
||||
#### Backend Development
|
||||
|
||||
To run Stirling-PDF locally:
|
||||
|
||||
1. Compile and run the project using built-in IDE methods or by running:
|
||||
1. Run the backend:
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
2. Access the application at `http://localhost:8080` in your web browser.
|
||||
2. The backend API will be available at `http://localhost:8080`
|
||||
|
||||
3. Manually test the features you're working on through the UI.
|
||||
3. API documentation is available at `http://localhost:8080/swagger-ui/index.html`
|
||||
|
||||
4. For API changes, use tools like Postman or curl to test endpoints directly.
|
||||
#### Frontend Development
|
||||
|
||||
1. Install dependencies (first time only):
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. The frontend will be available at `http://localhost:5173`
|
||||
|
||||
4. Vite automatically proxies API calls from `/api/*` to the backend at `localhost:8080`
|
||||
|
||||
Important notes:
|
||||
|
||||
- Frontend requires the backend to be running for full functionality
|
||||
- Hot module replacement (HMR) enables instant updates during development
|
||||
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
|
||||
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
|
||||
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
|
||||
|
||||
## 7. Contributing
|
||||
@@ -307,112 +332,170 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
|
||||
|
||||
Refer to the main README for a full list of customization options.
|
||||
|
||||
## 10. Language Translations
|
||||
## 10. Frontend Development (V2)
|
||||
|
||||
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
|
||||
### Architecture Overview
|
||||
|
||||
```bash
|
||||
/scripts/replace_translation_line.sh
|
||||
The V2 frontend is designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
### Key Components
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `frontend/src/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
### Tool Development
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
|
||||
- **Supporting Hooks**:
|
||||
- **useToolState**: UI state management (loading, progress, error, files)
|
||||
- **useToolApiCalls**: HTTP requests and file processing
|
||||
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
|
||||
|
||||
- **Utilities**:
|
||||
- **toolErrorHandler**: Standardized error extraction and i18n support
|
||||
- **toolResponseProcessor**: API response handling (single/zip/custom)
|
||||
- **toolOperationTracker**: FileContext integration utilities
|
||||
|
||||
**Three Tool Patterns**:
|
||||
|
||||
**Pattern 1: Single-File Tools** (Individual processing)
|
||||
- Backend processes one file per API call
|
||||
- Set `multiFileEndpoint: false`
|
||||
- Examples: Compress, Rotate
|
||||
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: (params, file: File) => { /* single file */ },
|
||||
multiFileEndpoint: false,
|
||||
});
|
||||
```
|
||||
|
||||
This script helps you make consistent replacements across language files.
|
||||
**Pattern 2: Multi-File Tools** (Batch processing)
|
||||
- Backend accepts `MultipartFile[]` arrays in single API call
|
||||
- Set `multiFileEndpoint: true`
|
||||
- Examples: Split, Merge, Overlay
|
||||
|
||||
When contributing translations:
|
||||
|
||||
1. Use the helper script for multi-file changes.
|
||||
2. Ensure all language files are updated consistently.
|
||||
3. The PR checks will verify consistency in language file updates.
|
||||
|
||||
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
|
||||
|
||||
## Code examples
|
||||
|
||||
### Overview of Thymeleaf
|
||||
|
||||
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
|
||||
|
||||
### Thymeleaf overview
|
||||
|
||||
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `app/core/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
|
||||
|
||||
Some examples of this are:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
```
|
||||
or
|
||||
```html
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'split',
|
||||
endpoint: '/api/v1/general/split-pages',
|
||||
buildFormData: (params, files: File[]) => { /* all files */ },
|
||||
multiFileEndpoint: true,
|
||||
filePrefix: 'split_',
|
||||
});
|
||||
```
|
||||
|
||||
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
|
||||
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
|
||||
**Pattern 3: Complex Tools** (Custom processing)
|
||||
- Tools with complex routing logic or non-standard processing
|
||||
- Provide `customProcessor` for full control
|
||||
- Examples: Convert, OCR
|
||||
|
||||
They can be more complex, such as:
|
||||
|
||||
```html
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
|
||||
```typescript
|
||||
return useToolOperation({
|
||||
operationType: 'convert',
|
||||
customProcessor: async (params, files) => { /* custom logic */ },
|
||||
});
|
||||
```
|
||||
|
||||
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
|
||||
**Benefits**:
|
||||
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
|
||||
- **Consistent**: All tools follow same pattern and interface
|
||||
- **Maintainable**: Single responsibility hooks, easy to test and modify
|
||||
- **i18n Ready**: Built-in internationalization support
|
||||
- **Type Safe**: Full TypeScript support with generic interfaces
|
||||
- **Memory Safe**: Automatic resource cleanup and blob URL management
|
||||
|
||||
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
|
||||
### Adding a New Tool
|
||||
|
||||
```java
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("exampleData", exampleData);
|
||||
return "new-feature";
|
||||
}
|
||||
See [ADDING_TOOLS.md](../ADDING_TOOLS.md) for a complete guide to creating new PDF tools.
|
||||
|
||||
### Internationalization
|
||||
|
||||
Translations are stored in JSON files at `frontend/public/locales/{language-code}/translation.json`.
|
||||
|
||||
To use translations in React components:
|
||||
|
||||
```typescript
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('myTool.title')}</h1>
|
||||
<p>{t('myTool.description')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
|
||||
See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new languages.
|
||||
|
||||
```html
|
||||
<tbody>
|
||||
<!-- Use th:each to iterate over the list -->
|
||||
<tr th:each="person : ${exampleData}">
|
||||
<td th:text="${person.id}"></td>
|
||||
<td th:text="${person.name}"></td>
|
||||
<td th:text="${person.age}"></td>
|
||||
<td th:text="${person.email}"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
```
|
||||
## 11. Backend Development
|
||||
|
||||
This would generate n entries of tr for each person in exampleData
|
||||
|
||||
### Adding a New Feature to the Backend (API)
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. **Create a New Controller:**
|
||||
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/api` directory.
|
||||
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
|
||||
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
|
||||
- Ensure to add API documentation annotations like `@Tag` and `@Operation`.
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/new-feature")
|
||||
@RequestMapping("/api/v1/pdf")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
public class NewFeatureController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
|
||||
public String newFeature() {
|
||||
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
|
||||
@PostMapping("/new-feature")
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> newFeature(
|
||||
@RequestPart("fileInput") MultipartFile file,
|
||||
@RequestParam("param1") String param1) {
|
||||
|
||||
// Process PDF
|
||||
byte[] result = processFile(file, param1);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=output.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Define the Service Layer:** (Not required but often useful)
|
||||
- Create a new service class in the `app/core/src/main/java/stirling/software/SPDF/service` directory.
|
||||
2. **Define the Service Layer:** (Optional but recommended)
|
||||
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
|
||||
- Implement the business logic for the new feature.
|
||||
|
||||
```java
|
||||
@@ -423,167 +506,76 @@ This would generate n entries of tr for each person in exampleData
|
||||
@Service
|
||||
public class NewFeatureService {
|
||||
|
||||
public String getNewFeatureData() {
|
||||
public byte[] processFile(MultipartFile file, String param1) {
|
||||
// Implement business logic here
|
||||
return "New Feature Data";
|
||||
return processedBytes;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2b. **Integrate the Service with the Controller:**
|
||||
|
||||
- Autowire the service class in the controller and use it to handle the API request.
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import stirling.software.SPDF.service.NewFeatureService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/new-feature")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
public class NewFeatureController {
|
||||
|
||||
@Autowired
|
||||
private NewFeatureService newFeatureService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
|
||||
public String newFeature() {
|
||||
return newFeatureService.getNewFeatureData();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Feature to the Frontend (UI)
|
||||
|
||||
1. **Create a New Thymeleaf Template:**
|
||||
- Create a new HTML file in the `app/core/src/main/resources/templates` directory.
|
||||
- Use Thymeleaf attributes to dynamically generate content.
|
||||
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<br><br>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 bg-card">
|
||||
<div class="tool-header">
|
||||
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
|
||||
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
|
||||
</div>
|
||||
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
|
||||
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
|
||||
<input type="hidden" id="customMode" name="customMode" value="">
|
||||
<div class="mb-3">
|
||||
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
|
||||
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
2. **Create a New Controller for the UI:**
|
||||
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/ui` directory.
|
||||
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
|
||||
3. **Integrate the Service with the Controller:**
|
||||
|
||||
```java
|
||||
package stirling.software.SPDF.controller.ui;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import stirling.software.SPDF.service.NewFeatureService;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/new-feature")
|
||||
public class NewFeatureUIController {
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/pdf")
|
||||
public class NewFeatureController {
|
||||
|
||||
@Autowired
|
||||
private NewFeatureService newFeatureService;
|
||||
|
||||
@GetMapping
|
||||
public String newFeaturePage(Model model) {
|
||||
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
|
||||
return "new-feature";
|
||||
@PostMapping("/new-feature")
|
||||
public ResponseEntity<byte[]> newFeature(
|
||||
@RequestPart("fileInput") MultipartFile file,
|
||||
@RequestParam("param1") String param1) {
|
||||
|
||||
byte[] result = newFeatureService.processFile(file, param1);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=output.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update the Navigation Bar:**
|
||||
- Add a link to the new feature page in the navigation bar.
|
||||
- Update the `app/core/src/main/resources/templates/fragments/navbar.html` file.
|
||||
### Multi-File Endpoints
|
||||
|
||||
```html
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
|
||||
</li>
|
||||
```
|
||||
For tools that process multiple files in one request:
|
||||
|
||||
## Adding New Translations to Existing Language Files in Stirling-PDF
|
||||
```java
|
||||
@PostMapping("/merge")
|
||||
public ResponseEntity<byte[]> mergePdfs(
|
||||
@RequestPart("fileInput") MultipartFile[] files) {
|
||||
|
||||
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
|
||||
// Process all files together
|
||||
byte[] merged = mergeService.mergeFiles(files);
|
||||
|
||||
### 1. Locate Existing Language Files
|
||||
|
||||
Find the existing `messages.properties` files in the `app/core/src/main/resources` directory. You'll see files like:
|
||||
|
||||
- `messages.properties` (default, usually English)
|
||||
- `messages_en_GB.properties`
|
||||
- `messages_fr_FR.properties`
|
||||
- `messages_de_DE.properties`
|
||||
- etc.
|
||||
|
||||
### 2. Add New Translation Entries
|
||||
|
||||
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
|
||||
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
|
||||
you might add:
|
||||
|
||||
```properties
|
||||
pdfSplitter.title=PDF Splitter
|
||||
pdfSplitter.description=Split your PDF into multiple documents
|
||||
pdfSplitter.button.split=Split PDF
|
||||
pdfSplitter.input.pages=Enter page numbers to split
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=merged.pdf")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.body(merged);
|
||||
}
|
||||
```
|
||||
|
||||
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
|
||||
## 12. Best Practices
|
||||
|
||||
### 3. Use Translations in Thymeleaf Templates
|
||||
### Frontend
|
||||
- Always use FileContext for file operations
|
||||
- Implement proper cleanup for PDF.js documents and blob URLs
|
||||
- Use the `useToolOperation` hook for consistent tool behavior
|
||||
- Follow TypeScript strict mode guidelines
|
||||
- Test with large files (100MB+) to ensure memory efficiency
|
||||
|
||||
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
|
||||
### Backend
|
||||
- Use PDFBox for PDF manipulation
|
||||
- Implement proper error handling and logging
|
||||
- Add Swagger documentation to all API endpoints
|
||||
- Use service layer for business logic
|
||||
- Follow Spring Boot best practices
|
||||
|
||||
```html
|
||||
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
|
||||
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
|
||||
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
|
||||
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
|
||||
```
|
||||
|
||||
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
|
||||
### General
|
||||
- Write clear commit messages
|
||||
- Update documentation for any API changes
|
||||
- Test in Docker before submitting PRs
|
||||
- Run `./gradlew spotlessApply` to format code
|
||||
- Ensure all tests pass with `./test.sh`
|
||||
|
||||
+149
-27
@@ -8,36 +8,66 @@
|
||||
|
||||
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:
|
||||
## Add Language to i18n Configuration
|
||||
|
||||
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
|
||||
Edit the file: [frontend/src/i18n.ts](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/src/i18n.ts)
|
||||
|
||||
Add your language to the `supportedLanguages` object. For example, to add Polish:
|
||||
|
||||
For example, to add Polish, you would add:
|
||||
|
||||
```html
|
||||
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
|
||||
```typescript
|
||||
export const supportedLanguages = {
|
||||
'en': 'English',
|
||||
'en-GB': 'English (UK)',
|
||||
// ... other languages ...
|
||||
'pl-PL': 'Polski', // Add your language here
|
||||
};
|
||||
```
|
||||
|
||||
The `data-bs-language-code` is the code used to reference the file in the next step.
|
||||
If your language uses right-to-left (RTL) text direction, also add it to the `rtlLanguages` array:
|
||||
|
||||
### Add Language Property File
|
||||
```typescript
|
||||
export const rtlLanguages = ['ar-AR', 'fa-IR', 'pl-PL']; // Add if RTL
|
||||
```
|
||||
|
||||
Start by copying the existing English property file:
|
||||
## Create Translation Directory
|
||||
|
||||
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
|
||||
Create a new directory for your language in `frontend/public/locales/`. For Polish, this would be:
|
||||
|
||||
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`.
|
||||
```bash
|
||||
mkdir -p frontend/public/locales/pl-PL
|
||||
```
|
||||
|
||||
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
|
||||
## Add Translation File
|
||||
|
||||
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).
|
||||
Start by copying the existing English (UK) translation file:
|
||||
|
||||
- [frontend/public/locales/en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.json)
|
||||
|
||||
Copy and rename it to `frontend/public/locales/{your-language-code}/translation.json`. In the Polish example:
|
||||
|
||||
```bash
|
||||
cp frontend/public/locales/en-GB/translation.json frontend/public/locales/pl-PL/translation.json
|
||||
```
|
||||
|
||||
Then translate all entries within that JSON file. The file uses nested JSON structure like:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers": {
|
||||
"title": "Add Page Numbers",
|
||||
"submit": "Add Page Numbers",
|
||||
"error": {
|
||||
"failed": "Add page numbers operation failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 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 for "error" does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
|
||||
|
||||
```toml
|
||||
[pl_PL]
|
||||
@@ -50,27 +80,119 @@ 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 `frontend/public/locales/en-GB/translation.json` 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 the `en-GB` translation file to maintain a reference for other languages.
|
||||
- After adding the new tags to the en-GB file, add and translate them in the respective language file (e.g., `pl-PL/translation.json`).
|
||||
|
||||
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
|
||||
## Testing Your Translation
|
||||
|
||||
#### Windows command
|
||||
### Start the development server
|
||||
|
||||
```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
|
||||
1. Start the frontend development server:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
2. The language selector should now include your new language
|
||||
|
||||
#### Linux command
|
||||
3. Select your language from the dropdown and verify all translations appear correctly
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
When adding a new language, you need to update:
|
||||
|
||||
- [ ] `frontend/src/i18n.ts` - Add to supportedLanguages (and rtlLanguages if needed)
|
||||
- [ ] `frontend/public/locales/{language-code}/translation.json` - Create and translate
|
||||
- [ ] `scripts/ignore_translation.toml` - Add untranslatable strings if needed
|
||||
|
||||
Then make a Pull Request (PR) into `main` for others to use!
|
||||
|
||||
If you do not have a Node.js environment, we are happy to verify that the changes work once you raise the PR (but we won't be able to verify the translations themselves).
|
||||
|
||||
## Translation Guidelines
|
||||
|
||||
- **Consistency**: Keep terminology consistent throughout the translation
|
||||
- **Context**: Consider the UI context when translating (e.g., button labels should be concise)
|
||||
- **Formatting**: Preserve placeholders like `{n}` or `{{count}}` in translations
|
||||
- **Testing**: Test your translations in the frontend interface
|
||||
- **RTL Languages**: If your language uses RTL, ensure you add it to the rtlLanguages array
|
||||
|
||||
## Advanced: Translation Management Scripts
|
||||
|
||||
For translators working on large translation files, Python scripts are available in `scripts/translations/` to help manage the workflow.
|
||||
|
||||
### Finding Untranslated Strings
|
||||
|
||||
To see which strings still need translation:
|
||||
|
||||
```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
|
||||
# Check translation status for your language
|
||||
python scripts/translations/translation_analyzer.py --language pl-PL --summary
|
||||
|
||||
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
|
||||
# See detailed list of missing translations
|
||||
python scripts/translations/translation_analyzer.py --language pl-PL --missing-only
|
||||
```
|
||||
|
||||
### Extracting Untranslated Strings
|
||||
|
||||
To extract only the strings that need translation into a separate file:
|
||||
|
||||
```bash
|
||||
# Extract to a compact JSON file
|
||||
python scripts/translations/compact_translator.py pl-PL --output to_translate.json
|
||||
```
|
||||
|
||||
This creates a file with just the untranslated entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers.title": "Add Page Numbers",
|
||||
"compress.header": "Compress PDF",
|
||||
"merge.submit": "Merge PDFs"
|
||||
}
|
||||
```
|
||||
|
||||
### Translating the Extracted File
|
||||
|
||||
Open `to_translate.json` and translate the values while keeping the keys unchanged:
|
||||
|
||||
```json
|
||||
{
|
||||
"addPageNumbers.title": "Dodaj numery stron",
|
||||
"compress.header": "Kompresuj PDF",
|
||||
"merge.submit": "Połącz pliki PDF"
|
||||
}
|
||||
```
|
||||
|
||||
### Merging Translations Back
|
||||
|
||||
After translating, merge your translations back into the main file:
|
||||
|
||||
```bash
|
||||
# Apply your translations
|
||||
python scripts/translations/translation_merger.py pl-PL apply-translations --translations-file to_translate.json
|
||||
|
||||
# Verify the result
|
||||
python scripts/translations/translation_analyzer.py --language pl-PL --summary
|
||||
```
|
||||
|
||||
### Validating Your Work
|
||||
|
||||
Before submitting, validate your translation file:
|
||||
|
||||
```bash
|
||||
# Check for JSON syntax errors
|
||||
python scripts/translations/json_validator.py frontend/public/locales/pl-PL/translation.json
|
||||
|
||||
# Check for missing placeholders
|
||||
python scripts/translations/validate_placeholders.py --language pl-PL
|
||||
|
||||
# Check for structural issues
|
||||
python scripts/translations/validate_json_structure.py --language pl-PL
|
||||
```
|
||||
|
||||
**Note**: These scripts require Python 3.7+ to be installed. See `scripts/translations/README.md` for detailed documentation.
|
||||
|
||||
+8
-1
@@ -50,7 +50,14 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
|
||||
- **Custom Ports**: Modify port mappings in docker-compose files
|
||||
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
|
||||
|
||||
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
|
||||
|
||||
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
|
||||
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
|
||||
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
|
||||
|
||||
## Development vs Production
|
||||
|
||||
- **Development**: Keep backend port 8080 exposed for debugging
|
||||
- **Production**: Remove backend port exposure, use only frontend proxy
|
||||
- **Production**: Remove backend port exposure, use only frontend proxy
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -44,6 +44,9 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -46,6 +46,9 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
@@ -6,6 +6,11 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -90,6 +95,14 @@ http {
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import globals from "globals";
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const srcGlobs = [
|
||||
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
];
|
||||
const nodeGlobs = [
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'*.config.{js,ts,mjs}',
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
@@ -15,7 +24,6 @@ export default defineConfig(
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
"no-undef": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
@@ -38,5 +46,23 @@ export default defineConfig(
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
// Config for browser scripts
|
||||
{
|
||||
files: srcGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
}
|
||||
}
|
||||
},
|
||||
// Config for node scripts
|
||||
{
|
||||
files: nodeGlobs,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="%BASE_URL%" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
Generated
+3213
-3582
File diff suppressed because it is too large
Load Diff
+85
-59
@@ -5,52 +5,54 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||
"@embedpdf/core": "^1.1.1",
|
||||
"@embedpdf/engines": "^1.1.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.1.1",
|
||||
"@embedpdf/plugin-loader": "^1.1.1",
|
||||
"@embedpdf/plugin-pan": "^1.1.1",
|
||||
"@embedpdf/plugin-render": "^1.1.1",
|
||||
"@embedpdf/plugin-rotate": "^1.1.1",
|
||||
"@embedpdf/plugin-scroll": "^1.1.1",
|
||||
"@embedpdf/plugin-search": "^1.1.1",
|
||||
"@embedpdf/plugin-selection": "^1.1.1",
|
||||
"@embedpdf/plugin-spread": "^1.1.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.1.1",
|
||||
"@embedpdf/plugin-tiling": "^1.1.1",
|
||||
"@embedpdf/plugin-viewport": "^1.1.1",
|
||||
"@embedpdf/plugin-zoom": "^1.1.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.1",
|
||||
"@embedpdf/engines": "^1.3.1",
|
||||
"@embedpdf/plugin-annotation": "^1.3.1",
|
||||
"@embedpdf/plugin-export": "^1.3.1",
|
||||
"@embedpdf/plugin-history": "^1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.1",
|
||||
"@embedpdf/plugin-loader": "^1.3.1",
|
||||
"@embedpdf/plugin-pan": "^1.3.1",
|
||||
"@embedpdf/plugin-render": "^1.3.1",
|
||||
"@embedpdf/plugin-rotate": "^1.3.1",
|
||||
"@embedpdf/plugin-scroll": "^1.3.1",
|
||||
"@embedpdf/plugin-search": "^1.3.1",
|
||||
"@embedpdf/plugin-selection": "^1.3.1",
|
||||
"@embedpdf/plugin-spread": "^1.3.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.1",
|
||||
"@embedpdf/plugin-tiling": "^1.3.1",
|
||||
"@embedpdf/plugin-viewport": "^1.3.1",
|
||||
"@embedpdf/plugin-zoom": "^1.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@iconify/react": "^6.0.0",
|
||||
"@mantine/core": "^8.0.1",
|
||||
"@mantine/dates": "^8.0.1",
|
||||
"@mantine/dropzone": "^8.0.1",
|
||||
"@mantine/hooks": "^8.0.1",
|
||||
"@mui/icons-material": "^7.1.0",
|
||||
"@mui/material": "^7.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@mantine/core": "^8.3.1",
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.9.0",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"axios": "^1.12.2",
|
||||
"globals": "^16.4.0",
|
||||
"i18next": "^25.5.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"web-vitals": "^2.1.4"
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"posthog-js": "^1.268.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"predev": "npm run generate-icons",
|
||||
@@ -70,12 +72,17 @@
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:install": "playwright install"
|
||||
"test:e2e:install": "playwright install",
|
||||
"update:minor": "npm outdated || npm update && npm audit fix && npm test",
|
||||
"update:major": "npx npm-check-updates -u && npm install",
|
||||
"update:interactive": "npx npm-check-updates -i",
|
||||
"update:minor-strict": "npx npm-check-updates -u --target minor && npm install"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -91,26 +98,45 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@iconify-json/material-symbols": "^1.2.33",
|
||||
"@iconify/utils": "^3.0.1",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"@vitest/coverage-v8": "^1.0.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jsdom": "^23.0.0",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify-json/material-symbols": "^1.2.37",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
"@types/google.picker": "^0.0.51",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"license-checker": "^25.0.1",
|
||||
"madge": "^8.0.0",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.42.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^1.0.0"
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"depcheck": {
|
||||
"ignoreMatches": [
|
||||
"@emotion/*",
|
||||
"tailwindcss",
|
||||
"@testing-library/user-event",
|
||||
"@vitest/coverage-v8"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,8 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
@@ -348,202 +350,257 @@
|
||||
"globalPopularity": "Global Popularity",
|
||||
"sortBy": "Sort by:",
|
||||
"multiTool": {
|
||||
"tags": "multiple,tools",
|
||||
"title": "PDF Multi Tool",
|
||||
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
|
||||
},
|
||||
"merge": {
|
||||
"tags": "combine,join,unite",
|
||||
"title": "Merge",
|
||||
"desc": "Easily merge multiple PDFs into one."
|
||||
},
|
||||
"split": {
|
||||
"tags": "divide,separate,break",
|
||||
"title": "Split",
|
||||
"desc": "Split PDFs into multiple documents"
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "turn,flip,orient",
|
||||
"title": "Rotate",
|
||||
"desc": "Easily rotate your PDFs."
|
||||
},
|
||||
"imageToPDF": {
|
||||
"tags": "convert,image,transform",
|
||||
"title": "Image to PDF",
|
||||
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
|
||||
},
|
||||
"pdfToImage": {
|
||||
"tags": "convert,image,extract",
|
||||
"title": "PDF to Image",
|
||||
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
|
||||
},
|
||||
"pdfOrganiser": {
|
||||
"tags": "organize,rearrange,reorder",
|
||||
"title": "Organize",
|
||||
"desc": "Remove/Rearrange pages in any order"
|
||||
},
|
||||
"addImage": {
|
||||
"tags": "insert,embed,place",
|
||||
"title": "Add image",
|
||||
"desc": "Adds a image onto a set location on the PDF"
|
||||
},
|
||||
"watermark": {
|
||||
"tags": "stamp,mark,overlay",
|
||||
"title": "Add Watermark",
|
||||
"desc": "Add a custom watermark to your PDF document."
|
||||
},
|
||||
"permissions": {
|
||||
"tags": "permissions,security,access",
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change the permissions of your PDF document"
|
||||
},
|
||||
"pageRemover": {
|
||||
"tags": "remove,delete,pages",
|
||||
"title": "Remove",
|
||||
"desc": "Delete unwanted pages from your PDF document."
|
||||
},
|
||||
"addPassword": {
|
||||
"tags": "password,encrypt,secure",
|
||||
"title": "Add Password",
|
||||
"desc": "Encrypt your PDF document with a password."
|
||||
},
|
||||
"changePermissions": {
|
||||
"tags": "permissions,restrictions,security",
|
||||
"title": "Change Permissions",
|
||||
"desc": "Change document restrictions and permissions."
|
||||
},
|
||||
"removePassword": {
|
||||
"tags": "unlock,remove,password",
|
||||
"title": "Remove Password",
|
||||
"desc": "Remove password protection from your PDF document."
|
||||
},
|
||||
"compress": {
|
||||
"tags": "shrink,reduce,optimize",
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"sanitize": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitize",
|
||||
"desc": "Remove potentially harmful elements from PDF files."
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"tags": "unlock,enable,edit",
|
||||
"title": "Unlock PDF Forms",
|
||||
"desc": "Remove read-only property of form fields in a PDF document."
|
||||
},
|
||||
"changeMetadata": {
|
||||
"tags": "edit,modify,update",
|
||||
"title": "Change Metadata",
|
||||
"desc": "Change/Remove/Add metadata from a PDF document"
|
||||
},
|
||||
"fileToPDF": {
|
||||
"tags": "convert,transform,change",
|
||||
"title": "Convert file to PDF",
|
||||
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
|
||||
},
|
||||
"ocr": {
|
||||
"tags": "extract,scan",
|
||||
"title": "OCR / Cleanup scans",
|
||||
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
|
||||
},
|
||||
"extractImages": {
|
||||
"tags": "pull,save,export",
|
||||
"title": "Extract Images",
|
||||
"desc": "Extracts all images from a PDF and saves them to zip"
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "convert,archive,long-term",
|
||||
"title": "PDF to PDF/A",
|
||||
"desc": "Convert PDF to PDF/A for long-term storage"
|
||||
},
|
||||
"PDFToWord": {
|
||||
"tags": "convert,word,doc",
|
||||
"title": "PDF to Word",
|
||||
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
|
||||
},
|
||||
"PDFToPresentation": {
|
||||
"tags": "convert,presentation,ppt",
|
||||
"title": "PDF to Presentation",
|
||||
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
|
||||
},
|
||||
"PDFToText": {
|
||||
"tags": "convert,text,rtf",
|
||||
"title": "PDF to RTF (Text)",
|
||||
"desc": "Convert PDF to Text or RTF format"
|
||||
},
|
||||
"PDFToHTML": {
|
||||
"tags": "convert,html,web",
|
||||
"title": "PDF to HTML",
|
||||
"desc": "Convert PDF to HTML format"
|
||||
},
|
||||
"PDFToXML": {
|
||||
"tags": "convert,xml,data",
|
||||
"title": "PDF to XML",
|
||||
"desc": "Convert PDF to XML format"
|
||||
},
|
||||
"ScannerImageSplit": {
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
"title": "Sign",
|
||||
"desc": "Adds signature to PDF by drawing, text or image"
|
||||
},
|
||||
"flatten": {
|
||||
"tags": "simplify,remove,interactive",
|
||||
"title": "Flatten",
|
||||
"desc": "Remove all interactive elements and forms from a PDF"
|
||||
},
|
||||
"repair": {
|
||||
"tags": "fix,restore",
|
||||
"title": "Repair",
|
||||
"desc": "Tries to repair a corrupt/broken PDF"
|
||||
},
|
||||
"removeBlanks": {
|
||||
"tags": "delete,clean,empty",
|
||||
"title": "Remove Blank pages",
|
||||
"desc": "Detects and removes blank pages from a document"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"tags": "delete,clean,strip",
|
||||
"title": "Remove Annotations",
|
||||
"desc": "Removes all comments/annotations from a PDF"
|
||||
},
|
||||
"compare": {
|
||||
"tags": "difference",
|
||||
"title": "Compare",
|
||||
"desc": "Compares and shows the differences between 2 PDF Documents"
|
||||
},
|
||||
"certSign": {
|
||||
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
|
||||
"title": "Sign with Certificate",
|
||||
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
|
||||
},
|
||||
"removeCertSign": {
|
||||
"tags": "remove,delete,unlock",
|
||||
"title": "Remove Certificate Sign",
|
||||
"desc": "Remove certificate signature from PDF"
|
||||
},
|
||||
"pageLayout": {
|
||||
"tags": "layout,arrange,combine",
|
||||
"title": "Multi-Page Layout",
|
||||
"desc": "Merge multiple pages of a PDF document into a single page"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,print,binding",
|
||||
"title": "Booklet Imposition",
|
||||
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,adjust,scale",
|
||||
"title": "Adjust page size/scale",
|
||||
"desc": "Change the size/scale of a page and/or its contents."
|
||||
},
|
||||
"pipeline": {
|
||||
"tags": "automation,script,workflow",
|
||||
"title": "Pipeline",
|
||||
"desc": "Run multiple actions on PDFs by defining pipeline scripts"
|
||||
},
|
||||
"addPageNumbers": {
|
||||
"tags": "number,pagination,count",
|
||||
"title": "Add Page Numbers",
|
||||
"desc": "Add Page numbers throughout a document in a set location"
|
||||
},
|
||||
"auto-rename": {
|
||||
"tags": "auto-detect,header-based,organize,relabel",
|
||||
"title": "Auto Rename PDF File",
|
||||
"desc": "Auto renames a PDF file based on its detected header"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
"title": "Crop PDF",
|
||||
"desc": "Crop a PDF to reduce its size (maintains text!)"
|
||||
},
|
||||
"autoSplitPDF": {
|
||||
"tags": "auto,split,QR",
|
||||
"title": "Auto Split Pages",
|
||||
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
|
||||
},
|
||||
"sanitizePDF": {
|
||||
"tags": "clean,purge,remove",
|
||||
"title": "Sanitize",
|
||||
"desc": "Remove scripts and other elements from PDF files"
|
||||
},
|
||||
"URLToPDF": {
|
||||
"tags": "convert,url,website",
|
||||
"title": "URL/Website To PDF",
|
||||
"desc": "Converts any http(s)URL to PDF"
|
||||
},
|
||||
"HTMLToPDF": {
|
||||
"tags": "convert,html,web",
|
||||
"title": "HTML to PDF",
|
||||
"desc": "Converts any HTML file or zip to PDF"
|
||||
},
|
||||
"MarkdownToPDF": {
|
||||
"tags": "convert,markdown,md",
|
||||
"title": "Markdown to PDF",
|
||||
"desc": "Converts any Markdown file to PDF"
|
||||
},
|
||||
"PDFToMarkdown": {
|
||||
"tags": "convert,markdown,md",
|
||||
"title": "PDF to Markdown",
|
||||
"desc": "Converts any PDF to Markdown"
|
||||
},
|
||||
"getPdfInfo": {
|
||||
"tags": "info,metadata,details",
|
||||
"title": "Get ALL Info on PDF",
|
||||
"desc": "Grabs any and all information possible on PDFs"
|
||||
},
|
||||
@@ -560,50 +617,62 @@
|
||||
"desc": "Searches and displays any JS injected into a PDF"
|
||||
},
|
||||
"autoRedact": {
|
||||
"tags": "auto,redact,censor",
|
||||
"title": "Auto Redact",
|
||||
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
|
||||
},
|
||||
"redact": {
|
||||
"tags": "censor,blackout,hide",
|
||||
"title": "Manual Redaction",
|
||||
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"PDFToCSV": {
|
||||
"tags": "convert,csv,table",
|
||||
"title": "PDF to CSV",
|
||||
"desc": "Extracts Tables from a PDF converting it to CSV"
|
||||
},
|
||||
"split-by-size-or-count": {
|
||||
"tags": "auto,split,size",
|
||||
"title": "Auto Split by Size/Count",
|
||||
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
|
||||
},
|
||||
"overlay-pdfs": {
|
||||
"tags": "overlay,combine,stack",
|
||||
"title": "Overlay PDFs",
|
||||
"desc": "Overlays PDFs on-top of another PDF"
|
||||
},
|
||||
"split-by-sections": {
|
||||
"tags": "split,sections,divide",
|
||||
"title": "Split PDF by Sections",
|
||||
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
|
||||
},
|
||||
"AddStampRequest": {
|
||||
"tags": "stamp,mark,seal",
|
||||
"title": "Add Stamp to PDF",
|
||||
"desc": "Add text or add image stamps at set locations"
|
||||
},
|
||||
"removeImage": {
|
||||
"tags": "remove,delete,clean",
|
||||
"title": "Remove image",
|
||||
"desc": "Remove image from PDF to reduce file size"
|
||||
},
|
||||
"splitByChapters": {
|
||||
"tags": "split,chapters,structure",
|
||||
"title": "Split PDF by Chapters",
|
||||
"desc": "Split a PDF into multiple files based on its chapter structure."
|
||||
},
|
||||
"validateSignature": {
|
||||
"tags": "validate,verify,certificate",
|
||||
"title": "Validate PDF Signature",
|
||||
"desc": "Verify digital signatures and certificates in PDF documents"
|
||||
},
|
||||
"swagger": {
|
||||
"tags": "API,documentation,test",
|
||||
"title": "API Documentation",
|
||||
"desc": "View API documentation and test endpoints"
|
||||
},
|
||||
"replace-color": {
|
||||
"tags": "color,replace,invert",
|
||||
"title": "Replace and Invert Color",
|
||||
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
|
||||
}
|
||||
@@ -700,7 +769,7 @@
|
||||
"header": "PDF Page Organizer",
|
||||
"submit": "Rearrange Pages",
|
||||
"mode": {
|
||||
"_value": "Mode",
|
||||
"_value": "Organization mode",
|
||||
"1": "Custom Page Order",
|
||||
"2": "Reverse Order",
|
||||
"3": "Duplex Sort",
|
||||
@@ -713,6 +782,19 @@
|
||||
"10": "Odd-Even Merge",
|
||||
"11": "Duplicate all pages"
|
||||
},
|
||||
"desc": {
|
||||
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
|
||||
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
|
||||
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
|
||||
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
|
||||
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for side‑stitch booklet printing (optimized for binding on the side).",
|
||||
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
|
||||
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
|
||||
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
|
||||
"REMOVE_FIRST": "Remove the first page from the document.",
|
||||
"REMOVE_LAST": "Remove the last page from the document.",
|
||||
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
|
||||
},
|
||||
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
|
||||
},
|
||||
"addImage": {
|
||||
@@ -987,7 +1069,13 @@
|
||||
"header": "Extract Images",
|
||||
"selectText": "Select image format to convert extracted images to",
|
||||
"allowDuplicates": "Save duplicate images",
|
||||
"submit": "Extract"
|
||||
"submit": "Extract",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting images from the PDF."
|
||||
}
|
||||
},
|
||||
"pdfToPDFA": {
|
||||
"tags": "archive,long-term,standard,conversion,storage,preservation",
|
||||
@@ -1060,7 +1148,6 @@
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"sign": {
|
||||
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
"upload": "Upload Image",
|
||||
@@ -1156,7 +1243,17 @@
|
||||
"tags": "comments,highlight,notes,markup,remove",
|
||||
"title": "Remove Annotations",
|
||||
"header": "Remove Annotations",
|
||||
"submit": "Remove"
|
||||
"submit": "Remove",
|
||||
"settings": {
|
||||
"title": "Settings"
|
||||
},
|
||||
"info": {
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
},
|
||||
"compare": {
|
||||
"tags": "differentiate,contrast,changes,analysis",
|
||||
@@ -1230,6 +1327,17 @@
|
||||
"addBorder": "Add Borders",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"bookletImposition": {
|
||||
"tags": "booklet,imposition,printing,binding,folding,signature",
|
||||
"title": "Booklet Imposition",
|
||||
"header": "Booklet Imposition",
|
||||
"submit": "Create Booklet",
|
||||
"files": {
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the booklet imposition."
|
||||
}
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "Adjust page-scale",
|
||||
@@ -1455,6 +1563,7 @@
|
||||
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize",
|
||||
"header": "Stamp PDF",
|
||||
"title": "Stamp PDF",
|
||||
"stampSetup": "Stamp Setup",
|
||||
"stampType": "Stamp Type",
|
||||
"stampText": "Stamp Text",
|
||||
"stampImage": "Stamp Image",
|
||||
@@ -1467,7 +1576,8 @@
|
||||
"overrideY": "Override Y Coordinate",
|
||||
"customMargin": "Custom Margin",
|
||||
"customColor": "Custom Text Color",
|
||||
"submit": "Submit"
|
||||
"submit": "Submit",
|
||||
"noStampSelected": "No stamp selected. Return to Step 1."
|
||||
},
|
||||
"removeImagePdf": {
|
||||
"tags": "Remove Image,Page operations,Back end,server side"
|
||||
@@ -1602,8 +1712,9 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
@@ -1723,10 +1834,16 @@
|
||||
}
|
||||
},
|
||||
"removeImage": {
|
||||
"title": "Remove image",
|
||||
"header": "Remove image",
|
||||
"removeImage": "Remove image",
|
||||
"submit": "Remove image"
|
||||
"title": "Remove Images",
|
||||
"header": "Remove Images",
|
||||
"removeImage": "Remove Images",
|
||||
"submit": "Remove Images",
|
||||
"results": {
|
||||
"title": "Remove Images Results"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to remove images from the PDF."
|
||||
}
|
||||
},
|
||||
"splitByChapters": {
|
||||
"title": "Split PDF by Chapters",
|
||||
@@ -1771,7 +1888,7 @@
|
||||
"title": "How we use Cookies",
|
||||
"description": {
|
||||
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
|
||||
"2": "If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
|
||||
},
|
||||
"acceptAllBtn": "Okay",
|
||||
"acceptNecessaryBtn": "No Thanks",
|
||||
@@ -1795,7 +1912,7 @@
|
||||
"1": "Strictly Necessary Cookies",
|
||||
"2": "Always Enabled"
|
||||
},
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off."
|
||||
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
@@ -2275,5 +2392,22 @@
|
||||
},
|
||||
"automate": {
|
||||
"copyToSaved": "Copy to Saved"
|
||||
},
|
||||
"AddAttachmentsRequest": {
|
||||
"attachments": "Select Attachments",
|
||||
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
|
||||
"selectFiles": "Select Files to Attach",
|
||||
"placeholder": "Choose files...",
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
}
|
||||
},
|
||||
"addAttachments": {
|
||||
"error": {
|
||||
"failed": "An error occurred while adding attachments to the PDF."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Vendored
-58353
File diff suppressed because one or more lines are too long
@@ -1,8 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('node:child_process');
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { argv } = require('node:process');
|
||||
const inputIdx = argv.indexOf('--input');
|
||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
|
||||
// __dirname is available in CommonJS by default
|
||||
|
||||
/**
|
||||
* Generate 3rd party licenses for frontend dependencies
|
||||
@@ -14,59 +21,54 @@ const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
|
||||
|
||||
// Ensure the output directory exists
|
||||
const outputDir = path.dirname(OUTPUT_FILE);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('🔍 Generating frontend license report...');
|
||||
|
||||
try {
|
||||
// Install license-checker if not present
|
||||
try {
|
||||
require.resolve('license-checker');
|
||||
} catch {
|
||||
console.log('📦 Installing license-checker...');
|
||||
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
|
||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
|
||||
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Generate license report using license-checker (more reliable)
|
||||
const licenseReport = execSync('npx license-checker --production --json', {
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON)
|
||||
});
|
||||
|
||||
let licenseData;
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse license data:', parseError.message);
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
// Generate license report using pinned license-checker; disable lifecycle scripts
|
||||
if (POSTPROCESS_ONLY) {
|
||||
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
|
||||
console.error('❌ --input file missing or not found');
|
||||
process.exit(1);
|
||||
}
|
||||
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
|
||||
} else {
|
||||
const licenseReport = execSync(
|
||||
// 'npx --yes license-checker@25.0.1 --production --json',
|
||||
'npx --yes license-report --only=prod --output=json',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON),
|
||||
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
|
||||
}
|
||||
);
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse license data:', parseError.message);
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!licenseData || typeof licenseData !== 'object') {
|
||||
if (!Array.isArray(licenseData)) {
|
||||
console.error('❌ Invalid license data structure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Convert license-checker format to array
|
||||
const licenseArray = Object.entries(licenseData).map(([key, value]) => {
|
||||
let name, version;
|
||||
|
||||
// Handle scoped packages like @mantine/core@1.0.0
|
||||
if (key.startsWith('@')) {
|
||||
const parts = key.split('@');
|
||||
name = `@${parts[1]}`;
|
||||
version = parts[2];
|
||||
} else {
|
||||
// Handle regular packages like react@18.0.0
|
||||
const lastAtIndex = key.lastIndexOf('@');
|
||||
name = key.substring(0, lastAtIndex);
|
||||
version = key.substring(lastAtIndex + 1);
|
||||
}
|
||||
|
||||
// Normalize license types for edge cases
|
||||
let licenseType = value.licenses;
|
||||
const licenseArray = licenseData.map(dep => {
|
||||
let licenseType = dep.licenseType;
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
@@ -88,13 +90,17 @@ try {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||
}
|
||||
|
||||
return {
|
||||
name: name,
|
||||
version: version || value.version || 'unknown',
|
||||
name: dep.name,
|
||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
|
||||
licenseType: licenseType,
|
||||
repository: value.repository,
|
||||
url: value.url,
|
||||
link: value.licenseUrl
|
||||
repository: dep.link,
|
||||
url: dep.link,
|
||||
link: dep.link
|
||||
};
|
||||
});
|
||||
|
||||
@@ -153,7 +159,7 @@ try {
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
|
||||
fs.writeFileSync(warningsFile, JSON.stringify({
|
||||
writeFileSync(warningsFile, JSON.stringify({
|
||||
warnings: problematicLicenses,
|
||||
generated: new Date().toISOString()
|
||||
}, null, 2));
|
||||
@@ -163,7 +169,7 @@ try {
|
||||
}
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
|
||||
console.log(`✅ License report generated successfully!`);
|
||||
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
|
||||
@@ -274,7 +280,8 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
|
||||
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE'
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
|
||||
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
|
||||
]);
|
||||
|
||||
// Helper function to normalize license names for comparison
|
||||
|
||||
+25
-16
@@ -1,10 +1,12 @@
|
||||
import React, { Suspense } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
|
||||
import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
|
||||
@@ -14,6 +16,7 @@ import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||
import { SignatureProvider } from "./contexts/SignatureContext";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
@@ -39,21 +42,27 @@ export default function App() {
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
<PreferencesProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</PreferencesProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</Suspense>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ import MobileLayout from './fileManager/MobileLayout';
|
||||
import DesktopLayout from './fileManager/DesktopLayout';
|
||||
import DragOverlay from './fileManager/DragOverlay';
|
||||
import { FileManagerProvider } from '../contexts/FileManagerContext';
|
||||
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
|
||||
import { loadScript } from '../utils/scriptLoader';
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@@ -20,7 +22,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@@ -84,6 +86,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Preload Google Drive scripts if configured
|
||||
useEffect(() => {
|
||||
if (isGoogleDriveConfigured()) {
|
||||
// Load scripts in parallel without blocking
|
||||
Promise.all([
|
||||
loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
]).catch((error) => {
|
||||
console.warn('Failed to preload Google Drive scripts:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Modal size constants for consistent scaling
|
||||
const modalHeight = '80vh';
|
||||
const modalWidth = isMobile ? '100%' : '80vw';
|
||||
@@ -123,7 +148,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
accept={{}}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
@@ -147,6 +171,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface PDFAnnotationContextValue {
|
||||
// Drawing mode management
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
|
||||
// Drawing settings
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
|
||||
// History operations
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
|
||||
// Image data management
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
|
||||
// Placement state
|
||||
isPlacementMode: boolean;
|
||||
|
||||
// Signature configuration
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
||||
|
||||
interface PDFAnnotationProviderProps {
|
||||
children: ReactNode;
|
||||
// These would come from the signature context
|
||||
activateDrawMode: () => void;
|
||||
deactivateDrawMode: () => void;
|
||||
activateSignaturePlacementMode: () => void;
|
||||
activateDeleteMode: () => void;
|
||||
updateDrawSettings: (color: string, size: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
isPlacementMode: boolean;
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
children,
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
}) => {
|
||||
const contextValue: PDFAnnotationContextValue = {
|
||||
activateDrawMode,
|
||||
deactivateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
activateDeleteMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
storeImageData,
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFAnnotationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PDFAnnotationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||
const context = useContext(PDFAnnotationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from './DrawingControls';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
enableImageUpload?: boolean;
|
||||
enableTextInput?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
interface BaseAnnotationToolProps {
|
||||
config: AnnotationToolConfig;
|
||||
children: React.ReactNode;
|
||||
onSignatureDataChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
config,
|
||||
children,
|
||||
onSignatureDataChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
activateSignaturePlacementMode,
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
setSignatureData(data);
|
||||
onSignatureDataChange?.(data);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = () => {
|
||||
if (activateSignaturePlacementMode) {
|
||||
activateSignaturePlacementMode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Drawing Controls (Undo/Redo/Place) */}
|
||||
<DrawingControls
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
showPlaceButton={config.showPlaceButton}
|
||||
placeButtonText={config.placeButtonText}
|
||||
/>
|
||||
|
||||
{/* Tool Content */}
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
selectedColor,
|
||||
signatureData,
|
||||
onSignatureDataChange: handleSignatureDataChange,
|
||||
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||
disabled
|
||||
})}
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<Text size="sm">
|
||||
Click anywhere on the PDF to place your annotation.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
title = "Choose Color"
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
swatchesPerRow={6}
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ColorSwatchButtonProps {
|
||||
color: string;
|
||||
onClick: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
color,
|
||||
onClick,
|
||||
size = 24
|
||||
}) => {
|
||||
return (
|
||||
<ColorSwatch
|
||||
color={color}
|
||||
size={size}
|
||||
radius={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
|
||||
import { ColorSwatchButton } from './ColorPicker';
|
||||
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
selectedColor: string;
|
||||
penSize: number;
|
||||
penSizeInput: string;
|
||||
onColorSwatchClick: () => void;
|
||||
onPenSizeChange: (size: number) => void;
|
||||
onPenSizeInputChange: (input: string) => void;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
onDrawingComplete?: () => void;
|
||||
disabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
modalWidth?: number;
|
||||
modalHeight?: number;
|
||||
additionalButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
selectedColor,
|
||||
penSize,
|
||||
penSizeInput,
|
||||
onColorSwatchClick,
|
||||
onPenSizeChange,
|
||||
onPenSizeInputChange,
|
||||
onSignatureDataChange,
|
||||
onDrawingComplete,
|
||||
disabled = false,
|
||||
width = 400,
|
||||
height = 150,
|
||||
}) => {
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
padRef.current = new SignaturePad(canvas, {
|
||||
penColor: selectedColor,
|
||||
minWidth: penSize * 0.5,
|
||||
maxWidth: penSize * 2.5,
|
||||
throttle: 10,
|
||||
minDistance: 5,
|
||||
velocityFilterWeight: 0.7,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = () => {
|
||||
// Clear pad ref so it reinitializes
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas.toDataURL('image/png');
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
|
||||
|
||||
// Find bounds of non-transparent pixels
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
|
||||
if (alpha > 0) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trimWidth = maxX - minX + 1;
|
||||
const trimHeight = maxY - minY + 1;
|
||||
|
||||
// Create trimmed canvas
|
||||
const trimmedCanvas = document.createElement('canvas');
|
||||
trimmedCanvas.width = trimWidth;
|
||||
trimmedCanvas.height = trimHeight;
|
||||
const trimmedCtx = trimmedCanvas.getContext('2d');
|
||||
if (trimmedCtx) {
|
||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
||||
}
|
||||
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (padRef.current && !padRef.current.isEmpty()) {
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
onSignatureDataChange(trimmedPng);
|
||||
|
||||
// Update preview canvas with proper aspect ratio
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
|
||||
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
|
||||
const scale = Math.min(
|
||||
previewCanvasRef.current.width / img.width,
|
||||
previewCanvasRef.current.height / img.height
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
|
||||
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
|
||||
|
||||
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = trimmedPng;
|
||||
|
||||
if (onDrawingComplete) {
|
||||
onDrawingComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (padRef.current) {
|
||||
padRef.current.off();
|
||||
padRef.current = null;
|
||||
}
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
if (padRef.current) {
|
||||
padRef.current.clear();
|
||||
}
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
}
|
||||
onSignatureDataChange(null);
|
||||
};
|
||||
|
||||
const updatePenColor = (color: string) => {
|
||||
if (padRef.current) {
|
||||
padRef.current.penColor = color;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePenSize = (size: number) => {
|
||||
if (padRef.current) {
|
||||
padRef.current.minWidth = size * 0.8;
|
||||
padRef.current.maxWidth = size * 1.2;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>Draw your signature</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Click to open drawing canvas
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||
<Popover
|
||||
opened={colorPickerOpen}
|
||||
onChange={setColorPickerOpen}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={() => setColorPickerOpen(!colorPickerOpen)}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={(color) => {
|
||||
onColorSwatchClick();
|
||||
updatePenColor(color);
|
||||
}}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
inputValue={penSizeInput}
|
||||
onValueChange={(size) => {
|
||||
onPenSizeChange(size);
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder="Size"
|
||||
size="compact-sm"
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
modalCanvasRef.current = el;
|
||||
if (el) initPad(el);
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
display: 'block',
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
height: '400px',
|
||||
cursor: 'crosshair',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
Clear Canvas
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingCanvas;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onPlaceSignature?: () => void;
|
||||
hasSignatureData?: boolean;
|
||||
disabled?: boolean;
|
||||
showPlaceButton?: boolean;
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaceSignature,
|
||||
hasSignatureData = false,
|
||||
disabled = false,
|
||||
showPlaceButton = true,
|
||||
placeButtonText = "Update and Place"
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{/* Undo/Redo Controls */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUndo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.undo', 'Undo')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRedo}
|
||||
disabled={disabled}
|
||||
flex={1}
|
||||
>
|
||||
{t('sign.redo', 'Redo')}
|
||||
</Button>
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
flex={1}
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { FileInput, Text, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
onImageChange: (file: File | null) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
onImageChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder,
|
||||
hint
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleImageChange = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
// Validate that it's actually an image file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('Selected file is not an image');
|
||||
return;
|
||||
}
|
||||
|
||||
onImageChange(file);
|
||||
} catch (error) {
|
||||
console.error('Error processing image file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
// Clear image data when no file is selected
|
||||
onImageChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<FileInput
|
||||
label={label || t('sign.image.label', 'Upload signature image')}
|
||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{hint || t('sign.image.hint', 'Upload an image of your signature')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
fontFamily: string;
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
text,
|
||||
onTextChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
fontFamily,
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
onTextColorChange,
|
||||
disabled = false,
|
||||
label,
|
||||
placeholder
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
const fontSizeCombobox = useCombobox();
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
|
||||
// Sync font size input with prop changes
|
||||
useEffect(() => {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
}, [fontSize]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
{ value: 'Courier', label: 'Courier' },
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
];
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={label || t('sign.text.name', 'Signer Name')}
|
||||
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Font Selection */}
|
||||
<Select
|
||||
label="Font"
|
||||
value={fontFamily}
|
||||
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||
data={fontOptions}
|
||||
disabled={disabled}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
/>
|
||||
|
||||
{/* Font Size and Color */}
|
||||
<Group grow>
|
||||
<Combobox
|
||||
onOptionSubmit={(optionValue) => {
|
||||
setFontSizeInput(optionValue);
|
||||
const size = parseInt(optionValue);
|
||||
if (!isNaN(size)) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
fontSizeCombobox.closeDropdown();
|
||||
}}
|
||||
store={fontSizeCombobox}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<TextInput
|
||||
label="Font Size"
|
||||
placeholder="Type or select font size (8-200)"
|
||||
value={fontSizeInput}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setFontSizeInput(value);
|
||||
|
||||
// Parse and validate the typed value in real-time
|
||||
const size = parseInt(value);
|
||||
if (!isNaN(size) && size >= 8 && size <= 200) {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
|
||||
fontSizeCombobox.openDropdown();
|
||||
fontSizeCombobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => fontSizeCombobox.openDropdown()}
|
||||
onFocus={() => fontSizeCombobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
fontSizeCombobox.closeDropdown();
|
||||
// Clean up invalid values on blur
|
||||
const size = parseInt(fontSizeInput);
|
||||
if (isNaN(size) || size < 8 || size > 200) {
|
||||
setFontSizeInput(fontSize.toString());
|
||||
} else {
|
||||
onFontSizeChange(size);
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{fontSizeOptions.map((size) => (
|
||||
<Combobox.Option value={size} key={size}>
|
||||
{size}px
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
|
||||
{/* Text Color Picker */}
|
||||
{onTextColorChange && (
|
||||
<Box>
|
||||
<TextInput
|
||||
label="Text Color"
|
||||
value={textColor}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer' }}
|
||||
rightSection={
|
||||
<Box
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: textColor,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4,
|
||||
cursor: disabled ? 'default' : 'pointer'
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
{onTextColorChange && (
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={textColor}
|
||||
onColorChange={onTextColorChange}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { DrawingCanvas } from '../shared/DrawingCanvas';
|
||||
|
||||
interface DrawingToolProps {
|
||||
onDrawingChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
onDrawingChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [selectedColor] = useState('#000000');
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
|
||||
const toolConfig = {
|
||||
enableDrawing: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Drawing"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onDrawingChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
penSize={penSize}
|
||||
penSizeInput={penSizeInput}
|
||||
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
|
||||
onPenSizeChange={setPenSize}
|
||||
onPenSizeInputChange={setPenSizeInput}
|
||||
onSignatureDataChange={onDrawingChange || (() => {})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { ImageUploader } from '../shared/ImageUploader';
|
||||
|
||||
interface ImageToolProps {
|
||||
onImageChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
setImageData(result);
|
||||
onImageChange?.(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageData(null);
|
||||
onImageChange?.(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableImageUpload: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Image"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onImageChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
disabled={disabled}
|
||||
label="Upload Image"
|
||||
placeholder="Select image file"
|
||||
hint="Upload a PNG, JPG, or other image file to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||
import { TextInputWithFont } from '../shared/TextInputWithFont';
|
||||
|
||||
interface TextToolProps {
|
||||
onTextChange?: (text: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const TextTool: React.FC<TextToolProps> = ({
|
||||
onTextChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [text, setText] = useState('');
|
||||
const [fontSize, setFontSize] = useState(16);
|
||||
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||
|
||||
const handleTextChange = (newText: string) => {
|
||||
setText(newText);
|
||||
onTextChange?.(newText);
|
||||
};
|
||||
|
||||
const handleSignatureDataChange = (data: string | null) => {
|
||||
if (data) {
|
||||
onTextChange?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
const toolConfig = {
|
||||
enableTextInput: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Text"
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={handleSignatureDataChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<TextInputWithFont
|
||||
text={text}
|
||||
onTextChange={handleTextChange}
|
||||
fontSize={fontSize}
|
||||
onFontSizeChange={setFontSize}
|
||||
fontFamily={fontFamily}
|
||||
onFontFamilyChange={setFontFamily}
|
||||
disabled={disabled}
|
||||
label="Text Content"
|
||||
placeholder="Enter text to place on the PDF"
|
||||
/>
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
import styles from './FileEditor.module.css';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleCardClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
// Reset input so same files can be selected again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t('fileEditor.addFiles', 'Add Files')}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '100%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
@@ -34,9 +34,9 @@
|
||||
.header {
|
||||
height: 2.25rem;
|
||||
border-radius: 0.0625rem 0.0625rem 0 0;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 6px;
|
||||
user-select: none;
|
||||
background: var(--bg-toolbar);
|
||||
@@ -56,6 +56,20 @@
|
||||
border-bottom: 1px solid var(--header-selected-bg);
|
||||
}
|
||||
|
||||
/* Error highlight (transient) */
|
||||
.headerError {
|
||||
background: var(--color-red-200);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--color-red-500);
|
||||
}
|
||||
|
||||
/* Unsupported (but not errored) header appearance */
|
||||
.headerUnsupported {
|
||||
background: var(--unsupported-bar-bg); /* neutral gray */
|
||||
color: #FFFFFF;
|
||||
border-bottom: 1px solid var(--unsupported-bar-border);
|
||||
}
|
||||
|
||||
/* Selected border color in light mode */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: var(--card-selected-border);
|
||||
@@ -72,14 +86,24 @@
|
||||
}
|
||||
|
||||
.headerIndex {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.kebab {
|
||||
justify-self: end;
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.headerIconButton {
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
@@ -201,6 +225,11 @@
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.pinned {
|
||||
color: #FFC107 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
@@ -217,6 +246,22 @@
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Error pill shown when a file failed processing */
|
||||
.errorPill {
|
||||
margin-left: 1.75rem;
|
||||
background: var(--color-red-500);
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
@@ -273,4 +318,84 @@
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Add File Card Styles
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.addFileIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-subtle);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileIcon {
|
||||
background: var(--color-blue-50);
|
||||
}
|
||||
|
||||
.addFileText {
|
||||
font-weight: 500;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addFileSubtext {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import AddFileCard from './AddFileCard';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
import { alert } from '../toast';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
|
||||
|
||||
@@ -35,6 +37,7 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
|
||||
@@ -46,8 +49,16 @@ const FileEditor = ({
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
@@ -82,7 +93,7 @@ const FileEditor = ({
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
@@ -157,18 +168,18 @@ const FileEditor = ({
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
showError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
// Add files to context and select them automatically
|
||||
await addFiles(allExtractedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
@@ -206,7 +217,7 @@ const FileEditor = ({
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||
setStatus(`Maximum ${maxAllowed} files can be selected`);
|
||||
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||
return;
|
||||
}
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
@@ -215,7 +226,7 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
@@ -271,13 +282,13 @@ const FileEditor = ({
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
||||
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
@@ -297,7 +308,49 @@ const FileEditor = ({
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, setStatus]);
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await actions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
body: result.errors.join('\n'),
|
||||
expandable: true,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Error unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
@@ -314,10 +367,10 @@ const FileEditor = ({
|
||||
try {
|
||||
// Use FileContext to handle loading stored files
|
||||
// The files are already in FileContext, just need to add them to active files
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
showError('Failed to load some files from storage');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -338,7 +391,7 @@ const FileEditor = ({
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Box p="md">
|
||||
|
||||
|
||||
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||
@@ -396,6 +449,14 @@ const FileEditor = ({
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
@@ -406,11 +467,12 @@ const FileEditor = ({
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
@@ -428,31 +490,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState } from '../../contexts/file/fileHooks';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
@@ -25,11 +29,12 @@ interface FileEditorThumbnailProps {
|
||||
selectedFiles: FileId[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -39,14 +44,17 @@ const FileEditorThumbnail = ({
|
||||
index,
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onSetStatus,
|
||||
onCloseFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -60,6 +68,9 @@ const FileEditorThumbnail = ({
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -187,9 +198,20 @@ const FileEditorThumbnail = ({
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -199,10 +221,7 @@ const FileEditorThumbnail = ({
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
style={{opacity: isDragging ? 0.9 : 1}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
@@ -210,13 +229,16 @@ const FileEditorThumbnail = ({
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t('error._value', 'Error')}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
@@ -236,18 +258,60 @@ const FileEditorThumbnail = ({
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.kebab}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download icon */}
|
||||
<Tooltip label={t('download', 'Download')}>
|
||||
<ActionIcon
|
||||
aria-label={t('download', 'Download')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Kebab menu */}
|
||||
<ActionIcon
|
||||
aria-label={t('moreOptions', 'More options')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowActions((v) => !v);
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions overlay */}
|
||||
@@ -263,39 +327,49 @@ const FileEditorThumbnail = ({
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
|
||||
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
</button>
|
||||
|
||||
{isZipFile && onUnzipFile && (
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<UnarchiveIcon fontSize="small" />
|
||||
<span>{t('fileManager.unzip', 'Unzip')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={styles.actionsDivider} />
|
||||
|
||||
<button
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
<span>{t('delete', 'Delete')}</span>
|
||||
<CloseIcon fontSize="small" />
|
||||
<span>{t('close', 'Close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -309,7 +383,7 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -328,10 +402,14 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
@@ -358,13 +436,6 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pin indicator (bottom-left) */}
|
||||
{isPinned && (
|
||||
<span className={styles.pinIndicator} aria-hidden>
|
||||
<PushPinIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { onLocalFileClick } = useFileManagerContext();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
onLocalFileClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}
|
||||
>
|
||||
{/* Container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '3rem 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
minWidth: '20rem',
|
||||
maxWidth: '28rem',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Stirling PDF Logo */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-file-manager)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? '100%' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyFilesState;
|
||||
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<ScrollArea style={{ flex: 1 }} p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||
{currentFile ? currentFile.name : ''}
|
||||
</Text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileHistoryGroup from './FileHistoryGroup';
|
||||
import EmptyFilesState from './EmptyFilesState';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -29,6 +29,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,15 +44,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 ? (
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
|
||||
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
|
||||
@@ -5,10 +5,12 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileListItemProps {
|
||||
@@ -38,7 +40,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
@@ -93,7 +98,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
@@ -192,6 +197,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Unzip option for ZIP files */}
|
||||
{isZipFile && !isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnzipFile(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.unzip', 'Unzip')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
onGoogleDriveSelect(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick files from Google Drive:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
@@ -67,15 +80,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={buttonProps.variant('drive')}
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={() => onSourceChange('drive')}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled
|
||||
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||
styles={buttonProps.getStyles('drive')}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,6 @@ const HiddenFileInput: React.FC = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept={["*/*"] as any}
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { HotkeyBinding } from '../../utils/hotkeys';
|
||||
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||
|
||||
interface HotkeyDisplayProps {
|
||||
binding: HotkeyBinding | null | undefined;
|
||||
size?: 'sm' | 'md';
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const baseKeyStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'var(--mantine-color-gray-1)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '0.125rem 0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1,
|
||||
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
||||
minWidth: '1.35rem',
|
||||
color: 'var(--mantine-color-text)',
|
||||
};
|
||||
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
||||
const { getDisplayParts } = useHotkeys();
|
||||
const parts = getDisplayParts(binding);
|
||||
|
||||
if (!binding || parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStyle = size === 'md'
|
||||
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
||||
: baseKeyStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||
fontWeight: muted ? 500 : 600,
|
||||
}}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={`${part}-${index}`}>
|
||||
<kbd style={keyStyle}>{part}</kbd>
|
||||
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyDisplay;
|
||||
@@ -4,7 +4,6 @@ import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { useToolManagement } from '../../hooks/useToolManagement';
|
||||
import './Workbench.css';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
@@ -14,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||
import Viewer from '../viewer/Viewer';
|
||||
import LandingPage from '../shared/LandingPage';
|
||||
import Footer from '../shared/Footer';
|
||||
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -39,8 +39,8 @@ export default function Workbench() {
|
||||
// Get navigation state - this is the source of truth
|
||||
const { selectedTool: selectedToolId } = useNavigationState();
|
||||
|
||||
// Get tool registry to look up selected tool
|
||||
const { toolRegistry } = useToolManagement();
|
||||
// Get tool registry from context (instead of direct hook call)
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
@@ -146,17 +146,22 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
{activeFiles.length > 0 && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@@ -317,6 +317,7 @@ const FileThumbnail = ({
|
||||
>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
|
||||
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import './PageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
@@ -36,6 +39,9 @@ const PageEditor = ({
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
|
||||
@@ -82,6 +88,12 @@ const PageEditor = ({
|
||||
updateUndoRedoState();
|
||||
}, [updateUndoRedoState]);
|
||||
|
||||
// Wrapper for executeCommand to track unsaved changes
|
||||
const executeCommandWithTracking = useCallback((command: any) => {
|
||||
undoManagerRef.current.executeCommand(command);
|
||||
setHasUnsavedChanges(true);
|
||||
}, [setHasUnsavedChanges]);
|
||||
|
||||
// Watch for container size changes to update split line positions
|
||||
useEffect(() => {
|
||||
const container = gridContainerRef.current;
|
||||
@@ -138,17 +150,16 @@ const PageEditor = ({
|
||||
// DOM-first command handlers
|
||||
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
}, []);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}, [executeCommandWithTracking]);
|
||||
|
||||
// Command factory functions for PageThumbnail
|
||||
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
}
|
||||
}), []);
|
||||
}), [executeCommandWithTracking]);
|
||||
|
||||
const createDeleteCommand = useCallback((pageIds: string[]) => ({
|
||||
execute: () => {
|
||||
@@ -174,10 +185,10 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}
|
||||
}
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const createSplitCommand = useCallback((position: number) => ({
|
||||
execute: () => {
|
||||
@@ -186,9 +197,9 @@ const PageEditor = ({
|
||||
() => splitPositions,
|
||||
setSplitPositions
|
||||
);
|
||||
undoManagerRef.current.executeCommand(splitCommand);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
}
|
||||
}), [splitPositions]);
|
||||
}), [splitPositions, executeCommandWithTracking]);
|
||||
|
||||
// Command executor for PageThumbnail
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
@@ -232,8 +243,8 @@ const PageEditor = ({
|
||||
() => selectedPageNumbers,
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
|
||||
|
||||
const handleDeletePage = useCallback((pageNumber: number) => {
|
||||
if (!displayDocument) return;
|
||||
@@ -251,8 +262,8 @@ const PageEditor = ({
|
||||
() => getPageNumbersFromIds(selectedPageIds),
|
||||
closePdf
|
||||
);
|
||||
undoManagerRef.current.executeCommand(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(deleteCommand);
|
||||
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -298,8 +309,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleSplitAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -344,8 +355,8 @@ const PageEditor = ({
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
undoManagerRef.current.executeCommand(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreak = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -358,8 +369,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handlePageBreakAll = useCallback(() => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
@@ -372,8 +383,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
|
||||
if (!displayDocument || files.length === 0) return;
|
||||
@@ -416,8 +427,8 @@ const PageEditor = ({
|
||||
() => displayDocument,
|
||||
setEditedDocument
|
||||
);
|
||||
undoManagerRef.current.executeCommand(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds]);
|
||||
executeCommandWithTracking(reorderCommand);
|
||||
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
@@ -499,13 +510,14 @@ const PageEditor = ({
|
||||
|
||||
// Step 4: Download the result
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
const onExportAll = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
@@ -514,87 +526,79 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// Step 2: Check if we have multiple documents (splits) or single document
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits) - export as ZIP
|
||||
const blobs: Blob[] = [];
|
||||
const filenames: string[] = [];
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const baseExportFilename = getExportFilename();
|
||||
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
|
||||
blobs.push(result.blob);
|
||||
filenames.push(result.filename);
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
// Step 3: Download
|
||||
if (files.length > 1) {
|
||||
// Multiple files - create ZIP
|
||||
const JSZip = await import('jszip');
|
||||
const zip = new JSZip.default();
|
||||
|
||||
blobs.forEach((blob, index) => {
|
||||
zip.file(filenames[index], blob);
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
|
||||
const exportFilename = getExportFilename();
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
} else {
|
||||
// Single document - regular export
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
processedDocuments,
|
||||
sourceFiles,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
processedDocuments,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
// Single file - download directly
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(() => {
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
|
||||
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
|
||||
setEditedDocument(documentToSet);
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions]);
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
@@ -658,7 +662,7 @@ const PageEditor = ({
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
@@ -779,7 +783,14 @@ const PageEditor = ({
|
||||
)}
|
||||
|
||||
|
||||
<NavigationWarningModal />
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -371,9 +371,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
data-original-rotation={page.rotation}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
@@ -17,32 +17,34 @@ export class RotatePageCommand extends DOMCommand {
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Only update DOM for immediate visual feedback
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property to match the animated CSS
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
let newRotation = currentRotation + this.degrees;
|
||||
|
||||
newRotation = ((newRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Only update DOM
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const previousRotation = currentRotation - this.degrees;
|
||||
let previousRotation = currentRotation - this.degrees;
|
||||
|
||||
previousRotation = ((previousRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/* AppConfigModal styles */
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 37.5rem; /* 600px */
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 15rem; /* 240px */
|
||||
height: 37.5rem; /* 600px */
|
||||
border-top-left-radius: 0.75rem; /* 12px */
|
||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-container {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 5rem; /* 80px - wider for larger icons */
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item.mobile {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-nav-section-items {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem; /* 8px 10px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
height: 37.5rem; /* 600px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-content-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import Overview from './config/configSections/Overview';
|
||||
import { createConfigNavSections } from './config/configNavSections';
|
||||
import { NavKey } from './config/types';
|
||||
import './AppConfigModal.css';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,131 +13,143 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const { config, loading, error, refetch } = useAppConfig();
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
sectionTitle: 'var(--modal-nav-section-title)',
|
||||
navItem: 'var(--modal-nav-item)',
|
||||
navItemActive: 'var(--modal-nav-item-active)',
|
||||
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||
contentBg: 'var(--modal-content-bg)',
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||
const handleLogout = () => {
|
||||
// In SaaS this would sign out, in open-source it does nothing
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const premiumConfig = config ? {
|
||||
premiumEnabled: config.premiumEnabled,
|
||||
premiumKey: config.premiumKey ? '***hidden***' : null,
|
||||
runningProOrHigher: config.runningProOrHigher,
|
||||
runningEE: config.runningEE,
|
||||
license: config.license,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
const legalConfig = config ? {
|
||||
termsAndConditions: config.termsAndConditions,
|
||||
privacyPolicy: config.privacyPolicy,
|
||||
cookiePolicy: config.cookiePolicy,
|
||||
impressum: config.impressum,
|
||||
accessibilityStatement: config.accessibilityStatement,
|
||||
} : null;
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="App Configuration (Testing)"
|
||||
size="lg"
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
style={{ zIndex: 1000 }}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
This modal shows the current application configuration for testing purposes only.
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={refetch}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map(section => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<ScrollArea h={400}>
|
||||
<Stack gap="lg">
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
{renderConfigSection('Legal Configuration', legalConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
|
||||
<Code block style={{ fontSize: '11px' }}>
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</Code>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import FitText from "./FitText";
|
||||
|
||||
export interface ButtonOption<T> {
|
||||
value: T;
|
||||
@@ -13,15 +14,19 @@ interface ButtonSelectorProps<T> {
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
buttonClassName?: string;
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
const ButtonSelector = <T extends string>({
|
||||
const ButtonSelector = <T extends string | number>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label = undefined,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
buttonClassName,
|
||||
textClassName,
|
||||
}: ButtonSelectorProps<T>) => {
|
||||
return (
|
||||
<Stack gap='var(--mantine-spacing-sm)'>
|
||||
@@ -41,14 +46,24 @@ const ButtonSelector = <T extends string>({
|
||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled || option.disabled}
|
||||
className={buttonClassName}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: 'auto',
|
||||
minHeight: '2.5rem',
|
||||
fontSize: 'var(--mantine-font-size-sm)'
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
lineHeight: '1.4',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<FitText
|
||||
text={option.label}
|
||||
lines={1}
|
||||
minimumFontScale={0.5}
|
||||
fontSize={10}
|
||||
className={textClassName}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useFileActions } from '../../contexts/file/fileHooks';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface DismissAllErrorsButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Check if there are any files in error state
|
||||
const hasErrors = state.ui.errorFileIds.length > 0;
|
||||
|
||||
// Don't render if there are no errors
|
||||
if (!hasErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismissAllErrors = () => {
|
||||
actions.clearAllFileErrors();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className={className}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<CloseIcon fontSize="small" />}
|
||||
onClick={handleDismissAllErrors}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default DismissAllErrorsButton;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef } from "react";
|
||||
import { useRef } from "react";
|
||||
import { FileButton, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
|
||||
const FileUploadButton = ({
|
||||
file,
|
||||
onChange,
|
||||
accept = "*/*",
|
||||
accept,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
variant = "outline",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '../../hooks/useCookieConsent';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import LocalIcon from './LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -40,7 +41,6 @@ const LandingPage = () => {
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
@@ -72,7 +72,7 @@ const LandingPage = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'}
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
height: 'auto',
|
||||
@@ -98,7 +98,7 @@ const LandingPage = () => {
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoWhiteText.svg' : '/branding/StirlingPDFLogoGreyText.svg'}
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
@@ -177,7 +177,6 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.zip"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
@@ -269,8 +269,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
|
||||
const isDisabled = !isEnglishGB;
|
||||
// Enable languages with >90% translation completion
|
||||
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
|
||||
return (
|
||||
<LanguageItem
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Box, Group, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -37,7 +36,7 @@ const MultiSelectControls = ({
|
||||
>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
</Button>
|
||||
|
||||
|
||||
{onAddToUpload && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -47,7 +46,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.addToUpload", "Add to Upload")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onOpenInFileEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -58,7 +57,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onOpenInPageEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -69,7 +68,7 @@ const MultiSelectControls = ({
|
||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -85,4 +84,4 @@ const MultiSelectControls = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectControls;
|
||||
export default MultiSelectControls;
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
|
||||
import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
cancelNavigation,
|
||||
confirmNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useNavigationGuard();
|
||||
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
|
||||
useNavigationGuard();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
@@ -36,13 +32,14 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
const _handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
const BUTTON_WIDTH = "10rem";
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
@@ -52,55 +49,58 @@ const NavigationWarningModal = ({
|
||||
<Modal
|
||||
opened={showNavigationWarning}
|
||||
onClose={handleKeepWorking}
|
||||
title="Unsaved Changes"
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
size="auto"
|
||||
closeOnClickOutside={true}
|
||||
closeOnEscape={true}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
You have unsaved changes to your PDF. What would you like to do?
|
||||
<Stack>
|
||||
<Stack ta="center" p="md">
|
||||
<Text size="md" fw="300">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
Keep Working
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
|
||||
{onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
Apply & Continue
|
||||
<Text size="lg" fw="500" >
|
||||
{t("areYouSure", "Are you sure you want to leave?")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Desktop layout: 2 groups side by side */}
|
||||
<Group justify="space-between" gap="xl" visibleFrom="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
Export & Continue
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
)}
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Mobile layout: centered stack of 4 buttons */}
|
||||
<Stack align="center" gap="sm" hiddenFrom="md">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
{onApplyAndContinue && (
|
||||
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
|
||||
{t("applyAndContinue", "Apply & Leave")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationWarningModal;
|
||||
export default NavigationWarningModal;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import styles from './ObscuredOverlay/ObscuredOverlay.module.css';
|
||||
|
||||
type ObscuredOverlayProps = {
|
||||
obscured: boolean;
|
||||
overlayMessage?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
onButtonClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
// Optional border radius for the overlay container. If undefined, no radius is applied.
|
||||
borderRadius?: string | number;
|
||||
};
|
||||
|
||||
export default function ObscuredOverlay({
|
||||
obscured,
|
||||
overlayMessage,
|
||||
buttonText,
|
||||
onButtonClick,
|
||||
children,
|
||||
borderRadius,
|
||||
}: ObscuredOverlayProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{children}
|
||||
{obscured && (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
style={{
|
||||
...(borderRadius !== undefined ? { borderRadius } : {}),
|
||||
}}
|
||||
>
|
||||
<div className={styles.overlayContent}>
|
||||
{overlayMessage && (
|
||||
<div className={styles.overlayMessage}>
|
||||
{overlayMessage}
|
||||
</div>
|
||||
)}
|
||||
{buttonText && onButtonClick && (
|
||||
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
|
||||
{buttonText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
background: rgba(16, 18, 27, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.overlayContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overlayMessage {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overlayButton {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -24,6 +26,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -41,10 +44,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// Helper function to render navigation buttons with URL support
|
||||
const renderNavButton = (config: ButtonConfig, index: number) => {
|
||||
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
|
||||
|
||||
|
||||
// Check if this button has URL navigation support
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
? getToolNavigation(config.id)
|
||||
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
|
||||
? getToolNavigation(config.id)
|
||||
: null;
|
||||
|
||||
const handleClick = (e?: React.MouseEvent) => {
|
||||
@@ -59,7 +62,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
return (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<ActionIcon
|
||||
{...(navProps ? {
|
||||
{...(navProps ? {
|
||||
component: "a" as const,
|
||||
href: navProps.href,
|
||||
onClick: (e: React.MouseEvent) => handleClick(e),
|
||||
@@ -150,8 +153,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
//},
|
||||
{
|
||||
id: 'config',
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
@@ -217,7 +220,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{/* {buttonConfigs
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
@@ -237,16 +240,18 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
))} */}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <AppConfigModal
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
QuickAccessBar.displayName = 'QuickAccessBar';
|
||||
|
||||
export default QuickAccessBar;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ToastProvider } from '../toast';
|
||||
import ToastRenderer from '../toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '../toast';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
@@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
{children}
|
||||
<ToastProvider>
|
||||
<ToastPortalBinder />
|
||||
{children}
|
||||
<ToastRenderer />
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,6 +14,8 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { SearchInterface } from '../viewer/SearchInterface';
|
||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
|
||||
@@ -39,9 +41,13 @@ export default function RightRail() {
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Signature context for checking if signatures have been applied
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
@@ -65,11 +71,16 @@ export default function RightRail() {
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
// Get export state for viewer mode
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
// Clear any previous error flags when selecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +93,8 @@ export default function RightRail() {
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
// Clear any previous error flags when deselecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
@@ -90,8 +103,17 @@ export default function RightRail() {
|
||||
}
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
const handleExportAll = useCallback(async () => {
|
||||
if (currentView === 'viewer') {
|
||||
// Check if signatures have been applied
|
||||
if (!signaturesApplied) {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use EmbedPDF export functionality for viewer mode
|
||||
viewerContext?.exportActions?.download();
|
||||
} else if (currentView === 'fileEditor') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
@@ -108,7 +130,7 @@ export default function RightRail() {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
@@ -272,6 +294,9 @@ export default function RightRail() {
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<ViewerAnnotationControls currentView={currentView} />
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
@@ -440,7 +465,9 @@ export default function RightRail() {
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0}
|
||||
disabled={
|
||||
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -107,3 +107,5 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user