mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-23 20:16:15 +00:00
Compare commits
19 Commits
a660e332e3
...
dc739efddf
Author | SHA1 | Date | |
---|---|---|---|
![]() |
dc739efddf | ||
![]() |
534064e303 | ||
![]() |
833e56826d | ||
![]() |
f596423f75 | ||
![]() |
be5ea72ea6 | ||
![]() |
6bc78b481e | ||
![]() |
f1d84d599d | ||
![]() |
ff9c0e9bd4 | ||
![]() |
190178a471 | ||
![]() |
a949019d5d | ||
![]() |
cd71075f79 | ||
![]() |
1a8d2f3d33 | ||
![]() |
8e8b417f5e | ||
![]() |
a57373b968 | ||
![]() |
7dad484aa7 | ||
![]() |
cfdb6eaa1e | ||
![]() |
8a367aab54 | ||
![]() |
f3fd85d777 | ||
![]() |
9d723eae69 |
@ -5,7 +5,10 @@ import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
public interface ServerCertificateService {
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
public interface ServerCertificateServiceInterface {
|
||||
|
||||
boolean isEnabled();
|
||||
|
||||
@ -27,40 +30,13 @@ public interface ServerCertificateService {
|
||||
|
||||
ServerCertificateInfo getServerCertificateInfo() throws Exception;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
class ServerCertificateInfo {
|
||||
private final boolean exists;
|
||||
private final String subject;
|
||||
private final String issuer;
|
||||
private final Date validFrom;
|
||||
private final Date validTo;
|
||||
|
||||
public ServerCertificateInfo(
|
||||
boolean exists, String subject, String issuer, Date validFrom, Date validTo) {
|
||||
this.exists = exists;
|
||||
this.subject = subject;
|
||||
this.issuer = issuer;
|
||||
this.validFrom = validFrom;
|
||||
this.validTo = validTo;
|
||||
}
|
||||
|
||||
public boolean isExists() {
|
||||
return exists;
|
||||
}
|
||||
|
||||
public String getSubject() {
|
||||
return subject;
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public Date getValidFrom() {
|
||||
return validFrom;
|
||||
}
|
||||
|
||||
public Date getValidTo() {
|
||||
return validTo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -43,8 +43,8 @@ public class BookletImpositionController {
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
"This operation combines page reordering for booklet printing with multi-page layout. "
|
||||
+ "It rearranges pages in the correct order for booklet printing and places multiple pages "
|
||||
+ "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO")
|
||||
+ "It rearranges pages in the correct order for booklet printing and places multiple pages "
|
||||
+ "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> createBookletImposition(
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
@ -56,7 +56,8 @@ public class BookletImpositionController {
|
||||
|
||||
// Validate pages per sheet for booklet
|
||||
if (pagesPerSheet != 2 && pagesPerSheet != 4) {
|
||||
throw new IllegalArgumentException("pagesPerSheet must be 2 or 4 for booklet imposition");
|
||||
throw new IllegalArgumentException(
|
||||
"pagesPerSheet must be 2 or 4 for booklet imposition");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
@ -65,9 +66,12 @@ public class BookletImpositionController {
|
||||
// Step 1: Reorder pages for booklet (reusing logic from RearrangePagesPDFController)
|
||||
List<Integer> bookletOrder = getBookletPageOrder(bookletType, totalPages);
|
||||
|
||||
// Step 2: Create new document with multi-page layout (reusing logic from MultiPageLayoutController)
|
||||
PDDocument newDocument = createBookletWithLayout(sourceDocument, bookletOrder, pagesPerSheet, addBorder, pageOrientation);
|
||||
|
||||
// Step 2: Create new document with multi-page layout (reusing logic from
|
||||
// MultiPageLayoutController)
|
||||
PDDocument newDocument =
|
||||
createBookletWithLayout(
|
||||
sourceDocument, bookletOrder, pagesPerSheet, addBorder, pageOrientation);
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@ -112,75 +116,93 @@ public class BookletImpositionController {
|
||||
}
|
||||
|
||||
// Reused and adapted logic from MultiPageLayoutController
|
||||
private PDDocument createBookletWithLayout(PDDocument sourceDocument, List<Integer> pageOrder,
|
||||
int pagesPerSheet, boolean addBorder, String pageOrientation) throws IOException {
|
||||
|
||||
PDDocument newDocument = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
|
||||
private PDDocument createBookletWithLayout(
|
||||
PDDocument sourceDocument,
|
||||
List<Integer> pageOrder,
|
||||
int pagesPerSheet,
|
||||
boolean addBorder,
|
||||
String pageOrientation)
|
||||
throws IOException {
|
||||
|
||||
PDDocument newDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
|
||||
int cols = pagesPerSheet == 2 ? 2 : 2; // 2x1 for 2 pages, 2x2 for 4 pages
|
||||
int rows = pagesPerSheet == 2 ? 1 : 2;
|
||||
|
||||
|
||||
int currentPageIndex = 0;
|
||||
int totalOrderedPages = pageOrder.size();
|
||||
|
||||
|
||||
while (currentPageIndex < totalOrderedPages) {
|
||||
// Use landscape orientation for booklets (A4 landscape -> A5 portrait when folded)
|
||||
PDRectangle pageSize = "LANDSCAPE".equals(pageOrientation) ?
|
||||
new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()) : PDRectangle.A4;
|
||||
PDRectangle pageSize =
|
||||
"LANDSCAPE".equals(pageOrientation)
|
||||
? new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth())
|
||||
: PDRectangle.A4;
|
||||
PDPage newPage = new PDPage(pageSize);
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
|
||||
float cellWidth = newPage.getMediaBox().getWidth() / cols;
|
||||
float cellHeight = newPage.getMediaBox().getHeight() / rows;
|
||||
|
||||
PDPageContentStream contentStream = new PDPageContentStream(
|
||||
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
|
||||
|
||||
PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
newDocument,
|
||||
newPage,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true);
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
|
||||
if (addBorder) {
|
||||
contentStream.setLineWidth(1.5f);
|
||||
contentStream.setStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
|
||||
// Place pages on the current sheet
|
||||
for (int sheetPosition = 0; sheetPosition < pagesPerSheet && currentPageIndex < totalOrderedPages; sheetPosition++) {
|
||||
for (int sheetPosition = 0;
|
||||
sheetPosition < pagesPerSheet && currentPageIndex < totalOrderedPages;
|
||||
sheetPosition++) {
|
||||
int sourcePageIndex = pageOrder.get(currentPageIndex);
|
||||
PDPage sourcePage = sourceDocument.getPage(sourcePageIndex);
|
||||
PDRectangle rect = sourcePage.getMediaBox();
|
||||
|
||||
|
||||
float scaleWidth = cellWidth / rect.getWidth();
|
||||
float scaleHeight = cellHeight / rect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
|
||||
int rowIndex = sheetPosition / cols;
|
||||
int colIndex = sheetPosition % cols;
|
||||
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
|
||||
float y = newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight - (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
float y =
|
||||
newPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - rect.getHeight() * scale) / 2);
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getScaleInstance(scale, scale));
|
||||
|
||||
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, sourcePageIndex);
|
||||
|
||||
PDFormXObject formXObject =
|
||||
layerUtility.importPageAsForm(sourceDocument, sourcePageIndex);
|
||||
contentStream.drawForm(formXObject);
|
||||
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
|
||||
|
||||
if (addBorder) {
|
||||
float borderX = colIndex * cellWidth;
|
||||
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
|
||||
|
||||
currentPageIndex++;
|
||||
}
|
||||
|
||||
|
||||
contentStream.close();
|
||||
}
|
||||
|
||||
|
||||
return newDocument;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,22 +13,33 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Config", description = "Configuration APIs")
|
||||
@RequestMapping("/api/v1/config")
|
||||
@RequiredArgsConstructor
|
||||
@Hidden
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
@ -62,6 +73,11 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Server certificate settings
|
||||
configData.put(
|
||||
"serverCertificateEnabled",
|
||||
serverCertificateService != null && serverCertificateService.isEnabled());
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
|
@ -53,6 +53,7 @@ import org.bouncycastle.operator.InputDecryptorProvider;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
|
||||
import org.bouncycastle.pkcs.PKCSException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@ -68,13 +69,12 @@ import io.micrometer.common.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.common.service.ServerCertificateService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ -82,7 +82,6 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class CertSignController {
|
||||
|
||||
static {
|
||||
@ -102,7 +101,15 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateService serverCertificateService;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public CertSignController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
private static void sign(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@ -197,6 +204,11 @@ public class CertSignController {
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
break;
|
||||
case "SERVER":
|
||||
if (serverCertificateService == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotAvailable",
|
||||
"Server certificate service is not available in this edition");
|
||||
}
|
||||
if (!serverCertificateService.isEnabled()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateDisabled",
|
||||
|
@ -37,4 +37,4 @@ public class BookletImpositionRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"LANDSCAPE", "PORTRAIT"})
|
||||
private String pageOrientation = "LANDSCAPE";
|
||||
}
|
||||
}
|
||||
|
@ -7,14 +7,14 @@ import org.springframework.stereotype.Component;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateService;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ServerCertificateInitializer {
|
||||
|
||||
private final ServerCertificateService serverCertificateService;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializeServerCertificate() {
|
||||
|
@ -14,7 +14,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateService;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-certificate")
|
||||
@ -26,16 +26,16 @@ import stirling.software.common.service.ServerCertificateService;
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class ServerCertificateController {
|
||||
|
||||
private final ServerCertificateService serverCertificateService;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@GetMapping("/info")
|
||||
@Operation(
|
||||
summary = "Get server certificate information",
|
||||
description = "Returns information about the current server certificate")
|
||||
public ResponseEntity<ServerCertificateService.ServerCertificateInfo>
|
||||
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
|
||||
getServerCertificateInfo() {
|
||||
try {
|
||||
ServerCertificateService.ServerCertificateInfo info =
|
||||
ServerCertificateServiceInterface.ServerCertificateInfo info =
|
||||
serverCertificateService.getServerCertificateInfo();
|
||||
return ResponseEntity.ok(info);
|
||||
} catch (Exception e) {
|
||||
@ -109,27 +109,26 @@ public class ServerCertificateController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/public-key")
|
||||
@GetMapping("/certificate")
|
||||
@Operation(
|
||||
summary = "Download server certificate public key",
|
||||
description =
|
||||
"Download the public key of the server certificate for validation purposes")
|
||||
public ResponseEntity<byte[]> getServerCertificatePublicKey() {
|
||||
summary = "Download server certificate",
|
||||
description = "Download the server certificate in DER format for validation purposes")
|
||||
public ResponseEntity<byte[]> getServerCertificate() {
|
||||
try {
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] publicKey = serverCertificateService.getServerCertificatePublicKey();
|
||||
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"server-cert.crt\"")
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(publicKey);
|
||||
"attachment; filename=\"server-cert.cer\"")
|
||||
.contentType(MediaType.valueOf("application/pkix-cert"))
|
||||
.body(certificate);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate public key", e);
|
||||
log.error("Failed to get server certificate", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
@ -11,8 +11,14 @@ import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
@ -23,10 +29,11 @@ import org.springframework.stereotype.Service;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ServerCertificateService implements stirling.software.common.service.ServerCertificateService {
|
||||
public class ServerCertificateService implements ServerCertificateServiceInterface {
|
||||
|
||||
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
|
||||
@ -185,6 +192,36 @@ public class ServerCertificateService implements stirling.software.common.servic
|
||||
new JcaX509v3CertificateBuilder(
|
||||
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||
|
||||
// Add PDF-specific certificate extensions for optimal PDF signing compatibility
|
||||
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||
|
||||
// 1) End-entity certificate, not a CA (critical)
|
||||
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
|
||||
// 2) Key usage for PDF digital signatures (critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.keyUsage,
|
||||
true,
|
||||
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// 3) Extended key usage for document signing (non-critical, widely accepted)
|
||||
certBuilder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
|
||||
|
||||
// 4) Subject Key Identifier for chain building (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.subjectKeyIdentifier,
|
||||
false,
|
||||
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// 5) Authority Key Identifier for self-signed cert (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.authorityKeyIdentifier,
|
||||
false,
|
||||
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Sign certificate
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||
@ -212,5 +249,4 @@ public class ServerCertificateService implements stirling.software.common.servic
|
||||
keyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
319
devGuide/FILE_HISTORY_SPECIFICATION.md
Normal file
319
devGuide/FILE_HISTORY_SPECIFICATION.md
Normal file
@ -0,0 +1,319 @@
|
||||
# Stirling PDF File History Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Stirling PDF implements a client-side file history system using IndexedDB storage. File metadata, including version history and tool chains, are stored as `StirlingFileStub` objects that travel alongside the actual file data. This enables comprehensive version tracking, tool history, and file lineage management without modifying PDF content.
|
||||
|
||||
## Storage Architecture
|
||||
|
||||
### IndexedDB-Based Storage
|
||||
File history is stored in the browser's IndexedDB using the `fileStorage` service, providing:
|
||||
- **Persistent storage**: Survives browser sessions and page reloads
|
||||
- **Large capacity**: Supports files up to 100GB+ with full metadata
|
||||
- **Fast queries**: Optimized for file browsing and history lookups
|
||||
- **Type safety**: Structured TypeScript interfaces
|
||||
|
||||
### Core Data Structures
|
||||
|
||||
```typescript
|
||||
interface StirlingFileStub extends BaseFileMetadata {
|
||||
id: FileId; // Unique file identifier (UUID)
|
||||
quickKey: string; // Deduplication key: name|size|lastModified
|
||||
thumbnailUrl?: string; // Generated thumbnail blob URL
|
||||
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||
|
||||
// File Metadata
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
createdAt: number;
|
||||
|
||||
// Version Control
|
||||
isLeaf: boolean; // True if this is the latest version
|
||||
versionNumber?: number; // Version number (1, 2, 3, etc.)
|
||||
originalFileId?: string; // UUID of the root file in version chain
|
||||
parentFileId?: string; // UUID of immediate parent file
|
||||
|
||||
// Tool History
|
||||
toolHistory?: ToolOperation[]; // Complete sequence of applied tools
|
||||
}
|
||||
|
||||
interface ToolOperation {
|
||||
toolName: string; // Tool identifier (e.g., 'compress', 'sanitize')
|
||||
timestamp: number; // When the tool was applied
|
||||
}
|
||||
|
||||
interface StoredStirlingFileRecord extends StirlingFileStub {
|
||||
data: ArrayBuffer; // Actual file content
|
||||
fileId: FileId; // Duplicate for indexing
|
||||
}
|
||||
```
|
||||
|
||||
## Version Management System
|
||||
|
||||
### Version Progression
|
||||
- **v1**: Original uploaded file (first version)
|
||||
- **v2**: First tool applied to original
|
||||
- **v3**: Second tool applied (inherits from v2)
|
||||
- **v4**: Third tool applied (inherits from v3)
|
||||
- **etc.**
|
||||
|
||||
### Leaf Node System
|
||||
Only the latest version of each file family is marked as `isLeaf: true`:
|
||||
- **Leaf files**: Show in default file list, available for tool processing
|
||||
- **History files**: Hidden by default, accessible via history expansion
|
||||
|
||||
### File Relationships
|
||||
```
|
||||
document.pdf (v1, isLeaf: false)
|
||||
↓ compress
|
||||
document.pdf (v2, isLeaf: false)
|
||||
↓ sanitize
|
||||
document.pdf (v3, isLeaf: true) ← Current active version
|
||||
```
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### 1. FileStorage Service (`fileStorage.ts`)
|
||||
|
||||
**Core Methods:**
|
||||
```typescript
|
||||
// Store file with complete metadata
|
||||
async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): Promise<void>
|
||||
|
||||
// Load file with metadata
|
||||
async getStirlingFile(id: FileId): Promise<StirlingFile | null>
|
||||
async getStirlingFileStub(id: FileId): Promise<StirlingFileStub | null>
|
||||
|
||||
// Query operations
|
||||
async getLeafStirlingFileStubs(): Promise<StirlingFileStub[]>
|
||||
async getAllStirlingFileStubs(): Promise<StirlingFileStub[]>
|
||||
|
||||
// Version management
|
||||
async markFileAsProcessed(fileId: FileId): Promise<boolean> // Set isLeaf = false
|
||||
async markFileAsLeaf(fileId: FileId): Promise<boolean> // Set isLeaf = true
|
||||
```
|
||||
|
||||
### 2. File Context Integration
|
||||
|
||||
**FileContext** manages runtime state with `StirlingFileStub[]` in memory:
|
||||
```typescript
|
||||
interface FileContextState {
|
||||
files: {
|
||||
ids: FileId[];
|
||||
byId: Record<FileId, StirlingFileStub>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Key Operations:**
|
||||
- `addFiles()`: Stores new files with initial metadata
|
||||
- `addStirlingFileStubs()`: Loads existing files from storage with preserved metadata
|
||||
- `consumeFiles()`: Processes files through tools, creating new versions
|
||||
|
||||
### 3. Tool Operation Integration
|
||||
|
||||
**Tool Processing Flow:**
|
||||
1. **Input**: User selects files (marked as `isLeaf: true`)
|
||||
2. **Processing**: Backend processes files and returns results
|
||||
3. **History Creation**: New `StirlingFileStub` created with:
|
||||
- Incremented version number
|
||||
- Updated tool history
|
||||
- Parent file reference
|
||||
4. **Storage**: Both parent (marked `isLeaf: false`) and child (marked `isLeaf: true`) stored
|
||||
5. **UI Update**: FileContext updated with new file state
|
||||
|
||||
**Child Stub Creation:**
|
||||
```typescript
|
||||
export function createChildStub(
|
||||
parentStub: StirlingFileStub,
|
||||
operation: { toolName: string; timestamp: number },
|
||||
resultingFile: File,
|
||||
thumbnail?: string
|
||||
): StirlingFileStub {
|
||||
return {
|
||||
id: createFileId(),
|
||||
name: resultingFile.name,
|
||||
size: resultingFile.size,
|
||||
type: resultingFile.type,
|
||||
lastModified: resultingFile.lastModified,
|
||||
quickKey: createQuickKey(resultingFile),
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true,
|
||||
|
||||
// Version Control
|
||||
versionNumber: (parentStub.versionNumber || 1) + 1,
|
||||
originalFileId: parentStub.originalFileId || parentStub.id,
|
||||
parentFileId: parentStub.id,
|
||||
|
||||
// Tool History
|
||||
toolHistory: [...(parentStub.toolHistory || []), operation],
|
||||
thumbnailUrl: thumbnail
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## UI Integration
|
||||
|
||||
### File Manager History Display
|
||||
|
||||
**FileManager** (`FileManager.tsx`) provides:
|
||||
- **Default View**: Shows only leaf files (`isLeaf: true`)
|
||||
- **History Expansion**: Click to show all versions of a file family
|
||||
- **History Groups**: Nested display using `FileHistoryGroup.tsx`
|
||||
|
||||
**FileListItem** (`FileListItem.tsx`) displays:
|
||||
- **Version Badges**: v1, v2, v3 indicators
|
||||
- **Tool Chain**: Complete processing history in tooltips
|
||||
- **History Actions**: "Show/Hide History" toggle, "Restore" for history files
|
||||
|
||||
### FileManagerContext Integration
|
||||
|
||||
**File Selection Flow:**
|
||||
```typescript
|
||||
// Recent files (from storage)
|
||||
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void
|
||||
// Calls: actions.addStirlingFileStubs(stirlingFileStubs, options)
|
||||
|
||||
// New uploads
|
||||
onFileUpload: (files: File[]) => void
|
||||
// Calls: actions.addFiles(files, options)
|
||||
```
|
||||
|
||||
**History Management:**
|
||||
```typescript
|
||||
// Toggle history visibility
|
||||
const { expandedFileIds, onToggleExpansion } = useFileManagerContext();
|
||||
|
||||
// Restore history file to current
|
||||
const handleAddToRecents = (file: StirlingFileStub) => {
|
||||
fileStorage.markFileAsLeaf(file.id); // Make this version current
|
||||
};
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### New File Upload
|
||||
```
|
||||
1. User uploads files → addFiles()
|
||||
2. Generate thumbnails and page count
|
||||
3. Create StirlingFileStub with isLeaf: true, versionNumber: 1
|
||||
4. Store both StirlingFile + StirlingFileStub in IndexedDB
|
||||
5. Dispatch to FileContext state
|
||||
```
|
||||
|
||||
### Tool Processing
|
||||
```
|
||||
1. User selects tool + files → useToolOperation()
|
||||
2. API processes files → returns processed File objects
|
||||
3. createChildStub() for each result:
|
||||
- Parent marked isLeaf: false
|
||||
- Child created with isLeaf: true, incremented version
|
||||
4. Store all files with updated metadata
|
||||
5. Update FileContext with new state
|
||||
```
|
||||
|
||||
### File Loading (Recent Files)
|
||||
```
|
||||
1. User selects from FileManager → onRecentFileSelect()
|
||||
2. addStirlingFileStubs() with preserved metadata
|
||||
3. Load actual StirlingFile data from storage
|
||||
4. Files appear in workbench with complete history intact
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Metadata Regeneration
|
||||
When loading files from storage, missing `processedFile` data is regenerated:
|
||||
```typescript
|
||||
// In addStirlingFileStubs()
|
||||
const needsProcessing = !record.processedFile ||
|
||||
!record.processedFile.pages ||
|
||||
record.processedFile.pages.length === 0;
|
||||
|
||||
if (needsProcessing) {
|
||||
const result = await generateThumbnailWithMetadata(stirlingFile);
|
||||
record.processedFile = createProcessedFile(result.pageCount, result.thumbnail);
|
||||
}
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
- **Blob URL Tracking**: Automatic cleanup of thumbnail URLs
|
||||
- **Lazy Loading**: Files loaded from storage only when needed
|
||||
- **LRU Caching**: File objects cached in memory with size limits
|
||||
|
||||
## File Deduplication
|
||||
|
||||
### QuickKey System
|
||||
Files are deduplicated using `quickKey` format:
|
||||
```typescript
|
||||
const quickKey = `${file.name}|${file.size}|${file.lastModified}`;
|
||||
```
|
||||
|
||||
This prevents duplicate uploads while allowing different versions of the same logical file.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Graceful Degradation
|
||||
- **Storage Failures**: Files continue to work without persistence
|
||||
- **Metadata Issues**: Missing metadata regenerated on demand
|
||||
- **Version Conflicts**: Automatic version number resolution
|
||||
|
||||
### Recovery Scenarios
|
||||
- **Corrupted Storage**: Automatic cleanup and re-initialization
|
||||
- **Missing Files**: Stubs cleaned up automatically
|
||||
- **Version Mismatches**: Automatic version chain reconstruction
|
||||
|
||||
## Developer Guidelines
|
||||
|
||||
### Adding File History to New Components
|
||||
|
||||
1. **Use FileContext Actions**:
|
||||
```typescript
|
||||
const { actions } = useFileActions();
|
||||
await actions.addFiles(files); // For new uploads
|
||||
await actions.addStirlingFileStubs(stubs); // For existing files
|
||||
```
|
||||
|
||||
2. **Preserve Metadata When Processing**:
|
||||
```typescript
|
||||
const childStub = createChildStub(parentStub, {
|
||||
toolName: 'compress',
|
||||
timestamp: Date.now()
|
||||
}, processedFile, thumbnail);
|
||||
```
|
||||
|
||||
3. **Handle Storage Operations**:
|
||||
```typescript
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stirlingFileStub);
|
||||
const stub = await fileStorage.getStirlingFileStub(fileId);
|
||||
```
|
||||
|
||||
### Testing File History
|
||||
|
||||
1. **Upload files**: Should show v1, marked as leaf
|
||||
2. **Apply tool**: Should create v2, mark v1 as non-leaf
|
||||
3. **Check FileManager**: History should show both versions
|
||||
4. **Restore old version**: Should mark old version as leaf
|
||||
5. **Check storage**: Both versions should persist in IndexedDB
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
- **Branch History**: Support for parallel processing branches
|
||||
- **History Export**: Export complete version history as JSON
|
||||
- **Conflict Resolution**: Handle concurrent modifications
|
||||
- **Cloud Sync**: Sync history across devices
|
||||
- **Compression**: Compress historical file data
|
||||
|
||||
### API Extensions
|
||||
- **Batch Operations**: Process multiple version chains simultaneously
|
||||
- **Search Integration**: Search within tool history and file metadata
|
||||
- **Analytics**: Track usage patterns and tool effectiveness
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Implementation**: Stirling PDF Frontend v2
|
||||
**Storage Version**: IndexedDB with fileStorage service
|
@ -51,11 +51,11 @@
|
||||
"filesSelected": "{{count}} files selected",
|
||||
"files": {
|
||||
"title": "Files",
|
||||
"placeholder": "Select a PDF file in the main view to get started",
|
||||
"upload": "Upload",
|
||||
"uploadFiles": "Upload Files",
|
||||
"addFiles": "Add files",
|
||||
"selectFromWorkbench": "Select files from the workbench or "
|
||||
"selectFromWorkbench": "Select files from the workbench or ",
|
||||
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
|
||||
},
|
||||
"noFavourites": "No favourites added",
|
||||
"downloadComplete": "Download Complete",
|
||||
@ -510,13 +510,9 @@
|
||||
"title": "Show Javascript",
|
||||
"desc": "Searches and displays any JS injected into a PDF"
|
||||
},
|
||||
"autoRedact": {
|
||||
"title": "Auto Redact",
|
||||
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
|
||||
},
|
||||
"redact": {
|
||||
"title": "Manual Redaction",
|
||||
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
"title": "Redact",
|
||||
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
|
||||
},
|
||||
"overlay-pdfs": {
|
||||
"title": "Overlay PDFs",
|
||||
@ -660,11 +656,29 @@
|
||||
"merge": {
|
||||
"tags": "merge,Page operations,Back end,server side",
|
||||
"title": "Merge",
|
||||
"header": "Merge multiple PDFs (2+)",
|
||||
"sortByName": "Sort by name",
|
||||
"sortByDate": "Sort by date",
|
||||
"removeCertSign": "Remove digital signature in the merged file?",
|
||||
"submit": "Merge"
|
||||
"removeDigitalSignature": "Remove digital signature in the merged file?",
|
||||
"generateTableOfContents": "Generate table of contents in the merged file?",
|
||||
"removeDigitalSignature.tooltip": {
|
||||
"title": "Remove Digital Signature",
|
||||
"description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF."
|
||||
},
|
||||
"generateTableOfContents.tooltip": {
|
||||
"title": "Generate Table of Contents",
|
||||
"description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers."
|
||||
},
|
||||
"submit": "Merge",
|
||||
"sortBy": {
|
||||
"description": "Files will be merged in the order they're selected. Drag to reorder or sort below.",
|
||||
"label": "Sort By",
|
||||
"filename": "File Name",
|
||||
"dateModified": "Date Modified",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"sort": "Sort"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while merging the PDFs."
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
"tags": "Page operations,divide,Multi Page,cut,server side",
|
||||
@ -681,7 +695,116 @@
|
||||
"8": "Document #6: Page 10"
|
||||
},
|
||||
"splitPages": "Enter pages to split on:",
|
||||
"submit": "Split"
|
||||
"submit": "Split",
|
||||
"steps": {
|
||||
"chooseMethod": "Choose Method",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"settings": {
|
||||
"selectMethodFirst": "Please select a split method first"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while splitting the PDF."
|
||||
},
|
||||
"method": {
|
||||
"label": "Choose split method",
|
||||
"placeholder": "Select how to split the PDF"
|
||||
},
|
||||
"methods": {
|
||||
"prefix": {
|
||||
"splitAt": "Split at",
|
||||
"splitBy": "Split by"
|
||||
},
|
||||
"byPages": {
|
||||
"name": "Page Numbers",
|
||||
"desc": "Extract specific pages (1,3,5-10)",
|
||||
"tooltip": "Enter page numbers separated by commas or ranges with hyphens"
|
||||
},
|
||||
"bySections": {
|
||||
"name": "Sections",
|
||||
"desc": "Divide pages into grid sections",
|
||||
"tooltip": "Split each page into horizontal and vertical sections"
|
||||
},
|
||||
"bySize": {
|
||||
"name": "File Size",
|
||||
"desc": "Limit maximum file size",
|
||||
"tooltip": "Specify maximum file size (e.g. 10MB, 500KB)"
|
||||
},
|
||||
"byPageCount": {
|
||||
"name": "Page Count",
|
||||
"desc": "Fixed pages per file",
|
||||
"tooltip": "Enter the number of pages for each split file"
|
||||
},
|
||||
"byDocCount": {
|
||||
"name": "Document Count",
|
||||
"desc": "Create specific number of files",
|
||||
"tooltip": "Enter how many files you want to create"
|
||||
},
|
||||
"byChapters": {
|
||||
"name": "Chapters",
|
||||
"desc": "Split at bookmark boundaries",
|
||||
"tooltip": "Uses PDF bookmarks to determine split points"
|
||||
},
|
||||
"byPageDivider": {
|
||||
"name": "Page Divider",
|
||||
"desc": "Auto-split with divider sheets",
|
||||
"tooltip": "Use QR code divider sheets between documents when scanning"
|
||||
}
|
||||
},
|
||||
"value": {
|
||||
"fileSize": {
|
||||
"label": "File Size",
|
||||
"placeholder": "e.g. 10MB, 500KB"
|
||||
},
|
||||
"pageCount": {
|
||||
"label": "Pages per File",
|
||||
"placeholder": "e.g. 5, 10"
|
||||
},
|
||||
"docCount": {
|
||||
"label": "Number of Files",
|
||||
"placeholder": "e.g. 3, 5"
|
||||
}
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Split Methods Overview"
|
||||
},
|
||||
"byPages": {
|
||||
"title": "Split at Page Numbers",
|
||||
"text": "Split your PDF at specific page numbers. Using 'n' splits after page n. Using 'n-m' splits before page n and after page m.",
|
||||
"bullet1": "Single split points: 3,7 (splits after pages 3 and 7)",
|
||||
"bullet2": "Range split points: 3-8 (splits before page 3 and after page 8)",
|
||||
"bullet3": "Mixed: 2,5-10,15 (splits after page 2, before page 5, after page 10, and after page 15)"
|
||||
},
|
||||
"bySections": {
|
||||
"title": "Split by Grid Sections",
|
||||
"text": "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas.",
|
||||
"bullet1": "Horizontal: Number of rows to create",
|
||||
"bullet2": "Vertical: Number of columns to create",
|
||||
"bullet3": "Merge: Combine all sections into one PDF"
|
||||
},
|
||||
"bySize": {
|
||||
"title": "Split by File Size",
|
||||
"text": "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments.",
|
||||
"bullet1": "Use MB for larger files (e.g., 10MB)",
|
||||
"bullet2": "Use KB for smaller files (e.g., 500KB)",
|
||||
"bullet3": "System will split at page boundaries"
|
||||
},
|
||||
"byCount": {
|
||||
"title": "Split by Count",
|
||||
"text": "Create multiple PDFs with a specific number of pages or documents each.",
|
||||
"bullet1": "Page Count: Fixed number of pages per file",
|
||||
"bullet2": "Document Count: Fixed number of output files",
|
||||
"bullet3": "Useful for batch processing workflows"
|
||||
},
|
||||
"byChapters": {
|
||||
"title": "Split by Chapters",
|
||||
"text": "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure.",
|
||||
"bullet1": "Bookmark Level: Which level to split on (1=top level)",
|
||||
"bullet2": "Include Metadata: Preserve document properties",
|
||||
"bullet3": "Allow Duplicates: Handle repeated bookmark names"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rotate": {
|
||||
"tags": "server side",
|
||||
@ -1561,15 +1684,64 @@
|
||||
"title": "Booklet Imposition",
|
||||
"header": "Booklet Imposition",
|
||||
"submit": "Create Booklet",
|
||||
"bookletType": {
|
||||
"label": "Style",
|
||||
"standard": "Standard",
|
||||
"sideStitch": "Side Stitch",
|
||||
"standardDesc": "Standard for saddle-stitched binding (fold in half)",
|
||||
"sideStitchDesc": "Side stitch for binding along the edge"
|
||||
},
|
||||
"pagesPerSheet": {
|
||||
"label": "Pages Per Sheet",
|
||||
"two": "2 Pages",
|
||||
"four": "4 Pages",
|
||||
"twoDesc": "Two pages side by side on each sheet (most common)",
|
||||
"fourDesc": "Four pages arranged in a 2x2 grid on each sheet"
|
||||
},
|
||||
"pageOrientation": {
|
||||
"label": "Page Orientation",
|
||||
"landscape": "Landscape",
|
||||
"portrait": "Portrait",
|
||||
"landscapeDesc": "A4 landscape → A5 portrait when folded (recommended, standard booklet size)",
|
||||
"portraitDesc": "A4 portrait → A6 when folded (smaller booklet)"
|
||||
},
|
||||
"addBorder": {
|
||||
"label": "Add borders around pages",
|
||||
"tooltip": "Adds borders around each page section to help with cutting and alignment",
|
||||
"description": "Helpful for cutting and alignment when printing"
|
||||
},
|
||||
"tooltip": {
|
||||
"title": "Booklet Imposition Guide",
|
||||
"overview": {
|
||||
"title": "What is Booklet Imposition?",
|
||||
"description": "Arranges PDF pages in the correct order for booklet printing. Pages are reordered so that when printed and folded, they appear in sequence.",
|
||||
"bullet1": "Creates printable booklets from regular PDFs",
|
||||
"bullet2": "Handles page ordering for folding",
|
||||
"bullet3": "Supports saddle-stitch and side-stitch binding"
|
||||
},
|
||||
"bookletTypes": {
|
||||
"title": "Booklet Types",
|
||||
"standard": "Standard: Saddle-stitched binding (staples along fold)",
|
||||
"sideStitch": "Side-Stitch: Binding along edge (spiral, ring, perfect)"
|
||||
},
|
||||
"pagesPerSheet": {
|
||||
"title": "Pages Per Sheet",
|
||||
"two": "2 Pages: Standard layout (most common)",
|
||||
"four": "4 Pages: Compact layout"
|
||||
},
|
||||
"orientation": {
|
||||
"title": "Page Orientation",
|
||||
"landscape": "Landscape: A4 → A5 booklet (recommended)",
|
||||
"portrait": "Portrait: A4 → A6 booklet (compact)"
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"placeholder": "Select PDF files to create booklet impositions from"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the booklet imposition."
|
||||
}
|
||||
},
|
||||
"scalePages": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "Adjust page-scale",
|
||||
"header": "Adjust page-scale",
|
||||
"pageSize": "Size of a page of the document.",
|
||||
@ -1577,6 +1749,44 @@
|
||||
"scaleFactor": "Zoom level (crop) of a page.",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"adjustPageScale": {
|
||||
"tags": "resize,modify,dimension,adapt",
|
||||
"title": "Adjust Page Scale",
|
||||
"header": "Adjust Page Scale",
|
||||
"scaleFactor": {
|
||||
"label": "Scale Factor"
|
||||
},
|
||||
"pageSize": {
|
||||
"label": "Target Page Size",
|
||||
"keep": "Keep Original Size",
|
||||
"letter": "Letter",
|
||||
"legal": "Legal"
|
||||
},
|
||||
"submit": "Adjust Page Scale",
|
||||
"error": {
|
||||
"failed": "An error occurred while adjusting the page scale."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Page Scale Settings Overview"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"text": "Adjust the size of PDF content and change the page dimensions."
|
||||
},
|
||||
"scaleFactor": {
|
||||
"title": "Scale Factor",
|
||||
"text": "Controls how large or small the content appears on the page. Content is scaled and centred - if scaled content is larger than the page size, it may be cropped.",
|
||||
"bullet1": "1.0 = Original size",
|
||||
"bullet2": "0.5 = Half size (50% smaller)",
|
||||
"bullet3": "2.0 = Double size (200% larger, may crop)"
|
||||
},
|
||||
"pageSize": {
|
||||
"title": "Target Page Size",
|
||||
"text": "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, whilst other options resize to standard paper sizes."
|
||||
}
|
||||
}
|
||||
},
|
||||
"add-page-numbers": {
|
||||
"tags": "paginate,label,organize,index"
|
||||
},
|
||||
@ -1698,50 +1908,123 @@
|
||||
"downloadJS": "Download Javascript",
|
||||
"submit": "Show"
|
||||
},
|
||||
"autoRedact": {
|
||||
"tags": "Redact,Hide,black out,black,marker,hidden",
|
||||
"title": "Auto Redact",
|
||||
"header": "Auto Redact",
|
||||
"colorLabel": "Colour",
|
||||
"textsToRedactLabel": "Text to Redact (line-separated)",
|
||||
"textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-Secret",
|
||||
"useRegexLabel": "Use Regex",
|
||||
"wholeWordSearchLabel": "Whole Word Search",
|
||||
"customPaddingLabel": "Custom Extra Padding",
|
||||
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
|
||||
"submitButton": "Submit"
|
||||
},
|
||||
"redact": {
|
||||
"tags": "Redact,Hide,black out,black,marker,hidden,manual",
|
||||
"title": "Manual Redaction",
|
||||
"header": "Manual Redaction",
|
||||
"tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact",
|
||||
"title": "Redact",
|
||||
"submit": "Redact",
|
||||
"textBasedRedaction": "Text based Redaction",
|
||||
"pageBasedRedaction": "Page-based Redaction",
|
||||
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
|
||||
"pageRedactionNumbers": {
|
||||
"title": "Pages",
|
||||
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
||||
"error": {
|
||||
"failed": "An error occurred while redacting the PDF."
|
||||
},
|
||||
"redactionColor": {
|
||||
"title": "Redaction Color"
|
||||
"modeSelector": {
|
||||
"title": "Redaction Method",
|
||||
"mode": "Mode",
|
||||
"automatic": "Automatic",
|
||||
"automaticDesc": "Redact text based on search terms",
|
||||
"manual": "Manual",
|
||||
"manualDesc": "Click and drag to redact specific areas",
|
||||
"manualComingSoon": "Manual redaction coming soon"
|
||||
},
|
||||
"export": "Export",
|
||||
"upload": "Upload",
|
||||
"boxRedaction": "Box draw redaction",
|
||||
"zoom": "Zoom",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"nextPage": "Next Page",
|
||||
"previousPage": "Previous Page",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"showThumbnails": "Show Thumbnails",
|
||||
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
|
||||
"showAttatchments": "Show Attachments",
|
||||
"showLayers": "Show Layers (double-click to reset all layers to the default state)",
|
||||
"colourPicker": "Colour Picker",
|
||||
"findCurrentOutlineItem": "Find current outline item",
|
||||
"applyChanges": "Apply Changes"
|
||||
"auto": {
|
||||
"header": "Auto Redact",
|
||||
"settings": {
|
||||
"title": "Redaction Settings",
|
||||
"advancedTitle": "Advanced"
|
||||
},
|
||||
"colorLabel": "Box Colour",
|
||||
"wordsToRedact": {
|
||||
"title": "Words to Redact",
|
||||
"placeholder": "Enter a word",
|
||||
"add": "Add",
|
||||
"examples": "Examples: Confidential, Top-Secret"
|
||||
},
|
||||
"useRegexLabel": "Use Regex",
|
||||
"wholeWordSearchLabel": "Whole Word Search",
|
||||
"customPaddingLabel": "Custom Extra Padding",
|
||||
"convertPDFToImageLabel": "Convert PDF to PDF-Image"
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": {
|
||||
"header": {
|
||||
"title": "Redaction Method"
|
||||
},
|
||||
"automatic": {
|
||||
"title": "Automatic Redaction",
|
||||
"text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Manual Redaction",
|
||||
"text": "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)"
|
||||
}
|
||||
},
|
||||
"words": {
|
||||
"header": {
|
||||
"title": "Words to Redact"
|
||||
},
|
||||
"description": {
|
||||
"title": "Text Matching",
|
||||
"text": "Enter words or phrases to find and redact in your document. Each word will be searched for separately."
|
||||
},
|
||||
"bullet1": "Add one word at a time",
|
||||
"bullet2": "Press Enter or click 'Add Another' to add",
|
||||
"bullet3": "Click × to remove words",
|
||||
"examples": {
|
||||
"title": "Common Examples",
|
||||
"text": "Typical words to redact include: bank details, email addresses, or specific names."
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"header": {
|
||||
"title": "Advanced Redaction Settings"
|
||||
},
|
||||
"color": {
|
||||
"title": "Box Colour & Padding",
|
||||
"text": "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."
|
||||
},
|
||||
"regex": {
|
||||
"title": "Use Regex",
|
||||
"text": "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns.",
|
||||
"bullet1": "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format",
|
||||
"bullet2": "Use with caution - test thoroughly"
|
||||
},
|
||||
"wholeWord": {
|
||||
"title": "Whole Word Search",
|
||||
"text": "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled."
|
||||
},
|
||||
"convert": {
|
||||
"title": "Convert to PDF-Image",
|
||||
"text": "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
|
||||
}
|
||||
}
|
||||
},
|
||||
"manual": {
|
||||
"header": "Manual Redaction",
|
||||
"textBasedRedaction": "Text-based Redaction",
|
||||
"pageBasedRedaction": "Page-based Redaction",
|
||||
"convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)",
|
||||
"pageRedactionNumbers": {
|
||||
"title": "Pages",
|
||||
"placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
||||
},
|
||||
"redactionColor": {
|
||||
"title": "Redaction Colour"
|
||||
},
|
||||
"export": "Export",
|
||||
"upload": "Upload",
|
||||
"boxRedaction": "Box draw redaction",
|
||||
"zoom": "Zoom",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"nextPage": "Next Page",
|
||||
"previousPage": "Previous Page",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"showThumbnails": "Show Thumbnails",
|
||||
"showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)",
|
||||
"showAttachments": "Show Attachments",
|
||||
"showLayers": "Show Layers (double-click to reset all layers to the default state)",
|
||||
"colourPicker": "Colour Picker",
|
||||
"findCurrentOutlineItem": "Find current outline item",
|
||||
"applyChanges": "Apply Changes"
|
||||
}
|
||||
},
|
||||
"tableExtraxt": {
|
||||
"tags": "CSV,Table Extraction,extract,convert"
|
||||
@ -1952,6 +2235,11 @@
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size.",
|
||||
"header": "Compress PDF",
|
||||
"method": {
|
||||
"title": "Compression Method",
|
||||
"quality": "Quality",
|
||||
"filesize": "File Size"
|
||||
},
|
||||
"credit": "This service uses qpdf for PDF Compress/Optimisation.",
|
||||
"grayscale": {
|
||||
"label": "Apply Grayscale for Compression"
|
||||
@ -2283,6 +2571,13 @@
|
||||
"storageLow": "Storage is running low. Consider removing old files.",
|
||||
"supportMessage": "Powered by browser database storage for unlimited capacity",
|
||||
"noFileSelected": "No files selected",
|
||||
"showHistory": "Show History",
|
||||
"hideHistory": "Hide History",
|
||||
"fileHistory": "File History",
|
||||
"loadingHistory": "Loading History...",
|
||||
"lastModified": "Last Modified",
|
||||
"toolChain": "Tools Applied",
|
||||
"restore": "Restore",
|
||||
"searchFiles": "Search files...",
|
||||
"recent": "Recent",
|
||||
"localFiles": "Local Files",
|
||||
|
@ -1107,7 +1107,6 @@
|
||||
"header": "Booklet Imposition",
|
||||
"submit": "Create Booklet",
|
||||
"files": {
|
||||
"placeholder": "Select PDF files to create booklet impositions from"
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while creating the booklet imposition."
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { useFileManager } from '../hooks/useFileManager';
|
||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||
import { Tool } from '../types/tool';
|
||||
@ -15,12 +15,12 @@ interface FileManagerProps {
|
||||
}
|
||||
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@ -34,33 +34,26 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
|
||||
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
|
||||
try {
|
||||
// Use stored files flow that preserves original IDs
|
||||
const filesWithMetadata = await Promise.all(
|
||||
files.map(async (metadata) => ({
|
||||
file: await convertToFile(metadata),
|
||||
originalId: metadata.id,
|
||||
metadata
|
||||
}))
|
||||
);
|
||||
onStoredFilesSelect(filesWithMetadata);
|
||||
// Use StirlingFileStubs directly - preserves all metadata!
|
||||
onRecentFileSelect(files);
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
}, [convertToFile, onStoredFilesSelect]);
|
||||
}, [onRecentFileSelect]);
|
||||
|
||||
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||
onFilesSelect(files);
|
||||
onFileUpload(files);
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to process dropped files:', error);
|
||||
}
|
||||
}
|
||||
}, [onFilesSelect, refreshRecentFiles]);
|
||||
}, [onFileUpload, refreshRecentFiles]);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
@ -85,7 +78,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
// Cleanup any blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// FileMetadata doesn't have blob URLs, so no cleanup needed
|
||||
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
||||
// Blob URLs are managed by FileContext and tool operations
|
||||
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
|
||||
};
|
||||
@ -146,7 +139,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onRecentFilesSelected={handleRecentFilesSelected}
|
||||
onNewFilesSelect={handleNewFileUpload}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
|
@ -78,22 +78,6 @@ const FileEditor = ({
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||
const recordToFileItem = useCallback((record: any) => {
|
||||
const file = selectors.getFile(record.id);
|
||||
if (!file) return null;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: file.name,
|
||||
pageCount: record.processedFile?.totalPages || 1,
|
||||
thumbnail: record.thumbnailUrl || '',
|
||||
size: file.size,
|
||||
file: file
|
||||
};
|
||||
}, [selectors]);
|
||||
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
@ -404,13 +388,10 @@ const FileEditor = ({
|
||||
}}
|
||||
>
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={fileItem}
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
@ -421,7 +402,7 @@ const FileEditor = ({
|
||||
onSetStatus={setStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(fileItem.name)}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -8,22 +8,17 @@ import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string | null;
|
||||
size: number;
|
||||
modifiedAt?: number | string | Date;
|
||||
}
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: FileItem;
|
||||
file: StirlingFileStub;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
@ -64,6 +59,8 @@ const FileEditorThumbnail = ({
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
// Prefer parent-provided handler if available
|
||||
if (typeof onDownloadFile === 'function') {
|
||||
@ -109,22 +106,21 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
file.pageCount > 0
|
||||
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
pageCount > 0
|
||||
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
: '',
|
||||
[file.pageCount]
|
||||
[pageCount]
|
||||
);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
const d =
|
||||
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
|
||||
const d = new Date(file.lastModified);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(d);
|
||||
}, [file.modifiedAt]);
|
||||
}, [file.lastModified]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
@ -350,7 +346,8 @@ const FileEditorThumbnail = ({
|
||||
lineClamp={3}
|
||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||
>
|
||||
{/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ''}
|
||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||
@ -360,9 +357,9 @@ const FileEditorThumbnail = ({
|
||||
{/* Preview area */}
|
||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnail && (
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
@ -399,6 +396,29 @@ const FileEditorThumbnail = ({
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
|
||||
{/* Tool chain display at bottom */}
|
||||
{file.toolHistory && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
left: '4px',
|
||||
right: '4px',
|
||||
padding: '4px 6px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth={'100%'}
|
||||
color='var(--mantine-color-gray-7)'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize } from '../../utils/fileUtils';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
interface CompactFileDetailsProps {
|
||||
currentFile: FileMetadata | null;
|
||||
currentFile: StirlingFileStub | null;
|
||||
thumbnail: string | null;
|
||||
selectedFiles: FileMetadata[];
|
||||
selectedFiles: StirlingFileStub[];
|
||||
currentFileIndex: number;
|
||||
numberOfFiles: number;
|
||||
isAnimating: boolean;
|
||||
@ -72,12 +72,19 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ''}
|
||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
{hasMultipleFiles && (
|
||||
<Text size="xs" c="blue">
|
||||
{currentFileIndex + 1} of {selectedFiles.length}
|
||||
</Text>
|
||||
)}
|
||||
{/* Compact tool chain for mobile */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation arrows for multiple files */}
|
||||
|
68
frontend/src/components/fileManager/FileHistoryGroup.tsx
Normal file
68
frontend/src/components/fileManager/FileHistoryGroup.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, Collapse, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import FileListItem from './FileListItem';
|
||||
|
||||
interface FileHistoryGroupProps {
|
||||
leafFile: StirlingFileStub;
|
||||
historyFiles: StirlingFileStub[];
|
||||
isExpanded: boolean;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
leafFile,
|
||||
historyFiles,
|
||||
isExpanded,
|
||||
onDownloadSingle,
|
||||
onFileDoubleClick,
|
||||
onHistoryFileRemove,
|
||||
isFileSupported,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sort history files by version number (oldest first, excluding the current leaf file)
|
||||
const sortedHistory = historyFiles
|
||||
.filter(file => file.id !== leafFile.id) // Exclude the leaf file itself
|
||||
.sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1));
|
||||
|
||||
if (!isExpanded || sortedHistory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse in={isExpanded}>
|
||||
<Box ml="md" mt="xs" mb="sm">
|
||||
<Group align="center" mb="sm">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t('fileManager.fileHistory', 'File History')} ({sortedHistory.length})
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box ml="md">
|
||||
{sortedHistory.map((historyFile, _index) => (
|
||||
<FileListItem
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
isSelected={false} // History files are not selectable
|
||||
isSupported={isFileSupported(historyFile.name)}
|
||||
onSelect={() => {}} // No selection for history files
|
||||
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
||||
onDownload={() => onDownloadSingle(historyFile)}
|
||||
onDoubleClick={() => onFileDoubleClick(historyFile)}
|
||||
isHistoryFile={true} // This enables "Add to Recents" in menu
|
||||
isLatestVersion={false} // History files are never latest
|
||||
// onAddToRecents is accessed from context by FileListItem
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileHistoryGroup;
|
@ -2,10 +2,11 @@ import React from 'react';
|
||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileInfoCardProps {
|
||||
currentFile: FileMetadata | null;
|
||||
currentFile: StirlingFileStub | null;
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
@ -53,11 +54,36 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last Modified')}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentFile ? '1.0' : ''}
|
||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||
{currentFile &&
|
||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
|
||||
v{currentFile ? (currentFile.versionNumber || 1) : ''}
|
||||
</Badge>}
|
||||
|
||||
</Group>
|
||||
|
||||
{/* Tool Chain Display */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box py="xs">
|
||||
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
|
||||
<ToolChain
|
||||
toolChain={currentFile.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
@ -4,6 +4,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileHistoryGroup from './FileHistoryGroup';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
@ -20,8 +21,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
loadedHistoryFiles,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onHistoryFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
@ -50,18 +54,37 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => (
|
||||
<FileListItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
/>
|
||||
))
|
||||
filteredFiles.map((file, index) => {
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
const isExpanded = expandedFileIds.has(file.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={file.id}>
|
||||
<FileListItem
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
isHistoryFile={false} // All files here are leaf files
|
||||
isLatestVersion={true} // All files here are the latest versions
|
||||
/>
|
||||
|
||||
<FileHistoryGroup
|
||||
leafFile={file}
|
||||
historyFiles={historyFiles}
|
||||
isExpanded={isExpanded}
|
||||
onDownloadSingle={onDownloadSingle}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
onHistoryFileRemove={onHistoryFileRemove}
|
||||
isFileSupported={isFileSupported}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
@ -3,12 +3,16 @@ import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@m
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: FileMetadata;
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: (shiftKey?: boolean) => void;
|
||||
@ -16,6 +20,8 @@ interface FileListItemProps {
|
||||
onDownload?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
isLast?: boolean;
|
||||
isHistoryFile?: boolean; // Whether this is a history file (indented)
|
||||
isLatestVersion?: boolean; // Whether this is the latest version (shows chevron)
|
||||
}
|
||||
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
@ -25,60 +31,89 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onDoubleClick
|
||||
onDoubleClick,
|
||||
isHistoryFile = false,
|
||||
isLatestVersion = false
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
// Get version information for this file
|
||||
const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId;
|
||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||
const isExpanded = expandedFileIds.has(leafFileId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-gray-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
cursor: isHistoryFile ? 'default' : 'pointer',
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-gray-1)'
|
||||
: (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: 'background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
msUserSelect: 'none',
|
||||
paddingLeft: isHistoryFile ? '2rem' : '0.75rem', // Indent history files
|
||||
borderLeft: isHistoryFile ? '3px solid var(--mantine-color-blue-4)' : 'none' // Visual indicator for history
|
||||
}}
|
||||
onClick={(e) => onSelect(e.shiftKey)}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
<Box>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
styles={{
|
||||
input: {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!isHistoryFile && (
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}} // Handled by parent onClick
|
||||
size="sm"
|
||||
pl="sm"
|
||||
pr="xs"
|
||||
styles={{
|
||||
input: {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
{file.isDraft && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
DRAFT
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
</Group>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{getFileSize(file)} • {getFileDate(file)}
|
||||
</Text>
|
||||
|
||||
{/* Tool chain for processed files */}
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
maxWidth={'150px'}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Three dots menu - fades in/out on hover */}
|
||||
@ -117,6 +152,46 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
{t('fileManager.download', 'Download')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{/* Show/Hide History option for latest version files */}
|
||||
{isLatestVersion && hasVersionHistory && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<HistoryIcon style={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpansion(leafFileId);
|
||||
}}
|
||||
>
|
||||
{
|
||||
(isExpanded ?
|
||||
t('fileManager.hideHistory', 'Hide History') :
|
||||
t('fileManager.showHistory', 'Show History')
|
||||
)
|
||||
}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Restore option for history files */}
|
||||
{isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToRecents(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
@ -42,7 +42,7 @@ export default function Workbench() {
|
||||
// Get tool registry to look up selected tool
|
||||
const { toolRegistry } = useToolManagement();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addToActiveFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
@ -81,7 +81,7 @@ export default function Workbench() {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
filesToMerge.forEach(addToActiveFiles);
|
||||
addFiles(filesToMerge);
|
||||
setCurrentView("viewer");
|
||||
}
|
||||
})}
|
||||
|
216
frontend/src/components/shared/ButtonSelector.test.tsx
Normal file
216
frontend/src/components/shared/ButtonSelector.test.tsx
Normal file
@ -0,0 +1,216 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import ButtonSelector from './ButtonSelector';
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('ButtonSelector', () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render all options as buttons', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
label="Test Label"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should highlight selected button with filled variant', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
label="Selection Label"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const selectedButton = screen.getByRole('button', { name: 'Option 1' });
|
||||
const unselectedButton = screen.getByRole('button', { name: 'Option 2' });
|
||||
|
||||
// Check data-variant attribute for filled/outline
|
||||
expect(selectedButton).toHaveAttribute('data-variant', 'filled');
|
||||
expect(unselectedButton).toHaveAttribute('data-variant', 'outline');
|
||||
expect(screen.getByText('Selection Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call onChange when button is clicked', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('option2');
|
||||
});
|
||||
|
||||
test('should handle undefined value (no selection)', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value={undefined}
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Both buttons should be outlined when no value is selected
|
||||
const button1 = screen.getByRole('button', { name: 'Option 1' });
|
||||
const button2 = screen.getByRole('button', { name: 'Option 2' });
|
||||
|
||||
expect(button1).toHaveAttribute('data-variant', 'outline');
|
||||
expect(button2).toHaveAttribute('data-variant', 'outline');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: 'disable buttons when disabled prop is true',
|
||||
options: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
],
|
||||
globalDisabled: true,
|
||||
expectedStates: [true, true],
|
||||
},
|
||||
{
|
||||
description: 'disable individual options when option.disabled is true',
|
||||
options: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2', disabled: true },
|
||||
],
|
||||
globalDisabled: false,
|
||||
expectedStates: [false, true],
|
||||
},
|
||||
])('should $description', ({ options, globalDisabled, expectedStates }) => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
disabled={globalDisabled}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
options.forEach((option, index) => {
|
||||
const button = screen.getByRole('button', { name: option.label });
|
||||
expect(button).toHaveProperty('disabled', expectedStates[index]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should not call onChange when disabled button is clicked', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2', disabled: true },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Option 2' }));
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not apply fullWidth styling when fullWidth is false', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
fullWidth={false}
|
||||
label="Layout Label"
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Option 1' });
|
||||
expect(button).not.toHaveStyle({ flex: '1' });
|
||||
expect(screen.getByText('Layout Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render label element when not provided', () => {
|
||||
const options = [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
];
|
||||
|
||||
const { container } = render(
|
||||
<TestWrapper>
|
||||
<ButtonSelector
|
||||
value="option1"
|
||||
onChange={mockOnChange}
|
||||
options={options}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should render buttons
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||
|
||||
// Stack should only contain the Group (buttons), no Text element for label
|
||||
const stackElement = container.querySelector('[class*="mantine-Stack-root"]');
|
||||
expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
|
||||
});
|
||||
});
|
62
frontend/src/components/shared/ButtonSelector.tsx
Normal file
62
frontend/src/components/shared/ButtonSelector.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
export interface ButtonOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ButtonSelectorProps<T> {
|
||||
value: T | undefined;
|
||||
onChange: (value: T) => void;
|
||||
options: ButtonOption<T>[];
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const ButtonSelector = <T extends string | number>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
label = undefined,
|
||||
disabled = false,
|
||||
fullWidth = true,
|
||||
}: ButtonSelectorProps<T>) => {
|
||||
return (
|
||||
<Stack gap='var(--mantine-spacing-sm)'>
|
||||
{/* Label (if it exists) */}
|
||||
{label && <Text style={{
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: "var(--mantine-line-height-sm)",
|
||||
fontWeight: "var(--font-weight-medium)",
|
||||
}}>{label}</Text>}
|
||||
|
||||
{/* Buttons */}
|
||||
<Group gap='4px'>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={value === option.value ? 'filled' : 'outline'}
|
||||
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled || option.disabled}
|
||||
style={{
|
||||
flex: fullWidth ? 1 : undefined,
|
||||
height: 'auto',
|
||||
minHeight: '2.5rem',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
lineHeight: '1.4',
|
||||
paddingTop: '0.5rem',
|
||||
paddingBottom: '0.5rem'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonSelector;
|
99
frontend/src/components/shared/CardSelector.tsx
Normal file
99
frontend/src/components/shared/CardSelector.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { Stack, Card, Text, Flex } from '@mantine/core';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CardOption<T = string> {
|
||||
value: T;
|
||||
prefixKey: string;
|
||||
nameKey: string;
|
||||
tooltipKey?: string;
|
||||
tooltipContent?: any[];
|
||||
}
|
||||
|
||||
export interface CardSelectorProps<T, K extends CardOption<T>> {
|
||||
options: K[];
|
||||
onSelect: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
getTooltipContent?: (option: K) => any[];
|
||||
}
|
||||
|
||||
const CardSelector = <T, K extends CardOption<T>>({
|
||||
options,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
getTooltipContent
|
||||
}: CardSelectorProps<T, K>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOptionClick = (value: T) => {
|
||||
if (!disabled) {
|
||||
onSelect(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltips = (option: K) => {
|
||||
if (getTooltipContent) {
|
||||
return getTooltipContent(option);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{options.map((option) => (
|
||||
<Tooltip
|
||||
key={option.value as string}
|
||||
sidebarTooltip
|
||||
tips={getTooltips(option)}
|
||||
>
|
||||
<Card
|
||||
radius="md"
|
||||
w="100%"
|
||||
h={'2.8rem'}
|
||||
style={{
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderColor: 'var(--mantine-color-gray-3)',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-3)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-2)';
|
||||
e.currentTarget.style.transform = 'translateY(0px)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<Flex align={'center'} pl="sm" w="100%">
|
||||
<Text size="sm" c="dimmed" ta="center" fw={350}>
|
||||
{t(option.prefixKey, "Prefix")}
|
||||
</Text>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
c={undefined}
|
||||
ta="center"
|
||||
style={{ marginLeft: '0.25rem' }}
|
||||
>
|
||||
{t(option.nameKey, "Option Name")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSelector;
|
@ -12,7 +12,7 @@ import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
record?: StirlingFileStub;
|
||||
fileStub?: StirlingFileStub;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
@ -22,12 +22,11 @@ interface FileCardProps {
|
||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||
}
|
||||
|
||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub);
|
||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
@ -177,7 +176,7 @@ const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSel
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{record?.id && (
|
||||
{fileStub?.id && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
|
@ -139,7 +139,7 @@ const FileGrid = ({
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={item.file}
|
||||
record={item.record}
|
||||
fileStub={item.record}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
||||
onView={onView && supported ? () => onView(item) : undefined}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Center } from '@mantine/core';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||
import DocumentStack from './filePreview/DocumentStack';
|
||||
import HoverOverlay from './filePreview/HoverOverlay';
|
||||
@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
// Core file data
|
||||
file: File | FileMetadata | null;
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
|
||||
// Optional features
|
||||
@ -22,7 +22,7 @@ export interface FilePreviewProps {
|
||||
isAnimating?: boolean;
|
||||
|
||||
// Event handlers
|
||||
onFileClick?: (file: File | FileMetadata | null) => void;
|
||||
onFileClick?: (file: File | StirlingFileStub | null) => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addMultipleFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { t } = useTranslation();
|
||||
@ -15,7 +15,7 @@ const LandingPage = () => {
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addMultipleFiles(files);
|
||||
await addFiles(files);
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = () => {
|
||||
@ -29,7 +29,7 @@ const LandingPage = () => {
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
await addMultipleFiles(files);
|
||||
await addFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = '';
|
||||
|
153
frontend/src/components/shared/ToolChain.tsx
Normal file
153
frontend/src/components/shared/ToolChain.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Reusable ToolChain component with smart truncation and tooltip expansion
|
||||
* Used across FileListItem, FileDetails, and FileThumbnail for consistent display
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Tooltip, Badge, Group } from '@mantine/core';
|
||||
import { ToolOperation } from '../../types/file';
|
||||
|
||||
interface ToolChainProps {
|
||||
toolChain: ToolOperation[];
|
||||
maxWidth?: string;
|
||||
displayStyle?: 'text' | 'badges' | 'compact';
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const ToolChain: React.FC<ToolChainProps> = ({
|
||||
toolChain,
|
||||
maxWidth = '100%',
|
||||
displayStyle = 'text',
|
||||
size = 'xs',
|
||||
color = 'var(--mantine-color-blue-7)'
|
||||
}) => {
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const toolNames = toolChain.map(tool => tool.toolName);
|
||||
|
||||
// Create full tool chain for tooltip
|
||||
const fullChainDisplay = displayStyle === 'badges' ? (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{toolChain.map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolName}-${index}`}>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{tool.toolName}
|
||||
</Badge>
|
||||
{index < toolChain.length - 1 && (
|
||||
<Text size="sm" c="dimmed">→</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm">{toolNames.join(' → ')}</Text>
|
||||
);
|
||||
|
||||
// Create truncated display based on available space
|
||||
const getTruncatedDisplay = () => {
|
||||
if (toolNames.length <= 2) {
|
||||
// Show all tools if 2 or fewer
|
||||
return { text: toolNames.join(' → '), isTruncated: false };
|
||||
} else {
|
||||
// Show first tool ... last tool for longer chains
|
||||
return {
|
||||
text: `${toolNames[0]} → +${toolNames.length-2} → ${toolNames[toolNames.length - 1]}`,
|
||||
isTruncated: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { text: truncatedText, isTruncated } = getTruncatedDisplay();
|
||||
|
||||
// Compact style for very small spaces
|
||||
if (displayStyle === 'compact') {
|
||||
const compactText = toolNames.length === 1 ? toolNames[0] : `${toolNames.length} tools`;
|
||||
const isCompactTruncated = toolNames.length > 1;
|
||||
|
||||
const compactElement = (
|
||||
<Text
|
||||
size={size}
|
||||
style={{
|
||||
color,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: `${maxWidth}`,
|
||||
cursor: isCompactTruncated ? 'help' : 'default'
|
||||
}}
|
||||
>
|
||||
{compactText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return isCompactTruncated ? (
|
||||
<Tooltip label={fullChainDisplay} multiline withinPortal>
|
||||
{compactElement}
|
||||
</Tooltip>
|
||||
) : compactElement;
|
||||
}
|
||||
|
||||
// Badge style for file details
|
||||
if (displayStyle === 'badges') {
|
||||
const isBadgesTruncated = toolChain.length > 3;
|
||||
|
||||
const badgesElement = (
|
||||
<div style={{ maxWidth: `${maxWidth}`, overflow: 'hidden' }}>
|
||||
<Group gap="2px" wrap="nowrap">
|
||||
{toolChain.slice(0, 3).map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolName}-${index}`}>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{tool.toolName}
|
||||
</Badge>
|
||||
{index < Math.min(toolChain.length - 1, 2) && (
|
||||
<Text size="xs" c="dimmed">→</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{toolChain.length > 3 && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">...</Text>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{toolChain[toolChain.length - 1].toolName}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
return isBadgesTruncated ? (
|
||||
<Tooltip label={`${toolNames.join(' → ')}`} withinPortal>
|
||||
{badgesElement}
|
||||
</Tooltip>
|
||||
) : badgesElement;
|
||||
}
|
||||
|
||||
// Text style (default) for file list items
|
||||
const textElement = (
|
||||
<Text
|
||||
size={size}
|
||||
style={{
|
||||
color,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: `${maxWidth}`,
|
||||
cursor: isTruncated ? 'help' : 'default'
|
||||
}}
|
||||
>
|
||||
{truncatedText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return isTruncated ? (
|
||||
<Tooltip label={fullChainDisplay} withinPortal>
|
||||
{textElement}
|
||||
</Tooltip>
|
||||
) : textElement;
|
||||
};
|
||||
|
||||
export default ToolChain;
|
@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, Image } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { FileMetadata } from '../../../types/file';
|
||||
import { StirlingFileStub } from '../../../types/fileContext';
|
||||
|
||||
export interface DocumentThumbnailProps {
|
||||
file: File | FileMetadata | null;
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
interface WatermarkTypeSettingsProps {
|
||||
watermarkType?: 'text' | 'image';
|
||||
@ -11,32 +11,21 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={watermarkType === 'text' ? 'filled' : 'outline'}
|
||||
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onWatermarkTypeChange('text')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('watermark.watermarkType.text', 'Text')}
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={watermarkType === 'image' ? 'filled' : 'outline'}
|
||||
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onWatermarkTypeChange('image')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
{t('watermark.watermarkType.image', 'Image')}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
<ButtonSelector
|
||||
value={watermarkType}
|
||||
onChange={onWatermarkTypeChange}
|
||||
options={[
|
||||
{
|
||||
value: 'text',
|
||||
label: t('watermark.watermarkType.text', 'Text'),
|
||||
},
|
||||
{
|
||||
value: 'image',
|
||||
label: t('watermark.watermarkType.image', 'Image'),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -0,0 +1,64 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AdjustPageScaleSettings from './AdjustPageScaleSettings';
|
||||
import { AdjustPageScaleParameters, PageSize } from '../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
|
||||
|
||||
// Mock useTranslation with predictable return values
|
||||
const mockT = vi.fn((key: string, fallback?: string) => fallback || `mock-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('AdjustPageScaleSettings', () => {
|
||||
const defaultParameters: AdjustPageScaleParameters = {
|
||||
scaleFactor: 1.0,
|
||||
pageSize: PageSize.KEEP,
|
||||
};
|
||||
|
||||
const mockOnParameterChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render without crashing', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<AdjustPageScaleSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Basic render test - component renders without throwing
|
||||
expect(screen.getByText('Scale Factor')).toBeInTheDocument();
|
||||
expect(screen.getByText('Target Page Size')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render with custom parameters', () => {
|
||||
const customParameters: AdjustPageScaleParameters = {
|
||||
scaleFactor: 2.5,
|
||||
pageSize: PageSize.A4,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<AdjustPageScaleSettings
|
||||
parameters={customParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Component renders successfully with custom parameters
|
||||
expect(screen.getByText('Scale Factor')).toBeInTheDocument();
|
||||
expect(screen.getByText('Target Page Size')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,55 @@
|
||||
import { Stack, NumberInput, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdjustPageScaleParameters, PageSize } from "../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
|
||||
|
||||
interface AdjustPageScaleSettingsProps {
|
||||
parameters: AdjustPageScaleParameters;
|
||||
onParameterChange: <K extends keyof AdjustPageScaleParameters>(key: K, value: AdjustPageScaleParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AdjustPageScaleSettings = ({ parameters, onParameterChange, disabled = false }: AdjustPageScaleSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pageSizeOptions = [
|
||||
{ value: PageSize.KEEP, label: t('adjustPageScale.pageSize.keep', 'Keep Original Size') },
|
||||
{ value: PageSize.A0, label: 'A0' },
|
||||
{ value: PageSize.A1, label: 'A1' },
|
||||
{ value: PageSize.A2, label: 'A2' },
|
||||
{ value: PageSize.A3, label: 'A3' },
|
||||
{ value: PageSize.A4, label: 'A4' },
|
||||
{ value: PageSize.A5, label: 'A5' },
|
||||
{ value: PageSize.A6, label: 'A6' },
|
||||
{ value: PageSize.LETTER, label: t('adjustPageScale.pageSize.letter', 'Letter') },
|
||||
{ value: PageSize.LEGAL, label: t('adjustPageScale.pageSize.legal', 'Legal') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label={t('adjustPageScale.scaleFactor.label', 'Scale Factor')}
|
||||
value={parameters.scaleFactor}
|
||||
onChange={(value) => onParameterChange('scaleFactor', typeof value === 'number' ? value : 1.0)}
|
||||
min={0.1}
|
||||
max={10.0}
|
||||
step={0.1}
|
||||
decimalScale={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={t('adjustPageScale.pageSize.label', 'Target Page Size')}
|
||||
value={parameters.pageSize}
|
||||
onChange={(value) => {
|
||||
if (value && Object.values(PageSize).includes(value as PageSize)) {
|
||||
onParameterChange('pageSize', value as PageSize);
|
||||
}
|
||||
}}
|
||||
data={pageSizeOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdjustPageScaleSettings;
|
@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { Button, Stack, Text, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Text, Divider } from "@mantine/core";
|
||||
import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
interface BookletImpositionSettingsProps {
|
||||
parameters: BookletImpositionParameters;
|
||||
@ -9,6 +11,7 @@ interface BookletImpositionSettingsProps {
|
||||
}
|
||||
|
||||
const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = false }: BookletImpositionSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@ -16,108 +19,48 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
|
||||
{/* Booklet Type */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>Booklet Type</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.bookletType === 'BOOKLET' ? 'filled' : 'outline'}
|
||||
color={parameters.bookletType === 'BOOKLET' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('bookletType', 'BOOKLET')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Standard<br />Booklet
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.bookletType === 'SIDE_STITCH_BOOKLET' ? 'filled' : 'outline'}
|
||||
color={parameters.bookletType === 'SIDE_STITCH_BOOKLET' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('bookletType', 'SIDE_STITCH_BOOKLET')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Side-Stitch<br />Booklet
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{parameters.bookletType === 'BOOKLET'
|
||||
? "Standard booklet for saddle-stitched binding (fold in half)"
|
||||
: "Side-stitched booklet for binding along the edge"}
|
||||
</Text>
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.bookletType.label', 'Booklet Type')}
|
||||
value={parameters.bookletType}
|
||||
onChange={(value) => onParameterChange('bookletType', value)}
|
||||
options={[
|
||||
{ value: 'BOOKLET', label: t('bookletImposition.bookletType.standard', 'Standard Booklet') },
|
||||
{ value: 'SIDE_STITCH_BOOKLET', label: t('bookletImposition.bookletType.sideStitch', 'Side-Stitch Booklet') }
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pages Per Sheet */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>Pages Per Sheet</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.pagesPerSheet === 2 ? 'filled' : 'outline'}
|
||||
color={parameters.pagesPerSheet === 2 ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('pagesPerSheet', 2)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
2 Pages<br />Per Sheet
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.pagesPerSheet === 4 ? 'filled' : 'outline'}
|
||||
color={parameters.pagesPerSheet === 4 ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('pagesPerSheet', 4)}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
4 Pages<br />Per Sheet
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{parameters.pagesPerSheet === 2
|
||||
? "Two pages side by side on each sheet (most common)"
|
||||
: "Four pages arranged in a 2x2 grid on each sheet"}
|
||||
</Text>
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.pagesPerSheet.label', 'Pages Per Sheet')}
|
||||
value={parameters.pagesPerSheet}
|
||||
onChange={(value) => onParameterChange('pagesPerSheet', value)}
|
||||
options={[
|
||||
{ value: 2, label: t('bookletImposition.pagesPerSheet.two', '2 Pages') },
|
||||
{ value: 4, label: t('bookletImposition.pagesPerSheet.four', '4 Pages') }
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Page Orientation */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>Page Orientation</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.pageOrientation === 'LANDSCAPE' ? 'filled' : 'outline'}
|
||||
color={parameters.pageOrientation === 'LANDSCAPE' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('pageOrientation', 'LANDSCAPE')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Landscape<br />(Recommended)
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.pageOrientation === 'PORTRAIT' ? 'filled' : 'outline'}
|
||||
color={parameters.pageOrientation === 'PORTRAIT' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('pageOrientation', 'PORTRAIT')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Portrait
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{parameters.pageOrientation === 'LANDSCAPE'
|
||||
? "A4 landscape → A5 portrait when folded (standard booklet size)"
|
||||
: "A4 portrait → A6 when folded (smaller booklet)"}
|
||||
</Text>
|
||||
<ButtonSelector
|
||||
label={t('bookletImposition.pageOrientation.label', 'Page Orientation')}
|
||||
value={parameters.pageOrientation}
|
||||
onChange={(value) => onParameterChange('pageOrientation', value)}
|
||||
options={[
|
||||
{ value: 'LANDSCAPE', label: t('bookletImposition.pageOrientation.landscape', 'Landscape') },
|
||||
{ value: 'PORTRAIT', label: t('bookletImposition.pageOrientation.portrait', 'Portrait') }
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
@ -125,8 +68,8 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
{/* Add Border Option */}
|
||||
<Stack gap="sm">
|
||||
<label
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
||||
title="Adds borders around each page section to help with cutting and alignment"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--mantine-spacing-xs)' }}
|
||||
title={t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -134,11 +77,8 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
|
||||
onChange={(e) => onParameterChange('addBorder', e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Text size="sm">Add borders around pages</Text>
|
||||
<Text size="sm">{t('bookletImposition.addBorder.label', 'Add borders around pages')}</Text>
|
||||
</label>
|
||||
<Text size="xs" c="dimmed" style={{ marginLeft: '24px' }}>
|
||||
Helpful for cutting and alignment when printing
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
interface CompressSettingsProps {
|
||||
parameters: CompressParameters;
|
||||
@ -18,33 +19,16 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
|
||||
|
||||
<Divider ml='-md'></Divider>
|
||||
{/* Compression Method */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>Compression Method</Text>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<Button
|
||||
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
|
||||
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('compressionMethod', 'quality')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Quality
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
|
||||
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
|
||||
onClick={() => onParameterChange('compressionMethod', 'filesize')}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
File Size
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
<ButtonSelector
|
||||
label={t('compress.method.title', 'Compression Method')}
|
||||
value={parameters.compressionMethod}
|
||||
onChange={(value) => onParameterChange('compressionMethod', value)}
|
||||
options={[
|
||||
{ value: 'quality', label: t('compress.method.quality', 'Quality') },
|
||||
{ value: 'filesize', label: t('compress.method.filesize', 'File Size') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Quality Adjustment */}
|
||||
{parameters.compressionMethod === 'quality' && (
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Stack, Button } from "@mantine/core";
|
||||
import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters";
|
||||
import { useAppConfig } from "../../../hooks/useAppConfig";
|
||||
|
||||
interface CertificateTypeSettingsProps {
|
||||
parameters: ManageSignaturesParameters;
|
||||
@ -8,6 +9,13 @@ interface CertificateTypeSettingsProps {
|
||||
}
|
||||
|
||||
const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = false }: CertificateTypeSettingsProps) => {
|
||||
const { config } = useAppConfig();
|
||||
const isServerCertificateEnabled = config?.serverCertificateEnabled ?? false;
|
||||
|
||||
// Reset to MANUAL if AUTO is selected but feature is disabled
|
||||
if (parameters.signMode === 'AUTO' && !isServerCertificateEnabled) {
|
||||
onParameterChange('signMode', 'MANUAL');
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@ -29,21 +37,23 @@ const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = fal
|
||||
Manual
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={parameters.signMode === 'AUTO' ? 'filled' : 'outline'}
|
||||
color={parameters.signMode === 'AUTO' ? 'green' : 'var(--text-muted)'}
|
||||
onClick={() => {
|
||||
onParameterChange('signMode', 'AUTO');
|
||||
// Clear cert type and files when switching to auto
|
||||
onParameterChange('certType', '');
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Auto
|
||||
</div>
|
||||
</Button>
|
||||
{isServerCertificateEnabled && (
|
||||
<Button
|
||||
variant={parameters.signMode === 'AUTO' ? 'filled' : 'outline'}
|
||||
color={parameters.signMode === 'AUTO' ? 'green' : 'var(--text-muted)'}
|
||||
onClick={() => {
|
||||
onParameterChange('signMode', 'AUTO');
|
||||
// Clear cert type and files when switching to auto
|
||||
onParameterChange('certType', '');
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
|
||||
Auto (server)
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
182
frontend/src/components/tools/merge/MergeFileSorter.test.tsx
Normal file
182
frontend/src/components/tools/merge/MergeFileSorter.test.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import MergeFileSorter from './MergeFileSorter';
|
||||
|
||||
// Mock useTranslation with predictable return values
|
||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('MergeFileSorter', () => {
|
||||
const mockOnSortFiles = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render sort options dropdown, direction toggle, and sort button', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should have a select dropdown (Mantine Select uses textbox role)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||
|
||||
// Should have direction toggle button
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2); // ActionIcon + Sort Button
|
||||
|
||||
// Should have sort button with text
|
||||
expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render description text', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should have filename selected by default', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const select = screen.getByRole('textbox');
|
||||
expect(select).toHaveValue('mock-merge.sortBy.filename');
|
||||
});
|
||||
|
||||
test('should show ascending direction by default', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should show ascending arrow icon
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
|
||||
});
|
||||
|
||||
test('should toggle direction when direction button is clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
|
||||
// Initially ascending
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
|
||||
|
||||
// Click to toggle to descending
|
||||
fireEvent.click(directionButton);
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending');
|
||||
|
||||
// Click again to toggle back to ascending
|
||||
fireEvent.click(directionButton);
|
||||
expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending');
|
||||
});
|
||||
|
||||
test('should call onSortFiles with correct parameters when sort button is clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
// Should be called with default values (filename, ascending)
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true);
|
||||
});
|
||||
|
||||
test('should call onSortFiles with dateModified when dropdown is changed', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Open the dropdown by clicking on the current selected value
|
||||
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
|
||||
fireEvent.mouseDown(currentSelection);
|
||||
|
||||
// Click on the dateModified option
|
||||
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
|
||||
fireEvent.click(dateModifiedOption);
|
||||
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
|
||||
});
|
||||
|
||||
test('should call onSortFiles with descending direction when toggled', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
|
||||
// Toggle to descending
|
||||
fireEvent.click(directionButton);
|
||||
|
||||
// Click sort
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false);
|
||||
});
|
||||
|
||||
test('should handle complex user interaction sequence', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeFileSorter onSortFiles={mockOnSortFiles} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const directionButton = screen.getAllByRole('button')[0];
|
||||
const sortButton = screen.getByText('mock-merge.sortBy.sort');
|
||||
|
||||
// 1. Change to dateModified
|
||||
const currentSelection = screen.getByText('mock-merge.sortBy.filename');
|
||||
fireEvent.mouseDown(currentSelection);
|
||||
const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified');
|
||||
fireEvent.click(dateModifiedOption);
|
||||
|
||||
// 2. Toggle to descending
|
||||
fireEvent.click(directionButton);
|
||||
|
||||
// 3. Click sort
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false);
|
||||
|
||||
// 4. Toggle back to ascending
|
||||
fireEvent.click(directionButton);
|
||||
|
||||
// 5. Sort again
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true);
|
||||
});
|
||||
});
|
77
frontend/src/components/tools/merge/MergeFileSorter.tsx
Normal file
77
frontend/src/components/tools/merge/MergeFileSorter.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SortIcon from '@mui/icons-material/Sort';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
|
||||
interface MergeFileSorterProps {
|
||||
onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MergeFileSorter: React.FC<MergeFileSorterProps> = ({
|
||||
onSortFiles,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename');
|
||||
const [ascending, setAscending] = useState(true);
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'filename', label: t('merge.sortBy.filename', 'File Name') },
|
||||
{ value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') },
|
||||
];
|
||||
|
||||
const handleSort = () => {
|
||||
onSortFiles(sortType, ascending);
|
||||
};
|
||||
|
||||
const handleDirectionToggle = () => {
|
||||
setAscending(!ascending);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="end" justify="space-between">
|
||||
<Select
|
||||
data={sortOptions}
|
||||
value={sortType}
|
||||
onChange={(value) => setSortType(value as 'filename' | 'dateModified')}
|
||||
disabled={disabled}
|
||||
label={t('merge.sortBy.label', 'Sort By')}
|
||||
size='xs'
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="md"
|
||||
onClick={handleDirectionToggle}
|
||||
disabled={disabled}
|
||||
title={ascending ? t('merge.sortBy.ascending', 'Ascending') : t('merge.sortBy.descending', 'Descending')}
|
||||
>
|
||||
{ascending ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<SortIcon />}
|
||||
onClick={handleSort}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('merge.sortBy.sort', 'Sort')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MergeFileSorter;
|
100
frontend/src/components/tools/merge/MergeSettings.test.tsx
Normal file
100
frontend/src/components/tools/merge/MergeSettings.test.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import MergeSettings from './MergeSettings';
|
||||
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
|
||||
|
||||
// Mock useTranslation with predictable return values
|
||||
const mockT = vi.fn((key: string) => `mock-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('MergeSettings', () => {
|
||||
const defaultParameters: MergeParameters = {
|
||||
removeDigitalSignature: false,
|
||||
generateTableOfContents: false,
|
||||
};
|
||||
|
||||
const mockOnParameterChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render both merge option checkboxes', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Should render one checkbox for each parameter
|
||||
const expectedCheckboxCount = Object.keys(defaultParameters).length;
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(expectedCheckboxCount);
|
||||
});
|
||||
|
||||
test('should show correct initial checkbox states based on parameters', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// Both checkboxes should be unchecked initially
|
||||
checkboxes.forEach(checkbox => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call onParameterChange with correct parameters when checkboxes are clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
|
||||
// Click the first checkbox (removeDigitalSignature - should toggle from false to true)
|
||||
fireEvent.click(checkboxes[0]);
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('removeDigitalSignature', true);
|
||||
|
||||
// Click the second checkbox (generateTableOfContents - should toggle from false to true)
|
||||
fireEvent.click(checkboxes[1]);
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('generateTableOfContents', true);
|
||||
});
|
||||
|
||||
test('should call translation function with correct keys', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MergeSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Verify that translation keys are being called
|
||||
expect(mockT).toHaveBeenCalledWith('merge.removeDigitalSignature', 'Remove digital signature in the merged file?');
|
||||
expect(mockT).toHaveBeenCalledWith('merge.generateTableOfContents', 'Generate table of contents in the merged file?');
|
||||
});
|
||||
|
||||
});
|
38
frontend/src/components/tools/merge/MergeSettings.tsx
Normal file
38
frontend/src/components/tools/merge/MergeSettings.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Stack, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MergeParameters } from '../../../hooks/tools/merge/useMergeParameters';
|
||||
|
||||
interface MergeSettingsProps {
|
||||
parameters: MergeParameters;
|
||||
onParameterChange: <K extends keyof MergeParameters>(key: K, value: MergeParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MergeSettings: React.FC<MergeSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Checkbox
|
||||
label={t('merge.removeDigitalSignature', 'Remove digital signature in the merged file?')}
|
||||
checked={parameters.removeDigitalSignature}
|
||||
onChange={(event) => onParameterChange('removeDigitalSignature', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label={t('merge.generateTableOfContents', 'Generate table of contents in the merged file?')}
|
||||
checked={parameters.generateTableOfContents}
|
||||
onChange={(event) => onParameterChange('generateTableOfContents', event.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MergeSettings;
|
@ -0,0 +1,211 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import RedactAdvancedSettings from './RedactAdvancedSettings';
|
||||
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
|
||||
|
||||
// Mock useTranslation
|
||||
const mockT = vi.fn((_key: string, fallback: string) => fallback);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('RedactAdvancedSettings', () => {
|
||||
const mockOnParameterChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render all advanced settings controls', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Box Colour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Extra Padding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Use Regex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Whole Word Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Convert PDF to PDF-Image (Used to remove text behind the box)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display current parameter values', () => {
|
||||
const customParameters = {
|
||||
...defaultParameters,
|
||||
redactColor: '#FF0000',
|
||||
customPadding: 0.5,
|
||||
useRegex: true,
|
||||
wholeWordSearch: true,
|
||||
convertPDFToImage: false,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={customParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Check color input value
|
||||
const colorInput = screen.getByDisplayValue('#FF0000');
|
||||
expect(colorInput).toBeInTheDocument();
|
||||
|
||||
// Check number input value
|
||||
const paddingInput = screen.getByDisplayValue('0.5');
|
||||
expect(paddingInput).toBeInTheDocument();
|
||||
|
||||
// Check checkbox states
|
||||
const useRegexCheckbox = screen.getByLabelText('Use Regex');
|
||||
const wholeWordCheckbox = screen.getByLabelText('Whole Word Search');
|
||||
const convertCheckbox = screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)');
|
||||
|
||||
expect(useRegexCheckbox).toBeChecked();
|
||||
expect(wholeWordCheckbox).toBeChecked();
|
||||
expect(convertCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('should call onParameterChange when color is changed', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const colorInput = screen.getByDisplayValue('#000000');
|
||||
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
|
||||
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
|
||||
});
|
||||
|
||||
test('should call onParameterChange when padding is changed', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const paddingInput = screen.getByDisplayValue('0.1');
|
||||
fireEvent.change(paddingInput, { target: { value: '0.5' } });
|
||||
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.5);
|
||||
});
|
||||
|
||||
test('should handle invalid padding values', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const paddingInput = screen.getByDisplayValue('0.1');
|
||||
|
||||
// Simulate NumberInput onChange with invalid value (empty string)
|
||||
const numberInput = paddingInput.closest('.mantine-NumberInput-root');
|
||||
if (numberInput) {
|
||||
// Find the input and trigger change with empty value
|
||||
fireEvent.change(paddingInput, { target: { value: '' } });
|
||||
|
||||
// The component should default to 0.1 for invalid values
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('customPadding', 0.1);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
paramName: 'useRegex' as const,
|
||||
label: 'Use Regex',
|
||||
initialValue: false,
|
||||
expectedValue: true,
|
||||
},
|
||||
{
|
||||
paramName: 'wholeWordSearch' as const,
|
||||
label: 'Whole Word Search',
|
||||
initialValue: false,
|
||||
expectedValue: true,
|
||||
},
|
||||
{
|
||||
paramName: 'convertPDFToImage' as const,
|
||||
label: 'Convert PDF to PDF-Image (Used to remove text behind the box)',
|
||||
initialValue: true,
|
||||
expectedValue: false,
|
||||
},
|
||||
])('should call onParameterChange when $paramName checkbox is toggled', ({ paramName, label, initialValue, expectedValue }) => {
|
||||
const customParameters = {
|
||||
...defaultParameters,
|
||||
[paramName]: initialValue,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={customParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText(label);
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith(paramName, expectedValue);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ controlType: 'color input', getValue: () => screen.getByDisplayValue('#000000') },
|
||||
{ controlType: 'padding input', getValue: () => screen.getByDisplayValue('0.1') },
|
||||
{ controlType: 'useRegex checkbox', getValue: () => screen.getByLabelText('Use Regex') },
|
||||
{ controlType: 'wholeWordSearch checkbox', getValue: () => screen.getByLabelText('Whole Word Search') },
|
||||
{ controlType: 'convertPDFToImage checkbox', getValue: () => screen.getByLabelText('Convert PDF to PDF-Image (Used to remove text behind the box)') },
|
||||
])('should disable $controlType when disabled prop is true', ({ getValue }) => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const control = getValue();
|
||||
expect(control).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should have correct padding input constraints', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactAdvancedSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// NumberInput in Mantine might not expose these attributes directly on the input element
|
||||
// Instead, check that the NumberInput component is rendered with correct placeholder
|
||||
const paddingInput = screen.getByPlaceholderText('0.1');
|
||||
expect(paddingInput).toBeInTheDocument();
|
||||
expect(paddingInput).toHaveDisplayValue('0.1');
|
||||
});
|
||||
});
|
@ -0,0 +1,69 @@
|
||||
import { Stack, NumberInput, ColorInput, Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
|
||||
|
||||
interface RedactAdvancedSettingsProps {
|
||||
parameters: RedactParameters;
|
||||
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RedactAdvancedSettings = ({ parameters, onParameterChange, disabled = false }: RedactAdvancedSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Box Color */}
|
||||
<ColorInput
|
||||
label={t('redact.auto.colorLabel', 'Box Colour')}
|
||||
value={parameters.redactColor}
|
||||
onChange={(value) => onParameterChange('redactColor', value)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
format="hex"
|
||||
/>
|
||||
|
||||
{/* Box Padding */}
|
||||
<NumberInput
|
||||
label={t('redact.auto.customPaddingLabel', 'Custom Extra Padding')}
|
||||
value={parameters.customPadding}
|
||||
onChange={(value) => onParameterChange('customPadding', typeof value === 'number' ? value : 0.1)}
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
placeholder="0.1"
|
||||
/>
|
||||
|
||||
{/* Use Regex */}
|
||||
<Checkbox
|
||||
label={t('redact.auto.useRegexLabel', 'Use Regex')}
|
||||
checked={parameters.useRegex}
|
||||
onChange={(e) => onParameterChange('useRegex', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Whole Word Search */}
|
||||
<Checkbox
|
||||
label={t('redact.auto.wholeWordSearchLabel', 'Whole Word Search')}
|
||||
checked={parameters.wholeWordSearch}
|
||||
onChange={(e) => onParameterChange('wholeWordSearch', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Convert PDF to PDF-Image */}
|
||||
<Checkbox
|
||||
label={t('redact.auto.convertPDFToImageLabel', 'Convert PDF to PDF-Image (Used to remove text behind the box)')}
|
||||
checked={parameters.convertPDFToImage}
|
||||
onChange={(e) => onParameterChange('convertPDFToImage', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedactAdvancedSettings;
|
33
frontend/src/components/tools/redact/RedactModeSelector.tsx
Normal file
33
frontend/src/components/tools/redact/RedactModeSelector.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RedactMode } from '../../../hooks/tools/redact/useRedactParameters';
|
||||
import ButtonSelector from '../../shared/ButtonSelector';
|
||||
|
||||
interface RedactModeSelectorProps {
|
||||
mode: RedactMode;
|
||||
onModeChange: (mode: RedactMode) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ButtonSelector
|
||||
label={t('redact.modeSelector.mode', 'Mode')}
|
||||
value={mode}
|
||||
onChange={onModeChange}
|
||||
options={[
|
||||
{
|
||||
value: 'automatic' as const,
|
||||
label: t('redact.modeSelector.automatic', 'Automatic'),
|
||||
},
|
||||
{
|
||||
value: 'manual' as const,
|
||||
label: t('redact.modeSelector.manual', 'Manual'),
|
||||
disabled: true, // Keep manual mode disabled until implemented
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,183 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import RedactSingleStepSettings from './RedactSingleStepSettings';
|
||||
import { defaultParameters } from '../../../hooks/tools/redact/useRedactParameters';
|
||||
|
||||
// Mock useTranslation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('RedactSingleStepSettings', () => {
|
||||
const mockOnParameterChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render mode selector', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Mode')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render automatic mode settings when mode is automatic', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Default mode is automatic, so these should be visible
|
||||
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
|
||||
expect(screen.getByText('Box Colour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Use Regex')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render manual mode settings when mode is manual', () => {
|
||||
const manualParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'manual' as const,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={manualParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Manual mode should show placeholder text
|
||||
expect(screen.getByText('Manual redaction interface will be available here when implemented.')).toBeInTheDocument();
|
||||
|
||||
// Automatic mode settings should not be visible
|
||||
expect(screen.queryByText('Words to Redact')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass through parameter changes from automatic settings', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Test adding a word
|
||||
const input = screen.getByPlaceholderText('Enter a word');
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'TestWord' } });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('wordsToRedact', ['TestWord']);
|
||||
});
|
||||
|
||||
test('should pass through parameter changes from advanced settings', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Test changing color
|
||||
const colorInput = screen.getByDisplayValue('#000000');
|
||||
fireEvent.change(colorInput, { target: { value: '#FF0000' } });
|
||||
|
||||
expect(mockOnParameterChange).toHaveBeenCalledWith('redactColor', '#FF0000');
|
||||
});
|
||||
|
||||
test('should disable all controls when disabled prop is true', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Mode selector buttons should be disabled
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Manual' })).toBeDisabled();
|
||||
|
||||
// Automatic settings controls should be disabled
|
||||
expect(screen.getByPlaceholderText('Enter a word')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '+ Add' })).toBeDisabled();
|
||||
expect(screen.getByDisplayValue('#000000')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should show current parameter values in automatic mode', () => {
|
||||
const customParameters = {
|
||||
...defaultParameters,
|
||||
wordsToRedact: ['Word1', 'Word2'],
|
||||
redactColor: '#FF0000',
|
||||
useRegex: true,
|
||||
customPadding: 0.5,
|
||||
};
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={customParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Check that word tags are displayed
|
||||
expect(screen.getByText('Word1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Word2')).toBeInTheDocument();
|
||||
|
||||
// Check that color is displayed
|
||||
expect(screen.getByDisplayValue('#FF0000')).toBeInTheDocument();
|
||||
|
||||
// Check that regex checkbox is checked
|
||||
const useRegexCheckbox = screen.getByLabelText('Use Regex');
|
||||
expect(useRegexCheckbox).toBeChecked();
|
||||
|
||||
// Check that padding value is displayed
|
||||
expect(screen.getByDisplayValue('0.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should maintain consistent spacing and layout', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<RedactSingleStepSettings
|
||||
parameters={defaultParameters}
|
||||
onParameterChange={mockOnParameterChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Check that the Stack container exists
|
||||
const container = screen.getByText('Mode').closest('.mantine-Stack-root');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -0,0 +1,61 @@
|
||||
import { Stack, Divider } from "@mantine/core";
|
||||
import { RedactParameters } from "../../../hooks/tools/redact/useRedactParameters";
|
||||
import RedactModeSelector from "./RedactModeSelector";
|
||||
import WordsToRedactInput from "./WordsToRedactInput";
|
||||
import RedactAdvancedSettings from "./RedactAdvancedSettings";
|
||||
|
||||
interface RedactSingleStepSettingsProps {
|
||||
parameters: RedactParameters;
|
||||
onParameterChange: <K extends keyof RedactParameters>(key: K, value: RedactParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RedactSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: RedactSingleStepSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Mode Selection */}
|
||||
<RedactModeSelector
|
||||
mode={parameters.mode}
|
||||
onModeChange={(mode) => onParameterChange('mode', mode)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Automatic Mode Settings */}
|
||||
{parameters.mode === 'automatic' && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{/* Words to Redact */}
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={parameters.wordsToRedact}
|
||||
onWordsChange={(words) => onParameterChange('wordsToRedact', words)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<RedactAdvancedSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Manual Mode Placeholder */}
|
||||
{parameters.mode === 'manual' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="md">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
Manual redaction interface will be available here when implemented.
|
||||
</div>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedactSingleStepSettings;
|
191
frontend/src/components/tools/redact/WordsToRedactInput.test.tsx
Normal file
191
frontend/src/components/tools/redact/WordsToRedactInput.test.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import WordsToRedactInput from './WordsToRedactInput';
|
||||
|
||||
// Mock useTranslation
|
||||
const mockT = vi.fn((_key: string, fallback: string) => fallback);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Wrapper component to provide Mantine context
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('WordsToRedactInput', () => {
|
||||
const mockOnWordsChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render with title and input field', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={[]}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Words to Redact')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Enter a word')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '+ Add' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ trigger: 'Add button click', action: (_input: HTMLElement, addButton: HTMLElement) => fireEvent.click(addButton) },
|
||||
{ trigger: 'Enter key press', action: (input: HTMLElement) => fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }) },
|
||||
])('should add word when $trigger', ({ action }) => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={[]}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter a word');
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'TestWord' } });
|
||||
action(input, addButton);
|
||||
|
||||
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
|
||||
});
|
||||
|
||||
test('should not add empty word', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={[]}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockOnWordsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not add duplicate word', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={['Existing']}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter a word');
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Existing' } });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockOnWordsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should trim whitespace when adding word', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={[]}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter a word');
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: ' TestWord ' } });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(mockOnWordsChange).toHaveBeenCalledWith(['TestWord']);
|
||||
});
|
||||
|
||||
test('should remove word when x button is clicked', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={['Word1', 'Word2']}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const removeButtons = screen.getAllByText('×');
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(mockOnWordsChange).toHaveBeenCalledWith(['Word2']);
|
||||
});
|
||||
|
||||
test('should clear input after adding word', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={[]}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter a word') as HTMLInputElement;
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'TestWord' } });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: 'disable Add button when input is empty', inputValue: '', expectedDisabled: true },
|
||||
{ description: 'enable Add button when input has text', inputValue: 'TestWord', expectedDisabled: false },
|
||||
])('should $description', ({ inputValue, expectedDisabled }) => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={[]}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter a word');
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: inputValue } });
|
||||
|
||||
expect(addButton).toHaveProperty('disabled', expectedDisabled);
|
||||
});
|
||||
|
||||
test('should disable all controls when disabled prop is true', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={['Word1']}
|
||||
onWordsChange={mockOnWordsChange}
|
||||
disabled={true}
|
||||
/>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter a word');
|
||||
const addButton = screen.getByRole('button', { name: '+ Add' });
|
||||
const removeButton = screen.getByText('×');
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(addButton).toBeDisabled();
|
||||
expect(removeButton.closest('button')).toBeDisabled();
|
||||
});
|
||||
});
|
99
frontend/src/components/tools/redact/WordsToRedactInput.tsx
Normal file
99
frontend/src/components/tools/redact/WordsToRedactInput.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, TextInput, Button, Group, ActionIcon } from '@mantine/core';
|
||||
|
||||
interface WordsToRedactInputProps {
|
||||
wordsToRedact: string[];
|
||||
onWordsChange: (words: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function WordsToRedactInput({ wordsToRedact, onWordsChange, disabled }: WordsToRedactInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentWord, setCurrentWord] = useState('');
|
||||
|
||||
const addWord = () => {
|
||||
if (currentWord.trim() && !wordsToRedact.includes(currentWord.trim())) {
|
||||
onWordsChange([...wordsToRedact, currentWord.trim()]);
|
||||
setCurrentWord('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeWord = (index: number) => {
|
||||
onWordsChange(wordsToRedact.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addWord();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('redact.auto.wordsToRedact.title', 'Words to Redact')}
|
||||
</Text>
|
||||
|
||||
{/* Current words */}
|
||||
{wordsToRedact.map((word, index) => (
|
||||
<Group key={index} justify="space-between" p="sm" style={{
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
border: `1px solid var(--mantine-color-gray-3)`,
|
||||
backgroundColor: 'var(--mantine-color-gray-0)'
|
||||
}}>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
title={word}
|
||||
>
|
||||
{word}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => removeWord(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
×
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{/* Add new word input */}
|
||||
<Group gap="sm" align="end">
|
||||
<TextInput
|
||||
placeholder={t('redact.auto.wordsToRedact.placeholder', 'Enter a word')}
|
||||
value={currentWord}
|
||||
onChange={(e) => setCurrentWord(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={addWord}
|
||||
disabled={disabled || !currentWord.trim()}
|
||||
>
|
||||
+ {t('redact.auto.wordsToRedact.add', 'Add')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Examples */}
|
||||
{wordsToRedact.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('redact.auto.wordsToRedact.examples', 'Examples: Confidential, Top-Secret')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -10,14 +10,15 @@ import { StirlingFile } from "../../../types/fileContext";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: StirlingFile[];
|
||||
placeholder?: string;
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
minFiles = 1,
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const { openFilesModal, onFileUpload } = useFilesModalContext();
|
||||
const { files: stirlingFileStubs } = useAllFiles();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
@ -44,7 +45,7 @@ const FileStatusIndicator = ({
|
||||
input.onchange = (event) => {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
if (files.length > 0) {
|
||||
onFilesSelect(files);
|
||||
onFileUpload(files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
@ -55,6 +56,14 @@ const FileStatusIndicator = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
if (minFiles === undefined || minFiles === 1) {
|
||||
return t("files.selectFromWorkbench", "Select files from the workbench or ");
|
||||
} else {
|
||||
return t("files.selectMultipleFromWorkbench", "Select at least {{count}} files from the workbench or ", { count: minFiles });
|
||||
}
|
||||
};
|
||||
|
||||
// Check if there are no files in the workbench
|
||||
if (stirlingFileStubs.length === 0) {
|
||||
// If no recent files, show upload button
|
||||
@ -89,12 +98,12 @@ const FileStatusIndicator = ({
|
||||
}
|
||||
|
||||
// Show selection status when there are files in workbench
|
||||
if (selectedFiles.length === 0) {
|
||||
if (selectedFiles.length < minFiles) {
|
||||
// If no recent files, show upload option
|
||||
if (!hasRecentFiles) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
{getPlaceholder() + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={handleNativeUpload}
|
||||
@ -109,7 +118,7 @@ const FileStatusIndicator = ({
|
||||
// If there are recent files, show add files option
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
|
||||
{getPlaceholder() + " "}
|
||||
<Anchor
|
||||
size="sm"
|
||||
onClick={() => openFilesModal()}
|
||||
@ -125,7 +134,7 @@ const FileStatusIndicator = ({
|
||||
|
||||
return (
|
||||
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
|
||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||
✓ {selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
@ -7,7 +7,7 @@ export interface FilesToolStepProps {
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
placeholder?: string;
|
||||
minFiles?: number;
|
||||
}
|
||||
|
||||
export function createFilesToolStep(
|
||||
@ -23,7 +23,7 @@ export function createFilesToolStep(
|
||||
}, (
|
||||
<FileStatusIndicator
|
||||
selectedFiles={props.selectedFiles}
|
||||
placeholder={props.placeholder || t("files.placeholder", "Select a PDF file in the main view to get started")}
|
||||
minFiles={props.minFiles}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
@ -53,7 +53,7 @@ const renderTooltipTitle = (
|
||||
<Text fw={400} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||
<LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
|
@ -22,7 +22,7 @@ export function ToolWorkflowTitle({ title, tooltip, description }: ToolWorkflowT
|
||||
<Text fw={500} size="lg" p="xs">
|
||||
{title}
|
||||
</Text>
|
||||
{tooltip && <LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
|
||||
{tooltip && <LocalIcon icon="info-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { StirlingFile } from '../../../types/fileContext';
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: StirlingFile[];
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
minFiles?: number;
|
||||
onCollapsedClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
@ -76,7 +76,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
{config.files.isVisible !== false && steps.createFilesStep({
|
||||
selectedFiles: config.files.selectedFiles,
|
||||
isCollapsed: config.files.isCollapsed,
|
||||
placeholder: config.files.placeholder,
|
||||
minFiles: config.files.minFiles,
|
||||
onCollapsedClick: config.files.onCollapsedClick
|
||||
})}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
|
||||
import { Stack, TextInput, Checkbox, Anchor, Text } from '@mantine/core';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isSplitMode, isSplitType, SPLIT_MODES, SPLIT_TYPES } from '../../../constants/splitConstants';
|
||||
import { SPLIT_METHODS } from '../../../constants/splitConstants';
|
||||
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
|
||||
|
||||
export interface SplitSettingsProps {
|
||||
@ -57,28 +58,37 @@ const SplitSettings = ({
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderBySizeOrCountForm = () => (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("split-by-size-or-count.type.label", "Split Type")}
|
||||
value={parameters.splitType}
|
||||
onChange={(v) => isSplitType(v) && onParameterChange('splitType', v)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
|
||||
{ value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
|
||||
{ value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") },
|
||||
]}
|
||||
/>
|
||||
const renderSplitValueForm = () => {
|
||||
let label, placeholder;
|
||||
|
||||
switch (parameters.method) {
|
||||
case SPLIT_METHODS.BY_SIZE:
|
||||
label = t("split.value.fileSize.label", "File Size");
|
||||
placeholder = t("split.value.fileSize.placeholder", "e.g. 10MB, 500KB");
|
||||
break;
|
||||
case SPLIT_METHODS.BY_PAGE_COUNT:
|
||||
label = t("split.value.pageCount.label", "Pages per File");
|
||||
placeholder = t("split.value.pageCount.placeholder", "e.g. 5, 10");
|
||||
break;
|
||||
case SPLIT_METHODS.BY_DOC_COUNT:
|
||||
label = t("split.value.docCount.label", "Number of Files");
|
||||
placeholder = t("split.value.docCount.placeholder", "e.g. 3, 5");
|
||||
break;
|
||||
default:
|
||||
label = t("split-by-size-or-count.value.label", "Split Value");
|
||||
placeholder = t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages");
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={t("split-by-size-or-count.value.label", "Split Value")}
|
||||
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={parameters.splitValue}
|
||||
onChange={(e) => onParameterChange('splitValue', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderByChaptersForm = () => (
|
||||
<Stack gap="sm">
|
||||
@ -104,28 +114,48 @@ const SplitSettings = ({
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderByPageDividerForm = () => (
|
||||
<Stack gap="sm">
|
||||
<Anchor
|
||||
href="https://stirlingpdf.io/files/Auto%20Splitter%20Divider%20(with%20instructions).pdf"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<LocalIcon icon="download-rounded" width="2rem" height="2rem" />
|
||||
{t("autoSplitPDF.dividerDownload2", "Download 'Auto Splitter Divider (with instructions).pdf'")}
|
||||
</Anchor>
|
||||
|
||||
<Checkbox
|
||||
label={t("autoSplitPDF.duplexMode", "Duplex Mode (Front and back scanning)")}
|
||||
checked={parameters.duplexMode}
|
||||
onChange={(e) => onParameterChange('duplexMode', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
// Don't render anything if no method is selected
|
||||
if (!parameters.method) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("split.settings.selectMethodFirst", "Please select a split method first")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Mode Selector */}
|
||||
<Select
|
||||
label="Choose split method"
|
||||
placeholder="Select how to split the PDF"
|
||||
value={parameters.mode}
|
||||
onChange={(v) => isSplitMode(v) && onParameterChange('mode', v)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
||||
{ value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") },
|
||||
{ value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") },
|
||||
{ value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Parameter Form */}
|
||||
{parameters.mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
|
||||
{parameters.mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
|
||||
{parameters.mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
|
||||
{parameters.mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
|
||||
{/* Method-Specific Form */}
|
||||
{parameters.method === SPLIT_METHODS.BY_PAGES && renderByPagesForm()}
|
||||
{parameters.method === SPLIT_METHODS.BY_SECTIONS && renderBySectionsForm()}
|
||||
{(parameters.method === SPLIT_METHODS.BY_SIZE ||
|
||||
parameters.method === SPLIT_METHODS.BY_PAGE_COUNT ||
|
||||
parameters.method === SPLIT_METHODS.BY_DOC_COUNT) && renderSplitValueForm()}
|
||||
{parameters.method === SPLIT_METHODS.BY_CHAPTERS && renderByChaptersForm()}
|
||||
{parameters.method === SPLIT_METHODS.BY_PAGE_DIVIDER && renderByPageDividerForm()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
31
frontend/src/components/tooltips/useAdjustPageScaleTips.ts
Normal file
31
frontend/src/components/tooltips/useAdjustPageScaleTips.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useAdjustPageScaleTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("adjustPageScale.tooltip.header.title", "Page Scale Settings Overview")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("adjustPageScale.tooltip.description.title", "Description"),
|
||||
description: t("adjustPageScale.tooltip.description.text", "Adjust the size of PDF content and change the page dimensions.")
|
||||
},
|
||||
{
|
||||
title: t("adjustPageScale.tooltip.scaleFactor.title", "Scale Factor"),
|
||||
description: t("adjustPageScale.tooltip.scaleFactor.text", "Controls how large or small the content appears on the page. Content is scaled and centered - if scaled content is larger than the page size, it may be cropped."),
|
||||
bullets: [
|
||||
t("adjustPageScale.tooltip.scaleFactor.bullet1", "1.0 = Original size"),
|
||||
t("adjustPageScale.tooltip.scaleFactor.bullet2", "0.5 = Half size (50% smaller)"),
|
||||
t("adjustPageScale.tooltip.scaleFactor.bullet3", "2.0 = Double size (200% larger, may crop)")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("adjustPageScale.tooltip.pageSize.title", "Target Page Size"),
|
||||
description: t("adjustPageScale.tooltip.pageSize.text", "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, while other options resize to standard paper sizes.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
44
frontend/src/components/tooltips/useBookletImpositionTips.ts
Normal file
44
frontend/src/components/tooltips/useBookletImpositionTips.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useBookletImpositionTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("bookletImposition.tooltip.title", "Booklet Imposition Guide")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("bookletImposition.tooltip.overview.title", "What is Booklet Imposition?"),
|
||||
description: t("bookletImposition.tooltip.overview.description", "Arranges PDF pages in the correct order for booklet printing. Pages are reordered so that when printed and folded, they appear in sequence."),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.overview.bullet1", "Creates printable booklets from regular PDFs"),
|
||||
t("bookletImposition.tooltip.overview.bullet2", "Handles page ordering for folding"),
|
||||
t("bookletImposition.tooltip.overview.bullet3", "Supports saddle-stitch and side-stitch binding")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.bookletTypes.title", "Booklet Types"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.bookletTypes.standard", "Standard: Saddle-stitched binding (staples along fold)"),
|
||||
t("bookletImposition.tooltip.bookletTypes.sideStitch", "Side-Stitch: Binding along edge (spiral, ring, perfect)")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.pagesPerSheet.title", "Pages Per Sheet"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.pagesPerSheet.two", "2 Pages: Standard layout (most common)"),
|
||||
t("bookletImposition.tooltip.pagesPerSheet.four", "4 Pages: Compact layout")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("bookletImposition.tooltip.orientation.title", "Page Orientation"),
|
||||
bullets: [
|
||||
t("bookletImposition.tooltip.orientation.landscape", "Landscape: A4 → A5 booklet (recommended)"),
|
||||
t("bookletImposition.tooltip.orientation.portrait", "Portrait: A4 → A6 booklet (compact)")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
19
frontend/src/components/tooltips/useMergeTips.tsx
Normal file
19
frontend/src/components/tooltips/useMergeTips.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useMergeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
tips: [
|
||||
{
|
||||
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
|
||||
description: t('merge.removeDigitalSignature.tooltip.description', 'Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF.')
|
||||
},
|
||||
{
|
||||
title: t('merge.generateTableOfContents.tooltip.title', 'Generate Table of Contents'),
|
||||
description: t('merge.generateTableOfContents.tooltip.description', 'Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers.')
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
79
frontend/src/components/tooltips/useRedactTips.ts
Normal file
79
frontend/src/components/tooltips/useRedactTips.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useRedactModeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("redact.tooltip.mode.header.title", "Redaction Method")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("redact.tooltip.mode.automatic.title", "Automatic Redaction"),
|
||||
description: t("redact.tooltip.mode.automatic.text", "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, SSNs, or confidential markers.")
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.mode.manual.title", "Manual Redaction"),
|
||||
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useRedactWordsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("redact.tooltip.words.header.title", "Words to Redact")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("redact.tooltip.words.description.title", "Text Matching"),
|
||||
description: t("redact.tooltip.words.description.text", "Enter words or phrases to find and redact in your document. Each word will be searched for separately."),
|
||||
bullets: [
|
||||
t("redact.tooltip.words.bullet1", "Add one word at a time"),
|
||||
t("redact.tooltip.words.bullet2", "Press Enter or click 'Add Another' to add"),
|
||||
t("redact.tooltip.words.bullet3", "Click × to remove words")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.words.examples.title", "Common Examples"),
|
||||
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export const useRedactAdvancedTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("redact.tooltip.advanced.color.title", "Box Colour & Padding"),
|
||||
description: t("redact.tooltip.advanced.color.text", "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text."),
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.advanced.regex.title", "Use Regex"),
|
||||
description: t("redact.tooltip.advanced.regex.text", "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns."),
|
||||
bullets: [
|
||||
t("redact.tooltip.advanced.regex.bullet1", "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format"),
|
||||
t("redact.tooltip.advanced.regex.bullet2", "Use with caution - test thoroughly")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.advanced.wholeWord.title", "Whole Word Search"),
|
||||
description: t("redact.tooltip.advanced.wholeWord.text", "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled.")
|
||||
},
|
||||
{
|
||||
title: t("redact.tooltip.advanced.convert.title", "Convert to PDF-Image"),
|
||||
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
24
frontend/src/components/tooltips/useSplitMethodTips.ts
Normal file
24
frontend/src/components/tooltips/useSplitMethodTips.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useSplitMethodTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("split.methodSelection.tooltip.title", "Choose Your Split Method")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.methodSelection.tooltip.header.title", "Split Method Selection"),
|
||||
description: t("split.methodSelection.tooltip.header.text", "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types."),
|
||||
bullets: [
|
||||
t("split.methodSelection.tooltip.bullet1", "Click on a method card to select it"),
|
||||
t("split.methodSelection.tooltip.bullet2", "Hover over each card to see a quick description"),
|
||||
t("split.methodSelection.tooltip.bullet3", "The settings step will appear after you select a method"),
|
||||
t("split.methodSelection.tooltip.bullet4", "You can change methods at any time before processing")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
134
frontend/src/components/tooltips/useSplitSettingsTips.ts
Normal file
134
frontend/src/components/tooltips/useSplitSettingsTips.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
import { SPLIT_METHODS, type SplitMethod } from '../../constants/splitConstants';
|
||||
|
||||
export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent | null => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!method) return null;
|
||||
|
||||
const tooltipMap: Record<SplitMethod, TooltipContent> = {
|
||||
[SPLIT_METHODS.BY_PAGES]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byPages.title", "Split at Page Numbers")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPages.title", "Split at Page Numbers"),
|
||||
description: t("split.tooltip.byPages.text", "Extract specific pages or ranges from your PDF. Use commas to separate individual pages and hyphens for ranges."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPages.bullet1", "Single pages: 1,3,5"),
|
||||
t("split.tooltip.byPages.bullet2", "Page ranges: 1-5,10-15"),
|
||||
t("split.tooltip.byPages.bullet3", "Mixed: 1,3-7,12,15-20")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_SECTIONS]: {
|
||||
header: {
|
||||
title: t("split.tooltip.bySections.title", "Split by Grid Sections")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.bySections.title", "Split by Grid Sections"),
|
||||
description: t("split.tooltip.bySections.text", "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas."),
|
||||
bullets: [
|
||||
t("split.tooltip.bySections.bullet1", "Horizontal: Number of rows to create"),
|
||||
t("split.tooltip.bySections.bullet2", "Vertical: Number of columns to create"),
|
||||
t("split.tooltip.bySections.bullet3", "Merge: Combine all sections into one PDF")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_SIZE]: {
|
||||
header: {
|
||||
title: t("split.tooltip.bySize.title", "Split by File Size")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.bySize.title", "Split by File Size"),
|
||||
description: t("split.tooltip.bySize.text", "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments."),
|
||||
bullets: [
|
||||
t("split.tooltip.bySize.bullet1", "Use MB for larger files (e.g., 10MB)"),
|
||||
t("split.tooltip.bySize.bullet2", "Use KB for smaller files (e.g., 500KB)"),
|
||||
t("split.tooltip.bySize.bullet3", "System will split at page boundaries")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_PAGE_COUNT]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byPageCount.title", "Split by Page Count")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPageCount.title", "Split by Page Count"),
|
||||
description: t("split.tooltip.byPageCount.text", "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPageCount.bullet1", "Enter the number of pages per output file"),
|
||||
t("split.tooltip.byPageCount.bullet2", "Last file may have fewer pages if not evenly divisible"),
|
||||
t("split.tooltip.byPageCount.bullet3", "Useful for batch processing workflows")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_DOC_COUNT]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byDocCount.title", "Split by Document Count")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byDocCount.title", "Split by Document Count"),
|
||||
description: t("split.tooltip.byDocCount.text", "Create a specific number of output files by evenly distributing pages across them."),
|
||||
bullets: [
|
||||
t("split.tooltip.byDocCount.bullet1", "Enter the number of output files you want"),
|
||||
t("split.tooltip.byDocCount.bullet2", "Pages are distributed as evenly as possible"),
|
||||
t("split.tooltip.byDocCount.bullet3", "Useful when you need a specific number of files")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_CHAPTERS]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byChapters.title", "Split by Chapters")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byChapters.title", "Split by Chapters"),
|
||||
description: t("split.tooltip.byChapters.text", "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure."),
|
||||
bullets: [
|
||||
t("split.tooltip.byChapters.bullet1", "Bookmark Level: Which level to split on (1=top level)"),
|
||||
t("split.tooltip.byChapters.bullet2", "Include Metadata: Preserve document properties"),
|
||||
t("split.tooltip.byChapters.bullet3", "Allow Duplicates: Handle repeated bookmark names")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_PAGE_DIVIDER]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byPageDivider.title", "Split by Page Divider")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPageDivider.title", "Split by Page Divider"),
|
||||
description: t("split.tooltip.byPageDivider.text", "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPageDivider.bullet1", "Print divider sheets from the download link"),
|
||||
t("split.tooltip.byPageDivider.bullet2", "Insert divider sheets between your documents"),
|
||||
t("split.tooltip.byPageDivider.bullet3", "Scan all documents together as one PDF"),
|
||||
t("split.tooltip.byPageDivider.bullet4", "Upload - divider pages are automatically detected and removed"),
|
||||
t("split.tooltip.byPageDivider.bullet5", "Enable Duplex Mode if scanning both sides of divider sheets")
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return tooltipMap[method];
|
||||
};
|
@ -372,11 +372,12 @@ const Viewer = ({
|
||||
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||||
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
|
||||
|
||||
// Get data directly from IndexedDB
|
||||
const arrayBuffer = await fileStorage.getFileData(fileId);
|
||||
if (!arrayBuffer) {
|
||||
// Get file directly from IndexedDB
|
||||
const file = await fileStorage.getStirlingFile(fileId);
|
||||
if (!file) {
|
||||
throw new Error('File not found in IndexedDB - may have been purged by browser');
|
||||
}
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Store reference for cleanup
|
||||
currentArrayBufferRef.current = arrayBuffer;
|
||||
|
@ -1,30 +1,78 @@
|
||||
export const SPLIT_MODES = {
|
||||
export const SPLIT_METHODS = {
|
||||
BY_PAGES: 'byPages',
|
||||
BY_SECTIONS: 'bySections',
|
||||
BY_SIZE_OR_COUNT: 'bySizeOrCount',
|
||||
BY_CHAPTERS: 'byChapters'
|
||||
BY_SIZE: 'bySize',
|
||||
BY_PAGE_COUNT: 'byPageCount',
|
||||
BY_DOC_COUNT: 'byDocCount',
|
||||
BY_CHAPTERS: 'byChapters',
|
||||
BY_PAGE_DIVIDER: 'byPageDivider'
|
||||
} as const;
|
||||
|
||||
export const SPLIT_TYPES = {
|
||||
SIZE: 'size',
|
||||
PAGES: 'pages',
|
||||
DOCS: 'docs'
|
||||
} as const;
|
||||
|
||||
export const ENDPOINTS = {
|
||||
[SPLIT_MODES.BY_PAGES]: 'split-pages',
|
||||
[SPLIT_MODES.BY_SECTIONS]: 'split-pdf-by-sections',
|
||||
[SPLIT_MODES.BY_SIZE_OR_COUNT]: 'split-by-size-or-count',
|
||||
[SPLIT_MODES.BY_CHAPTERS]: 'split-pdf-by-chapters'
|
||||
[SPLIT_METHODS.BY_PAGES]: 'split-pages',
|
||||
[SPLIT_METHODS.BY_SECTIONS]: 'split-pdf-by-sections',
|
||||
[SPLIT_METHODS.BY_SIZE]: 'split-by-size-or-count',
|
||||
[SPLIT_METHODS.BY_PAGE_COUNT]: 'split-by-size-or-count',
|
||||
[SPLIT_METHODS.BY_DOC_COUNT]: 'split-by-size-or-count',
|
||||
[SPLIT_METHODS.BY_CHAPTERS]: 'split-pdf-by-chapters',
|
||||
[SPLIT_METHODS.BY_PAGE_DIVIDER]: 'auto-split-pdf'
|
||||
} as const;
|
||||
|
||||
export type SplitMode = typeof SPLIT_MODES[keyof typeof SPLIT_MODES];
|
||||
export type SplitType = typeof SPLIT_TYPES[keyof typeof SPLIT_TYPES];
|
||||
|
||||
export const isSplitMode = (value: string | null): value is SplitMode => {
|
||||
return Object.values(SPLIT_MODES).includes(value as SplitMode);
|
||||
export type SplitMethod = typeof SPLIT_METHODS[keyof typeof SPLIT_METHODS];
|
||||
export const isSplitMethod = (value: string | null): value is SplitMethod => {
|
||||
return Object.values(SPLIT_METHODS).includes(value as SplitMethod);
|
||||
}
|
||||
|
||||
export const isSplitType = (value: string | null): value is SplitType => {
|
||||
return Object.values(SPLIT_TYPES).includes(value as SplitType);
|
||||
import { CardOption } from '../components/shared/CardSelector';
|
||||
|
||||
export interface MethodOption extends CardOption<SplitMethod> {
|
||||
tooltipKey: string;
|
||||
}
|
||||
|
||||
export const METHOD_OPTIONS: MethodOption[] = [
|
||||
{
|
||||
value: SPLIT_METHODS.BY_PAGES,
|
||||
prefixKey: "split.methods.prefix.splitAt",
|
||||
nameKey: "split.methods.byPages.name",
|
||||
tooltipKey: "split.methods.byPages.tooltip"
|
||||
},
|
||||
{
|
||||
value: SPLIT_METHODS.BY_CHAPTERS,
|
||||
prefixKey: "split.methods.prefix.splitBy",
|
||||
nameKey: "split.methods.byChapters.name",
|
||||
tooltipKey: "split.methods.byChapters.tooltip"
|
||||
},
|
||||
{
|
||||
value: SPLIT_METHODS.BY_SECTIONS,
|
||||
prefixKey: "split.methods.prefix.splitBy",
|
||||
nameKey: "split.methods.bySections.name",
|
||||
tooltipKey: "split.methods.bySections.tooltip"
|
||||
},
|
||||
{
|
||||
value: SPLIT_METHODS.BY_SIZE,
|
||||
prefixKey: "split.methods.prefix.splitBy",
|
||||
nameKey: "split.methods.bySize.name",
|
||||
tooltipKey: "split.methods.bySize.tooltip"
|
||||
},
|
||||
{
|
||||
value: SPLIT_METHODS.BY_PAGE_COUNT,
|
||||
prefixKey: "split.methods.prefix.splitBy",
|
||||
nameKey: "split.methods.byPageCount.name",
|
||||
tooltipKey: "split.methods.byPageCount.tooltip"
|
||||
},
|
||||
{
|
||||
value: SPLIT_METHODS.BY_DOC_COUNT,
|
||||
prefixKey: "split.methods.prefix.splitBy",
|
||||
nameKey: "split.methods.byDocCount.name",
|
||||
tooltipKey: "split.methods.byDocCount.tooltip"
|
||||
},
|
||||
{
|
||||
value: SPLIT_METHODS.BY_PAGE_DIVIDER,
|
||||
prefixKey: "split.methods.prefix.splitBy",
|
||||
nameKey: "split.methods.byPageDivider.name",
|
||||
tooltipKey: "split.methods.byPageDivider.tooltip"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
@ -22,13 +22,12 @@ import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile
|
||||
} from '../types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
||||
import { createFileSelectors } from './file/fileSelectors';
|
||||
import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { FileLifecycleManager } from './file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||
@ -73,58 +72,44 @@ function FileContextInner({
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []);
|
||||
|
||||
const selectFiles = (addedFilesWithIds: AddedFile[]) => {
|
||||
const selectFiles = (stirlingFiles: StirlingFile[]) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const newFileIds = addedFilesWithIds.map(({ id }) => id);
|
||||
const newFileIds = stirlingFiles.map(stirlingFile => stirlingFile.fileId);
|
||||
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
|
||||
}
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
const stirlingFiles = await addFiles({ files, ...options }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && addedFilesWithIds.length > 0) {
|
||||
selectFiles(addedFilesWithIds);
|
||||
if (options?.selectFiles && stirlingFiles.length > 0) {
|
||||
selectFiles(stirlingFiles);
|
||||
}
|
||||
|
||||
// Persist to IndexedDB if enabled
|
||||
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
|
||||
await Promise.all(addedFilesWithIds.map(async ({ file, id, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, id, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
return stirlingFiles;
|
||||
}, [enablePersistence]);
|
||||
|
||||
return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, [indexedDB, enablePersistence]);
|
||||
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
}, []);
|
||||
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
|
||||
// StirlingFileStubs preserve all metadata - perfect for FileManager use case!
|
||||
const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
if (options?.selectFiles && result.length > 0) {
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result.map(({ file, id }) => createStirlingFile(file, id));
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
|
||||
// Action creators
|
||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
|
||||
}, []);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
@ -143,8 +128,7 @@ function FileContextInner({
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
...baseActions,
|
||||
addFiles: addRawFiles,
|
||||
addProcessedFiles,
|
||||
addStoredFiles,
|
||||
addStirlingFileStubs: addStirlingFileStubsAction,
|
||||
removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => {
|
||||
// Remove from memory and cleanup resources
|
||||
lifecycleManager.removeFiles(fileIds, stateRef);
|
||||
@ -199,8 +183,7 @@ function FileContextInner({
|
||||
}), [
|
||||
baseActions,
|
||||
addRawFiles,
|
||||
addProcessedFiles,
|
||||
addStoredFiles,
|
||||
addStirlingFileStubsAction,
|
||||
lifecycleManager,
|
||||
setHasUnsavedChanges,
|
||||
consumeFilesWrapper,
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
import { FileId } from '../types/file';
|
||||
import { groupFilesByOriginal } from '../utils/fileHistoryUtils';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
@ -10,27 +11,34 @@ interface FileManagerContextValue {
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: FileId[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileMetadata[];
|
||||
filteredFiles: FileMetadata[];
|
||||
selectedFiles: StirlingFileStub[];
|
||||
filteredFiles: StirlingFileStub[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
selectedFilesSet: Set<string>;
|
||||
selectedFilesSet: Set<FileId>;
|
||||
expandedFileIds: Set<FileId>;
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
onLocalFileClick: () => void;
|
||||
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
|
||||
onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileMetadata) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onOpenFiles: () => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectAll: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
onDownloadSingle: (file: FileMetadata) => void;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onToggleExpansion: (fileId: FileId) => void;
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: FileMetadata[];
|
||||
recentFiles: StirlingFileStub[];
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
}
|
||||
@ -41,8 +49,8 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
||||
// Provider component props
|
||||
interface FileManagerProviderProps {
|
||||
children: React.ReactNode;
|
||||
recentFiles: FileMetadata[];
|
||||
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files
|
||||
recentFiles: StirlingFileStub[];
|
||||
onRecentFilesSelected: (files: StirlingFileStub[]) => void; // For selecting stored files
|
||||
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
||||
onClose: () => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
@ -55,7 +63,7 @@ interface FileManagerProviderProps {
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onRecentFilesSelected,
|
||||
onNewFilesSelect,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
@ -68,19 +76,44 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const [expandedFileIds, setExpandedFileIds] = useState<Set<FileId>>(new Set());
|
||||
const [loadedHistoryFiles, setLoadedHistoryFiles] = useState<Map<FileId, StirlingFileStub[]>>(new Map()); // Cache for loaded history
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track blob URLs for cleanup
|
||||
const createdBlobUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
||||
// Group files by original file ID for version management
|
||||
const fileGroups = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return new Map();
|
||||
|
||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||
(recentFiles || []).filter(file =>
|
||||
// Convert StirlingFileStub to FileRecord-like objects for grouping utility
|
||||
const recordsForGrouping = recentFiles.map(file => ({
|
||||
...file,
|
||||
originalFileId: file.originalFileId,
|
||||
versionNumber: file.versionNumber || 1
|
||||
}));
|
||||
|
||||
return groupFilesByOriginal(recordsForGrouping);
|
||||
}, [recentFiles]);
|
||||
|
||||
// Get files to display with expansion logic
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!recentFiles || recentFiles.length === 0) return [];
|
||||
|
||||
// Only return leaf files - history files will be handled by separate components
|
||||
return recentFiles;
|
||||
}, [recentFiles]);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
displayFiles.filter(file => selectedFilesSet.has(file.id));
|
||||
|
||||
const filteredFiles = !searchTerm ? displayFiles :
|
||||
displayFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
@ -97,7 +130,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||
const handleFileSelect = useCallback((file: StirlingFileStub, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
@ -138,27 +171,214 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [filteredFiles, lastClickedIndex]);
|
||||
|
||||
const handleFileRemove = useCallback((index: number) => {
|
||||
// Helper function to safely determine which files can be deleted
|
||||
const getSafeFilesToDelete = useCallback((
|
||||
fileIds: FileId[],
|
||||
allStoredStubs: StirlingFileStub[]
|
||||
): FileId[] => {
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
const filesToDelete = new Set<FileId>();
|
||||
const filesToPreserve = new Set<FileId>();
|
||||
|
||||
// First, identify all files in the lineages of the leaf files being deleted
|
||||
for (const leafFileId of fileIds) {
|
||||
const currentFile = fileMap.get(leafFileId);
|
||||
if (!currentFile) continue;
|
||||
|
||||
// Always include the leaf file itself for deletion
|
||||
filesToDelete.add(leafFileId);
|
||||
|
||||
// If this is a processed file with history, trace back through its lineage
|
||||
if (currentFile.versionNumber && currentFile.versionNumber > 1) {
|
||||
const originalFileId = currentFile.originalFileId || currentFile.id;
|
||||
|
||||
// Find all files in this history chain
|
||||
const chainFiles = allStoredStubs.filter((file: StirlingFileStub) =>
|
||||
(file.originalFileId || file.id) === originalFileId
|
||||
);
|
||||
|
||||
// Add all files in this lineage as candidates for deletion
|
||||
chainFiles.forEach(file => filesToDelete.add(file.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Now identify files that must be preserved because they're referenced by OTHER lineages
|
||||
for (const file of allStoredStubs) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// If this file is a leaf node (not being deleted) and its lineage overlaps with files we want to delete
|
||||
if (file.isLeaf !== false && !fileIds.includes(file.id)) {
|
||||
// Find all files in this preserved lineage
|
||||
const preservedChainFiles = allStoredStubs.filter((chainFile: StirlingFileStub) =>
|
||||
(chainFile.originalFileId || chainFile.id) === fileOriginalId
|
||||
);
|
||||
|
||||
// Mark all files in this preserved lineage as must-preserve
|
||||
preservedChainFiles.forEach(chainFile => filesToPreserve.add(chainFile.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Final list: files to delete minus files that must be preserved
|
||||
let safeToDelete = Array.from(filesToDelete).filter(fileId => !filesToPreserve.has(fileId));
|
||||
|
||||
// Check for orphaned non-leaf files after main deletion
|
||||
const remainingFiles = allStoredStubs.filter(file => !safeToDelete.includes(file.id));
|
||||
const orphanedNonLeafFiles: FileId[] = [];
|
||||
|
||||
for (const file of remainingFiles) {
|
||||
// Only check non-leaf files (files that have been processed and have children)
|
||||
if (file.isLeaf === false) {
|
||||
const fileOriginalId = file.originalFileId || file.id;
|
||||
|
||||
// Check if this non-leaf file has any living descendants
|
||||
const hasLivingDescendants = remainingFiles.some(otherFile => {
|
||||
// Check if otherFile is a descendant of this file
|
||||
const otherOriginalId = otherFile.originalFileId || otherFile.id;
|
||||
return (
|
||||
// Direct parent relationship
|
||||
otherFile.parentFileId === file.id ||
|
||||
// Same lineage but different from this file
|
||||
(otherOriginalId === fileOriginalId && otherFile.id !== file.id)
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasLivingDescendants) {
|
||||
orphanedNonLeafFiles.push(file.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orphaned non-leaf files to deletion list
|
||||
safeToDelete = [...safeToDelete, ...orphanedNonLeafFiles];
|
||||
|
||||
return safeToDelete;
|
||||
}, []);
|
||||
|
||||
// Shared internal delete logic
|
||||
const performFileDelete = useCallback(async (fileToRemove: StirlingFileStub, fileIndex: number) => {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Get all stored files to analyze lineages
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// Get safe files to delete (respecting shared lineages)
|
||||
const filesToDelete = getSafeFilesToDelete([deletedFileId], allStoredStubs);
|
||||
|
||||
// Clear from selection immediately
|
||||
setSelectedFileIds(prev => prev.filter(id => !filesToDelete.includes(id)));
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
filesToDelete.forEach(id => newExpanded.delete(id));
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// Remove cache entries for all deleted files
|
||||
filesToDelete.forEach(id => newCache.delete(id as FileId));
|
||||
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => !filesToDelete.includes(histFile.id));
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
for (const fileId of filesToDelete) {
|
||||
await fileStorage.deleteStirlingFile(fileId as FileId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Call the parent's deletion logic for the main file only
|
||||
onFileRemove(fileIndex);
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}, [getSafeFilesToDelete, setSelectedFileIds, setExpandedFileIds, setLoadedHistoryFiles, onFileRemove, refreshRecentFiles]);
|
||||
|
||||
const handleFileRemove = useCallback(async (index: number) => {
|
||||
const fileToRemove = filteredFiles[index];
|
||||
if (fileToRemove) {
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
|
||||
await performFileDelete(fileToRemove, index);
|
||||
}
|
||||
onFileRemove(index);
|
||||
}, [filteredFiles, onFileRemove]);
|
||||
}, [filteredFiles, performFileDelete]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||
// Handle deletion by fileId (more robust than index-based)
|
||||
const handleFileRemoveById = useCallback(async (fileId: FileId) => {
|
||||
// Find the file and its index in filteredFiles
|
||||
const fileIndex = filteredFiles.findIndex(file => file.id === fileId);
|
||||
const fileToRemove = filteredFiles[fileIndex];
|
||||
|
||||
if (fileToRemove && fileIndex !== -1) {
|
||||
await performFileDelete(fileToRemove, fileIndex);
|
||||
}
|
||||
}, [filteredFiles, performFileDelete]);
|
||||
|
||||
// Handle deletion of specific history files (not index-based)
|
||||
const handleHistoryFileRemove = useCallback(async (fileToRemove: StirlingFileStub) => {
|
||||
const deletedFileId = fileToRemove.id;
|
||||
|
||||
// Clear from expanded state to prevent ghost entries
|
||||
setExpandedFileIds(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
newExpanded.delete(deletedFileId);
|
||||
return newExpanded;
|
||||
});
|
||||
|
||||
// Clear from history cache - remove all files in the chain
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newCache = new Map(prev);
|
||||
|
||||
// Remove cache entries for all deleted files
|
||||
newCache.delete(deletedFileId);
|
||||
|
||||
// Also remove deleted files from any other file's history cache
|
||||
for (const [mainFileId, historyFiles] of newCache.entries()) {
|
||||
const filteredHistory = historyFiles.filter(histFile => deletedFileId != histFile.id);
|
||||
if (filteredHistory.length !== historyFiles.length) {
|
||||
newCache.set(mainFileId, filteredHistory);
|
||||
}
|
||||
}
|
||||
|
||||
return newCache;
|
||||
});
|
||||
|
||||
// Delete safe files from IndexedDB
|
||||
try {
|
||||
await fileStorage.deleteStirlingFile(deletedFileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from chain:', error);
|
||||
}
|
||||
|
||||
// Refresh to ensure consistent state
|
||||
await refreshRecentFiles();
|
||||
}, [filteredFiles, onFileRemove, refreshRecentFiles, getSafeFilesToDelete]);
|
||||
|
||||
const handleFileDoubleClick = useCallback((file: StirlingFileStub) => {
|
||||
if (isFileSupported(file.name)) {
|
||||
onFilesSelected([file]);
|
||||
onRecentFilesSelected([file]);
|
||||
onClose();
|
||||
}
|
||||
}, [isFileSupported, onFilesSelected, onClose]);
|
||||
}, [isFileSupported, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
onFilesSelected(selectedFiles);
|
||||
onRecentFilesSelected(selectedFiles);
|
||||
onClose();
|
||||
}
|
||||
}, [selectedFiles, onFilesSelected, onClose]);
|
||||
}, [selectedFiles, onRecentFilesSelected, onClose]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchTerm(value);
|
||||
@ -196,25 +416,14 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
if (selectedFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
// Get files to delete based on current filtered view
|
||||
const filesToDelete = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
// Delete files from storage
|
||||
for (const file of filesToDelete) {
|
||||
await fileStorage.deleteFile(file.id);
|
||||
// Delete each selected file using the proven single delete logic
|
||||
for (const fileId of selectedFileIds) {
|
||||
await handleFileRemoveById(fileId);
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
setSelectedFileIds([]);
|
||||
|
||||
// Refresh the file list
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete selected files:', error);
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
|
||||
}, [selectedFileIds, handleFileRemoveById]);
|
||||
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
@ -235,7 +444,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [selectedFileIds, filteredFiles]);
|
||||
|
||||
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
|
||||
const handleDownloadSingle = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
await downloadFiles([file]);
|
||||
} catch (error) {
|
||||
@ -243,6 +452,94 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleExpansion = useCallback(async (fileId: FileId) => {
|
||||
const isCurrentlyExpanded = expandedFileIds.has(fileId);
|
||||
|
||||
// Update expansion state
|
||||
setExpandedFileIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(fileId)) {
|
||||
newSet.delete(fileId);
|
||||
} else {
|
||||
newSet.add(fileId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Load complete history chain if expanding
|
||||
if (!isCurrentlyExpanded) {
|
||||
const currentFileMetadata = recentFiles.find(f => f.id === fileId);
|
||||
if (currentFileMetadata && (currentFileMetadata.versionNumber || 1) > 1) {
|
||||
try {
|
||||
// Get all stored file metadata for chain traversal
|
||||
const allStoredStubs = await fileStorage.getAllStirlingFileStubs();
|
||||
const fileMap = new Map(allStoredStubs.map(f => [f.id, f]));
|
||||
|
||||
// Get the current file's IndexedDB data
|
||||
const currentStoredStub = fileMap.get(fileId as FileId);
|
||||
if (!currentStoredStub) {
|
||||
console.warn(`No stored file found for ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build complete history chain using IndexedDB metadata
|
||||
const historyFiles: StirlingFileStub[] = [];
|
||||
|
||||
// Find the original file
|
||||
|
||||
// Collect only files in this specific branch (ancestors of current file)
|
||||
const chainFiles: StirlingFileStub[] = [];
|
||||
const allFiles = Array.from(fileMap.values());
|
||||
|
||||
// Build a map for fast parent lookups
|
||||
const fileIdMap = new Map<FileId, StirlingFileStub>();
|
||||
allFiles.forEach(f => fileIdMap.set(f.id, f));
|
||||
|
||||
// Trace back from current file through parent chain
|
||||
let currentFile = fileIdMap.get(fileId);
|
||||
while (currentFile?.parentFileId) {
|
||||
const parentFile = fileIdMap.get(currentFile.parentFileId);
|
||||
if (parentFile) {
|
||||
chainFiles.push(parentFile);
|
||||
currentFile = parentFile;
|
||||
} else {
|
||||
break; // Parent not found, stop tracing
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by version number (oldest first for history display)
|
||||
chainFiles.sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1));
|
||||
|
||||
// StirlingFileStubs already have all the data we need - no conversion required!
|
||||
historyFiles.push(...chainFiles);
|
||||
|
||||
// Cache the loaded history files
|
||||
setLoadedHistoryFiles(prev => new Map(prev.set(fileId as FileId, historyFiles)));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load history chain for file ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear loaded history when collapsing
|
||||
setLoadedHistoryFiles(prev => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(fileId as FileId);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}, [expandedFileIds, recentFiles]);
|
||||
|
||||
const handleAddToRecents = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
// Mark the file as a leaf node so it appears in recent files
|
||||
await fileStorage.markFileAsLeaf(file.id);
|
||||
|
||||
// Refresh the recent files list to show updated state
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to add to recents:', error);
|
||||
}
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
@ -274,12 +571,16 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
onLocalFileClick: handleLocalFileClick,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFileRemove: handleFileRemove,
|
||||
onHistoryFileRemove: handleHistoryFileRemove,
|
||||
onFileDoubleClick: handleFileDoubleClick,
|
||||
onOpenFiles: handleOpenFiles,
|
||||
onSearchChange: handleSearchChange,
|
||||
@ -288,6 +589,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onDeleteSelected: handleDeleteSelected,
|
||||
onDownloadSelected: handleDownloadSelected,
|
||||
onDownloadSingle: handleDownloadSingle,
|
||||
onToggleExpansion: handleToggleExpansion,
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onNewFilesSelect,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
@ -300,10 +604,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleFileRemoveById,
|
||||
performFileDelete,
|
||||
handleFileDoubleClick,
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
@ -311,6 +620,9 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleSelectAll,
|
||||
handleDeleteSelected,
|
||||
handleDownloadSelected,
|
||||
handleToggleExpansion,
|
||||
handleAddToRecents,
|
||||
onNewFilesSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
|
@ -1,15 +1,15 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { FileId } from '../types/file';
|
||||
import { useFileActions } from './FileContext';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void;
|
||||
onFileUpload: (files: File[]) => void;
|
||||
onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
@ -17,7 +17,8 @@ interface FilesModalContextType {
|
||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||
|
||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
const { actions } = useFileActions();
|
||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
|
||||
@ -36,39 +37,45 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
onModalClose?.();
|
||||
}, [onModalClose]);
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler([file], insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addToActiveFiles(file);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
const handleFileUpload = useCallback((files: File[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
customHandler(files, insertAfterPage);
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addMultipleFiles(files);
|
||||
addFiles(files);
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [addFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
|
||||
const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
const files = filesWithMetadata.map(item => item.file);
|
||||
customHandler(files, insertAfterPage);
|
||||
// Load the actual files from storage for custom handler
|
||||
try {
|
||||
const loadedFiles: File[] = [];
|
||||
for (const stub of stirlingFileStubs) {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (stirlingFile) {
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedFiles.length > 0) {
|
||||
customHandler(loadedFiles, insertAfterPage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load files for custom handler:', error);
|
||||
}
|
||||
} else {
|
||||
// Use normal file handling
|
||||
addStoredFiles(filesWithMetadata);
|
||||
// Normal case - use addStirlingFileStubs to preserve metadata
|
||||
if (actions.addStirlingFileStubs) {
|
||||
actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true });
|
||||
} else {
|
||||
console.error('addStirlingFileStubs action not available');
|
||||
}
|
||||
}
|
||||
closeFilesModal();
|
||||
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
}, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage]);
|
||||
|
||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||
setOnModalClose(() => callback);
|
||||
@ -78,18 +85,16 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
onFileSelect: handleFileSelect,
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onStoredFilesSelect: handleStoredFilesSelect,
|
||||
onFileUpload: handleFileUpload,
|
||||
onRecentFileSelect: handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileSelect,
|
||||
handleFilesSelect,
|
||||
handleStoredFilesSelect,
|
||||
handleFileUpload,
|
||||
handleRecentFileSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
@ -4,28 +4,30 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StirlingFileStub, createStirlingFile, createQuickKey } from '../types/fileContext';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
interface IndexedDBContextValue {
|
||||
// Core CRUD operations
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
|
||||
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<StirlingFileStub>;
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<StirlingFileStub | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||
loadAllMetadata: () => Promise<StirlingFileStub[]>;
|
||||
loadLeafMetadata: () => Promise<StirlingFileStub[]>; // Only leaf files for recent files list
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Utilities
|
||||
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||
markFileAsProcessed: (fileId: FileId) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
||||
@ -56,26 +58,42 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<StirlingFileStub> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
// Store in IndexedDB (no history data - that's handled by direct fileStorage calls now)
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
|
||||
// Create minimal stub for storage
|
||||
const stub: StirlingFileStub = {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
quickKey: createQuickKey(file),
|
||||
thumbnailUrl: thumbnail,
|
||||
isLeaf: true,
|
||||
createdAt: Date.now(),
|
||||
versionNumber: 1,
|
||||
originalFileId: fileId,
|
||||
toolHistory: []
|
||||
};
|
||||
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
const storedFile = await fileStorage.getStirlingFileStub(fileId);
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
// Return metadata
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
thumbnail
|
||||
};
|
||||
// Return StirlingFileStub from the stored file (no conversion needed)
|
||||
if (!storedFile) {
|
||||
throw new Error(`Failed to retrieve stored file after saving: ${file.name}`);
|
||||
}
|
||||
|
||||
return storedFile;
|
||||
}, []);
|
||||
|
||||
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||
@ -88,14 +106,11 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
}
|
||||
|
||||
// Load from IndexedDB
|
||||
const storedFile = await fileStorage.getFile(fileId);
|
||||
const storedFile = await fileStorage.getStirlingFile(fileId);
|
||||
if (!storedFile) return null;
|
||||
|
||||
// Reconstruct File object
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
// StirlingFile is already a File object, no reconstruction needed
|
||||
const file = storedFile;
|
||||
|
||||
// Cache for future use with LRU eviction
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
@ -104,34 +119,9 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
return file;
|
||||
}, [evictLRUEntries]);
|
||||
|
||||
const loadMetadata = useCallback(async (fileId: FileId): Promise<FileMetadata | null> => {
|
||||
// Try to get from cache first (no IndexedDB hit)
|
||||
const cached = fileCache.current.get(fileId);
|
||||
if (cached) {
|
||||
const file = cached.file;
|
||||
return {
|
||||
id: fileId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified
|
||||
};
|
||||
}
|
||||
|
||||
// Load metadata from IndexedDB (efficient - no data field)
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const fileMetadata = metadata.find(m => m.id === fileId);
|
||||
|
||||
if (!fileMetadata) return null;
|
||||
|
||||
return {
|
||||
id: fileMetadata.id,
|
||||
name: fileMetadata.name,
|
||||
type: fileMetadata.type,
|
||||
size: fileMetadata.size,
|
||||
lastModified: fileMetadata.lastModified,
|
||||
thumbnail: fileMetadata.thumbnail
|
||||
};
|
||||
const loadMetadata = useCallback(async (fileId: FileId): Promise<StirlingFileStub | null> => {
|
||||
// Load stub directly from storage service
|
||||
return await fileStorage.getStirlingFileStub(fileId);
|
||||
}, []);
|
||||
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
@ -139,20 +129,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteFile(fileId);
|
||||
await fileStorage.deleteStirlingFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const loadLeafMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getLeafStirlingFileStubs(); // Only get leaf files
|
||||
|
||||
return metadata.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
type: m.type,
|
||||
size: m.size,
|
||||
lastModified: m.lastModified,
|
||||
thumbnail: m.thumbnail
|
||||
}));
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
const metadata = await fileStorage.getAllStirlingFileStubs();
|
||||
|
||||
// All files are already StirlingFileStub objects, no processing needed
|
||||
return metadata;
|
||||
}, []);
|
||||
|
||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||
@ -160,7 +152,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
fileIds.forEach(id => fileCache.current.delete(id));
|
||||
|
||||
// Remove from IndexedDB in parallel
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteStirlingFile(id)));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
@ -179,16 +171,22 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
return await fileStorage.updateThumbnail(fileId, thumbnail);
|
||||
}, []);
|
||||
|
||||
const markFileAsProcessed = useCallback(async (fileId: FileId): Promise<boolean> => {
|
||||
return await fileStorage.markFileAsProcessed(fileId);
|
||||
}, []);
|
||||
|
||||
const value: IndexedDBContextValue = {
|
||||
saveFile,
|
||||
loadFile,
|
||||
loadMetadata,
|
||||
deleteFile,
|
||||
loadAllMetadata,
|
||||
loadLeafMetadata,
|
||||
deleteMultiple,
|
||||
clearAll,
|
||||
getStorageStats,
|
||||
updateThumbnail
|
||||
updateThumbnail,
|
||||
markFileAsProcessed
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -125,16 +125,18 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
return state; // File doesn't exist, no-op
|
||||
}
|
||||
|
||||
const updatedRecord = {
|
||||
...existingRecord,
|
||||
...updates
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
byId: {
|
||||
...state.files.byId,
|
||||
[id]: {
|
||||
...existingRecord,
|
||||
...updates
|
||||
}
|
||||
[id]: updatedRecord
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -145,12 +147,17 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
|
||||
// Validate that all IDs exist in current state
|
||||
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||
|
||||
// Reorder selected files by passed order
|
||||
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id));
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
...state.files,
|
||||
ids: validIds
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds,
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -234,11 +241,14 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputStirlingFileStubs } = action.payload;
|
||||
|
||||
return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
|
||||
}
|
||||
|
||||
|
||||
case 'UNDO_CONSUME_FILES': {
|
||||
const { inputStirlingFileStubs, outputFileIds } = action.payload;
|
||||
|
||||
return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
|
||||
}
|
||||
|
||||
|
@ -6,15 +6,18 @@ import {
|
||||
StirlingFileStub,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
toStirlingFileStub,
|
||||
createNewStirlingFileStub,
|
||||
createFileId,
|
||||
createQuickKey
|
||||
createQuickKey,
|
||||
createStirlingFile,
|
||||
ProcessedFileMetadata,
|
||||
} from '../../types/fileContext';
|
||||
import { FileId, FileMetadata } from '../../types/file';
|
||||
import { FileId } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { buildQuickKeySet } from './fileSelectors';
|
||||
|
||||
import { StirlingFile } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
@ -69,345 +72,283 @@ export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* File addition types
|
||||
* Generate fresh ProcessedFileMetadata for a file
|
||||
* Used when tools process files to ensure metadata matches actual file content
|
||||
*/
|
||||
type AddFileKind = 'raw' | 'processed' | 'stored';
|
||||
export async function generateProcessedFileMetadata(file: File): Promise<ProcessedFileMetadata | undefined> {
|
||||
// Only generate metadata for PDF files
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
return createProcessedFile(result.pageCount, result.thumbnail);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child StirlingFileStub from a parent stub with proper history management.
|
||||
* Used when a tool processes an existing file to create a new version with incremented history.
|
||||
*
|
||||
* @param parentStub - The parent StirlingFileStub to create a child from
|
||||
* @param operation - Tool operation information (toolName, timestamp)
|
||||
* @param resultingFile - The processed File object
|
||||
* @param thumbnail - Optional thumbnail for the child
|
||||
* @param processedFileMetadata - Optional fresh metadata for the processed file
|
||||
* @returns New child StirlingFileStub with proper version history
|
||||
*/
|
||||
export function createChildStub(
|
||||
parentStub: StirlingFileStub,
|
||||
operation: { toolName: string; timestamp: number },
|
||||
resultingFile: File,
|
||||
thumbnail?: string,
|
||||
processedFileMetadata?: ProcessedFileMetadata
|
||||
): StirlingFileStub {
|
||||
const newFileId = createFileId();
|
||||
|
||||
// Build new tool history by appending to parent's history
|
||||
const parentToolHistory = parentStub.toolHistory || [];
|
||||
const newToolHistory = [...parentToolHistory, operation];
|
||||
|
||||
// Calculate new version number
|
||||
const newVersionNumber = (parentStub.versionNumber || 1) + 1;
|
||||
|
||||
// Determine original file ID (root of the version chain)
|
||||
const originalFileId = parentStub.originalFileId || parentStub.id;
|
||||
|
||||
// Copy parent metadata but exclude processedFile to prevent stale data
|
||||
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
||||
|
||||
return {
|
||||
// Copy parent metadata (excluding processedFile)
|
||||
...parentMetadata,
|
||||
|
||||
// Update identity and version info
|
||||
id: newFileId,
|
||||
versionNumber: newVersionNumber,
|
||||
parentFileId: parentStub.id,
|
||||
originalFileId: originalFileId,
|
||||
toolHistory: newToolHistory,
|
||||
createdAt: Date.now(),
|
||||
isLeaf: true, // New child is the current leaf node
|
||||
name: resultingFile.name,
|
||||
size: resultingFile.size,
|
||||
type: resultingFile.type,
|
||||
lastModified: resultingFile.lastModified,
|
||||
thumbnailUrl: thumbnail,
|
||||
|
||||
// Set fresh processedFile metadata (no inheritance from parent)
|
||||
processedFile: processedFileMetadata
|
||||
};
|
||||
}
|
||||
|
||||
interface AddFileOptions {
|
||||
// For 'raw' files
|
||||
files?: File[];
|
||||
|
||||
// For 'processed' files
|
||||
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||
|
||||
// For 'stored' files
|
||||
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||
|
||||
// Insertion position
|
||||
insertAfterPageId?: string;
|
||||
}
|
||||
|
||||
export interface AddedFile {
|
||||
file: File;
|
||||
id: FileId;
|
||||
thumbnail?: string;
|
||||
// Auto-selection after adding
|
||||
selectFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||
* Unified file addition helper - replaces addFiles
|
||||
*/
|
||||
export async function addFiles(
|
||||
kind: AddFileKind,
|
||||
options: AddFileOptions,
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager
|
||||
): Promise<AddedFile[]> {
|
||||
lifecycleManager: FileLifecycleManager,
|
||||
enablePersistence: boolean = false
|
||||
): Promise<StirlingFile[]> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const stirlingFileStubs: StirlingFileStub[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||
|
||||
switch (kind) {
|
||||
case 'raw': {
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
const { files = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||
|
||||
for (const file of files) {
|
||||
const quickKey = createQuickKey(file);
|
||||
for (const file of files) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count immediately
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
// Non-PDF files: simple thumbnail generation, no page count
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create record with immediate thumbnail and page metadata
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create initial processedFile metadata with page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
}
|
||||
break;
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
case 'processed': {
|
||||
const { filesWithThumbnails = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||
const quickKey = createQuickKey(file);
|
||||
// Generate processedFile metadata using centralized function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create processedFile with provided metadata
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
// Extract thumbnail for non-PDF files or use from processedFile for PDFs
|
||||
let thumbnail: string | undefined;
|
||||
if (processedFileMetadata) {
|
||||
// PDF file - use thumbnail from processedFile metadata
|
||||
thumbnail = processedFileMetadata.thumbnailUrl;
|
||||
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${processedFileMetadata.totalPages} pages, thumbnail: SUCCESS`);
|
||||
} else if (!file.type.startsWith('application/pdf')) {
|
||||
// Non-PDF files: simple thumbnail generation, no processedFile metadata
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stored': {
|
||||
const { filesWithMetadata = [] } = options;
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||
|
||||
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
// Try to preserve original ID, but generate new if it conflicts
|
||||
let fileId = originalId;
|
||||
if (filesRef.current.has(originalId)) {
|
||||
fileId = createFileId();
|
||||
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||
}
|
||||
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
|
||||
// Generate processedFile metadata for stored files
|
||||
let pageCount: number = 1;
|
||||
|
||||
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||
if (file.type.startsWith('application/pdf')) {
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||
|
||||
// Get page count from PDF
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
pageCount = pdf.numPages;
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
pageCount = 0; // Non-PDFs have no page count
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||
}
|
||||
|
||||
// Restore metadata from storage
|
||||
if (metadata.thumbnail) {
|
||||
record.thumbnailUrl = metadata.thumbnail;
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (metadata.thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Create processedFile metadata with correct page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
// Create new filestub with processedFile metadata
|
||||
const fileStub = createNewStirlingFileStub(file, fileId, thumbnail, processedFileMetadata);
|
||||
if (thumbnail) {
|
||||
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||
if (thumbnail.startsWith('blob:')) {
|
||||
lifecycleManager.trackBlobUrl(thumbnail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
fileStub.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
stirlingFileStubs.push(fileStub);
|
||||
|
||||
// Create StirlingFile directly
|
||||
const stirlingFile = createStirlingFile(file, fileId);
|
||||
stirlingFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Persist to storage if enabled using fileStorage service
|
||||
if (enablePersistence && stirlingFiles.length > 0) {
|
||||
await Promise.all(stirlingFiles.map(async (stirlingFile, index) => {
|
||||
try {
|
||||
// Get corresponding stub with all metadata
|
||||
const fileStub = stirlingFileStubs[index];
|
||||
|
||||
// Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly
|
||||
await fileStorage.storeStirlingFile(stirlingFile, fileStub);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, fileStub);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to storage:', stirlingFile.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (stirlingFileStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
|
||||
}
|
||||
|
||||
return addedFiles;
|
||||
return stirlingFiles;
|
||||
} finally {
|
||||
// Always release mutex even if error occurs
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to process files into records with thumbnails and metadata
|
||||
*/
|
||||
async function processFilesIntoRecords(
|
||||
files: File[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toStirlingFileStub(file, fileId);
|
||||
if (thumbnail) {
|
||||
record.thumbnailUrl = thumbnail;
|
||||
}
|
||||
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
}
|
||||
|
||||
return { record, file, fileId, thumbnail };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to persist files to IndexedDB
|
||||
*/
|
||||
async function persistFilesToIndexedDB(
|
||||
stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||
): Promise<void> {
|
||||
await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
* Now accepts pre-created StirlingFiles and StirlingFileStubs to preserve all metadata
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
outputStirlingFiles: StirlingFile[],
|
||||
outputStirlingFileStubs: StirlingFileStub[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<FileId[]> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputStirlingFiles.length} output files with pre-created stubs`);
|
||||
|
||||
// Process output files with thumbnails and metadata
|
||||
const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
|
||||
// Persist output files to IndexedDB if available
|
||||
if (indexedDB) {
|
||||
await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
|
||||
// Validate that we have matching files and stubs
|
||||
if (outputStirlingFiles.length !== outputStirlingFileStubs.length) {
|
||||
throw new Error(`Mismatch between output files (${outputStirlingFiles.length}) and stubs (${outputStirlingFileStubs.length})`);
|
||||
}
|
||||
|
||||
// Dispatch the consume action
|
||||
// Store StirlingFiles in filesRef using their existing IDs (no ID generation needed)
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
if (stirlingFile.fileId !== stub.id) {
|
||||
console.warn(`📄 consumeFiles: ID mismatch between StirlingFile (${stirlingFile.fileId}) and stub (${stub.id})`);
|
||||
}
|
||||
|
||||
filesRef.current.set(stirlingFile.fileId, stirlingFile);
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Stored StirlingFile ${stirlingFile.name} with ID ${stirlingFile.fileId}`);
|
||||
}
|
||||
|
||||
// Mark input files as processed in storage (no longer leaf nodes)
|
||||
if(!outputStirlingFileStubs.reduce((areAllV1, stub) => areAllV1 && (stub.versionNumber == 1), true)) {
|
||||
await Promise.all(
|
||||
inputFileIds.map(async (fileId) => {
|
||||
try {
|
||||
await fileStorage.markFileAsProcessed(fileId);
|
||||
if (DEBUG) console.log(`📄 Marked file ${fileId} as processed (no longer leaf)`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to mark file ${fileId} as processed:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Save output files directly to fileStorage with complete metadata
|
||||
for (let i = 0; i < outputStirlingFiles.length; i++) {
|
||||
const stirlingFile = outputStirlingFiles[i];
|
||||
const stub = outputStirlingFileStubs[i];
|
||||
|
||||
try {
|
||||
// Use fileStorage directly with complete metadata from stub
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
|
||||
if (DEBUG) console.log(`📄 Saved StirlingFile ${stirlingFile.name} directly to storage with complete metadata:`, {
|
||||
fileId: stirlingFile.fileId,
|
||||
versionNumber: stub.versionNumber,
|
||||
originalFileId: stub.originalFileId,
|
||||
parentFileId: stub.parentFileId,
|
||||
toolChainLength: stub.toolHistory?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to persist output file to fileStorage:', stirlingFile.name, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the consume action with pre-created stubs (no processing needed)
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
|
||||
outputStirlingFileStubs: outputStirlingFileStubs
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputStirlingFileStubs.map(({ fileId }) => fileId);
|
||||
return outputStirlingFileStubs.map(stub => stub.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -518,6 +459,97 @@ export async function undoConsumeFiles(
|
||||
/**
|
||||
* Action factory functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add files using existing StirlingFileStubs from storage - preserves all metadata
|
||||
* Use this when loading files that already exist in storage (FileManager, etc.)
|
||||
* StirlingFileStubs come with proper thumbnails, history, processing state
|
||||
*/
|
||||
export async function addStirlingFileStubs(
|
||||
stirlingFileStubs: StirlingFileStub[],
|
||||
options: { insertAfterPageId?: string; selectFiles?: boolean } = {},
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
_lifecycleManager: FileLifecycleManager
|
||||
): Promise<StirlingFile[]> {
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
|
||||
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
const validStubs: StirlingFileStub[] = [];
|
||||
const loadedFiles: StirlingFile[] = [];
|
||||
|
||||
for (const stub of stirlingFileStubs) {
|
||||
// Check for duplicates using quickKey
|
||||
if (existingQuickKeys.has(stub.quickKey || '')) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate StirlingFileStub: ${stub.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the actual StirlingFile from storage
|
||||
const stirlingFile = await fileStorage.getStirlingFile(stub.id);
|
||||
if (!stirlingFile) {
|
||||
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${stub.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the loaded file in filesRef
|
||||
filesRef.current.set(stub.id, stirlingFile);
|
||||
|
||||
// Use the original stub (preserves thumbnails, history, metadata!)
|
||||
const record = { ...stub };
|
||||
|
||||
// Store insertion position if provided
|
||||
if (options.insertAfterPageId !== undefined) {
|
||||
record.insertAfterPageId = options.insertAfterPageId;
|
||||
}
|
||||
|
||||
// Check if processedFile data needs regeneration for proper Page Editor support
|
||||
if (stirlingFile.type.startsWith('application/pdf')) {
|
||||
const needsProcessing = !record.processedFile ||
|
||||
!record.processedFile.pages ||
|
||||
record.processedFile.pages.length === 0 ||
|
||||
record.processedFile.totalPages !== record.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
|
||||
|
||||
// Use centralized metadata generation function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
|
||||
if (processedFileMetadata) {
|
||||
record.processedFile = processedFileMetadata;
|
||||
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
|
||||
} else {
|
||||
// Fallback for files that couldn't be processed
|
||||
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
|
||||
if (!record.processedFile) {
|
||||
record.processedFile = createProcessedFile(1); // Fallback to 1 page
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingQuickKeys.add(stub.quickKey || '');
|
||||
validStubs.push(record);
|
||||
loadedFiles.push(stirlingFile);
|
||||
}
|
||||
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (validStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
} finally {
|
||||
addFilesMutex.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
|
||||
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
||||
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
||||
|
@ -136,13 +136,13 @@ export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStu
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
|
||||
export function useSelectedFiles(): { selectedFiles: StirlingFile[]; selectedRecords: StirlingFileStub[]; selectedFileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
files: selectors.getSelectedFiles(),
|
||||
records: selectors.getSelectedStirlingFileStubs(),
|
||||
fileIds: state.ui.selectedFileIds
|
||||
selectedFiles: selectors.getSelectedFiles(),
|
||||
selectedRecords: selectors.getSelectedStirlingFileStubs(),
|
||||
selectedFileIds: state.ui.selectedFileIds
|
||||
}), [state.ui.selectedFileIds, selectors]);
|
||||
}
|
||||
|
||||
@ -169,7 +169,6 @@ export function useFileContext() {
|
||||
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
|
||||
|
||||
// File ID lookup
|
||||
findFileId: (file: File) => {
|
||||
return state.files.ids.find(id => {
|
||||
|
@ -11,6 +11,7 @@ import ChangePermissions from "../tools/ChangePermissions";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import Merge from '../tools/Merge';
|
||||
import Repair from "../tools/Repair";
|
||||
import AutoRename from "../tools/AutoRename";
|
||||
import SingleLargePage from "../tools/SingleLargePage";
|
||||
@ -34,8 +35,10 @@ import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCerti
|
||||
import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { manageSignaturesOperationConfig } from "../hooks/tools/manageSignatures/useManageSignaturesOperation";
|
||||
import { bookletImpositionOperationConfig } from "../hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { mergeOperationConfig } from '../hooks/tools/merge/useMergeOperation';
|
||||
import { autoRenameOperationConfig } from "../hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperation";
|
||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
@ -50,7 +53,13 @@ import ChangePermissionsSettings from "../components/tools/changePermissions/Cha
|
||||
import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings";
|
||||
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
|
||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@ -341,11 +350,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
"adjust-page-size-scale": {
|
||||
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.scalePages.title", "Adjust page size/scale"),
|
||||
component: null,
|
||||
|
||||
component: AdjustPageScale,
|
||||
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["scale-pages"],
|
||||
operationConfig: adjustPageScaleOperationConfig,
|
||||
settingsComponent: AdjustPageScaleSettings,
|
||||
},
|
||||
addPageNumbers: {
|
||||
icon: <LocalIcon icon="123-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@ -689,12 +701,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
mergePdfs: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.merge.title", "Merge"),
|
||||
component: null,
|
||||
|
||||
component: Merge,
|
||||
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
settingsComponent: MergeSettings
|
||||
},
|
||||
"multi-tool": {
|
||||
icon: <LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@ -721,10 +735,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
redact: {
|
||||
icon: <LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.redact.title", "Redact"),
|
||||
component: null,
|
||||
component: Redact,
|
||||
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["auto-redact"],
|
||||
operationConfig: redactOperationConfig,
|
||||
settingsComponent: RedactSingleStepSettings,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -119,7 +119,6 @@ describe('useAddPasswordOperation', () => {
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'addPassword' }
|
||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||
renderHook(() => useAddPasswordOperation());
|
||||
|
@ -30,7 +30,6 @@ export const addPasswordOperationConfig = {
|
||||
buildFormData: buildAddPasswordFormData,
|
||||
operationType: 'addPassword',
|
||||
endpoint: '/api/v1/security/add-password',
|
||||
filePrefix: 'encrypted_', // Will be overridden in hook with translation
|
||||
defaultParameters: fullDefaultParameters,
|
||||
} as const;
|
||||
|
||||
@ -39,7 +38,6 @@ export const useAddPasswordOperation = () => {
|
||||
|
||||
return useToolOperation<AddPasswordFullParameters>({
|
||||
...addPasswordOperationConfig,
|
||||
filePrefix: t('addPassword.filenamePrefix', 'encrypted') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -39,7 +39,6 @@ export const addWatermarkOperationConfig = {
|
||||
buildFormData: buildAddWatermarkFormData,
|
||||
operationType: 'watermark',
|
||||
endpoint: '/api/v1/security/add-watermark',
|
||||
filePrefix: 'watermarked_', // Will be overridden in hook with translation
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@ -48,7 +47,6 @@ export const useAddWatermarkOperation = () => {
|
||||
|
||||
return useToolOperation<AddWatermarkParameters>({
|
||||
...addWatermarkOperationConfig,
|
||||
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AdjustPageScaleParameters, defaultParameters } from './useAdjustPageScaleParameters';
|
||||
|
||||
export const buildAdjustPageScaleFormData = (parameters: AdjustPageScaleParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("scaleFactor", parameters.scaleFactor.toString());
|
||||
formData.append("pageSize", parameters.pageSize);
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const adjustPageScaleOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildAdjustPageScaleFormData,
|
||||
operationType: 'adjustPageScale',
|
||||
endpoint: '/api/v1/general/scale-pages',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useAdjustPageScaleOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<AdjustPageScaleParameters>({
|
||||
...adjustPageScaleOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('adjustPageScale.error.failed', 'An error occurred while adjusting the page scale.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,142 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from './useAdjustPageScaleParameters';
|
||||
|
||||
describe('useAdjustPageScaleParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
expect(result.current.parameters.scaleFactor).toBe(1.0);
|
||||
expect(result.current.parameters.pageSize).toBe(PageSize.KEEP);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ paramName: 'scaleFactor' as const, value: 0.5 },
|
||||
{ paramName: 'scaleFactor' as const, value: 2.0 },
|
||||
{ paramName: 'scaleFactor' as const, value: 10.0 },
|
||||
{ paramName: 'pageSize' as const, value: PageSize.A4 },
|
||||
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
|
||||
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
|
||||
])('should update parameter $paramName to $value', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
});
|
||||
|
||||
expect(result.current.parameters[paramName]).toBe(value);
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
result.current.updateParameter('scaleFactor', 2.5);
|
||||
result.current.updateParameter('pageSize', PageSize.A3);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.scaleFactor).toBe(2.5);
|
||||
expect(result.current.parameters.pageSize).toBe(PageSize.A3);
|
||||
|
||||
// Then reset
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test('should return correct endpoint name', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('scale-pages');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: 'with default parameters',
|
||||
setup: () => {},
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
description: 'with valid scale factor 0.1',
|
||||
setup: (hook: AdjustPageScaleParametersHook) => {
|
||||
hook.updateParameter('scaleFactor', 0.1);
|
||||
},
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
description: 'with valid scale factor 10.0',
|
||||
setup: (hook: AdjustPageScaleParametersHook) => {
|
||||
hook.updateParameter('scaleFactor', 10.0);
|
||||
},
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
description: 'with A4 page size',
|
||||
setup: (hook: AdjustPageScaleParametersHook) => {
|
||||
hook.updateParameter('pageSize', PageSize.A4);
|
||||
},
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
description: 'with invalid scale factor 0',
|
||||
setup: (hook: AdjustPageScaleParametersHook) => {
|
||||
hook.updateParameter('scaleFactor', 0);
|
||||
},
|
||||
expected: false
|
||||
},
|
||||
{
|
||||
description: 'with negative scale factor',
|
||||
setup: (hook: AdjustPageScaleParametersHook) => {
|
||||
hook.updateParameter('scaleFactor', -0.5);
|
||||
},
|
||||
expected: false
|
||||
}
|
||||
])('should validate parameters correctly $description', ({ setup, expected }) => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
act(() => {
|
||||
setup(result.current);
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(expected);
|
||||
});
|
||||
|
||||
test('should handle all PageSize enum values', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
Object.values(PageSize).forEach(pageSize => {
|
||||
act(() => {
|
||||
result.current.updateParameter('pageSize', pageSize);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.pageSize).toBe(pageSize);
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle scale factor edge cases', () => {
|
||||
const { result } = renderHook(() => useAdjustPageScaleParameters());
|
||||
|
||||
// Test very small valid scale factor
|
||||
act(() => {
|
||||
result.current.updateParameter('scaleFactor', 0.01);
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Test scale factor just above zero
|
||||
act(() => {
|
||||
result.current.updateParameter('scaleFactor', 0.001);
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Test exactly zero (invalid)
|
||||
act(() => {
|
||||
result.current.updateParameter('scaleFactor', 0);
|
||||
});
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
});
|
||||
});
|
@ -0,0 +1,37 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export enum PageSize {
|
||||
KEEP = 'KEEP',
|
||||
A0 = 'A0',
|
||||
A1 = 'A1',
|
||||
A2 = 'A2',
|
||||
A3 = 'A3',
|
||||
A4 = 'A4',
|
||||
A5 = 'A5',
|
||||
A6 = 'A6',
|
||||
LETTER = 'LETTER',
|
||||
LEGAL = 'LEGAL'
|
||||
}
|
||||
|
||||
export interface AdjustPageScaleParameters extends BaseParameters {
|
||||
scaleFactor: number;
|
||||
pageSize: PageSize;
|
||||
}
|
||||
|
||||
export const defaultParameters: AdjustPageScaleParameters = {
|
||||
scaleFactor: 1.0,
|
||||
pageSize: PageSize.KEEP,
|
||||
};
|
||||
|
||||
export type AdjustPageScaleParametersHook = BaseParametersHook<AdjustPageScaleParameters>;
|
||||
|
||||
export const useAdjustPageScaleParameters = (): AdjustPageScaleParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'scale-pages',
|
||||
validateFn: (params) => {
|
||||
return params.scaleFactor > 0;
|
||||
},
|
||||
});
|
||||
};
|
@ -28,7 +28,6 @@ export const autoRenameOperationConfig = {
|
||||
buildFormData: buildAutoRenameFormData,
|
||||
operationType: 'autoRename',
|
||||
endpoint: '/api/v1/misc/auto-rename',
|
||||
filePrefix: 'autoRename_',
|
||||
preserveBackendFilename: true, // Use filename from backend response headers
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
@ -42,6 +42,5 @@ export function useAutomateOperation() {
|
||||
toolType: ToolType.custom,
|
||||
operationType: 'automate',
|
||||
customProcessor,
|
||||
filePrefix: '' // No prefix needed since automation handles naming internally
|
||||
});
|
||||
}
|
||||
|
@ -113,7 +113,6 @@ describe('useChangePermissionsOperation', () => {
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
|
||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||
renderHook(() => useChangePermissionsOperation());
|
||||
|
@ -28,7 +28,6 @@ export const changePermissionsOperationConfig = {
|
||||
buildFormData: buildChangePermissionsFormData,
|
||||
operationType: 'change-permissions',
|
||||
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
|
||||
filePrefix: 'permissions_',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
@ -28,7 +28,6 @@ export const compressOperationConfig = {
|
||||
buildFormData: buildCompressFormData,
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
filePrefix: 'compressed_',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
@ -83,7 +83,7 @@ export const createFileFromResponse = (
|
||||
targetExtension = 'pdf';
|
||||
}
|
||||
|
||||
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
||||
const fallbackFilename = `${originalName}.${targetExtension}`;
|
||||
|
||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
};
|
||||
@ -136,7 +136,6 @@ export const convertOperationConfig = {
|
||||
toolType: ToolType.custom,
|
||||
customProcessor: convertProcessor, // Can't use callback version here
|
||||
operationType: 'convert',
|
||||
filePrefix: 'converted_',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
@ -17,7 +17,6 @@ export const flattenOperationConfig = {
|
||||
buildFormData: buildFlattenFormData,
|
||||
operationType: 'flatten',
|
||||
endpoint: '/api/v1/misc/flatten',
|
||||
filePrefix: 'flattened_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
@ -27,7 +26,6 @@ export const useFlattenOperation = () => {
|
||||
|
||||
return useToolOperation<FlattenParameters>({
|
||||
...flattenOperationConfig,
|
||||
filePrefix: t('flatten.filenamePrefix', 'flattened') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('flatten.error.failed', 'An error occurred while flattening the PDF.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
|
138
frontend/src/hooks/tools/merge/useMergeOperation.test.ts
Normal file
138
frontend/src/hooks/tools/merge/useMergeOperation.test.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useMergeOperation } from './useMergeOperation';
|
||||
import type { MergeParameters } from './useMergeParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation');
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the translation hook
|
||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: mockT })
|
||||
}));
|
||||
|
||||
// Mock the error handler
|
||||
vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
createStandardErrorHandler: vi.fn(() => 'error-handler-function')
|
||||
}));
|
||||
|
||||
// Import the mocked function
|
||||
import { MultiFileToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
describe('useMergeOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation<MergeParameters>);
|
||||
|
||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as MultiFileToolOperationConfig<MergeParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
downloadUrl: null,
|
||||
downloadFilename: '',
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
status: '',
|
||||
isGeneratingThumbnails: false,
|
||||
progress: null,
|
||||
executeOperation: vi.fn(),
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation: vi.fn(),
|
||||
undoOperation: function (): Promise<void> {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseToolOperation.mockReturnValue(mockToolOperationReturn);
|
||||
});
|
||||
|
||||
test('should build FormData correctly', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
const config = getToolConfig();
|
||||
const mockFiles = [
|
||||
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
|
||||
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
const parameters: MergeParameters = {
|
||||
removeDigitalSignature: true,
|
||||
generateTableOfContents: false
|
||||
};
|
||||
|
||||
const formData = config.buildFormData(parameters, mockFiles);
|
||||
|
||||
// Verify files are appended
|
||||
expect(formData.getAll('fileInput')).toHaveLength(2);
|
||||
expect(formData.getAll('fileInput')[0]).toBe(mockFiles[0]);
|
||||
expect(formData.getAll('fileInput')[1]).toBe(mockFiles[1]);
|
||||
|
||||
// Verify parameters are appended correctly
|
||||
expect(formData.get('sortType')).toBe('orderProvided');
|
||||
expect(formData.get('removeCertSign')).toBe('true');
|
||||
expect(formData.get('generateToc')).toBe('false');
|
||||
});
|
||||
|
||||
test('should handle response correctly', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
const config = getToolConfig();
|
||||
const mockBlob = new Blob(['merged content'], { type: 'application/pdf' });
|
||||
const mockFiles = [
|
||||
new File(['content1'], 'file1.pdf', { type: 'application/pdf' }),
|
||||
new File(['content2'], 'file2.pdf', { type: 'application/pdf' })
|
||||
];
|
||||
|
||||
const result = config.responseHandler!(mockBlob, mockFiles) as File[];
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('merged_file1.pdf');
|
||||
expect(result[0].type).toBe('application/pdf');
|
||||
expect(result[0].size).toBe(mockBlob.size);
|
||||
});
|
||||
|
||||
test('should return the hook result from useToolOperation', () => {
|
||||
const { result } = renderHook(() => useMergeOperation());
|
||||
|
||||
expect(result.current).toBe(mockToolOperationReturn);
|
||||
});
|
||||
|
||||
test('should use correct translation keys for error handling', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
expect(mockT).toHaveBeenCalledWith('merge.error.failed', 'An error occurred while merging the PDFs.');
|
||||
});
|
||||
|
||||
test('should build FormData with different parameter combinations', () => {
|
||||
renderHook(() => useMergeOperation());
|
||||
|
||||
const config = getToolConfig();
|
||||
const mockFiles = [new File(['test'], 'test.pdf', { type: 'application/pdf' })];
|
||||
|
||||
// Test case 1: All options disabled
|
||||
const params1: MergeParameters = {
|
||||
removeDigitalSignature: false,
|
||||
generateTableOfContents: false
|
||||
};
|
||||
const formData1 = config.buildFormData(params1, mockFiles);
|
||||
expect(formData1.get('removeCertSign')).toBe('false');
|
||||
expect(formData1.get('generateToc')).toBe('false');
|
||||
|
||||
// Test case 2: All options enabled
|
||||
const params2: MergeParameters = {
|
||||
removeDigitalSignature: true,
|
||||
generateTableOfContents: true
|
||||
};
|
||||
const formData2 = config.buildFormData(params2, mockFiles);
|
||||
expect(formData2.get('removeCertSign')).toBe('true');
|
||||
expect(formData2.get('generateToc')).toBe('true');
|
||||
});
|
||||
});
|
41
frontend/src/hooks/tools/merge/useMergeOperation.ts
Normal file
41
frontend/src/hooks/tools/merge/useMergeOperation.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ResponseHandler, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { MergeParameters } from './useMergeParameters';
|
||||
|
||||
const buildFormData = (parameters: MergeParameters, files: File[]): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
files.forEach((file) => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
|
||||
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
||||
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
const mergeResponseHandler: ResponseHandler = (blob: Blob, originalFiles: File[]): File[] => {
|
||||
const filename = `merged_${originalFiles[0].name}`
|
||||
return [new File([blob], filename, { type: 'application/pdf' })];
|
||||
};
|
||||
|
||||
// Operation configuration for automation
|
||||
export const mergeOperationConfig: ToolOperationConfig<MergeParameters> = {
|
||||
toolType: ToolType.multiFile,
|
||||
buildFormData,
|
||||
operationType: 'merge',
|
||||
endpoint: '/api/v1/general/merge-pdfs',
|
||||
filePrefix: 'merged_',
|
||||
responseHandler: mergeResponseHandler,
|
||||
};
|
||||
|
||||
export const useMergeOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<MergeParameters>({
|
||||
...mergeOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('merge.error.failed', 'An error occurred while merging the PDFs.'))
|
||||
});
|
||||
};
|
68
frontend/src/hooks/tools/merge/useMergeParameters.test.ts
Normal file
68
frontend/src/hooks/tools/merge/useMergeParameters.test.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMergeParameters, defaultParameters } from './useMergeParameters';
|
||||
|
||||
describe('useMergeParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ paramName: 'removeDigitalSignature' as const, value: true },
|
||||
{ paramName: 'removeDigitalSignature' as const, value: false },
|
||||
{ paramName: 'generateTableOfContents' as const, value: true },
|
||||
{ paramName: 'generateTableOfContents' as const, value: false }
|
||||
])('should update parameter $paramName to $value', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
});
|
||||
|
||||
expect(result.current.parameters[paramName]).toBe(value);
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
// First, change some parameters
|
||||
act(() => {
|
||||
result.current.updateParameter('removeDigitalSignature', true);
|
||||
result.current.updateParameter('generateTableOfContents', true);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.removeDigitalSignature).toBe(true);
|
||||
expect(result.current.parameters.generateTableOfContents).toBe(true);
|
||||
|
||||
// Then reset
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test('should validate parameters correctly - always returns true', () => {
|
||||
const { result } = renderHook(() => useMergeParameters());
|
||||
|
||||
// Default state should be valid
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Change parameters and validate again
|
||||
act(() => {
|
||||
result.current.updateParameter('removeDigitalSignature', true);
|
||||
result.current.updateParameter('generateTableOfContents', true);
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
|
||||
// Reset and validate again
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(true);
|
||||
});
|
||||
});
|
21
frontend/src/hooks/tools/merge/useMergeParameters.ts
Normal file
21
frontend/src/hooks/tools/merge/useMergeParameters.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { BaseParametersHook, useBaseParameters } from '../shared/useBaseParameters';
|
||||
|
||||
export interface MergeParameters extends BaseParameters {
|
||||
removeDigitalSignature: boolean;
|
||||
generateTableOfContents: boolean;
|
||||
};
|
||||
|
||||
export const defaultParameters: MergeParameters = {
|
||||
removeDigitalSignature: false,
|
||||
generateTableOfContents: false,
|
||||
};
|
||||
|
||||
export type MergeParametersHook = BaseParametersHook<MergeParameters>;
|
||||
|
||||
export const useMergeParameters = (): MergeParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: "merge-pdfs",
|
||||
});
|
||||
};
|
@ -88,8 +88,8 @@ export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extr
|
||||
throw new Error(`Response is not a valid PDF. Header: "${head}"`);
|
||||
}
|
||||
|
||||
const base = stripExt(originalFiles[0].name);
|
||||
return [new File([blob], `ocr_${base}.pdf`, { type: 'application/pdf' })];
|
||||
const originalName = originalFiles[0].name;
|
||||
return [new File([blob], originalName, { type: 'application/pdf' })];
|
||||
};
|
||||
|
||||
// Static configuration object (without t function dependencies)
|
||||
@ -98,7 +98,6 @@ export const ocrOperationConfig = {
|
||||
buildFormData: buildOCRFormData,
|
||||
operationType: 'ocr',
|
||||
endpoint: '/api/v1/misc/ocr-pdf',
|
||||
filePrefix: 'ocr_',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
142
frontend/src/hooks/tools/redact/useRedactOperation.test.ts
Normal file
142
frontend/src/hooks/tools/redact/useRedactOperation.test.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { describe, expect, test, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { buildRedactFormData, redactOperationConfig, useRedactOperation } from './useRedactOperation';
|
||||
import { defaultParameters, RedactParameters } from './useRedactParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: vi.fn((_key: string, fallback: string) => fallback) })
|
||||
}));
|
||||
|
||||
// Mock the error handler utility
|
||||
vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
createStandardErrorHandler: vi.fn(() => vi.fn())
|
||||
}));
|
||||
|
||||
describe('buildRedactFormData', () => {
|
||||
const mockFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
|
||||
|
||||
test('should build form data for automatic mode', () => {
|
||||
const parameters: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'automatic',
|
||||
wordsToRedact: ['Confidential', 'Secret'],
|
||||
useRegex: true,
|
||||
wholeWordSearch: true,
|
||||
redactColor: '#FF0000',
|
||||
customPadding: 0.5,
|
||||
convertPDFToImage: false,
|
||||
};
|
||||
|
||||
const formData = buildRedactFormData(parameters, mockFile);
|
||||
|
||||
expect(formData.get('fileInput')).toBe(mockFile);
|
||||
expect(formData.get('listOfText')).toBe('Confidential\nSecret');
|
||||
expect(formData.get('useRegex')).toBe('true');
|
||||
expect(formData.get('wholeWordSearch')).toBe('true');
|
||||
expect(formData.get('redactColor')).toBe('FF0000'); // Hash should be removed
|
||||
expect(formData.get('customPadding')).toBe('0.5');
|
||||
expect(formData.get('convertPDFToImage')).toBe('false');
|
||||
});
|
||||
|
||||
test('should handle empty words array', () => {
|
||||
const parameters: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'automatic',
|
||||
wordsToRedact: [],
|
||||
};
|
||||
|
||||
const formData = buildRedactFormData(parameters, mockFile);
|
||||
|
||||
expect(formData.get('listOfText')).toBe('');
|
||||
});
|
||||
|
||||
test('should join multiple words with newlines', () => {
|
||||
const parameters: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'automatic',
|
||||
wordsToRedact: ['Word1', 'Word2', 'Word3'],
|
||||
};
|
||||
|
||||
const formData = buildRedactFormData(parameters, mockFile);
|
||||
|
||||
expect(formData.get('listOfText')).toBe('Word1\nWord2\nWord3');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: 'remove hash from redact color', redactColor: '#123456', expected: '123456' },
|
||||
{ description: 'handle redact color without hash', redactColor: 'ABCDEF', expected: 'ABCDEF' },
|
||||
])('should $description', ({ redactColor, expected }) => {
|
||||
const parameters: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'automatic',
|
||||
redactColor,
|
||||
};
|
||||
|
||||
const formData = buildRedactFormData(parameters, mockFile);
|
||||
|
||||
expect(formData.get('redactColor')).toBe(expected);
|
||||
});
|
||||
|
||||
test('should convert boolean parameters to strings', () => {
|
||||
const parameters: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'automatic',
|
||||
useRegex: false,
|
||||
wholeWordSearch: true,
|
||||
convertPDFToImage: false,
|
||||
};
|
||||
|
||||
const formData = buildRedactFormData(parameters, mockFile);
|
||||
|
||||
expect(formData.get('useRegex')).toBe('false');
|
||||
expect(formData.get('wholeWordSearch')).toBe('true');
|
||||
expect(formData.get('convertPDFToImage')).toBe('false');
|
||||
});
|
||||
|
||||
test('should throw error for manual mode (not implemented)', () => {
|
||||
const parameters: RedactParameters = {
|
||||
...defaultParameters,
|
||||
mode: 'manual',
|
||||
};
|
||||
|
||||
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRedactOperation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should call useToolOperation with correct configuration', async () => {
|
||||
const { useToolOperation } = await import('../shared/useToolOperation');
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
renderHook(() => useRedactOperation());
|
||||
|
||||
expect(mockUseToolOperation).toHaveBeenCalledWith({
|
||||
...redactOperationConfig,
|
||||
getErrorMessage: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
test('should provide error handler to useToolOperation', async () => {
|
||||
const { useToolOperation } = await import('../shared/useToolOperation');
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
renderHook(() => useRedactOperation());
|
||||
|
||||
const callArgs = mockUseToolOperation.mock.calls[0][0];
|
||||
expect(typeof callArgs.getErrorMessage).toBe('function');
|
||||
});
|
||||
});
|
50
frontend/src/hooks/tools/redact/useRedactOperation.ts
Normal file
50
frontend/src/hooks/tools/redact/useRedactOperation.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RedactParameters, defaultParameters } from './useRedactParameters';
|
||||
|
||||
// Static configuration that can be used by both the hook and automation executor
|
||||
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
if (parameters.mode === 'automatic') {
|
||||
// Convert array to newline-separated string as expected by backend
|
||||
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
|
||||
formData.append("useRegex", parameters.useRegex.toString());
|
||||
formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
|
||||
formData.append("redactColor", parameters.redactColor.replace('#', ''));
|
||||
formData.append("customPadding", parameters.customPadding.toString());
|
||||
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
|
||||
} else {
|
||||
// Manual mode parameters would go here when implemented
|
||||
throw new Error('Manual redaction not yet implemented');
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static configuration object
|
||||
export const redactOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRedactFormData,
|
||||
operationType: 'redact',
|
||||
endpoint: (parameters: RedactParameters) => {
|
||||
if (parameters.mode === 'automatic') {
|
||||
return '/api/v1/security/auto-redact';
|
||||
} else {
|
||||
// Manual redaction endpoint would go here when implemented
|
||||
throw new Error('Manual redaction not yet implemented');
|
||||
}
|
||||
},
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useRedactOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<RedactParameters>({
|
||||
...redactOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('redact.error.failed', 'An error occurred while redacting the PDF.'))
|
||||
});
|
||||
};
|
134
frontend/src/hooks/tools/redact/useRedactParameters.test.ts
Normal file
134
frontend/src/hooks/tools/redact/useRedactParameters.test.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRedactParameters, defaultParameters } from './useRedactParameters';
|
||||
|
||||
describe('useRedactParameters', () => {
|
||||
test('should initialize with default parameters', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ paramName: 'mode' as const, value: 'manual' as const },
|
||||
{ paramName: 'wordsToRedact' as const, value: ['word1', 'word2'] },
|
||||
{ paramName: 'useRegex' as const, value: true },
|
||||
{ paramName: 'wholeWordSearch' as const, value: true },
|
||||
{ paramName: 'redactColor' as const, value: '#FF0000' },
|
||||
{ paramName: 'customPadding' as const, value: 0.5 },
|
||||
{ paramName: 'convertPDFToImage' as const, value: false }
|
||||
])('should update parameter $paramName', ({ paramName, value }) => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter(paramName, value);
|
||||
});
|
||||
|
||||
expect(result.current.parameters[paramName]).toStrictEqual(value);
|
||||
});
|
||||
|
||||
test('should reset parameters to defaults', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
// Modify some parameters
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'manual');
|
||||
result.current.updateParameter('wordsToRedact', ['test']);
|
||||
result.current.updateParameter('useRegex', true);
|
||||
});
|
||||
|
||||
// Reset parameters
|
||||
act(() => {
|
||||
result.current.resetParameters();
|
||||
});
|
||||
|
||||
expect(result.current.parameters).toStrictEqual(defaultParameters);
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test.each([
|
||||
{ description: 'validate when wordsToRedact has non-empty words in automatic mode', wordsToRedact: ['word1', 'word2'], expected: true },
|
||||
{ description: 'not validate when wordsToRedact is empty in automatic mode', wordsToRedact: [], expected: false },
|
||||
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
|
||||
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
|
||||
])('should $description', ({ wordsToRedact, expected }) => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'automatic');
|
||||
result.current.updateParameter('wordsToRedact', wordsToRedact);
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(expected);
|
||||
});
|
||||
|
||||
test('should not validate in manual mode (not implemented)', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'manual');
|
||||
});
|
||||
|
||||
expect(result.current.validateParameters()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endpoint handling', () => {
|
||||
test('should return correct endpoint for automatic mode', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'automatic');
|
||||
});
|
||||
|
||||
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
|
||||
});
|
||||
|
||||
test('should throw error for manual mode (not implemented)', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('mode', 'manual');
|
||||
});
|
||||
|
||||
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
|
||||
});
|
||||
});
|
||||
|
||||
test('should maintain parameter state across updates', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('redactColor', '#FF0000');
|
||||
result.current.updateParameter('customPadding', 0.5);
|
||||
result.current.updateParameter('wordsToRedact', ['word1']);
|
||||
});
|
||||
|
||||
// All parameters should be updated
|
||||
expect(result.current.parameters.redactColor).toBe('#FF0000');
|
||||
expect(result.current.parameters.customPadding).toBe(0.5);
|
||||
expect(result.current.parameters.wordsToRedact).toEqual(['word1']);
|
||||
|
||||
// Other parameters should remain at defaults
|
||||
expect(result.current.parameters.mode).toBe('automatic');
|
||||
expect(result.current.parameters.useRegex).toBe(false);
|
||||
expect(result.current.parameters.wholeWordSearch).toBe(false);
|
||||
expect(result.current.parameters.convertPDFToImage).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle array parameter updates correctly', () => {
|
||||
const { result } = renderHook(() => useRedactParameters());
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('wordsToRedact', ['initial']);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.wordsToRedact).toEqual(['initial']);
|
||||
|
||||
act(() => {
|
||||
result.current.updateParameter('wordsToRedact', ['updated', 'multiple']);
|
||||
});
|
||||
|
||||
expect(result.current.parameters.wordsToRedact).toEqual(['updated', 'multiple']);
|
||||
});
|
||||
});
|
48
frontend/src/hooks/tools/redact/useRedactParameters.ts
Normal file
48
frontend/src/hooks/tools/redact/useRedactParameters.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export type RedactMode = 'automatic' | 'manual';
|
||||
|
||||
export interface RedactParameters extends BaseParameters {
|
||||
mode: RedactMode;
|
||||
|
||||
// Automatic redaction parameters
|
||||
wordsToRedact: string[];
|
||||
useRegex: boolean;
|
||||
wholeWordSearch: boolean;
|
||||
redactColor: string;
|
||||
customPadding: number;
|
||||
convertPDFToImage: boolean;
|
||||
}
|
||||
|
||||
export const defaultParameters: RedactParameters = {
|
||||
mode: 'automatic',
|
||||
wordsToRedact: [],
|
||||
useRegex: false,
|
||||
wholeWordSearch: false,
|
||||
redactColor: '#000000',
|
||||
customPadding: 0.1,
|
||||
convertPDFToImage: true,
|
||||
};
|
||||
|
||||
export type RedactParametersHook = BaseParametersHook<RedactParameters>;
|
||||
|
||||
export const useRedactParameters = (): RedactParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: (params) => {
|
||||
if (params.mode === 'automatic') {
|
||||
return '/api/v1/security/auto-redact';
|
||||
}
|
||||
// Manual redaction endpoint would go here when implemented
|
||||
throw new Error('Manual redaction not yet implemented');
|
||||
},
|
||||
validateFn: (params) => {
|
||||
if (params.mode === 'automatic') {
|
||||
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
|
||||
}
|
||||
// Manual mode validation would go here when implemented
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
@ -16,7 +16,6 @@ export const removeCertificateSignOperationConfig = {
|
||||
buildFormData: buildRemoveCertificateSignFormData,
|
||||
operationType: 'remove-certificate-sign',
|
||||
endpoint: '/api/v1/security/remove-cert-sign',
|
||||
filePrefix: 'unsigned_', // Will be overridden in hook with translation
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@ -25,7 +24,6 @@ export const useRemoveCertificateSignOperation = () => {
|
||||
|
||||
return useToolOperation<RemoveCertificateSignParameters>({
|
||||
...removeCertificateSignOperationConfig,
|
||||
filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
|
||||
});
|
||||
};
|
||||
|
@ -97,7 +97,6 @@ describe('useRemovePasswordOperation', () => {
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'removePassword' }
|
||||
])('should configure $property correctly', ({ property, expectedValue }) => {
|
||||
renderHook(() => useRemovePasswordOperation());
|
||||
|
@ -17,7 +17,6 @@ export const removePasswordOperationConfig = {
|
||||
buildFormData: buildRemovePasswordFormData,
|
||||
operationType: 'removePassword',
|
||||
endpoint: '/api/v1/security/remove-password',
|
||||
filePrefix: 'decrypted_', // Will be overridden in hook with translation
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@ -26,7 +25,6 @@ export const useRemovePasswordOperation = () => {
|
||||
|
||||
return useToolOperation<RemovePasswordParameters>({
|
||||
...removePasswordOperationConfig,
|
||||
filePrefix: t('removePassword.filenamePrefix', 'decrypted') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -16,7 +16,6 @@ export const repairOperationConfig = {
|
||||
buildFormData: buildRepairFormData,
|
||||
operationType: 'repair',
|
||||
endpoint: '/api/v1/misc/repair',
|
||||
filePrefix: 'repaired_', // Will be overridden in hook with translation
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@ -25,7 +24,6 @@ export const useRepairOperation = () => {
|
||||
|
||||
return useToolOperation<RepairParameters>({
|
||||
...repairOperationConfig,
|
||||
filePrefix: t('repair.filenamePrefix', 'repaired') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -25,7 +25,6 @@ export const sanitizeOperationConfig = {
|
||||
buildFormData: buildSanitizeFormData,
|
||||
operationType: 'sanitize',
|
||||
endpoint: '/api/v1/security/sanitize-pdf',
|
||||
filePrefix: 'sanitized_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
@ -35,7 +34,6 @@ export const useSanitizeOperation = () => {
|
||||
|
||||
return useToolOperation<SanitizeParameters>({
|
||||
...sanitizeOperationConfig,
|
||||
filePrefix: t('sanitize.filenamePrefix', 'sanitized') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('sanitize.error.failed', 'An error occurred while sanitising the PDF.'))
|
||||
});
|
||||
};
|
||||
|
@ -6,12 +6,12 @@ import { ToolOperationHook } from './useToolOperation';
|
||||
import { BaseParametersHook } from './useBaseParameters';
|
||||
import { StirlingFile } from '../../../types/fileContext';
|
||||
|
||||
interface BaseToolReturn<TParams> {
|
||||
interface BaseToolReturn<TParams, TParamsHook extends BaseParametersHook<TParams>> {
|
||||
// File management
|
||||
selectedFiles: StirlingFile[];
|
||||
|
||||
// Tool-specific hooks
|
||||
params: BaseParametersHook<TParams>;
|
||||
params: TParamsHook;
|
||||
operation: ToolOperationHook<TParams>;
|
||||
|
||||
// Endpoint validation
|
||||
@ -33,12 +33,14 @@ interface BaseToolReturn<TParams> {
|
||||
/**
|
||||
* Base tool hook for tool components. Manages standard behaviour for tools.
|
||||
*/
|
||||
export function useBaseTool<TParams>(
|
||||
export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TParams>>(
|
||||
toolName: string,
|
||||
useParams: () => BaseParametersHook<TParams>,
|
||||
useParams: () => TParamsHook,
|
||||
useOperation: () => ToolOperationHook<TParams>,
|
||||
props: BaseToolProps,
|
||||
): BaseToolReturn<TParams> {
|
||||
options?: { minFiles?: number }
|
||||
): BaseToolReturn<TParams, TParamsHook> {
|
||||
const minFiles = options?.minFiles ?? 1;
|
||||
const { onPreviewFile, onComplete, onError } = props;
|
||||
|
||||
// File selection
|
||||
@ -96,7 +98,7 @@ export function useBaseTool<TParams>(
|
||||
}, [operation, onPreviewFile]);
|
||||
|
||||
// Standard computed state
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasFiles = selectedFiles.length >= minFiles;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
const settingsCollapsed = !hasFiles || hasResults;
|
||||
|
||||
|
@ -6,7 +6,7 @@ import type { ProcessingProgress } from './useToolState';
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
endpoint: string | ((params: TParams) => string);
|
||||
buildFormData: (params: TParams, file: File) => FormData;
|
||||
filePrefix: string;
|
||||
filePrefix?: string;
|
||||
responseHandler?: ResponseHandler;
|
||||
preserveBackendFilename?: boolean;
|
||||
}
|
||||
|
@ -6,8 +6,9 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ProcessingProgress, ResponseHandler };
|
||||
@ -31,7 +32,7 @@ interface BaseToolOperationConfig<TParams> {
|
||||
operationType: string;
|
||||
|
||||
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
||||
filePrefix: string;
|
||||
filePrefix?: string;
|
||||
|
||||
/**
|
||||
* Whether to preserve the filename provided by the backend in response headers.
|
||||
@ -165,18 +166,20 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Reset state
|
||||
actions.setLoading(true);
|
||||
actions.setError(null);
|
||||
actions.resetResults();
|
||||
cleanupBlobUrls();
|
||||
|
||||
// Prepare files with history metadata injection (for PDFs)
|
||||
actions.setStatus('Processing files...');
|
||||
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
|
||||
// Convert StirlingFile to regular File objects for API processing
|
||||
const validRegularFiles = extractFiles(validFiles);
|
||||
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
||||
const filesForAPI = extractFiles(validFiles);
|
||||
|
||||
switch (config.toolType) {
|
||||
case ToolType.singleFile: {
|
||||
@ -190,18 +193,17 @@ export const useToolOperation = <TParams>(
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
params,
|
||||
validRegularFiles,
|
||||
filesForAPI,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolType.multiFile: {
|
||||
// Multi-file processing - single API call with all files
|
||||
actions.setStatus('Processing files...');
|
||||
const formData = config.buildFormData(params, validRegularFiles);
|
||||
const formData = config.buildFormData(params, filesForAPI);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
@ -209,11 +211,11 @@ export const useToolOperation = <TParams>(
|
||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||
if (config.responseHandler) {
|
||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||
processedFiles = await config.responseHandler(response.data, validRegularFiles);
|
||||
processedFiles = await config.responseHandler(response.data, filesForAPI);
|
||||
} else if (response.data.type === 'application/pdf' ||
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
// Single PDF response (e.g. split with merge option) - use original filename
|
||||
const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
||||
const originalFileName = filesForAPI[0]?.name || 'document.pdf';
|
||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||
processedFiles = [singleFile];
|
||||
} else {
|
||||
@ -230,13 +232,14 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
case ToolType.custom:
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||
processedFiles = await config.customProcessor(params, filesForAPI);
|
||||
break;
|
||||
}
|
||||
|
||||
if (processedFiles.length > 0) {
|
||||
actions.setFiles(processedFiles);
|
||||
|
||||
|
||||
// Generate thumbnails and download URL concurrently
|
||||
actions.setGeneratingThumbnails(true);
|
||||
const [thumbnails, downloadInfo] = await Promise.all([
|
||||
@ -264,7 +267,40 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
}
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
|
||||
// Create new tool operation
|
||||
const newToolOperation = {
|
||||
toolName: config.operationType,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Generate fresh processedFileMetadata for all processed files to ensure accuracy
|
||||
actions.setStatus('Generating metadata for processed files...');
|
||||
const processedFileMetadataArray = await Promise.all(
|
||||
processedFiles.map(file => generateProcessedFileMetadata(file))
|
||||
);
|
||||
const shouldBranchHistory = processedFiles.length != inputStirlingFileStubs.length;
|
||||
// Create output stubs with fresh metadata (no inheritance of stale processedFile data)
|
||||
const outputStirlingFileStubs = shouldBranchHistory
|
||||
? processedFiles.map((file, index) =>
|
||||
createNewStirlingFileStub(file, undefined, thumbnails[index], processedFileMetadataArray[index])
|
||||
)
|
||||
: processedFiles.map((resultingFile, index) =>
|
||||
createChildStub(
|
||||
inputStirlingFileStubs[index],
|
||||
newToolOperation,
|
||||
resultingFile,
|
||||
thumbnails[index],
|
||||
processedFileMetadataArray[index]
|
||||
)
|
||||
);
|
||||
|
||||
// Create StirlingFile objects from processed files and child stubs
|
||||
const outputStirlingFiles = processedFiles.map((file, index) => {
|
||||
const childStub = outputStirlingFileStubs[index];
|
||||
return createStirlingFile(file, childStub.id);
|
||||
});
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
|
@ -16,7 +16,6 @@ export const singleLargePageOperationConfig = {
|
||||
buildFormData: buildSingleLargePageFormData,
|
||||
operationType: 'single-large-page',
|
||||
endpoint: '/api/v1/general/pdf-to-single-page',
|
||||
filePrefix: 'single_page_', // Will be overridden in hook with translation
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@ -25,7 +24,6 @@ export const useSingleLargePageOperation = () => {
|
||||
|
||||
return useToolOperation<SingleLargePageParameters>({
|
||||
...singleLargePageOperationConfig,
|
||||
filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
|
||||
});
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SplitParameters, defaultParameters } from './useSplitParameters';
|
||||
import { SPLIT_MODES } from '../../../constants/splitConstants';
|
||||
import { SPLIT_METHODS } from '../../../constants/splitConstants';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
|
||||
// Static functions that can be used by both the hook and automation executor
|
||||
@ -12,46 +12,58 @@ export const buildSplitFormData = (parameters: SplitParameters, file: File): For
|
||||
|
||||
formData.append("fileInput", file);
|
||||
|
||||
switch (parameters.mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
switch (parameters.method) {
|
||||
case SPLIT_METHODS.BY_PAGES:
|
||||
formData.append("pageNumbers", parameters.pages);
|
||||
break;
|
||||
case SPLIT_MODES.BY_SECTIONS:
|
||||
case SPLIT_METHODS.BY_SECTIONS:
|
||||
formData.append("horizontalDivisions", parameters.hDiv);
|
||||
formData.append("verticalDivisions", parameters.vDiv);
|
||||
formData.append("merge", parameters.merge.toString());
|
||||
break;
|
||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||
formData.append(
|
||||
"splitType",
|
||||
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
|
||||
);
|
||||
case SPLIT_METHODS.BY_SIZE:
|
||||
formData.append("splitType", "0");
|
||||
formData.append("splitValue", parameters.splitValue);
|
||||
break;
|
||||
case SPLIT_MODES.BY_CHAPTERS:
|
||||
case SPLIT_METHODS.BY_PAGE_COUNT:
|
||||
formData.append("splitType", "1");
|
||||
formData.append("splitValue", parameters.splitValue);
|
||||
break;
|
||||
case SPLIT_METHODS.BY_DOC_COUNT:
|
||||
formData.append("splitType", "2");
|
||||
formData.append("splitValue", parameters.splitValue);
|
||||
break;
|
||||
case SPLIT_METHODS.BY_CHAPTERS:
|
||||
formData.append("bookmarkLevel", parameters.bookmarkLevel);
|
||||
formData.append("includeMetadata", parameters.includeMetadata.toString());
|
||||
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
||||
break;
|
||||
case SPLIT_METHODS.BY_PAGE_DIVIDER:
|
||||
formData.append("duplexMode", parameters.duplexMode.toString());
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown split mode: ${parameters.mode}`);
|
||||
throw new Error(`Unknown split method: ${parameters.method}`);
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
export const getSplitEndpoint = (parameters: SplitParameters): string => {
|
||||
switch (parameters.mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
switch (parameters.method) {
|
||||
case SPLIT_METHODS.BY_PAGES:
|
||||
return "/api/v1/general/split-pages";
|
||||
case SPLIT_MODES.BY_SECTIONS:
|
||||
case SPLIT_METHODS.BY_SECTIONS:
|
||||
return "/api/v1/general/split-pdf-by-sections";
|
||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||
case SPLIT_METHODS.BY_SIZE:
|
||||
case SPLIT_METHODS.BY_PAGE_COUNT:
|
||||
case SPLIT_METHODS.BY_DOC_COUNT:
|
||||
return "/api/v1/general/split-by-size-or-count";
|
||||
case SPLIT_MODES.BY_CHAPTERS:
|
||||
case SPLIT_METHODS.BY_CHAPTERS:
|
||||
return "/api/v1/general/split-pdf-by-chapters";
|
||||
case SPLIT_METHODS.BY_PAGE_DIVIDER:
|
||||
return "/api/v1/misc/auto-split-pdf";
|
||||
default:
|
||||
throw new Error(`Unknown split mode: ${parameters.mode}`);
|
||||
throw new Error(`Unknown split method: ${parameters.method}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -61,7 +73,6 @@ export const splitOperationConfig = {
|
||||
buildFormData: buildSplitFormData,
|
||||
operationType: 'splitPdf',
|
||||
endpoint: getSplitEndpoint,
|
||||
filePrefix: 'split_',
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user