Merge remote-tracking branch 'origin/V2' into

codex/add-pdf-to-json-and-json-to-pdf-features
This commit is contained in:
Anthony Stirling 2025-11-12 21:13:42 +00:00
commit ded2edcfc5
217 changed files with 156630 additions and 17093 deletions

View File

@ -261,6 +261,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "pdf-to-json");
addEndpointToGroup("Convert", "json-to-pdf");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@ -388,6 +390,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript

View File

@ -8,4 +8,6 @@ public interface UserServiceInterface {
long getTotalUsersCount();
boolean isCurrentUserAdmin();
boolean isCurrentUserFirstLogin();
}

View File

@ -1,658 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class FormUtils {
private FormUtils() {}
public static boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public static void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Do not mutate the source AcroForm; skip bad widgets during copy
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Refresh appearances to ensure widgets render correctly across viewers
try {
// Use reflection to avoid compile-time dependency on PDFBox version
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
m.invoke(newAcroForm);
} catch (NoSuchMethodException nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
} catch (Throwable t) {
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
}
}
private static void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (sourceField instanceof PDTextField pdtextfield) {
createSimpleTextField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdtextfield,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
createSimpleCheckBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdCheckBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
createSimpleRadioButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdRadioButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDComboBox pdComboBox) {
createSimpleComboBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdComboBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDListBox pdlistbox) {
createSimpleListBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdlistbox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
createSimpleSignatureField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdSignatureField,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDPushButton pdPushButton) {
createSimplePushButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdPushButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private static void createSimpleTextField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDTextField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTextField newTextField = new PDTextField(newAcroForm);
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newTextField,
sourceField.getPartialName(),
"textField",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getValueAsString() != null) {
newTextField.setValue(sourceField.getValueAsString());
}
} catch (Exception e) {
log.warn(
"Failed to create text field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleCheckBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDCheckBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newCheckBox,
sourceField.getPartialName(),
"checkBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.isChecked()) {
newCheckBox.check();
} else {
newCheckBox.unCheck();
}
} catch (Exception e) {
log.warn(
"Failed to create checkbox field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleRadioButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDRadioButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newRadioButton,
sourceField.getPartialName(),
"radioButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getExportValues() != null) {
newRadioButton.setExportValues(sourceField.getExportValues());
}
if (sourceField.getValue() != null) {
newRadioButton.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create radio button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleComboBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDComboBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDComboBox newComboBox = new PDComboBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newComboBox,
sourceField.getPartialName(),
"comboBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newComboBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newComboBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create combo box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleListBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDListBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDListBox newListBox = new PDListBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newListBox,
sourceField.getPartialName(),
"listBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newListBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newListBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create list box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleSignatureField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDSignatureField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newSignatureField,
sourceField.getPartialName(),
"signature",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
} catch (Exception e) {
log.warn(
"Failed to create signature field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimplePushButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDPushButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDPushButton newPushButton = new PDPushButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newPushButton,
sourceField.getPartialName(),
"pushButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} catch (Exception e) {
log.warn(
"Failed to create push button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private static String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget w : widgets) {
if (w != null) {
map.put(w, field);
}
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}

View File

@ -1,5 +1,6 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@ -241,6 +242,11 @@ public final class RegexPatternUtils {
return getPattern("\\s+");
}
/** Pattern for matching punctuation characters */
public Pattern getPunctuationPattern() {
return getPattern("[\\p{Punct}]+");
}
/** Pattern for matching newlines (Windows and Unix style) */
public Pattern getNewlinesPattern() {
return getPattern("\\r?\\n");
@ -286,6 +292,24 @@ public final class RegexPatternUtils {
return getPattern("[^a-zA-Z0-9 ]");
}
/** Pattern for removing bracketed indices like [0], [Child], etc. in field names */
public Pattern getFormFieldBracketPattern() {
return getPattern("\\[[^\\]]*\\]");
}
/** Pattern that replaces underscores or hyphens with spaces */
public Pattern getUnderscoreHyphenPattern() {
return getPattern("[-_]+");
}
/**
* Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces.
* Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size
*/
public Pattern getCamelCaseBoundaryPattern() {
return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])");
}
/** Pattern for removing angle brackets */
public Pattern getAngleBracketsPattern() {
return getPattern("[<>]");
@ -335,6 +359,26 @@ public final class RegexPatternUtils {
return getPattern("[1-9][0-9]{0,2}");
}
/**
* Pattern for very simple generic field tokens such as "field", "text", "checkbox" with
* optional numeric suffix (e.g. "field 1"). Case-insensitive.
*/
public Pattern getGenericFieldNamePattern() {
return getPattern(
"^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$",
Pattern.CASE_INSENSITIVE);
}
/** Pattern for short identifiers like t1, f2, a10 etc. */
public Pattern getSimpleFormFieldPattern() {
return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$");
}
/** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */
public Pattern getOptionalTNumericPattern() {
return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for validating mathematical expressions */
public Pattern getMathExpressionPattern() {
return getPattern("[0-9n+\\-*/() ]+");
@ -467,6 +511,11 @@ public final class RegexPatternUtils {
return getPattern("/");
}
/** Supported logical types when creating new fields programmatically */
public Set<String> getSupportedNewFieldTypes() {
return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature");
}
/**
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
* compilation overhead for frequent patterns.

View File

@ -28,6 +28,8 @@ public class InitialSetup {
private final ApplicationProperties applicationProperties;
private static boolean isNewServer = false;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
@ -88,6 +90,13 @@ public class InitialSetup {
}
public void initSetAppVersion() throws IOException {
// Check if this is a new server before setting the version
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
@ -99,4 +108,8 @@ public class InitialSetup {
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
public static boolean isNewServer() {
return isNewServer;
}
}

View File

@ -26,7 +26,6 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@ -137,26 +136,6 @@ public class MultiPageLayoutController {
contentStream.close();
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
FormUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();

View File

@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@ -78,6 +79,22 @@ public class ConfigController {
}
configData.put("isAdmin", isAdmin);
// Check if this is a new server (version was 0.0.0 before initialization)
configData.put("isNewServer", InitialSetup.isNewServer());
// Check if the current user is a first-time user
boolean isNewUser =
false; // Default to false when security is disabled or user not found
if (userService != null) {
try {
isNewUser = userService.isCurrentUserFirstLogin();
} catch (Exception e) {
// If there's an error, assume not new user for safety
isNewUser = false;
}
}
configData.put("isNewUser", isNewUser);
// System settings
configData.put(
"enableAlphaFunctionality",

View File

@ -7,6 +7,8 @@
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
@ -25,6 +27,12 @@
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}

View File

@ -0,0 +1,215 @@
package stirling.software.proprietary.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.util.FormUtils;
@RestController
@RequestMapping("/api/v1/form")
@Tag(name = "Forms", description = "PDF form APIs")
@RequiredArgsConstructor
public class FormFillController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private static ResponseEntity<byte[]> saveDocument(PDDocument document, String baseName)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf");
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
original = "document";
}
if (!original.toLowerCase().endsWith(".pdf")) {
return original + "_" + suffix;
}
String withoutExtension = original.substring(0, original.length() - 4);
return withoutExtension + "_" + suffix;
}
private static void requirePdf(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "{0} must be in PDF format", "file");
}
}
private static String decodePart(byte[] payload) {
if (payload == null || payload.length == 0) {
return null;
}
return new String(payload, StandardCharsets.UTF_8);
}
@PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Inspect PDF form fields",
description = "Returns metadata describing each field in the provided PDF form")
public ResponseEntity<FormUtils.FormFieldExtraction> listFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file)
throws IOException {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.FormFieldExtraction extraction =
FormUtils.extractFieldsWithTemplate(document);
return ResponseEntity.ok(extraction);
}
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
description =
"Updates existing fields in the provided PDF and returns the updated file")
public ResponseEntity<byte[]> modifyFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@RequestPart(value = "updates", required = false) byte[] updatesPayload)
throws IOException {
String rawUpdates = decodePart(updatesPayload);
List<FormUtils.ModifyFormFieldDefinition> modifications =
FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates);
if (modifications.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Delete form fields",
description = "Removes the specified fields from the PDF and returns the updated file")
public ResponseEntity<byte[]> deleteFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON array of field names or objects with a name property,"
+ " matching the /fields response format",
example = "[{\"name\":\"Field1\"}]")
@RequestPart(value = "names", required = false)
byte[] namesPayload)
throws IOException {
String rawNames = decodePart(namesPayload);
List<String> names = FormPayloadParser.parseNameList(objectMapper, rawNames);
if (names.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Fill PDF form fields",
description =
"Populates the supplied PDF form using values from the provided JSON payload"
+ " and returns the filled PDF")
public ResponseEntity<byte[]> fillForm(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON object of field-value pairs to apply",
example = "{\"field\":\"value\"}")
@RequestPart(value = "data", required = false)
byte[] valuesPayload,
@RequestParam(value = "flatten", defaultValue = "false") boolean flatten)
throws IOException {
String rawValues = decodePart(valuesPayload);
Map<String, Object> values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
return processSingleFile(
file,
"filled",
document -> FormUtils.applyFieldValues(document, values, flatten, true));
}
private ResponseEntity<byte[]> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
processor.accept(document);
return saveDocument(document, baseName);
}
}
@FunctionalInterface
private interface DocumentProcessor {
void accept(PDDocument document) throws IOException;
}
}

View File

@ -0,0 +1,295 @@
package stirling.software.proprietary.controller.api.form;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.util.FormUtils;
final class FormPayloadParser {
private static final String KEY_FIELDS = "fields";
private static final String KEY_NAME = "name";
private static final String KEY_TARGET_NAME = "targetName";
private static final String KEY_FIELD_NAME = "fieldName";
private static final String KEY_FIELD = "field";
private static final String KEY_VALUE = "value";
private static final String KEY_DEFAULT_VALUE = "defaultValue";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json)
throws IOException {
if (json == null || json.isBlank()) {
return Map.of();
}
JsonNode root;
try {
root = objectMapper.readTree(json);
} catch (IOException e) {
// Fallback to legacy direct map parse (will throw again if invalid)
return objectMapper.readValue(json, MAP_TYPE);
}
if (root == null || root.isNull()) {
return Map.of();
}
// 1. If payload already a flat object with no special wrapping, keep legacy behavior
if (root.isObject()) {
// a) Prefer explicit 'template' object if present (new combined /fields response)
JsonNode templateNode = root.get("template");
if (templateNode != null && templateNode.isObject()) {
return objectToLinkedMap(templateNode);
}
// b) Accept an inline 'fields' array of field definitions (build map from them)
JsonNode fieldsNode = root.get(KEY_FIELDS);
if (fieldsNode != null && fieldsNode.isArray()) {
Map<String, Object> record = extractFieldInfoArray(fieldsNode);
if (!record.isEmpty()) {
return record;
}
}
// c) Fallback: treat entire object as the value map (legacy behavior)
return objectToLinkedMap(root);
}
// 2. If an array was supplied to /fill (non-standard), treat first element as record
if (root.isArray()) {
if (root.isEmpty()) {
return Map.of();
}
JsonNode first = root.get(0);
if (first != null && first.isObject()) {
if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) {
return extractFieldInfoArray(root);
}
return objectToLinkedMap(first);
}
return Map.of();
}
// 3. Anything else: fallback to strict map parse
return objectMapper.readValue(json, MAP_TYPE);
}
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
final JsonNode root = objectMapper.readTree(json);
if (root == null || root.isNull()) {
return List.of();
}
final Set<String> names = new LinkedHashSet<>();
if (root.isArray()) {
collectNames(root, names);
} else if (root.isObject()) {
if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) {
collectNames(root.get(KEY_FIELDS), names);
} else {
final String single = extractName(root);
if (nonBlank(single)) {
names.add(single);
}
}
} else if (root.isTextual()) {
final String single = trimToNull(root.asText());
if (single != null) {
names.add(single);
}
}
if (!names.isEmpty()) {
return List.copyOf(names);
}
try {
return objectMapper.readValue(json, STRING_LIST_TYPE);
} catch (IOException e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"names payload",
"expected array of strings or objects with 'name'-like properties");
}
}
private static Map<String, Object> extractFieldInfoArray(JsonNode fieldsNode) {
final Map<String, Object> record = new LinkedHashMap<>();
if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) {
return record;
}
for (JsonNode fieldNode : fieldsNode) {
if (fieldNode == null || !fieldNode.isObject()) {
continue;
}
final String name = extractName(fieldNode);
if (!nonBlank(name)) {
continue;
}
JsonNode valueNode = fieldNode.get(KEY_VALUE);
if ((valueNode == null || valueNode.isNull())
&& fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) {
valueNode = fieldNode.get(KEY_DEFAULT_VALUE);
}
final String normalized = normalizeFieldValue(valueNode);
record.put(name, normalized == null ? "" : normalized);
}
return record;
}
private static String normalizeFieldValue(JsonNode valueNode) {
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isArray()) {
final List<String> values = new ArrayList<>();
for (JsonNode element : valueNode) {
final String text = coerceScalarToString(element);
if (text != null) {
values.add(text);
}
}
return String.join(",", values);
}
if (valueNode.isObject()) {
// Preserve object as JSON string
return valueNode.toString();
}
// Scalar (text/number/boolean)
return coerceScalarToString(valueNode);
}
private static String coerceScalarToString(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToEmpty(node.asText());
}
if (node.isNumber()) {
return node.numberValue().toString();
}
if (node.isBoolean()) {
return Boolean.toString(node.booleanValue());
}
// Fallback for other scalar-like nodes
return trimToEmpty(node.asText());
}
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
if (arrayNode == null || !arrayNode.isArray()) {
return;
}
for (JsonNode node : arrayNode) {
final String name = extractName(node);
if (nonBlank(name)) {
sink.add(name);
}
}
}
private static String extractName(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToNull(node.asText());
}
if (node.isObject()) {
final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(direct)) {
return direct;
}
final JsonNode field = node.get(KEY_FIELD);
if (field != null && field.isObject()) {
final String nested =
textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(nested)) {
return nested;
}
}
}
return null;
}
private static String textProperty(JsonNode node, String... keys) {
for (String key : keys) {
final JsonNode valueNode = node.get(key);
final String value = coerceScalarToString(valueNode);
if (nonBlank(value)) {
return value;
}
}
return null;
}
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
final Map<String, Object> result = new LinkedHashMap<>();
objectNode
.fieldNames()
.forEachRemaining(
key -> {
final JsonNode v = objectNode.get(key);
if (v == null || v.isNull()) {
result.put(key, null);
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
result.put(key, coerceScalarToString(v));
} else {
result.put(key, v.toString());
}
});
return result;
}
private static boolean nonBlank(String s) {
return s != null && !s.isBlank();
}
private static String trimToNull(String s) {
if (s == null) return null;
final String t = s.trim();
return t.isEmpty() ? null : t;
}
private static String trimToEmpty(String s) {
return s == null ? "" : s.trim();
}
}

View File

@ -742,4 +742,31 @@ public class UserController {
return errorMessage;
}
}
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
User user = userOpt.get();
user.setHasCompletedInitialSetup(true);
userRepository.save(user);
log.info("User {} completed initial setup", username);
return ResponseEntity.ok().body(Map.of("success", true));
} catch (Exception e) {
log.error("Error completing initial setup", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to complete initial setup");
}
}
}

View File

@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "roleName")
private String roleName;
@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable {
this.isFirstLogin = isFirstLogin;
}
public boolean hasCompletedInitialSetup() {
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
}
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}

View File

@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface {
return false;
}
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
if (username != null) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
return !userOpt.get().hasCompletedInitialSetup();
}
}
} catch (Exception e) {
log.debug("Error checking first login status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {

View File

@ -0,0 +1,345 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@UtilityClass
public class FormCopyUtils {
public boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Temporarily set NeedAppearances to true during field creation
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (rowIndex >= rows) {
continue;
}
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Generate appearance streams and embed them authoritatively
boolean appearancesGenerated = false;
try {
newAcroForm.refreshAppearances();
appearancesGenerated = true;
} catch (NoSuchMethodError nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; "
+ "leaving NeedAppearances=true for viewer-side rendering.");
} catch (Exception t) {
log.warn(
"Failed to refresh field appearances via AcroForm: {}. "
+ "Leaving NeedAppearances=true as fallback.",
t.getMessage(),
t);
}
// After successful appearance generation, set NeedAppearances to false
// to signal that appearance streams are now embedded authoritatively
if (appearancesGenerated) {
try {
newAcroForm.setNeedAppearances(false);
} catch (Exception e) {
log.debug(
"Failed to set NeedAppearances to false: {}. "
+ "Appearances were generated but flag could not be updated.",
e.getMessage());
}
}
}
private void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (!(sourceField instanceof PDTerminalField terminalField)) {
continue;
}
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
if (handler == null) {
log.debug(
"Skipping unsupported field type '{}' for widget '{}'",
sourceField.getClass().getSimpleName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName));
continue;
}
copyFieldUsingHandler(
handler,
terminalField,
newAcroForm,
destinationPage,
destinationAnnotations,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private void copyFieldUsingHandler(
FormFieldTypeSupport handler,
PDTerminalField sourceField,
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTerminalField newField = handler.createField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newField,
sourceField.getPartialName(),
handler.fallbackWidgetName(),
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
handler.copyFromOriginal(sourceField, newField);
} catch (Exception e) {
log.warn(
"Failed to copy {} field '{}': {}",
handler.typeName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName),
e.getMessage(),
e);
}
}
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget != null) {
map.put(widget, field);
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}

View File

@ -0,0 +1,368 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public enum FormFieldTypeSupport {
TEXT("text", "textField", PDTextField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
PDTextField textField = new PDTextField(acroForm);
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
return textField;
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDTextField src = (PDTextField) source;
PDTextField dst = (PDTextField) target;
String value = src.getValueAsString();
if (value != null) {
dst.setValue(value);
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
}
}
},
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDCheckBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDCheckBox src = (PDCheckBox) source;
PDCheckBox dst = (PDCheckBox) target;
if (src.isChecked()) {
dst.check();
} else {
dst.unCheck();
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDCheckBox checkBox = (PDCheckBox) field;
if (!options.isEmpty()) {
checkBox.setExportValues(options);
}
ensureCheckBoxAppearance(checkBox);
if (FormUtils.isChecked(definition.defaultValue())) {
checkBox.check();
} else {
checkBox.unCheck();
}
}
private static void ensureCheckBoxAppearance(PDCheckBox checkBox) {
try {
if (checkBox.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
PDAppearanceCharacteristicsDictionary appearanceChars =
widget.getAppearanceCharacteristics();
if (appearanceChars == null) {
appearanceChars =
new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(appearanceChars);
}
appearanceChars.setBorderColour(
new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE));
appearanceChars.setBackground(
new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE));
appearanceChars.setNormalCaption("4");
widget.setPrinted(true);
widget.setReadOnly(false);
widget.setHidden(false);
} catch (Exception e) {
log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage());
}
}
},
RADIO("radio", "radioButton", PDRadioButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDRadioButton(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDRadioButton src = (PDRadioButton) source;
PDRadioButton dst = (PDRadioButton) target;
if (src.getExportValues() != null) {
dst.setExportValues(src.getExportValues());
}
if (src.getValue() != null) {
dst.setValue(src.getValue());
}
}
},
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDComboBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDComboBox src = (PDComboBox) source;
PDComboBox dst = (PDComboBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDComboBox comboBox = (PDComboBox) field;
if (!options.isEmpty()) {
comboBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.resolveOptions(comboBox);
String comboName =
Optional.ofNullable(comboBox.getFullyQualifiedName())
.orElseGet(comboBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, comboName);
if (filtered != null) {
comboBox.setValue(filtered);
}
}
}
},
LISTBOX("listbox", "listBox", PDListBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDListBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDListBox src = (PDListBox) source;
PDListBox dst = (PDListBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDListBox listBox = (PDListBox) field;
listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect()));
if (!options.isEmpty()) {
listBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.collectChoiceAllowedValues(listBox);
String listBoxName =
Optional.ofNullable(listBox.getFullyQualifiedName())
.orElseGet(listBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
if (Boolean.TRUE.equals(definition.multiSelect())) {
List<String> selections = FormUtils.parseMultiChoiceSelections(defaultValue);
List<String> filtered =
FormUtils.filterChoiceSelections(
selections, allowedOptions, listBoxName);
if (!filtered.isEmpty()) {
listBox.setValue(filtered);
}
} else {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, listBoxName);
if (filtered != null) {
listBox.setValue(filtered);
}
}
}
}
},
SIGNATURE("signature", "signature", PDSignatureField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
};
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
Collectors.toUnmodifiableMap(
FormFieldTypeSupport::typeName, Function.identity()));
private final String typeName;
private final String fallbackWidgetName;
private final Class<? extends PDTerminalField> fieldClass;
FormFieldTypeSupport(
String typeName,
String fallbackWidgetName,
Class<? extends PDTerminalField> fieldClass) {
this.typeName = typeName;
this.fallbackWidgetName = fallbackWidgetName;
this.fieldClass = fieldClass;
}
public static FormFieldTypeSupport forField(PDField field) {
if (field == null) {
return null;
}
for (FormFieldTypeSupport handler : values()) {
if (handler.fieldClass.isInstance(field)) {
return handler;
}
}
return null;
}
public static FormFieldTypeSupport forTypeName(String typeName) {
if (typeName == null) {
return null;
}
return BY_TYPE.get(typeName);
}
private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) {
if (sourceField == null || targetField == null) {
return;
}
try {
int flags = sourceField.getCOSObject().getInt(COSName.FF);
targetField.getCOSObject().setInt(COSName.FF, flags);
} catch (Exception e) {
// ignore and continue
}
if (sourceField instanceof PDListBox sourceList
&& targetField instanceof PDListBox targetList) {
try {
targetList.setMultiSelect(sourceList.isMultiSelect());
} catch (Exception ignored) {
// ignore
}
}
}
String typeName() {
return typeName;
}
String fallbackWidgetName() {
return fallbackWidgetName;
}
abstract PDTerminalField createField(PDAcroForm acroForm);
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
// default no-op
}
boolean doesNotsupportsDefinitionCreation() {
return true;
}
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
// default no-op
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
package stirling.software.proprietary.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior")
class FormUtilsTest {
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
PDPage page = new PDPage();
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle)
throws IOException {
attachWidget(setup, field, rectangle);
}
private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle)
throws IOException {
field.setExportValues(List.of("Yes"));
attachWidget(setup, field, rectangle);
}
private static void attachWidget(
SetupDocument setup,
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
PDRectangle rectangle)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page);
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
widgets = new ArrayList<>();
} else {
widgets = new ArrayList<>(widgets);
}
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm.getFields().add(field);
setup.page.getAnnotations().add(widget);
}
@Test
void extractFormFieldsReturnsFieldMetadata() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("firstName");
attachField(setup, textField, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
assertEquals(1, fields.size());
FormUtils.FormFieldInfo info = fields.get(0);
assertEquals("firstName", info.name());
assertEquals("text", info.type());
assertEquals(0, info.pageIndex());
assertEquals("", info.value());
}
}
@Test
void applyFieldValuesPopulatesTextAndCheckbox() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachField(setup, textField, new PDRectangle(60, 720, 220, 20));
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
checkBox.setPartialName("subscribed");
attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16));
FormUtils.applyFieldValues(
document, Map.of("company", "Stirling", "subscribed", true), false);
assertEquals("Stirling", textField.getValueAsString());
assertTrue(checkBox.isChecked());
FormUtils.applyFieldValues(document, Map.of("subscribed", false), false);
assertFalse(checkBox.isChecked());
assertEquals("Off", checkBox.getValue());
}
}
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
}

View File

@ -10,24 +10,24 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@ -442,7 +442,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -489,7 +488,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -513,7 +511,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@ -597,7 +594,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -614,7 +610,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -632,7 +627,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -669,7 +663,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -704,7 +697,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -741,7 +733,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -817,7 +808,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -973,7 +963,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1017,7 +1006,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2048,7 +2036,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2099,7 +2086,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2167,7 +2153,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@ -3851,7 +3836,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4175,7 +4159,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4186,7 +4169,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4247,7 +4229,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -4961,6 +4942,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
@ -4970,6 +4952,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
@ -4980,6 +4963,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
@ -4992,6 +4976,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
@ -5018,7 +5003,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5703,7 +5687,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -6749,8 +6732,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7145,7 +7127,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7316,7 +7297,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -8639,7 +8619,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9447,7 +9426,6 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@ -11224,7 +11202,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11504,7 +11481,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -11887,7 +11863,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -11897,7 +11872,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13376,9 +13350,9 @@
}
},
"node_modules/svelte": {
"version": "5.42.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz",
"integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==",
"version": "5.43.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz",
"integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==",
"license": "MIT",
"peer": true,
"dependencies": {
@ -13612,7 +13586,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -13914,7 +13887,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -13997,7 +13969,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14202,7 +14173,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14354,7 +14324,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14368,7 +14337,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",

View File

@ -6,24 +6,24 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@emotion/react": "^11.14.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
@ -67,6 +67,7 @@
"preview": "vite preview",
"tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",

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 it is too large Load Diff

View File

@ -4455,6 +4455,17 @@
"startTour": "Start Tour",
"startTourDescription": "Take a guided tour of Stirling PDF's key features"
},
"adminOnboarding": {
"welcome": "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
"configButton": "Click the <strong>Config</strong> button to access all system settings and administrative controls.",
"settingsOverview": "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation.",
"teamsAndUsers": "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
"systemCustomization": "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
"databaseSection": "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
"connectionsSection": "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
"adminTools": "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform.",
"wrapUp": "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."
},
"pdfTextEditor": {
"viewLabel": "PDF Text Editor",
"title": "PDF Editor",

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 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 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 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 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

View File

@ -81,6 +81,137 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "atk"
version = "0.18.2"
@ -155,12 +286,6 @@ dependencies = [
"wyz",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -188,6 +313,19 @@ dependencies = [
"objc2 0.6.3",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "borsh"
version = "1.5.7"
@ -420,36 +558,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cocoa"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f425db7937052c684daec3bd6375c8abe2d146dca4b8b143d6db777c39138f3a"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation",
"core-foundation 0.9.4",
"core-graphics 0.22.3",
"foreign-types 0.3.2",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"libc",
"objc",
]
[[package]]
name = "combine"
version = "4.6.7"
@ -460,6 +568,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@ -502,19 +619,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"core-graphics-types 0.1.3",
"foreign-types 0.3.2",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.24.0"
@ -523,22 +627,11 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.10.1",
"core-graphics-types 0.2.0",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"libc",
]
[[package]]
name = "core-graphics-types"
version = "0.2.0"
@ -834,6 +927,33 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "env_filter"
version = "0.1.4"
@ -871,6 +991,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@ -1026,6 +1167,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.31"
@ -1412,6 +1566,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@ -1992,15 +2152,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.14.1"
@ -2148,6 +2299,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@ -2200,15 +2364,6 @@ dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-sys"
version = "0.3.5"
@ -2541,6 +2696,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
@ -2576,6 +2741,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -2757,6 +2928,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -2789,6 +2971,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@ -3689,7 +3885,7 @@ checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08"
dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics 0.24.0",
"core-graphics",
"foreign-types 0.5.0",
"js-sys",
"log",
@ -3735,14 +3931,17 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"cocoa",
"log",
"objc",
"once_cell",
"reqwest 0.11.27",
"serde",
"serde_json",
@ -3751,6 +3950,7 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
]
@ -3887,7 +4087,7 @@ dependencies = [
"bitflags 2.10.0",
"block2 0.6.2",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
"core-graphics",
"crossbeam-channel",
"dispatch",
"dlopen2",
@ -4137,6 +4337,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd707f8c86b4e3004e2c141fa24351f1909ba40ce1b8437e30d5ed5277dd3710"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.9.1"
@ -4544,9 +4759,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "tracing-core"
version = "0.1.34"
@ -4596,6 +4823,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "uds_windows"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
dependencies = [
"memoffset",
"tempfile",
"winapi",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
@ -5611,6 +5849,67 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"nix",
"ordered-stream",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 0.7.13",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus_macros"
version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.108",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
dependencies = [
"serde",
"static_assertions",
"winnow 0.7.13",
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.27"
@ -5684,3 +5983,43 @@ dependencies = [
"quote",
"syn 2.0.108",
]
[[package]]
name = "zvariant"
version = "5.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 0.7.13",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro2",
"quote",
"syn 2.0.108",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.108",
"winnow 0.7.13",
]

View File

@ -10,6 +10,9 @@ rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
warnings = "deny"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@ -25,11 +28,6 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
# macOS-specific dependencies for native file opening
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
cocoa = "0.24"
once_cell = "1.19"

View File

@ -234,6 +234,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📤 Backend: {}", output_str));
// Look for startup indicators
@ -250,6 +252,8 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators

View File

@ -1,48 +1,47 @@
use crate::utils::add_log;
use std::sync::Mutex;
// Store the opened file path globally
static OPENED_FILE: Mutex<Option<String>> = Mutex::new(None);
// Store the opened file paths globally (supports multiple files)
static OPENED_FILES: Mutex<Vec<String>> = Mutex::new(Vec::new());
// Set the opened file path (called by macOS file open events)
pub fn set_opened_file(file_path: String) {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = Some(file_path.clone());
add_log(format!("📂 File opened via file open event: {}", file_path));
// Add an opened file path
pub fn add_opened_file(file_path: String) {
let mut opened_files = OPENED_FILES.lock().unwrap();
opened_files.push(file_path.clone());
add_log(format!("📂 File stored for later retrieval: {}", file_path));
}
// Command to get opened file path (if app was launched with a file)
// Command to get opened file paths (if app was launched with files)
#[tauri::command]
pub async fn get_opened_file() -> Result<Option<String>, String> {
// First check if we have a file from macOS file open events
{
let opened_file = OPENED_FILE.lock().unwrap();
if let Some(ref file_path) = *opened_file {
add_log(format!("📂 Returning stored opened file: {}", file_path));
return Ok(Some(file_path.clone()));
}
}
// Fallback to command line arguments (Windows/Linux)
pub async fn get_opened_files() -> Result<Vec<String>, String> {
let mut all_files: Vec<String> = Vec::new();
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
let args: Vec<String> = std::env::args().collect();
// Look for a PDF file argument (skip the first arg which is the executable)
for arg in args.iter().skip(1) {
if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 PDF file opened via command line: {}", arg));
return Ok(Some(arg.clone()));
}
let pdf_files: Vec<String> = args.iter()
.skip(1)
.filter(|arg| std::path::Path::new(arg).exists())
.cloned()
.collect();
all_files.extend(pdf_files);
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
{
let opened_files = OPENED_FILES.lock().unwrap();
all_files.extend(opened_files.clone());
}
Ok(None)
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
Ok(all_files)
}
// Command to clear the opened file (after processing)
// Command to clear the opened files (after processing)
#[tauri::command]
pub async fn clear_opened_file() -> Result<(), String> {
let mut opened_file = OPENED_FILE.lock().unwrap();
*opened_file = None;
add_log("📂 Cleared opened file".to_string());
pub async fn clear_opened_files() -> Result<(), String> {
let mut opened_files = OPENED_FILES.lock().unwrap();
opened_files.clear();
add_log("📂 Cleared opened files".to_string());
Ok(())
}

View File

@ -4,4 +4,4 @@ pub mod files;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_file, clear_opened_file, set_opened_file};
pub use files::{get_opened_files, clear_opened_files, add_opened_file};

View File

@ -1,189 +0,0 @@
/// Multi-platform file opening handler
///
/// This module provides unified file opening support across platforms:
/// - macOS: Uses native NSApplication delegate (proper Apple Events)
/// - Windows/Linux: Uses command line arguments (fallback approach)
/// - All platforms: Runtime event handling via Tauri events
use crate::utils::add_log;
use crate::commands::set_opened_file;
use tauri::AppHandle;
/// Initialize file handling for the current platform
pub fn initialize_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Initializing file handler...".to_string());
// Platform-specific initialization
#[cfg(target_os = "macos")]
{
add_log("🍎 Using macOS native file handler".to_string());
macos_native::register_open_file_handler(app);
}
#[cfg(not(target_os = "macos"))]
{
add_log("🖥️ Using command line argument file handler".to_string());
let _ = app; // Suppress unused variable warning
}
// Universal: Check command line arguments (works on all platforms)
check_command_line_args();
}
/// Early initialization for macOS delegate registration
pub fn early_init() {
#[cfg(target_os = "macos")]
{
add_log("🔄 Early macOS initialization...".to_string());
macos_native::register_delegate_early();
}
}
/// Check command line arguments for file paths (universal fallback)
fn check_command_line_args() {
let args: Vec<String> = std::env::args().collect();
add_log(format!("🔍 DEBUG: All command line args: {:?}", args));
// Check command line arguments for file opening
for (i, arg) in args.iter().enumerate() {
add_log(format!("🔍 DEBUG: Arg {}: {}", i, arg));
if i > 0 && arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
add_log(format!("📂 File argument detected: {}", arg));
set_opened_file(arg.clone());
break; // Only handle the first PDF file
}
}
}
/// Handle runtime file open events (for future single-instance support)
#[allow(dead_code)]
pub fn handle_runtime_file_open(file_path: String) {
if file_path.ends_with(".pdf") && std::path::Path::new(&file_path).exists() {
add_log(format!("📂 Runtime file open: {}", file_path));
set_opened_file(file_path);
}
}
#[cfg(target_os = "macos")]
mod macos_native {
use objc::{class, msg_send, sel, sel_impl};
use objc::runtime::{Class, Object, Sel};
use cocoa::appkit::NSApplication;
use cocoa::base::{id, nil};
use once_cell::sync::Lazy;
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
use crate::utils::add_log;
use crate::commands::set_opened_file;
// Static app handle storage
static APP_HANDLE: Lazy<Mutex<Option<AppHandle<tauri::Wry>>>> = Lazy::new(|| Mutex::new(None));
// Store files opened during launch
static LAUNCH_FILES: Lazy<Mutex<Vec<String>>> = Lazy::new(|| Mutex::new(Vec::new()));
extern "C" fn open_files(_self: &Object, _cmd: Sel, _sender: id, filenames: id) {
unsafe {
add_log(format!("📂 macOS native openFiles event called"));
// filenames is an NSArray of NSString objects
let count: usize = msg_send![filenames, count];
add_log(format!("📂 Number of files to open: {}", count));
for i in 0..count {
let filename: id = msg_send![filenames, objectAtIndex: i];
let cstr = {
let bytes: *const std::os::raw::c_char = msg_send![filename, UTF8String];
std::ffi::CStr::from_ptr(bytes)
};
if let Ok(path) = cstr.to_str() {
add_log(format!("📂 macOS file open: {}", path));
if path.ends_with(".pdf") {
// Always set the opened file for command-line interface
set_opened_file(path.to_string());
if let Some(app) = APP_HANDLE.lock().unwrap().as_ref() {
// App is running, emit event immediately
add_log(format!("✅ App running, emitting file event: {}", path));
let _ = app.emit("macos://open-file", path.to_string());
} else {
// App not ready yet, store for later processing
add_log(format!("🚀 App not ready, storing file for later: {}", path));
LAUNCH_FILES.lock().unwrap().push(path.to_string());
}
}
}
}
}
}
// Register the delegate immediately when the module loads
pub fn register_delegate_early() {
add_log("🔧 Registering macOS delegate early...".to_string());
unsafe {
let ns_app = NSApplication::sharedApplication(nil);
// Check if there's already a delegate
let existing_delegate: id = msg_send![ns_app, delegate];
if existing_delegate != nil {
add_log("⚠️ Tauri already has an NSApplication delegate, trying to extend it...".to_string());
// Try to add our method to the existing delegate's class
let delegate_class: id = msg_send![existing_delegate, class];
let class_name: *const std::os::raw::c_char = msg_send![delegate_class, name];
let class_name_str = std::ffi::CStr::from_ptr(class_name).to_string_lossy();
add_log(format!("🔍 Existing delegate class: {}", class_name_str));
// This approach won't work with existing classes, so let's try a different method
// We'll use method swizzling or create a new delegate that forwards to the old one
add_log("🔄 Will try alternative approach...".to_string());
}
let delegate_class = Class::get("StirlingAppDelegate").unwrap_or_else(|| {
let superclass = class!(NSObject);
let mut decl = objc::declare::ClassDecl::new("StirlingAppDelegate", superclass).unwrap();
// Add file opening delegate method (modern plural version)
decl.add_method(
sel!(application:openFiles:),
open_files as extern "C" fn(&Object, Sel, id, id)
);
decl.register()
});
let delegate: id = msg_send![delegate_class, new];
let _: () = msg_send![ns_app, setDelegate:delegate];
}
add_log("✅ macOS delegate registered early".to_string());
}
pub fn register_open_file_handler(app: &AppHandle<tauri::Wry>) {
add_log("🔧 Connecting app handle to file handler...".to_string());
// Store the app handle
*APP_HANDLE.lock().unwrap() = Some(app.clone());
// Process any files that were opened during launch
let launch_files = {
let mut files = LAUNCH_FILES.lock().unwrap();
let result = files.clone();
files.clear();
result
};
for file_path in launch_files {
add_log(format!("📂 Processing stored launch file: {}", file_path));
set_opened_file(file_path.clone());
let _ = app.emit("macos://open-file", file_path);
}
add_log("✅ macOS file handler connected successfully".to_string());
}
}

View File

@ -1,30 +1,45 @@
use tauri::{RunEvent, WindowEvent, Emitter};
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
mod utils;
mod commands;
mod file_handler;
use commands::{start_backend, check_backend_health, get_opened_file, clear_opened_file, cleanup_backend, set_opened_file};
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize file handler early for macOS
file_handler::early_init();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
add_log(format!("📂 Second instance detected with args: {:?}", args));
// Scan args for PDF files (skip first arg which is the executable)
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Also emit event for immediate handling if frontend is ready
let _ = app.emit("file-opened", arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
}
}))
.setup(|_app| {
add_log("🚀 Tauri app setup started".to_string());
// Initialize platform-specific file handler
file_handler::initialize_file_handler(&app.handle());
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_file, clear_opened_file, get_tauri_logs])
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
@ -49,8 +64,9 @@ pub fn run() {
let file_path = url_str.strip_prefix("file://").unwrap_or(url_str);
if file_path.ends_with(".pdf") {
add_log(format!("📂 Processing opened PDF: {}", file_path));
set_opened_file(file_path.to_string());
let _ = app_handle.emit("macos://open-file", file_path.to_string());
add_opened_file(file_path.to_string());
// Use unified event name for consistency across platforms
let _ = app_handle.emit("file-opened", file_path.to_string());
}
}
}
@ -62,4 +78,4 @@ pub fn run() {
}
}
});
}
}

View File

@ -22,7 +22,7 @@
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "dmg", "msi"],
"targets": ["deb", "rpm", "dmg", "app", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",

View File

@ -8,14 +8,16 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@ -23,19 +25,38 @@ function ScarfTrackingInitializer() {
return null;
}
// Component to run app-level initialization (must be inside AppProviders for context access)
function AppInitializer() {
useAppInitialization();
return null;
}
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
export interface AppProvidersProps {
children: ReactNode;
appConfigRetryOptions?: AppConfigRetryOptions;
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
}
/**
* Core application providers
* Contains all providers needed for the core
*/
export function AppProviders({ children }: { children: ReactNode }) {
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<OnboardingProvider>
<AppConfigProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
@ -46,7 +67,9 @@ export function AppProviders({ children }: { children: ReactNode }) {
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
{children}
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>

View File

@ -3,6 +3,7 @@ import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColor
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface DrawingCanvasProps {
selectedColor: string;
@ -177,19 +178,21 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<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}
/>
<PrivateContent>
<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}
/>
</PrivateContent>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
@ -246,23 +249,25 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
</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',
}}
/>
<PrivateContent>
<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',
}}
/>
</PrivateContent>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>

View File

@ -1,6 +1,7 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
@ -40,13 +41,15 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
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}
/>
<PrivateContent>
<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}
/>
</PrivateContent>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload an image of your signature')}
</Text>

View File

@ -14,6 +14,7 @@ import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadBlob } from '@app/utils/downloadUtils';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
interface FileEditorProps {
@ -65,6 +66,15 @@ const FileEditor = ({
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
// Compute effective max allowed files based on the active tool and mode
const maxAllowed = useMemo<number>(() => {
const rawMax = selectedTool?.maxFiles;
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
useEffect(() => {
if (toolMode) {
@ -83,7 +93,10 @@ const FileEditor = ({
const localSelectedIds = contextSelectedIds;
const handleSelectAllFiles = useCallback(() => {
setSelectedFiles(state.files.ids);
// Respect maxAllowed: if limited, select the last N files
const allIds = state.files.ids;
const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds;
setSelectedFiles(idsToSelect);
try {
clearAllFileErrors();
} catch (error) {
@ -91,7 +104,7 @@ const FileEditor = ({
console.warn('Failed to clear file errors on select all:', error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
const handleDeselectAllFiles = useCallback(() => {
setSelectedFiles([]);
@ -131,6 +144,13 @@ const FileEditor = ({
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
} catch (err) {
@ -138,7 +158,7 @@ const FileEditor = ({
showError(errorMessage);
console.error('File processing error:', err);
}
}, [addFiles, showStatus, showError]);
}, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
@ -156,24 +176,33 @@ const FileEditor = ({
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
// In tool mode, typically allow multiple files unless specified otherwise
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// Check if we've hit the selection limit
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
return;
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
newSelection = [...currentSelectedIds, contextFileId];
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
setSelectedFiles(selectedFileIds.slice(-maxAllowed));
}
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
// File reordering handler for drag and drop

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@ -21,6 +21,7 @@ import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@ -63,7 +64,7 @@ const FileEditorThumbnail = ({
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useIsMobile();
const [showCloseModal, setShowCloseModal] = useState(false);
// Resolve the actual File object for pin/unpin operations
@ -92,6 +93,13 @@ const FileEditorThumbnail = ({
return (m?.[1] || '').toUpperCase();
}, [file.name]);
const extLower = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toLowerCase();
}, [file.name]);
const isCBZ = extLower === 'cbz';
const pageLabel = useMemo(
() =>
pageCount > 0
@ -205,7 +213,7 @@ const FileEditorThumbnail = ({
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
},
hidden: !isZipFile || !onUnzipFile,
hidden: !isZipFile || !onUnzipFile || isCBZ,
},
{
id: 'close',
@ -328,8 +336,8 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
{file.name}
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Text
size="sm"
@ -353,20 +361,20 @@ const FileEditorThumbnail = ({
>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
className="ph-no-capture"
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
}}
style={{
maxWidth: '80%',
<PrivateContent>
<img
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
@ -378,6 +386,7 @@ const FileEditorThumbnail = ({
alignSelf: 'start'
}}
/>
</PrivateContent>
)}
</div>

View File

@ -6,6 +6,7 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface CompactFileDetailsProps {
currentFile: StirlingFileStub | null;
@ -41,18 +42,19 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Small preview */}
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && thumbnail ? (
<img
className='ph-no-capture'
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
<PrivateContent>
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
</PrivateContent>
) : currentFile ? (
<Center style={{
width: '100%',
@ -67,8 +69,8 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text className='ph-no-capture' size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : 'No file selected'}</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import ToolChain from '@app/components/shared/ToolChain';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileInfoCardProps {
currentFile: StirlingFileStub | null;
@ -26,7 +27,9 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" c="dimmed">
<PrivateContent>{t('fileManager.fileName', 'Name')}</PrivateContent>
</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>

View File

@ -13,6 +13,7 @@ import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { zipFileService } from '@app/services/zipFileService';
import ToolChain from '@app/components/shared/ToolChain';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileListItemProps {
file: StirlingFileStub;
@ -99,7 +100,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>

View File

@ -81,6 +81,7 @@ export default function Workbench() {
switch (currentView) {
case "fileEditor":
return (
<FileEditor
toolMode={!!selectedToolId}
@ -98,6 +99,7 @@ export default function Workbench() {
);
case "viewer":
return (
<Viewer
sidebarsVisible={sidebarsVisible}
@ -110,6 +112,7 @@ export default function Workbench() {
);
case "pageEditor":
return (
<>
<PageEditor
@ -143,6 +146,8 @@ export default function Workbench() {
default:
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@ -154,7 +159,7 @@ export default function Workbench() {
return (
<Box
className="flex-1 h-full min-w-80 relative flex flex-col"
className="flex-1 h-full min-w-0 relative flex flex-col"
data-tour="workbench"
style={
isRainbowMode
@ -182,10 +187,11 @@ export default function Workbench() {
{/* Main content area */}
<Box
className={`flex-1 min-h-0 relative z-10 ${styles.workbenchScrollable}`}
className={`flex-1 min-h-0 relative z-10 ${currentView === 'viewer' || !isBaseWorkbench(currentView) ? '' : styles.workbenchScrollable}`}
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
overflow: currentView === 'viewer' || !isBaseWorkbench(currentView) ? 'hidden' : undefined,
}}
>
{renderMainContent()}

View File

@ -6,3 +6,28 @@
ry: 8px;
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
}
/* Add glowing border to navigation items during admin tour */
.modal-nav-item.tour-nav-glow {
position: relative;
box-shadow:
0 0 0 2px var(--mantine-primary-color-filled),
0 0 15px var(--mantine-primary-color-filled),
inset 0 0 15px rgba(59, 130, 246, 0.1);
border-radius: 8px;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 20px var(--mantine-primary-color-filled),
inset 0 0 20px rgba(59, 130, 246, 0.1);
}
50% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 30px var(--mantine-primary-color-filled),
inset 0 0 30px rgba(59, 130, 246, 0.2);
}
}

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { CloseButton, ActionIcon } from '@mantine/core';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
@ -32,6 +33,18 @@ enum TourStep {
WRAP_UP,
}
enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
function TourContent() {
const { isOpen } = useOnboarding();
const { setIsOpen, setCurrentStep } = useTour();
@ -54,8 +67,36 @@ function TourContent() {
export default function OnboardingTour() {
const { t } = useTranslation();
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding();
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
// Helper to add glow to multiple elements
const addGlowToElements = (selectors: string[]) => {
selectors.forEach(selector => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add('tour-content-glow');
} else {
element.classList.add('tour-nav-glow');
}
}
});
};
// Helper to remove all glows
const removeAllGlows = () => {
document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow'));
document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow'));
};
// Cleanup glows when tour closes
React.useEffect(() => {
if (!isOpen) {
removeAllGlows();
}
return () => removeAllGlows();
}, [isOpen]);
const {
saveWorkbenchState,
restoreWorkbenchState,
@ -70,6 +111,13 @@ export default function OnboardingTour() {
modifyCropSettings,
executeTool,
} = useTourOrchestration();
const {
saveAdminState,
restoreAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
} = useAdminTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = {
@ -202,8 +250,125 @@ export default function OnboardingTour() {
},
};
// Convert to array using enum's numeric ordering
const steps = Object.values(stepsConfig);
// Define admin tour steps
const adminStepsConfig: Record<AdminTourStep, StepType> = {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
position: 'right',
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
position: 'right',
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: '.modal-nav',
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
position: 'right',
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('people');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminGeneral');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminDatabase');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
navigateToSection('adminConnections');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
actionAfter: async () => {
// Scroll for the NEXT step before it shows
await scrollNavToSection('adminAudit');
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
position: 'right',
padding: 10,
action: () => {
// Just navigate, scroll already happened in previous step
removeAllGlows();
navigateToSection('adminAudit');
setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
position: 'right',
padding: 10,
action: () => {
removeAllGlows();
},
},
};
// Select steps based on tour type
const steps = tourType === 'admin'
? Object.values(adminStepsConfig)
: Object.values(stepsConfig);
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
@ -213,7 +378,11 @@ export default function OnboardingTour() {
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
restoreWorkbenchState();
if (tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
completeTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
@ -222,7 +391,11 @@ export default function OnboardingTour() {
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
restoreWorkbenchState();
if (tourType === 'admin') {
restoreAdminState();
} else {
restoreWorkbenchState();
}
completeTour();
};
@ -243,7 +416,9 @@ export default function OnboardingTour() {
}}
/>
<TourProvider
key={tourType}
steps={steps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
onClickClose={handleCloseTour}
onClickMask={advanceTour}
onClickHighlighted={(e, clickProps) => {

View File

@ -12,6 +12,7 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
import styles from '@app/components/pageEditor/PageEditor.module.css';
import { useFileContext } from '@app/contexts/FileContext';
import { FileId } from '@app/types/file';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileItem {
id: FileId;
@ -316,29 +317,30 @@ const FileThumbnail = ({
}}
>
{file.thumbnail && (
<img
className="ph-no-capture"
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
<PrivateContent>
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
/>
/>
</PrivateContent>
)}
</div>

View File

@ -1,6 +1,6 @@
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useIsMobile } from '@app/hooks/useIsMobile';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
@ -14,6 +14,7 @@ import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import styles from '@app/components/pageEditor/PageEditor.module.css';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface PageThumbnailProps {
@ -68,7 +69,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const isMobile = useIsMobile();
const dragElementRef = useRef<HTMLDivElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
@ -442,21 +443,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}}></div>
</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%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
<PrivateContent>
<img
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
</PrivateContent>
) : (
<div style={{ textAlign: 'center' }}>
<Text size="lg" c="dimmed">📄</Text>

View File

@ -1,12 +1,12 @@
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
import { createConfigNavSections } from '@app/components/shared/config/configNavSections';
import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import '@app/components/shared/AppConfigModal.css';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AppConfigModalProps {
@ -15,10 +15,10 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const [active, setActive] = useState<NavKey>('general');
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
const [active, setActive] = useState<NavKey>('general');
const isMobile = useMediaQuery("(max-width: 1024px)");
const { config } = useAppConfig();
// Extract section from URL path (e.g., /settings/people -> people)
@ -154,6 +154,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? 'not-allowed' : 'pointer',
}}
data-tour={`admin-${item.key}-nav`}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
@ -185,7 +186,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
</div>
{/* Right content */}
<div className="modal-content">
<div className="modal-content" data-tour="settings-content-area">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div

View File

@ -3,6 +3,7 @@ import { Menu, Loader, Group, Text } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import FitText from '@app/components/shared/FitText';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileDropdownMenuProps {
displayName: string;
@ -31,7 +32,9 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
) : (
<VisibilityIcon fontSize="small" />
)}
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
<PrivateContent>
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} />
</PrivateContent>
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
@ -61,7 +64,9 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
>
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
<PrivateContent>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
</PrivateContent>
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">

View File

@ -0,0 +1,40 @@
import React from 'react';
interface PrivateContentProps extends React.HTMLAttributes<HTMLSpanElement> {
children: React.ReactNode;
}
/**
* Wrapper component for content that should not be captured by analytics tools.
* Currently applies the 'ph-no-capture' className to prevent PostHog capture.
*
* Uses `display: contents` to be layout-invisible - the wrapper exists in the DOM
* for analytics filtering, but doesn't affect layout, flexbox, grid, or styling.
*
* Use this component to wrap any content containing sensitive or private information
* that should be excluded from analytics tracking.
*
* @example
* <PrivateContent>
* <Text>Sensitive filename.pdf</Text>
* </PrivateContent>
*
* <PrivateContent>
* <img src={thumbnail} alt="preview" />
* </PrivateContent>
*/
export const PrivateContent: React.FC<PrivateContentProps> = ({
children,
className = '',
style,
...props
}) => {
const combinedClassName = `ph-no-capture${className ? ` ${className}` : ''}`;
const combinedStyle = { display: 'contents' as const, ...style };
return (
<span className={combinedClassName} style={combinedStyle} {...props}>
{children}
</span>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import { ActionIcon, Stack, Divider } from "@mantine/core";
import { ActionIcon, Stack, Divider, Menu } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
import LocalIcon from '@app/components/shared/LocalIcon';
@ -21,6 +21,7 @@ import {
getNavButtonStyle,
getActiveNavButton,
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
@ -179,7 +180,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
size: 'lg',
type: 'action',
onClick: () => {
startTour();
// This will be overridden by the wrapper logic
},
},
{
@ -258,11 +259,70 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{/* Bottom section */}
<Stack gap="lg" align="center">
{bottomButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
{bottomButtons.map((buttonConfig, index) => {
// Handle help button with menu or direct action
if (buttonConfig.id === 'help') {
const isAdmin = config?.isAdmin === true;
// If not admin, just show button that starts tools tour directly
if (!isAdmin) {
return (
<div
key={buttonConfig.id}
data-tour="help-button"
onClick={() => startTour('tools')}
>
{renderNavButton(buttonConfig, index)}
</div>
);
}
// If admin, show menu with both options
return (
<div key={buttonConfig.id} data-tour="help-button">
<Menu position="right" offset={10} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}>
<Menu.Target>
<div>{renderNavButton(buttonConfig, index)}</div>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<LocalIcon icon="view-carousel-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => startTour('tools')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.toolsTour", "Tools Tour")}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do")}
</div>
</div>
</Menu.Item>
<Menu.Item
leftSection={<LocalIcon icon="admin-panel-settings-rounded" width="1.25rem" height="1.25rem" />}
onClick={() => startTour('admin')}
>
<div>
<div style={{ fontWeight: 500 }}>
{t("quickAccess.helpMenu.adminTour", "Admin Tour")}
</div>
<div style={{ fontSize: '0.875rem', opacity: 0.7 }}>
{t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features")}
</div>
</div>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</div>
);
}
return (
<React.Fragment key={buttonConfig.id}>
{renderNavButton(buttonConfig, index)}
</React.Fragment>
);
})}
</Stack>
</div>
</div>

View File

@ -65,6 +65,10 @@ export const Tooltip: React.FC<TooltipProps> = ({
const clickPendingRef = useRef(false);
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
// Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target
const isDomNode = (value: unknown): value is Node =>
typeof Node !== 'undefined' && value instanceof Node;
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
@ -103,9 +107,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
const target = e.target as unknown;
const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target));
const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target));
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
@ -172,7 +176,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
@ -236,7 +240,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
@ -258,7 +262,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]

View File

@ -9,6 +9,7 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
import { PrivateContent } from '@app/components/shared/PrivateContent';
const viewOptionStyle: React.CSSProperties = {
@ -54,7 +55,7 @@ const createViewOptions = (
) : (
<VisibilityIcon fontSize="small" />
)}
<span className="ph-no-capture">{displayName}</span>
<PrivateContent>{displayName}</PrivateContent>
</div>
),
value: "viewer",
@ -167,7 +168,8 @@ const TopControls = ({
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<div className="flex justify-center mt-[0.5rem]" style={{ pointerEvents: 'auto' }}>
<SegmentedControl
data-tour="view-switcher"
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}

View File

@ -0,0 +1,96 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import CancelIcon from "@mui/icons-material/Cancel";
import { CSSProperties } from "react";
interface ZipWarningModalProps {
opened: boolean;
onConfirm: () => void;
onCancel: () => void;
fileCount: number;
zipFileName: string;
}
const WARNING_ICON_STYLE: CSSProperties = {
fontSize: 36,
display: 'block',
margin: '0 auto 8px',
color: 'var(--mantine-color-blue-6)'
};
const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onCancel}
title={t("zipWarning.title", "Large ZIP File")}
centered
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack ta="center" p="md" gap="sm">
<WarningAmberIcon style={WARNING_ICON_STYLE} />
<Text size="md" fw={300}>
{zipFileName}
</Text>
<Text size="lg" fw={500}>
{t("zipWarning.message", {
count: fileCount,
defaultValue: "This ZIP contains {{count}} files. Extract anyway?"
})}
</Text>
</Stack>
{/* Desktop layout: centered buttons */}
<Group justify="center" gap="sm" visibleFrom="md">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onCancel}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.cancel", "Cancel")}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onConfirm}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.confirm", "Extract")}
</Button>
</Group>
{/* Mobile layout: vertical stack */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button
variant="light"
color="var(--mantine-color-gray-8)"
onClick={onCancel}
leftSection={<CancelIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.cancel", "Cancel")}
</Button>
<Button
variant="filled"
color="var(--mantine-color-blue-9)"
onClick={onConfirm}
leftSection={<CheckCircleOutlineIcon fontSize="small" />}
w="10rem"
>
{t("zipWarning.confirm", "Extract")}
</Button>
</Stack>
</Modal>
);
};
export default ZipWarningModal;

View File

@ -205,7 +205,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality', 'Enable Alpha Features')}</Text>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableAlphaFunctionality.label', 'Enable Alpha Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableAlphaFunctionality.description', 'Enable experimental and alpha-stage features (may be unstable)')}
</Text>
@ -221,7 +221,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF', 'Enable URL to PDF')}</Text>
<Text fw={500} size="sm">{t('admin.settings.advanced.enableUrlToPDF.label', 'Enable URL to PDF')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.enableUrlToPDF.description', 'Allow conversion of web pages to PDF documents (internal use only)')}
</Text>
@ -237,7 +237,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize', 'Disable HTML Sanitization')}</Text>
<Text fw={500} size="sm">{t('admin.settings.advanced.disableSanitize.label', 'Disable HTML Sanitization')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.disableSanitize.description', 'Disable HTML sanitization (WARNING: Security risk - can lead to XSS injections)')}
</Text>
@ -262,7 +262,7 @@ export default function AdminAdvancedSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.advanced.maxDPI', 'Maximum DPI')}</span>
<span>{t('admin.settings.advanced.maxDPI.label', 'Maximum DPI')}</span>
<PendingBadge show={isFieldPending('maxDPI')} />
</Group>
}
@ -278,7 +278,7 @@ export default function AdminAdvancedSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.advanced.tessdataDir', 'Tessdata Directory')}</span>
<span>{t('admin.settings.advanced.tessdataDir.label', 'Tessdata Directory')}</span>
<PendingBadge show={isFieldPending('tessdataDir')} />
</Group>
}
@ -295,7 +295,7 @@ export default function AdminAdvancedSection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement', 'Temp File Management')}</Text>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.advanced.tempFileManagement.label', 'Temp File Management')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.advanced.tempFileManagement.description', 'Configure temporary file storage and cleanup behavior')}
</Text>
@ -303,7 +303,7 @@ export default function AdminAdvancedSection() {
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.baseTmpDir', 'Base Temp Directory')}
label={t('admin.settings.advanced.tempFileManagement.baseTmpDir.label', 'Base Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.baseTmpDir.description', 'Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)')}
value={settings.tempFileManagement?.baseTmpDir || ''}
onChange={(e) => setSettings({
@ -316,7 +316,7 @@ export default function AdminAdvancedSection() {
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.libreofficeDir', 'LibreOffice Temp Directory')}
label={t('admin.settings.advanced.tempFileManagement.libreofficeDir.label', 'LibreOffice Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.libreofficeDir.description', 'Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)')}
value={settings.tempFileManagement?.libreofficeDir || ''}
onChange={(e) => setSettings({
@ -329,7 +329,7 @@ export default function AdminAdvancedSection() {
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.systemTempDir', 'System Temp Directory')}
label={t('admin.settings.advanced.tempFileManagement.systemTempDir.label', 'System Temp Directory')}
description={t('admin.settings.advanced.tempFileManagement.systemTempDir.description', 'System temp directory to clean (only used if cleanupSystemTemp is enabled)')}
value={settings.tempFileManagement?.systemTempDir || ''}
onChange={(e) => setSettings({
@ -342,7 +342,7 @@ export default function AdminAdvancedSection() {
<div>
<TextInput
label={t('admin.settings.advanced.tempFileManagement.prefix', 'Temp File Prefix')}
label={t('admin.settings.advanced.tempFileManagement.prefix.label', 'Temp File Prefix')}
description={t('admin.settings.advanced.tempFileManagement.prefix.description', 'Prefix for temp file names')}
value={settings.tempFileManagement?.prefix || 'stirling-pdf-'}
onChange={(e) => setSettings({
@ -355,7 +355,7 @@ export default function AdminAdvancedSection() {
<div>
<NumberInput
label={t('admin.settings.advanced.tempFileManagement.maxAgeHours', 'Max Age (hours)')}
label={t('admin.settings.advanced.tempFileManagement.maxAgeHours.label', 'Max Age (hours)')}
description={t('admin.settings.advanced.tempFileManagement.maxAgeHours.description', 'Maximum age in hours before temp files are cleaned up')}
value={settings.tempFileManagement?.maxAgeHours ?? 24}
onChange={(value) => setSettings({
@ -369,7 +369,7 @@ export default function AdminAdvancedSection() {
<div>
<NumberInput
label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes', 'Cleanup Interval (minutes)')}
label={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.label', 'Cleanup Interval (minutes)')}
description={t('admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes.description', 'How often to run cleanup (in minutes)')}
value={settings.tempFileManagement?.cleanupIntervalMinutes ?? 30}
onChange={(value) => setSettings({
@ -383,7 +383,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup', 'Startup Cleanup')}</Text>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.startupCleanup.label', 'Startup Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.startupCleanup.description', 'Clean up old temp files on application startup')}
</Text>
@ -402,7 +402,7 @@ export default function AdminAdvancedSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp', 'Cleanup System Temp')}</Text>
<Text fw={500} size="sm">{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.label', 'Cleanup System Temp')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.advanced.tempFileManagement.cleanupSystemTemp.description', 'Whether to clean broader system temp directory (use with caution)')}
</Text>
@ -424,7 +424,7 @@ export default function AdminAdvancedSection() {
{/* Process Executor Limits */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor', 'Process Executor Limits')}</Text>
<Text fw={600} size="sm">{t('admin.settings.advanced.processExecutor.label', 'Process Executor Limits')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.advanced.processExecutor.description', 'Configure session limits and timeouts for each process executor')}
</Text>
@ -436,7 +436,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.libreOfficeSessionLimit ?? 1}
onChange={(value) => setSettings({
@ -450,7 +450,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.libreOfficetimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -473,7 +473,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pdfToHtmlSessionLimit ?? 1}
onChange={(value) => setSettings({
@ -487,7 +487,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pdfToHtmltimeoutMinutes ?? 20}
onChange={(value) => setSettings({
@ -510,7 +510,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.qpdfSessionLimit ?? 4}
onChange={(value) => setSettings({
@ -524,7 +524,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.qpdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -547,7 +547,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.tesseractSessionLimit ?? 1}
onChange={(value) => setSettings({
@ -561,7 +561,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.tesseractTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -584,7 +584,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.pythonOpenCvSessionLimit ?? 8}
onChange={(value) => setSettings({
@ -598,7 +598,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.pythonOpenCvtimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -621,7 +621,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.weasyPrintSessionLimit ?? 16}
onChange={(value) => setSettings({
@ -635,7 +635,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.weasyPrinttimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -658,7 +658,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.installAppSessionLimit ?? 1}
onChange={(value) => setSettings({
@ -672,7 +672,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.installApptimeoutMinutes ?? 60}
onChange={(value) => setSettings({
@ -695,7 +695,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.calibreSessionLimit ?? 1}
onChange={(value) => setSettings({
@ -709,7 +709,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.calibretimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -732,7 +732,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ghostscriptSessionLimit ?? 8}
onChange={(value) => setSettings({
@ -746,7 +746,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ghostscriptTimeoutMinutes ?? 30}
onChange={(value) => setSettings({
@ -769,7 +769,7 @@ export default function AdminAdvancedSection() {
<Accordion.Panel>
<Stack gap="sm">
<NumberInput
label={t('admin.settings.advanced.processExecutor.sessionLimit', 'Session Limit')}
label={t('admin.settings.advanced.processExecutor.sessionLimit.label', 'Session Limit')}
description={t('admin.settings.advanced.processExecutor.sessionLimit.description', 'Maximum concurrent instances')}
value={settings.processExecutor?.sessionLimit?.ocrMyPdfSessionLimit ?? 2}
onChange={(value) => setSettings({
@ -783,7 +783,7 @@ export default function AdminAdvancedSection() {
max={100}
/>
<NumberInput
label={t('admin.settings.advanced.processExecutor.timeout', 'Timeout (minutes)')}
label={t('admin.settings.advanced.processExecutor.timeout.label', 'Timeout (minutes)')}
description={t('admin.settings.advanced.processExecutor.timeout.description', 'Maximum execution time')}
value={settings.processExecutor?.timeoutMinutes?.ocrMyPdfTimeoutMinutes ?? 30}
onChange={(value) => setSettings({

View File

@ -328,7 +328,7 @@ export default function AdminConnectionsSection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin', 'SSO Auto Login')}</Text>
<Text fw={600} size="sm">{t('admin.settings.connections.ssoAutoLogin.label', 'SSO Auto Login')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
</Group>

View File

@ -123,7 +123,7 @@ export default function AdminDatabaseSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom', 'Enable Custom Database')}</Text>
<Text fw={500} size="sm">{t('admin.settings.database.enableCustom.label', 'Enable Custom Database')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.database.enableCustom.description', 'Use your own custom database configuration instead of the default embedded database')}
</Text>
@ -143,7 +143,7 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.customUrl', 'Custom Database URL')}</span>
<span>{t('admin.settings.database.customUrl.label', 'Custom Database URL')}</span>
<PendingBadge show={isFieldPending('customDatabaseUrl')} />
</Group>
}
@ -158,7 +158,7 @@ export default function AdminDatabaseSection() {
<Select
label={
<Group gap="xs">
<span>{t('admin.settings.database.type', 'Database Type')}</span>
<span>{t('admin.settings.database.type.label', 'Database Type')}</span>
<PendingBadge show={isFieldPending('type')} />
</Group>
}
@ -178,7 +178,7 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.hostName', 'Host Name')}</span>
<span>{t('admin.settings.database.hostName.label', 'Host Name')}</span>
<PendingBadge show={isFieldPending('hostName')} />
</Group>
}
@ -193,7 +193,7 @@ export default function AdminDatabaseSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.port', 'Port')}</span>
<span>{t('admin.settings.database.port.label', 'Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
@ -209,7 +209,7 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.name', 'Database Name')}</span>
<span>{t('admin.settings.database.name.label', 'Database Name')}</span>
<PendingBadge show={isFieldPending('name')} />
</Group>
}
@ -224,7 +224,7 @@ export default function AdminDatabaseSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.username', 'Username')}</span>
<span>{t('admin.settings.database.username.label', 'Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
@ -239,7 +239,7 @@ export default function AdminDatabaseSection() {
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.database.password', 'Password')}</span>
<span>{t('admin.settings.database.password.label', 'Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}

View File

@ -117,7 +117,7 @@ export default function AdminEndpointsSection() {
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.toRemove', 'Disabled Endpoints')}</span>
<span>{t('admin.settings.endpoints.toRemove.label', 'Disabled Endpoints')}</span>
<PendingBadge show={isFieldPending('toRemove')} />
</Group>
}
@ -136,7 +136,7 @@ export default function AdminEndpointsSection() {
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.endpoints.groupsToRemove', 'Disabled Endpoint Groups')}</span>
<span>{t('admin.settings.endpoints.groupsToRemove.label', 'Disabled Endpoint Groups')}</span>
<PendingBadge show={isFieldPending('groupsToRemove')} />
</Group>
}

View File

@ -106,7 +106,7 @@ export default function AdminFeaturesSection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate', 'Server Certificate')}</Text>
<Text fw={600} size="sm">{t('admin.settings.features.serverCertificate.label', 'Server Certificate')}</Text>
<Badge color="blue" size="sm">PRO</Badge>
</Group>
@ -116,7 +116,7 @@ export default function AdminFeaturesSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled', 'Enable Server Certificate')}</Text>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.enabled.label', 'Enable Server Certificate')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.enabled.description', 'Enable server-side certificate for "Sign with Stirling-PDF" option')}
</Text>
@ -137,7 +137,7 @@ export default function AdminFeaturesSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.organizationName', 'Organization Name')}</span>
<span>{t('admin.settings.features.serverCertificate.organizationName.label', 'Organization Name')}</span>
<PendingBadge show={isFieldPending('serverCertificate.organizationName')} />
</Group>
}
@ -155,7 +155,7 @@ export default function AdminFeaturesSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.features.serverCertificate.validity', 'Certificate Validity (days)')}</span>
<span>{t('admin.settings.features.serverCertificate.validity.label', 'Certificate Validity (days)')}</span>
<PendingBadge show={isFieldPending('serverCertificate.validity')} />
</Group>
}
@ -172,7 +172,7 @@ export default function AdminFeaturesSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup', 'Regenerate on Startup')}</Text>
<Text fw={500} size="sm">{t('admin.settings.features.serverCertificate.regenerateOnStartup.label', 'Regenerate on Startup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.features.serverCertificate.regenerateOnStartup.description', 'Generate new certificate on each application startup')}
</Text>

View File

@ -180,7 +180,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.appNameNavbar', 'Navbar Brand')}</span>
<span>{t('admin.settings.general.appNameNavbar.label', 'Navbar Brand')}</span>
<PendingBadge show={isFieldPending('ui.appNameNavbar')} />
</Group>
}
@ -195,7 +195,7 @@ export default function AdminGeneralSection() {
<MultiSelect
label={
<Group gap="xs">
<span>{t('admin.settings.general.languages', 'Available Languages')}</span>
<span>{t('admin.settings.general.languages.label', 'Available Languages')}</span>
<PendingBadge show={isFieldPending('ui.languages')} />
</Group>
}
@ -225,7 +225,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.defaultLocale', 'Default Locale')}</span>
<span>{t('admin.settings.general.defaultLocale.label', 'Default Locale')}</span>
<PendingBadge show={isFieldPending('system.defaultLocale')} />
</Group>
}
@ -240,7 +240,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.fileUploadLimit', 'File Upload Limit')}</span>
<span>{t('admin.settings.general.fileUploadLimit.label', 'File Upload Limit')}</span>
<PendingBadge show={isFieldPending('system.fileUploadLimit')} />
</Group>
}
@ -253,7 +253,7 @@ export default function AdminGeneralSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate', 'Show Update Notifications')}</Text>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdate.label', 'Show Update Notifications')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdate.description', 'Display notifications when a new version is available')}
</Text>
@ -269,7 +269,7 @@ export default function AdminGeneralSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin', 'Show Updates to Admins Only')}</Text>
<Text fw={500} size="sm">{t('admin.settings.general.showUpdateOnlyAdmin.label', 'Show Updates to Admins Only')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.showUpdateOnlyAdmin.description', 'Restrict update notifications to admin users only')}
</Text>
@ -285,7 +285,7 @@ export default function AdminGeneralSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles', 'Custom HTML Files')}</Text>
<Text fw={500} size="sm">{t('admin.settings.general.customHTMLFiles.label', 'Custom HTML Files')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customHTMLFiles.description', 'Allow serving custom HTML files from the customFiles directory')}
</Text>
@ -305,13 +305,13 @@ export default function AdminGeneralSection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata', 'Custom Metadata')}</Text>
<Text fw={600} size="sm">{t('admin.settings.general.customMetadata.label', 'Custom Metadata')}</Text>
<Badge color="yellow" size="sm">PRO</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate', 'Auto Update Metadata')}</Text>
<Text fw={500} size="sm">{t('admin.settings.general.customMetadata.autoUpdate.label', 'Auto Update Metadata')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.general.customMetadata.autoUpdate.description', 'Automatically update PDF metadata on all processed documents')}
</Text>
@ -335,7 +335,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.author', 'Default Author')}</span>
<span>{t('admin.settings.general.customMetadata.author.label', 'Default Author')}</span>
<PendingBadge show={isFieldPending('customMetadata.author')} />
</Group>
}
@ -356,7 +356,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.creator', 'Default Creator')}</span>
<span>{t('admin.settings.general.customMetadata.creator.label', 'Default Creator')}</span>
<PendingBadge show={isFieldPending('customMetadata.creator')} />
</Group>
}
@ -377,7 +377,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customMetadata.producer', 'Default Producer')}</span>
<span>{t('admin.settings.general.customMetadata.producer.label', 'Default Producer')}</span>
<PendingBadge show={isFieldPending('customMetadata.producer')} />
</Group>
}
@ -400,19 +400,19 @@ export default function AdminGeneralSection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths', 'Custom Paths')}</Text>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.general.customPaths.label', 'Custom Paths')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.general.customPaths.description', 'Configure custom file system paths for pipeline processing and external tools')}
</Text>
</div>
<Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline', 'Pipeline Directories')}</Text>
<Text fw={500} size="sm" mt="xs">{t('admin.settings.general.customPaths.pipeline.label', 'Pipeline Directories')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir', 'Watched Folders Directory')}</span>
<span>{t('admin.settings.general.customPaths.pipeline.watchedFoldersDir.label', 'Watched Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.watchedFoldersDir')} />
</Group>
}
@ -436,7 +436,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir', 'Finished Folders Directory')}</span>
<span>{t('admin.settings.general.customPaths.pipeline.finishedFoldersDir.label', 'Finished Folders Directory')}</span>
<PendingBadge show={isFieldPending('customPaths.pipeline.finishedFoldersDir')} />
</Group>
}
@ -456,13 +456,13 @@ export default function AdminGeneralSection() {
/>
</div>
<Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations', 'External Tool Paths')}</Text>
<Text fw={500} size="sm" mt="md">{t('admin.settings.general.customPaths.operations.label', 'External Tool Paths')}</Text>
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.weasyprint', 'WeasyPrint Executable')}</span>
<span>{t('admin.settings.general.customPaths.operations.weasyprint.label', 'WeasyPrint Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.weasyprint')} />
</Group>
}
@ -486,7 +486,7 @@ export default function AdminGeneralSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.general.customPaths.operations.unoconvert', 'Unoconvert Executable')}</span>
<span>{t('admin.settings.general.customPaths.operations.unoconvert.label', 'Unoconvert Executable')}</span>
<PendingBadge show={isFieldPending('customPaths.operations.unoconvert')} />
</Group>
}

View File

@ -87,7 +87,7 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.termsAndConditions', 'Terms and Conditions')}</span>
<span>{t('admin.settings.legal.termsAndConditions.label', 'Terms and Conditions')}</span>
<PendingBadge show={isFieldPending('termsAndConditions')} />
</Group>
}
@ -102,7 +102,7 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.privacyPolicy', 'Privacy Policy')}</span>
<span>{t('admin.settings.legal.privacyPolicy.label', 'Privacy Policy')}</span>
<PendingBadge show={isFieldPending('privacyPolicy')} />
</Group>
}
@ -117,7 +117,7 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.accessibilityStatement', 'Accessibility Statement')}</span>
<span>{t('admin.settings.legal.accessibilityStatement.label', 'Accessibility Statement')}</span>
<PendingBadge show={isFieldPending('accessibilityStatement')} />
</Group>
}
@ -132,7 +132,7 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.cookiePolicy', 'Cookie Policy')}</span>
<span>{t('admin.settings.legal.cookiePolicy.label', 'Cookie Policy')}</span>
<PendingBadge show={isFieldPending('cookiePolicy')} />
</Group>
}
@ -147,7 +147,7 @@ export default function AdminLegalSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.legal.impressum', 'Impressum')}</span>
<span>{t('admin.settings.legal.impressum.label', 'Impressum')}</span>
<PendingBadge show={isFieldPending('impressum')} />
</Group>
}

View File

@ -122,7 +122,7 @@ export default function AdminMailSection() {
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled', 'Enable Mail')}</Text>
<Text fw={500} size="sm">{t('admin.settings.mail.enabled.label', 'Enable Mail')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enabled.description', 'Enable email notifications and SMTP functionality')}
</Text>
@ -138,7 +138,7 @@ export default function AdminMailSection() {
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites', 'Enable Email Invites')}</Text>
<Text fw={500} size="sm">{t('admin.settings.mail.enableInvites.label', 'Enable Email Invites')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.mail.enableInvites.description', 'Allow admins to invite users via email with auto-generated passwords')}
</Text>
@ -157,7 +157,7 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.host', 'SMTP Host')}</span>
<span>{t('admin.settings.mail.host.label', 'SMTP Host')}</span>
<PendingBadge show={isFieldPending('host')} />
</Group>
}
@ -172,7 +172,7 @@ export default function AdminMailSection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.port', 'SMTP Port')}</span>
<span>{t('admin.settings.mail.port.label', 'SMTP Port')}</span>
<PendingBadge show={isFieldPending('port')} />
</Group>
}
@ -188,7 +188,7 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.username', 'SMTP Username')}</span>
<span>{t('admin.settings.mail.username.label', 'SMTP Username')}</span>
<PendingBadge show={isFieldPending('username')} />
</Group>
}
@ -202,7 +202,7 @@ export default function AdminMailSection() {
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.password', 'SMTP Password')}</span>
<span>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}
@ -216,7 +216,7 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.from', 'From Address')}</span>
<span>{t('admin.settings.mail.from.label', 'From Address')}</span>
<PendingBadge show={isFieldPending('from')} />
</Group>
}
@ -231,7 +231,7 @@ export default function AdminMailSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.frontendUrl', 'Frontend URL')}</span>
<span>{t('admin.settings.mail.frontendUrl.label', 'Frontend URL')}</span>
<PendingBadge show={isFieldPending('frontendUrl')} />
</Group>
}

View File

@ -90,7 +90,7 @@ export default function AdminPremiumSection() {
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key', 'License Key')}</span>
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
@ -103,7 +103,7 @@ export default function AdminPremiumSection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled', 'Enable Premium Features')}</Text>
<Text fw={500} size="sm">{t('admin.settings.premium.enabled.label', 'Enable Premium Features')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.premium.enabled.description', 'Enable license key checks for pro/enterprise features')}
</Text>

View File

@ -116,7 +116,7 @@ export default function AdminPrivacySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics', 'Enable Analytics')}</Text>
<Text fw={500} size="sm">{t('admin.settings.privacy.enableAnalytics.label', 'Enable Analytics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.enableAnalytics.description', 'Collect anonymous usage analytics to help improve the application')}
</Text>
@ -132,7 +132,7 @@ export default function AdminPrivacySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled', 'Enable Metrics')}</Text>
<Text fw={500} size="sm">{t('admin.settings.privacy.metricsEnabled.label', 'Enable Metrics')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.metricsEnabled.description', 'Enable collection of performance and usage metrics')}
</Text>
@ -155,7 +155,7 @@ export default function AdminPrivacySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility', 'Google Visibility')}</Text>
<Text fw={500} size="sm">{t('admin.settings.privacy.googleVisibility.label', 'Google Visibility')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.privacy.googleVisibility.description', 'Allow search engines to index this application')}
</Text>

View File

@ -197,7 +197,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin', 'Enable Login')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.enableLogin.label', 'Enable Login')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.enableLogin.description', 'Require users to log in before accessing the application')}
</Text>
@ -213,7 +213,7 @@ export default function AdminSecuritySection() {
<div>
<Select
label={t('admin.settings.security.loginMethod', 'Login Method')}
label={t('admin.settings.security.loginMethod.label', 'Login Method')}
description={t('admin.settings.security.loginMethod.description', 'The authentication method to use for user login')}
value={settings.loginMethod || 'all'}
onChange={(value) => setSettings({ ...settings, loginMethod: value || 'all' })}
@ -236,7 +236,7 @@ export default function AdminSecuritySection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.security.loginAttemptCount', 'Login Attempt Limit')}</span>
<span>{t('admin.settings.security.loginAttemptCount.label', 'Login Attempt Limit')}</span>
<PendingBadge show={isFieldPending('loginAttemptCount')} />
</Group>
}
@ -252,7 +252,7 @@ export default function AdminSecuritySection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.security.loginResetTimeMinutes', 'Login Reset Time (minutes)')}</span>
<span>{t('admin.settings.security.loginResetTimeMinutes.label', 'Login Reset Time (minutes)')}</span>
<PendingBadge show={isFieldPending('loginResetTimeMinutes')} />
</Group>
}
@ -266,7 +266,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled', 'Disable CSRF Protection')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled.label', 'Disable CSRF Protection')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
</Text>
@ -297,11 +297,11 @@ export default function AdminSecuritySection() {
{/* JWT Settings */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt', 'JWT Configuration')}</Text>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.jwt.label', 'JWT Configuration')}</Text>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence', 'Enable Key Persistence')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.persistence.label', 'Enable Key Persistence')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.persistence.description', 'Store JWT keys persistently (required for multi-instance deployments)')}
</Text>
@ -317,7 +317,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation', 'Enable Key Rotation')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyRotation.label', 'Enable Key Rotation')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyRotation.description', 'Automatically rotate JWT signing keys for improved security')}
</Text>
@ -333,7 +333,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup', 'Enable Key Cleanup')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.enableKeyCleanup.label', 'Enable Key Cleanup')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.enableKeyCleanup.description', 'Automatically remove old JWT keys after retention period')}
</Text>
@ -351,7 +351,7 @@ export default function AdminSecuritySection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.security.jwt.keyRetentionDays', 'Key Retention Days')}</span>
<span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
</Group>
}
@ -365,7 +365,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie', 'Secure Cookie')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.jwt.secureCookie.label', 'Secure Cookie')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.jwt.secureCookie.description', 'Require HTTPS for JWT cookies (recommended for production)')}
</Text>
@ -385,13 +385,13 @@ export default function AdminSecuritySection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600} size="sm">{t('admin.settings.security.audit', 'Audit Logging')}</Text>
<Text fw={600} size="sm">{t('admin.settings.security.audit.label', 'Audit Logging')}</Text>
<Badge color="grape" size="sm">ENTERPRISE</Badge>
</Group>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.audit.enabled', 'Enable Audit Logging')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.audit.enabled.label', 'Enable Audit Logging')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.audit.enabled.description', 'Track user actions and system events for compliance and security monitoring')}
</Text>
@ -409,7 +409,7 @@ export default function AdminSecuritySection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.security.audit.level', 'Audit Level')}</span>
<span>{t('admin.settings.security.audit.level.label', 'Audit Level')}</span>
<PendingBadge show={isFieldPending('audit.level')} />
</Group>
}
@ -425,7 +425,7 @@ export default function AdminSecuritySection() {
<NumberInput
label={
<Group gap="xs">
<span>{t('admin.settings.security.audit.retentionDays', 'Audit Retention (days)')}</span>
<span>{t('admin.settings.security.audit.retentionDays.label', 'Audit Retention (days)')}</span>
<PendingBadge show={isFieldPending('audit.retentionDays')} />
</Group>
}
@ -443,7 +443,7 @@ export default function AdminSecuritySection() {
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity', 'HTML URL Security')}</Text>
<Text fw={600} size="sm" mb="xs">{t('admin.settings.security.htmlUrlSecurity.label', 'HTML URL Security')}</Text>
<Text size="xs" c="dimmed">
{t('admin.settings.security.htmlUrlSecurity.description', 'Configure URL access restrictions for HTML processing to prevent SSRF attacks')}
</Text>
@ -451,7 +451,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled', 'Enable URL Security')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.enabled.label', 'Enable URL Security')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.enabled.description', 'Enable URL security restrictions for HTML to PDF conversions')}
</Text>
@ -475,7 +475,7 @@ export default function AdminSecuritySection() {
<Select
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.level', 'Security Level')}</span>
<span>{t('admin.settings.security.htmlUrlSecurity.level.label', 'Security Level')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.level')} />
</Group>
}
@ -507,7 +507,7 @@ export default function AdminSecuritySection() {
<Textarea
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains', 'Allowed Domains (Whitelist)')}</span>
<span>{t('admin.settings.security.htmlUrlSecurity.allowedDomains.label', 'Allowed Domains (Whitelist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.allowedDomains')} />
</Group>
}
@ -534,7 +534,7 @@ export default function AdminSecuritySection() {
<Textarea
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains', 'Blocked Domains (Blacklist)')}</span>
<span>{t('admin.settings.security.htmlUrlSecurity.blockedDomains.label', 'Blocked Domains (Blacklist)')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.blockedDomains')} />
</Group>
}
@ -561,7 +561,7 @@ export default function AdminSecuritySection() {
<Textarea
label={
<Group gap="xs">
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds', 'Internal TLDs')}</span>
<span>{t('admin.settings.security.htmlUrlSecurity.internalTlds.label', 'Internal TLDs')}</span>
<PendingBadge show={isFieldPending('html.urlSecurity.internalTlds')} />
</Group>
}
@ -588,7 +588,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks', 'Block Private Networks')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.label', 'Block Private Networks')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockPrivateNetworks.description', 'Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)')}
</Text>
@ -610,7 +610,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost', 'Block Localhost')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.label', 'Block Localhost')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockLocalhost.description', 'Block localhost and loopback addresses (127.x.x.x, ::1)')}
</Text>
@ -632,7 +632,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal', 'Block Link-Local Addresses')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.label', 'Block Link-Local Addresses')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockLinkLocal.description', 'Block link-local addresses (169.254.x.x, fe80::/10)')}
</Text>
@ -654,7 +654,7 @@ export default function AdminSecuritySection() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata', 'Block Cloud Metadata Endpoints')}</Text>
<Text fw={500} size="sm">{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.label', 'Block Cloud Metadata Endpoints')}</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('admin.settings.security.htmlUrlSecurity.blockCloudMetadata.description', 'Block cloud provider metadata endpoints (169.254.169.254)')}
</Text>

View File

@ -1,7 +1,8 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
export interface DocumentThumbnailProps {
file: File | StirlingFileStub | null;
@ -35,13 +36,14 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
if (thumbnail) {
return (
<Box style={containerStyle} onClick={onClick}>
<Image
className='ph-no-capture'
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
<PrivateContent>
<Image
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
style={{ maxWidth: '100%', maxHeight: '100%' }}
/>
</PrivateContent>
{children}
</Box>
);
@ -50,13 +52,9 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PictureAsPdfIcon
className='ph-no-capture'
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
/>
<PrivateContent>
{getFileTypeIcon(file)}
</PrivateContent>
</Center>
{children}
</Box>

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