mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86afba440 | ||
|
|
ebab5a4456 | ||
|
|
436c8cbed2 | ||
|
|
81dc90cd6d | ||
|
|
917edc43b3 | ||
|
|
3c48740c5e |
@@ -0,0 +1,56 @@
|
||||
name: Clear GitHub Actions Cache
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- clear-github-cache
|
||||
|
||||
jobs:
|
||||
clear-cache:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Clear all caches
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const caches = await github.rest.actions.getActionsCacheList({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
for (const cache of caches.data.actions_caches) {
|
||||
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
|
||||
await github.rest.actions.deleteActionsCacheById({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
cache_id: cache.id,
|
||||
});
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Handle pagination if more than 100 caches
|
||||
let totalCount = caches.data.total_count;
|
||||
while (deleted < totalCount) {
|
||||
const moreCaches = await github.rest.actions.getActionsCacheList({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
if (moreCaches.data.actions_caches.length === 0) break;
|
||||
for (const cache of moreCaches.data.actions_caches) {
|
||||
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
|
||||
await github.rest.actions.deleteActionsCacheById({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
cache_id: cache.id,
|
||||
});
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Successfully deleted ${deleted} caches.`);
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- baseDockerImage
|
||||
- accessIssueFix
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -34,6 +35,8 @@ jobs:
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
elif [ "${{ github.ref_name }}" == "accessIssueFix" ]; then
|
||||
VERSION="1.0.3"
|
||||
else
|
||||
VERSION="1.0.0"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Rollback Latest Tags to Version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to rollback to (e.g. 2.8.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Install crane
|
||||
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rollback all latest tags to v${{ inputs.version }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
DOCKER_HUB_ORG_USERNAME: ${{ secrets.DOCKER_HUB_ORG_USERNAME }}
|
||||
REPO_OWNER: ${{ steps.repoowner.outputs.lowercase }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
IMAGES=(
|
||||
"${DOCKER_HUB_USERNAME}/s-pdf"
|
||||
"ghcr.io/${REPO_OWNER}/s-pdf"
|
||||
"ghcr.io/${REPO_OWNER}/stirling-pdf"
|
||||
"${DOCKER_HUB_ORG_USERNAME}/stirling-pdf"
|
||||
)
|
||||
|
||||
VARIANTS=(
|
||||
"${VERSION}:latest"
|
||||
"${VERSION}-fat:latest-fat"
|
||||
"${VERSION}-ultra-lite:latest-ultra-lite"
|
||||
)
|
||||
|
||||
FAILED=0
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
for variant in "${VARIANTS[@]}"; do
|
||||
SOURCE_TAG="${variant%%:*}"
|
||||
TARGET_TAG="${variant##*:}"
|
||||
|
||||
echo "::group::${image} — ${SOURCE_TAG} → ${TARGET_TAG}"
|
||||
|
||||
if crane manifest "${image}:${SOURCE_TAG}" > /dev/null 2>&1; then
|
||||
crane cp "${image}:${SOURCE_TAG}" "${image}:${TARGET_TAG}"
|
||||
echo "✅ ${image}:${TARGET_TAG} now points to ${SOURCE_TAG}"
|
||||
else
|
||||
echo "::warning::⚠️ ${image}:${SOURCE_TAG} not found, skipping"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "::warning::Some source tags were not found. This is expected if not all variants exist for this version."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Rollback to ${VERSION} complete!"
|
||||
@@ -27,10 +27,10 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
@@ -43,7 +43,7 @@ dependencies {
|
||||
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
|
||||
@@ -66,7 +66,7 @@ dependencies {
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.16.2'
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
@@ -82,7 +82,7 @@ dependencies {
|
||||
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
|
||||
+7
-1
@@ -37,6 +37,7 @@ import stirling.software.SPDF.model.PipelineConfig;
|
||||
import stirling.software.SPDF.model.PipelineOperation;
|
||||
import stirling.software.SPDF.model.PipelineResult;
|
||||
import stirling.software.SPDF.service.ApiDocService;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
@@ -111,7 +112,12 @@ public class PipelineProcessor {
|
||||
|
||||
private String getApiKeyForUser() {
|
||||
if (userService == null) return "";
|
||||
return userService.getCurrentUserApiKey();
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null && !username.equals("anonymousUser")) {
|
||||
return userService.getApiKeyForUser(username);
|
||||
}
|
||||
// Scheduled/internal context — no user in security context
|
||||
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
}
|
||||
|
||||
private String getBaseUrl() {
|
||||
|
||||
@@ -34,6 +34,7 @@ public class TextFinder extends PDFTextStripper {
|
||||
this.useRegex = useRegex;
|
||||
this.wholeWordSearch = wholeWordSearch;
|
||||
this.setWordSeparator(" ");
|
||||
this.setLineSeparator("\n");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -37,7 +37,7 @@ spotless {
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
@@ -51,8 +51,8 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.43'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.16.1'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.17.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
@@ -62,7 +62,7 @@ dependencies {
|
||||
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.10'
|
||||
implementation('com.coveo:saml-client:5.0.0') {
|
||||
exclude group: 'org.opensaml', module: 'opensaml-core'
|
||||
|
||||
+6
-1
@@ -293,7 +293,12 @@ public class SecurityConfiguration {
|
||||
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
// TODO: IPRateLimitingFilter disabled — limit is 1M (no-op) and raw Filter
|
||||
// impl causes Spring Security async dispatch bug (response already committed
|
||||
// errors on StreamingResponseBody endpoints). Re-enable once converted to
|
||||
// OncePerRequestFilter with proper config-driven limits.
|
||||
// .addFilterBefore(rateLimitingFilter,
|
||||
// UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
|
||||
http.sessionManagement(
|
||||
|
||||
+15
-11
@@ -2,13 +2,13 @@ plugins {
|
||||
id "java"
|
||||
id "jacoco"
|
||||
id "io.spring.dependency-management" version "1.1.7"
|
||||
id "org.springframework.boot" version "4.0.3"
|
||||
id "org.springframework.boot" version "4.0.5"
|
||||
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
|
||||
id "io.swagger.swaggerhub" version "1.3.2"
|
||||
id "com.diffplug.spotless" version "8.1.0"
|
||||
id "com.github.jk1.dependency-license-report" version "3.0.1"
|
||||
id "com.diffplug.spotless" version "8.4.0"
|
||||
id "com.github.jk1.dependency-license-report" version "3.1.1"
|
||||
//id "nebula.lint" version "19.0.3"
|
||||
id "org.sonarqube" version "7.2.2.6593"
|
||||
id "org.sonarqube" version "7.2.3.7755"
|
||||
}
|
||||
|
||||
import com.github.jk1.license.render.*
|
||||
@@ -20,17 +20,17 @@ import org.gradle.api.tasks.testing.Test
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
|
||||
ext {
|
||||
springBootVersion = "4.0.3"
|
||||
springBootVersion = "4.0.5"
|
||||
pdfboxVersion = "3.0.7"
|
||||
imageioVersion = "3.13.1"
|
||||
lombokVersion = "1.18.42"
|
||||
lombokVersion = "1.18.44"
|
||||
bouncycastleVersion = "1.83"
|
||||
springSecuritySamlVersion = "7.0.2"
|
||||
springSecuritySamlVersion = "7.0.4"
|
||||
openSamlVersion = "5.2.1"
|
||||
commonmarkVersion = "0.27.1"
|
||||
commonmarkVersion = "0.28.0"
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
logback = "1.5.32"
|
||||
junitPlatformVersion = "1.12.2"
|
||||
// junit-platform-launcher version managed by Spring Boot BOM
|
||||
modernJavaVersion = 21
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.9.0'
|
||||
version = '2.9.2'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
@@ -198,6 +198,10 @@ subprojects {
|
||||
imports {
|
||||
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
|
||||
}
|
||||
dependencies {
|
||||
// Override BOM-managed commons-lang3 for CVE-2025-48924 fix
|
||||
dependency 'org.apache.commons:commons-lang3:3.20.0'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -523,7 +527,7 @@ dependencies {
|
||||
}
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
|
||||
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
|
||||
|
||||
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.2")
|
||||
testImplementation "com.squareup.okhttp3:mockwebserver"
|
||||
|
||||
@@ -91,6 +91,15 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
DBUS_SESSION_BUS_ADDRESS=/dev/null \
|
||||
SAL_TMP=/tmp/stirling-pdf/libre
|
||||
|
||||
# Metadata labels
|
||||
|
||||
@@ -91,8 +91,17 @@ ENV VERSION_TAG=$VERSION_TAG \
|
||||
_JVM_OPTS_BALANCED="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
_JVM_OPTS_PERFORMANCE="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/configs/heap_dumps -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -XX:+UseCompactObjectHeaders -XX:+UseStringDeduplication -XX:+AlwaysPreTouch -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true -Djava.awt.headless=true" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
FAT_DOCKER=true \
|
||||
INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf \
|
||||
TEMP=/tmp/stirling-pdf \
|
||||
TMP=/tmp/stirling-pdf \
|
||||
DBUS_SESSION_BUS_ADDRESS=/dev/null \
|
||||
SAL_TMP=/tmp/stirling-pdf/libre
|
||||
|
||||
# Metadata labels
|
||||
|
||||
@@ -4422,6 +4422,8 @@ title = "Markdown To PDF"
|
||||
submit = "Merge"
|
||||
tags = "merge,Page operations,Back end,server side"
|
||||
title = "Merge"
|
||||
viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them."
|
||||
goToFileEditor = "Go to file editor"
|
||||
|
||||
[merge.error]
|
||||
failed = "An error occurred while merging the PDFs."
|
||||
@@ -7523,6 +7525,11 @@ endpointUnavailable = "This tool is unavailable on your server."
|
||||
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
|
||||
invalidParams = "Fill in the required settings."
|
||||
noFiles = "Add a file to get started."
|
||||
viewerMode = "Switch to the file editor to select multiple files."
|
||||
singleFileScope = "Only applying to: {{fileName}}"
|
||||
scopeThisFile = "this file"
|
||||
scopeFiles = "files"
|
||||
selectFilesHint = "Select files in Active Files to run this tool"
|
||||
|
||||
[tools]
|
||||
noSearchResults = "No tools found"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.9.0",
|
||||
"version": "2.9.2",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -52,8 +52,8 @@ const FileEditor = ({
|
||||
// Get navigation actions
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
// Get viewer context for setting active file index
|
||||
const { setActiveFileIndex } = useViewer();
|
||||
// Get viewer context for setting active file index and ID
|
||||
const { setActiveFileIndex, setActiveFileId } = useViewer();
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
@@ -351,12 +351,11 @@ const FileEditor = ({
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
if (index !== -1) {
|
||||
// Set the file as selected in context, sync the viewer index, and switch to viewer
|
||||
setSelectedFiles([fileId]);
|
||||
setActiveFileId(fileId as string);
|
||||
setActiveFileIndex(index);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeStirlingFileStubs, setSelectedFiles, setActiveFileIndex, navActions.setWorkbench]);
|
||||
}, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
@@ -26,11 +26,11 @@ import { formatFileSize } from '@app/utils/fileUtils';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
||||
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { truncateCenter } from '@app/utils/textUtils';
|
||||
|
||||
|
||||
|
||||
@@ -461,8 +461,8 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
|
||||
<FileEditorFileName file={file} />
|
||||
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
|
||||
@@ -6,16 +6,7 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
// Truncate text from the center: "very-long-filename.pdf" -> "very-lo...ame.pdf"
|
||||
function truncateCenter(text: string, maxLength: number = 25): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const ellipsis = '...';
|
||||
const charsToShow = maxLength - ellipsis.length;
|
||||
const frontChars = Math.ceil(charsToShow / 2);
|
||||
const backChars = Math.floor(charsToShow / 2);
|
||||
return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars);
|
||||
}
|
||||
import { truncateCenter } from '@app/utils/textUtils';
|
||||
|
||||
interface FileDropdownMenuProps {
|
||||
displayName: string;
|
||||
|
||||
@@ -53,6 +53,7 @@ const OperationButton = ({
|
||||
: t('tool.endpointUnavailable', 'This tool is unavailable on your server.'),
|
||||
noFiles: t('tool.noFiles', 'Add a file to get started.'),
|
||||
invalidParams: t('tool.invalidParams', 'Fill in the required settings.'),
|
||||
viewerMode: t('tool.viewerMode', 'Switch to the file editor to select multiple files.'),
|
||||
};
|
||||
|
||||
const tooltipLabel = blockedByBackend
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import OperationButton, { OperationButtonProps } from '@app/components/tools/shared/OperationButton';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
import { useAllFiles } from '@app/contexts/FileContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
|
||||
export interface ScopedOperationButtonProps extends OperationButtonProps {
|
||||
selectedFiles: StirlingFile[];
|
||||
disableScopeHints?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps OperationButton with scope-aware button text and a filename note.
|
||||
*
|
||||
* - Viewer mode (multiple files loaded): appends "(this file)" to button text and
|
||||
* shows a note naming the exact file that will be processed.
|
||||
* - File-editor mode with N>1 selected files: appends "(N files)" to button text.
|
||||
* - File-editor mode with 0 selected files: shows a hint to select files.
|
||||
* - All other cases: no change to button text or layout.
|
||||
*/
|
||||
export function ScopedOperationButton({ selectedFiles, disableScopeHints, ...props }: ScopedOperationButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { workbench } = useNavigationState();
|
||||
const { activeFileIndex } = useViewer();
|
||||
const { files: allFiles } = useAllFiles();
|
||||
|
||||
const isViewerMode = workbench === 'viewer';
|
||||
const isFileEditorMode = workbench === 'fileEditor';
|
||||
const hasMultipleFilesLoaded = allFiles.length > 1;
|
||||
const baseText = props.submitText ?? t('submit', 'Submit');
|
||||
|
||||
const disabledForViewerMode = props.disabledReason === 'viewerMode';
|
||||
|
||||
let scopedText = baseText;
|
||||
if (!disableScopeHints && !disabledForViewerMode) {
|
||||
if (isViewerMode && hasMultipleFilesLoaded) {
|
||||
scopedText = `${baseText} (${t('tool.scopeThisFile', 'this file')})`;
|
||||
} else if (!isViewerMode && selectedFiles.length > 1) {
|
||||
scopedText = `${baseText} (${selectedFiles.length} ${t('tool.scopeFiles', 'files')})`;
|
||||
}
|
||||
}
|
||||
|
||||
const viewerFileName = !disableScopeHints && !disabledForViewerMode && isViewerMode && hasMultipleFilesLoaded
|
||||
? allFiles[activeFileIndex]?.name
|
||||
: null;
|
||||
|
||||
const showSelectFilesHint = !disableScopeHints && isFileEditorMode && allFiles.length > 0 && selectedFiles.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<OperationButton {...props} submitText={scopedText} />
|
||||
{viewerFileName && (
|
||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||
{t('tool.singleFileScope', 'Only applying to: {{fileName}}', { fileName: viewerFileName })}
|
||||
</Text>
|
||||
)}
|
||||
{showSelectFilesHint && (
|
||||
<Text size="xs" c="dimmed" ta="center" mx="md" mt={2}>
|
||||
{t('tool.selectFilesHint', 'Select files in Active Files to run this tool')}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { createToolSteps, ToolStepProvider } from '@app/components/tools/shared/ToolStep';
|
||||
import OperationButton from '@app/components/tools/shared/OperationButton';
|
||||
import { ScopedOperationButton } from '@app/components/tools/shared/ScopedOperationButton';
|
||||
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
@@ -57,6 +57,8 @@ export interface ExecuteButtonConfig {
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
showCloudBadge?: boolean;
|
||||
/** Suppress the automatic "(this file)" / "(N files)" scope hints in the button text. */
|
||||
disableScopeHints?: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewStepConfig<TParams = unknown> {
|
||||
@@ -77,6 +79,8 @@ export interface ToolFlowConfig<TParams = unknown> {
|
||||
// Optional preview content rendered between steps and the execute button
|
||||
preview?: React.ReactNode;
|
||||
executeButton?: ExecuteButtonConfig;
|
||||
/** Optional content rendered immediately below the execute button (e.g. contextual help). */
|
||||
belowExecuteButton?: React.ReactNode;
|
||||
review: ReviewStepConfig<TParams>;
|
||||
forceStepNumbers?: boolean;
|
||||
}
|
||||
@@ -128,17 +132,22 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
|
||||
: eb.paramsValid === false ? 'invalidParams'
|
||||
: null;
|
||||
return (
|
||||
<OperationButton
|
||||
onClick={eb.onClick}
|
||||
isLoading={config.review.operation.isLoading}
|
||||
disabled={eb.disabled}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
loadingText={eb.loadingText}
|
||||
submitText={eb.text}
|
||||
showCloudBadge={eb.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
|
||||
data-testid={eb.testId}
|
||||
data-tour="run-button"
|
||||
/>
|
||||
<>
|
||||
<ScopedOperationButton
|
||||
selectedFiles={config.files.selectedFiles ?? []}
|
||||
disableScopeHints={eb.disableScopeHints}
|
||||
onClick={eb.onClick}
|
||||
isLoading={config.review.operation.isLoading}
|
||||
disabled={eb.disabled}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
loadingText={eb.loadingText}
|
||||
submitText={eb.text}
|
||||
showCloudBadge={eb.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
|
||||
data-testid={eb.testId}
|
||||
data-tour="run-button"
|
||||
/>
|
||||
{config.belowExecuteButton}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@@ -176,8 +176,6 @@ const EmbedPdfViewerContent = ({
|
||||
// Similar to scroll preservation - track rotation across file reloads
|
||||
const pendingRotationRestoreRef = useRef<number | null>(null);
|
||||
const rotationRestoreAttemptsRef = useRef<number>(0);
|
||||
// Track the file ID we should be viewing after a save (to handle list reordering)
|
||||
const pendingFileIdRef = useRef<string | null>(null);
|
||||
|
||||
const formApplyInProgressRef = useRef(false);
|
||||
|
||||
@@ -188,11 +186,12 @@ const EmbedPdfViewerContent = ({
|
||||
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFilesRef = useRef(activeFiles);
|
||||
activeFilesRef.current = activeFiles;
|
||||
const activeFileIds = activeFiles.map(f => f.fileId);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = useNavigationGuard();
|
||||
@@ -277,41 +276,46 @@ const EmbedPdfViewerContent = ({
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
|
||||
const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex;
|
||||
const hasInitializedFromSelection = useRef(false);
|
||||
|
||||
// When viewer opens with a selected file, switch to that file
|
||||
// activeFileId (from ViewerContext) is the stable source of truth.
|
||||
// We derive activeFileIndex from it so reorders after tool operations don't lose the viewed file.
|
||||
const { activeFileId, setActiveFileId } = useViewer();
|
||||
|
||||
// Stable string key representing the current file list order.
|
||||
// Using a joined ID string avoids depending on the activeFiles array reference,
|
||||
// which is a new object every render and would cause an infinite effect loop.
|
||||
const fileIdsKey = activeFiles.map(f => f.fileId).join(',');
|
||||
|
||||
// When the file list actually changes, re-derive activeFileIndex from the stable activeFileId.
|
||||
useEffect(() => {
|
||||
if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) {
|
||||
const selectedFileId = selectedFileIds[0];
|
||||
const index = activeFiles.findIndex(f => f.fileId === selectedFileId);
|
||||
if (index !== -1 && index !== activeFileIndex) {
|
||||
setActiveFileIndex(index);
|
||||
}
|
||||
hasInitializedFromSelection.current = true;
|
||||
if (!activeFileId || activeFiles.length === 0) return;
|
||||
const newIndex = activeFiles.findIndex(f => f.fileId === activeFileId);
|
||||
if (newIndex !== -1 && newIndex !== activeFileIndex) {
|
||||
setActiveFileIndex(newIndex);
|
||||
}
|
||||
}, [selectedFileIds, activeFiles, activeFileIndex]);
|
||||
}, [fileIdsKey, activeFileId]); // stable primitives — no infinite loop
|
||||
|
||||
// Reset active tab if it's out of bounds
|
||||
// When the user manually switches file tabs, keep activeFileId in sync.
|
||||
// Skips the initial mount to avoid overwriting an activeFileId set by handleViewFile.
|
||||
const activeFileIndexMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!activeFileIndexMountedRef.current) {
|
||||
activeFileIndexMountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
const fileId = activeFilesRef.current[activeFileIndex]?.fileId;
|
||||
if (fileId && fileId !== activeFileId) {
|
||||
setActiveFileId(fileId);
|
||||
}
|
||||
}, [activeFileIndex]);
|
||||
|
||||
// Reset active tab if it's out of bounds (safety net)
|
||||
useEffect(() => {
|
||||
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
|
||||
setActiveFileIndex(0);
|
||||
}
|
||||
}, [activeFiles.length, activeFileIndex]);
|
||||
|
||||
// After saving a file, the list may reorder (sorted by version).
|
||||
// Track the saved file's ID and update activeFileIndex to follow it.
|
||||
useEffect(() => {
|
||||
if (pendingFileIdRef.current && activeFiles.length > 0) {
|
||||
const targetFileId = pendingFileIdRef.current;
|
||||
const newIndex = activeFiles.findIndex(f => f.fileId === targetFileId);
|
||||
if (newIndex !== -1 && newIndex !== activeFileIndex) {
|
||||
setActiveFileIndex(newIndex);
|
||||
}
|
||||
// Clear the pending file ID once we've found and switched to it
|
||||
pendingFileIdRef.current = null;
|
||||
}
|
||||
}, [activeFiles, activeFileIndex, setActiveFileIndex]);
|
||||
|
||||
// Determine which file to display
|
||||
const currentFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
@@ -627,11 +631,9 @@ const EmbedPdfViewerContent = ({
|
||||
// Store the rotation to restore after file replacement
|
||||
pendingRotationRestoreRef.current = currentRotation;
|
||||
rotationRestoreAttemptsRef.current = 0;
|
||||
// Store the new file ID so we can track it after the list reorders
|
||||
// Track the new file ID so the viewer follows it after the list reorders
|
||||
const newFileId = stubs[0]?.id;
|
||||
if (newFileId) {
|
||||
pendingFileIdRef.current = newFileId;
|
||||
}
|
||||
if (newFileId) setActiveFileId(newFileId);
|
||||
|
||||
// Step 4: Consume only the current file (replace in context)
|
||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||
@@ -682,11 +684,9 @@ const EmbedPdfViewerContent = ({
|
||||
pendingRotationRestoreRef.current = currentRotation;
|
||||
rotationRestoreAttemptsRef.current = 0;
|
||||
|
||||
// Store the new file ID for tracking
|
||||
// Track the new file ID so the viewer follows it after the list reorders
|
||||
const newFileId = stubs[0]?.id;
|
||||
if (newFileId) {
|
||||
pendingFileIdRef.current = newFileId;
|
||||
}
|
||||
if (newFileId) setActiveFileId(newFileId);
|
||||
|
||||
// Replace the current file in context
|
||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||
@@ -738,9 +738,7 @@ const EmbedPdfViewerContent = ({
|
||||
rotationRestoreAttemptsRef.current = 0;
|
||||
|
||||
const newFileId = stubs[0]?.id;
|
||||
if (newFileId) {
|
||||
pendingFileIdRef.current = newFileId;
|
||||
}
|
||||
if (newFileId) setActiveFileId(newFileId);
|
||||
|
||||
await actions.consumeFiles([currentFileId], stirlingFiles, stubs);
|
||||
} catch (error) {
|
||||
|
||||
@@ -260,9 +260,11 @@ export const NavigationProvider: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the tool in the registry to get its proper workbench
|
||||
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
|
||||
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
||||
// Look up the tool in the registry to get its proper workbench.
|
||||
// Regular tools have no workbench preference — keep the current view so
|
||||
// opening a tool doesn't unexpectedly switch the user to the viewer.
|
||||
const tool = isValidToolId(toolId) ? toolRegistry[toolId] : null;
|
||||
const workbench = (tool && tool.workbench) ? tool.workbench : state.workbench;
|
||||
|
||||
// Validate toolId and convert to ToolId type
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
|
||||
@@ -317,14 +317,12 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
actions.setSelectedTool(validToolId);
|
||||
|
||||
// Get the tool from registry to determine workbench
|
||||
// Switch workbench only when required: leaving a custom view, or the tool declares one.
|
||||
const tool = getSelectedTool(toolId);
|
||||
if (wasInCustomWorkbench) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
} else if (tool && tool.workbench) {
|
||||
actions.setWorkbench(tool.workbench);
|
||||
} else {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
|
||||
// Clear search query when selecting a tool
|
||||
@@ -342,8 +340,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
} else if (tool && tool.workbench) {
|
||||
actions.setWorkbench(tool.workbench);
|
||||
} else {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
|
||||
@@ -120,7 +120,9 @@ export interface ViewerContextType {
|
||||
isAnnotationMode: boolean;
|
||||
setAnnotationMode: (enabled: boolean) => void;
|
||||
|
||||
// Active file index for multi-file viewing
|
||||
// Active file tracking — ID is the stable source of truth; index is derived from it
|
||||
activeFileId: string | null;
|
||||
setActiveFileId: (id: string | null) => void;
|
||||
activeFileIndex: number;
|
||||
setActiveFileIndex: (index: number) => void;
|
||||
|
||||
@@ -204,6 +206,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
const [activeFileId, setActiveFileId] = useState<string | null>(null);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||
const [pdfRenderMode, setPdfRenderModeState] = useState<PdfRenderMode>(
|
||||
() => preferencesService.getPreference('pdfRenderMode')
|
||||
@@ -485,7 +488,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
|
||||
// Active file index
|
||||
// Active file tracking
|
||||
activeFileId,
|
||||
setActiveFileId,
|
||||
activeFileIndex,
|
||||
setActiveFileIndex,
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { BaseParameters } from '@app/types/parameters';
|
||||
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
||||
|
||||
@@ -20,7 +21,7 @@ export const defaultParameters: OverlayPdfsParameters = {
|
||||
export type OverlayPdfsParametersHook = BaseParametersHook<OverlayPdfsParameters>;
|
||||
|
||||
export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
|
||||
return useBaseParameters<OverlayPdfsParameters>({
|
||||
const base = useBaseParameters<OverlayPdfsParameters>({
|
||||
defaultParameters,
|
||||
endpointName: 'overlay-pdfs',
|
||||
validateFn: (params) => {
|
||||
@@ -32,6 +33,18 @@ export const useOverlayPdfsParameters = (): OverlayPdfsParametersHook => {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Overlay files are chosen independently of the base file selection, so they
|
||||
// must survive the parameter reset that fires when the workbench selection
|
||||
// transitions from 0 → 1+ files. Only mode/position/counts are reset.
|
||||
const resetParameters = useCallback(() => {
|
||||
base.setParameters(prev => ({
|
||||
...defaultParameters,
|
||||
overlayFiles: prev.overlayFiles,
|
||||
}));
|
||||
}, [base.setParameters]);
|
||||
|
||||
return { ...base, resetParameters };
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ExecuteDisabledReason =
|
||||
| 'endpointUnavailable'
|
||||
| 'noFiles'
|
||||
| 'invalidParams'
|
||||
| 'viewerMode'
|
||||
| null;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFileSelection } from '@app/contexts/FileContext';
|
||||
import { useEndpointEnabled } from '@app/hooks/useEndpointConfig';
|
||||
import { useViewScopedFiles } from '@app/hooks/tools/shared/useViewScopedFiles';
|
||||
import { BaseToolProps } from '@app/types/tool';
|
||||
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
||||
@@ -38,14 +38,24 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
useParams: () => TParamsHook,
|
||||
useOperation: () => ToolOperationHook<TParams>,
|
||||
props: BaseToolProps,
|
||||
options?: { minFiles?: number }
|
||||
options?: {
|
||||
minFiles?: number;
|
||||
/** When true, uses the full file selection rather than the viewer-scoped single file. */
|
||||
ignoreViewerScope?: boolean;
|
||||
}
|
||||
): BaseToolReturn<TParams, TParamsHook> {
|
||||
const minFiles = options?.minFiles ?? 1;
|
||||
const ignoreViewerScope = options?.ignoreViewerScope ?? false;
|
||||
const { onPreviewFile, onComplete, onError } = props;
|
||||
|
||||
// File selection
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const previousFileCount = useRef(selectedFiles.length);
|
||||
const viewerScopedFiles = useViewScopedFiles(ignoreViewerScope);
|
||||
|
||||
// In viewer mode: scope to the single displayed file (unless ignoreViewerScope).
|
||||
// In fileEditor with a selection: scope to the selected files.
|
||||
// All other cases (pageEditor, custom, no selection): use all loaded files.
|
||||
const effectiveFiles = viewerScopedFiles;
|
||||
|
||||
const previousFileCount = useRef(effectiveFiles.length);
|
||||
|
||||
// Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs)
|
||||
const skipNextSelectionResetRef = useRef(false);
|
||||
@@ -59,7 +69,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(params.getEndpointName());
|
||||
|
||||
// Standard computed state - defined early so it's available in useEffects
|
||||
const hasFiles = selectedFiles.length >= minFiles;
|
||||
const hasFiles = effectiveFiles.length >= minFiles;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
const settingsCollapsed = !hasFiles || hasResults;
|
||||
|
||||
@@ -77,13 +87,13 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
}
|
||||
}, [hasResults]);
|
||||
|
||||
// Reset results when user manually changes file selection
|
||||
// Reset results when effective files change (viewer file switch or selection change)
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
if (effectiveFiles.length === 0) return;
|
||||
|
||||
const currentSelection = selectedFiles.map(f => f.fileId).sort().join(',');
|
||||
const currentSelection = effectiveFiles.map(f => f.fileId).sort().join(',');
|
||||
|
||||
if (currentSelection === previousSelectionRef.current) return; // No change
|
||||
if (currentSelection === previousSelectionRef.current) return;
|
||||
|
||||
// Skip reset if this is the auto-selection after operation completed
|
||||
if (skipNextSelectionResetRef.current) {
|
||||
@@ -92,15 +102,14 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
return;
|
||||
}
|
||||
|
||||
// User manually selected different files - reset results
|
||||
previousSelectionRef.current = currentSelection;
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [selectedFiles]);
|
||||
}, [effectiveFiles]);
|
||||
|
||||
// Reset parameters when transitioning from 0 files to at least 1 file
|
||||
useEffect(() => {
|
||||
const currentFileCount = selectedFiles.length;
|
||||
const currentFileCount = effectiveFiles.length;
|
||||
const prevFileCount = previousFileCount.current;
|
||||
|
||||
if (prevFileCount === 0 && currentFileCount > 0) {
|
||||
@@ -108,12 +117,12 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
}
|
||||
|
||||
previousFileCount.current = currentFileCount;
|
||||
}, [selectedFiles.length]);
|
||||
}, [effectiveFiles.length]);
|
||||
|
||||
// Standard handlers
|
||||
const handleExecute = useCallback(async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
await operation.executeOperation(params.parameters, effectiveFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
@@ -123,7 +132,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
onError(message);
|
||||
}
|
||||
}
|
||||
}, [operation, params.parameters, selectedFiles, onComplete, onError, toolName]);
|
||||
}, [operation, params.parameters, effectiveFiles, onComplete, onError, toolName]);
|
||||
|
||||
const handleThumbnailClick = useCallback((file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
@@ -143,7 +152,7 @@ export function useBaseTool<TParams, TParamsHook extends BaseParametersHook<TPar
|
||||
|
||||
return {
|
||||
// File management
|
||||
selectedFiles,
|
||||
selectedFiles: effectiveFiles,
|
||||
|
||||
// Tool-specific hooks
|
||||
params,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useCallback, useRef, useEffect, useContext } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useToolState } from '@app/hooks/tools/shared/useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from '@app/hooks/tools/shared/useToolApiCalls';
|
||||
import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
|
||||
@@ -63,6 +64,8 @@ export const useToolOperation = <TParams>(
|
||||
const { t } = useTranslation();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const viewerContext = useContext(ViewerContext);
|
||||
const setActiveFileId = viewerContext?.setActiveFileId ?? (() => {});
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
@@ -348,6 +351,9 @@ export const useToolOperation = <TParams>(
|
||||
const toConsumeInputIds = successSourceIds.filter((id) => inputFileIds.includes(id));
|
||||
console.debug('[useToolOperation] Consuming files (version)', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
// Tell the viewer to follow the replacement file — consumeFiles prepends the new file
|
||||
// to the list, so activeFileIndex would point to the wrong file without this.
|
||||
if (outputFileIds.length === 1) setActiveFileId(outputFileIds[0]);
|
||||
|
||||
// Notify on desktop when processing completes
|
||||
await notifyPdfProcessingComplete(outputFileIds.length);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAllFiles, useSelectedFiles } from '@app/contexts/FileContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
|
||||
/**
|
||||
* Returns the effective file set for tool operations.
|
||||
*
|
||||
* - Viewer: scopes to the single file currently shown, unless ignoreViewerScope is true.
|
||||
* - FileEditor: scopes to the selected subset (empty selection → empty → button disabled).
|
||||
* - PageEditor / custom workbenches: returns all loaded files (selection tracks pages, not files).
|
||||
*/
|
||||
export function useViewScopedFiles(ignoreViewerScope = false): StirlingFile[] {
|
||||
const { activeFileIndex } = useViewer();
|
||||
const { files: allFiles } = useAllFiles();
|
||||
const { workbench } = useNavigationState();
|
||||
const { selectedFiles } = useSelectedFiles();
|
||||
|
||||
return useMemo(() => {
|
||||
if (workbench === 'viewer' && !ignoreViewerScope) {
|
||||
const viewerFile = allFiles[activeFileIndex];
|
||||
return viewerFile ? [viewerFile] : allFiles;
|
||||
}
|
||||
|
||||
if (workbench === 'fileEditor') {
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}, [workbench, allFiles, activeFileIndex, selectedFiles, ignoreViewerScope]);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.9.0',
|
||||
appVersion: '2.9.2',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
@@ -12,7 +12,7 @@ import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachment
|
||||
|
||||
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const addAttachmentsTips = useAddAttachmentsTips();
|
||||
|
||||
const params = useAddAttachmentsParameters();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
@@ -12,7 +12,7 @@ import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbe
|
||||
|
||||
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const params = useAddPageNumbersParameters();
|
||||
const operation = useAddPageNumbersOperation();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
@@ -16,7 +16,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useAddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
|
||||
import { useAddStampOperation } from "@app/components/tools/addStamp/useAddStampOperation";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
@@ -17,7 +17,7 @@ import StampPositionFormattingSettings from "@app/components/tools/addStamp/Stam
|
||||
|
||||
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false);
|
||||
const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
@@ -24,7 +24,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const [collapsedType, setCollapsedType] = useState(false);
|
||||
const [collapsedStyle, setCollapsedStyle] = useState(true);
|
||||
|
||||
@@ -23,9 +23,9 @@ import type { FileId } from '@app/types/file';
|
||||
import type { StirlingFile } from '@app/types/fileContext';
|
||||
import DocumentThumbnail from '@app/components/shared/filePreview/DocumentThumbnail';
|
||||
import type { CompareWorkbenchData } from '@app/types/compare';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { getDefaultWorkbench } from '@app/types/workbench';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { truncateCenter } from '@app/utils/textUtils';
|
||||
|
||||
const CUSTOM_VIEW_ID = 'compareWorkbenchView';
|
||||
const CUSTOM_WORKBENCH_ID = 'custom:compareWorkbenchView' as const;
|
||||
@@ -48,7 +48,7 @@ const Compare = (props: BaseToolProps) => {
|
||||
useCompareParameters,
|
||||
useCompareOperation,
|
||||
props,
|
||||
{ minFiles: 2 }
|
||||
{ minFiles: 2, ignoreViewerScope: true }
|
||||
);
|
||||
|
||||
const operation = base.operation as CompareOperationHook;
|
||||
@@ -93,33 +93,57 @@ const Compare = (props: BaseToolProps) => {
|
||||
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
|
||||
}, []);
|
||||
|
||||
// Auto-map from workbench selection: always reflect the first two selected files in order.
|
||||
// This also handles deselection by promoting the remaining selection to base and clearing comparison.
|
||||
// On mount: clear selections unless exactly 2 files are loaded.
|
||||
useEffect(() => {
|
||||
// Use selected IDs directly from state so it works even if File objects aren't loaded yet
|
||||
const selectedIds = (fileState.ui.selectedFileIds as FileId[]) ?? [];
|
||||
if ((fileState.files.ids as FileId[]).length !== 2) {
|
||||
fileActions.clearSelections();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Determine next base: keep current if still selected; otherwise use the first selected id
|
||||
const nextBase: FileId | null = params.baseFileId && selectedIds.includes(params.baseFileId)
|
||||
? (params.baseFileId as FileId)
|
||||
: (selectedIds[0] ?? null);
|
||||
// Track previous file count to detect the transition to exactly 2 files.
|
||||
const prevAllIdsLengthRef = useRef<number | null>(null);
|
||||
|
||||
// Determine next comparison: keep current if still selected and distinct; otherwise use the first other selected id
|
||||
let nextComp: FileId | null = null;
|
||||
if (params.comparisonFileId && selectedIds.includes(params.comparisonFileId) && params.comparisonFileId !== nextBase) {
|
||||
nextComp = params.comparisonFileId as FileId;
|
||||
} else {
|
||||
nextComp = (selectedIds.find(id => id !== nextBase) ?? null) as FileId | null;
|
||||
// Auto-fill slots when the file count first reaches 2; respect manual picker changes after that.
|
||||
useEffect(() => {
|
||||
const selectedIds = fileState.ui.selectedFileIds as FileId[];
|
||||
const allIds = fileState.files.ids as FileId[];
|
||||
const prevLength = prevAllIdsLengthRef.current;
|
||||
prevAllIdsLengthRef.current = allIds.length;
|
||||
|
||||
if (allIds.length === 2 && prevLength !== 2) {
|
||||
// Transitioned to exactly 2 files — auto-fill both slots.
|
||||
const [firstId, secondId] = allIds as [FileId, FileId];
|
||||
fileActions.setSelectedFiles([firstId, secondId]);
|
||||
base.params.setParameters(prev => {
|
||||
if (prev.baseFileId === firstId && prev.comparisonFileId === secondId) return prev;
|
||||
return { ...prev, baseFileId: firstId, comparisonFileId: secondId };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextBase !== params.baseFileId || nextComp !== params.comparisonFileId) {
|
||||
base.params.setParameters(prev => ({
|
||||
...prev,
|
||||
baseFileId: nextBase,
|
||||
comparisonFileId: nextComp,
|
||||
}));
|
||||
if (selectedIds.length > 2) {
|
||||
fileActions.setSelectedFiles(selectedIds.slice(0, 2) as FileId[]);
|
||||
return;
|
||||
}
|
||||
}, [fileState.ui.selectedFileIds, base.params, params.baseFileId, params.comparisonFileId]);
|
||||
|
||||
const nextBase = (selectedIds[0] ?? null) as FileId | null;
|
||||
const nextComp = (selectedIds[1] ?? null) as FileId | null;
|
||||
base.params.setParameters(prev => {
|
||||
if (prev.baseFileId === nextBase && prev.comparisonFileId === nextComp) return prev;
|
||||
return { ...prev, baseFileId: nextBase, comparisonFileId: nextComp };
|
||||
});
|
||||
}, [fileState.ui.selectedFileIds, fileState.files.ids]);
|
||||
|
||||
// Clear a slot if its file is removed from the workbench.
|
||||
useEffect(() => {
|
||||
const allIds = fileState.files.ids as FileId[];
|
||||
if (params.baseFileId && !allIds.includes(params.baseFileId as FileId)) {
|
||||
base.params.setParameters(prev => ({ ...prev, baseFileId: null }));
|
||||
}
|
||||
if (params.comparisonFileId && !allIds.includes(params.comparisonFileId as FileId)) {
|
||||
base.params.setParameters(prev => ({ ...prev, comparisonFileId: null }));
|
||||
}
|
||||
}, [fileState.files.ids]);
|
||||
|
||||
// Track workbench data and drive loading/result state transitions
|
||||
const lastProcessedAtRef = useRef<number | null>(null);
|
||||
@@ -397,14 +421,10 @@ const Compare = (props: BaseToolProps) => {
|
||||
<Box className="compare-tool__thumbnail" style={{ alignSelf: 'center' }}>
|
||||
<DocumentThumbnail file={stub ?? null} thumbnail={stub?.thumbnailUrl || null} />
|
||||
</Box>
|
||||
<Stack className="compare-tool__details">
|
||||
<FitText
|
||||
text={stub?.name || ''}
|
||||
minimumFontScale={0.8}
|
||||
lines={3}
|
||||
style={{ fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
<Stack className="compare-tool__details" style={{ minWidth: 0, overflow: 'hidden', flex: 1 }}>
|
||||
<Text fw={600} title={stub?.name}>
|
||||
{truncateCenter(stub?.name || '', 50)}
|
||||
</Text>
|
||||
{pageCount && dateText && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -576,6 +596,7 @@ const Compare = (props: BaseToolProps) => {
|
||||
onClick: handleExecuteCompare,
|
||||
disabled: !canExecute,
|
||||
testId: 'compare-execute',
|
||||
disableScopeHints: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useFileState, useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
@@ -15,7 +16,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const convertParams = useConvertParameters();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import MergeSettings from "@app/components/tools/merge/MergeSettings";
|
||||
import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter";
|
||||
@@ -9,6 +10,7 @@ import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useMergeTips } from "@app/components/tooltips/useMergeTips";
|
||||
import { useFileManagement, useSelectedFiles, useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
|
||||
const Merge = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,8 +26,20 @@ const Merge = (props: BaseToolProps) => {
|
||||
useMergeParameters,
|
||||
useMergeOperation,
|
||||
props,
|
||||
{ minFiles: 2 }
|
||||
{ minFiles: 2, ignoreViewerScope: true }
|
||||
);
|
||||
|
||||
const { workbench } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const isViewerMode = workbench === 'viewer';
|
||||
|
||||
const hasAutoSwitchedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isViewerMode && !hasAutoSwitchedRef.current) {
|
||||
hasAutoSwitchedRef.current = true;
|
||||
navActions.setWorkbench('fileEditor');
|
||||
}
|
||||
}, []);
|
||||
const naturalCompare = useCallback((a: string, b: string): number => {
|
||||
const isDigit = (char: string) => char >= '0' && char <= '9';
|
||||
|
||||
@@ -136,7 +150,22 @@ const Merge = (props: BaseToolProps) => {
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
disabledReason: isViewerMode ? 'viewerMode' : undefined,
|
||||
},
|
||||
belowExecuteButton: isViewerMode && !base.hasResults ? (
|
||||
<Stack align="center" gap={6} mx="md" mt={4}>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("merge.viewerModeHint", "Merge needs 2 or more files. Head to the file editor to select them.")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navActions.setWorkbench('fileEditor')}
|
||||
>
|
||||
{t("merge.goToFileEditor", "Go to file editor")}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : undefined,
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useAdvancedOCRTips } from "@app/components/tooltips/useAdvancedOCRTips"
|
||||
|
||||
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const ocrParams = useOCRParameters();
|
||||
const ocrOperation = useOCROperation();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings";
|
||||
import { useReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
|
||||
@@ -11,7 +11,7 @@ import { useReorganizePagesOperation } from "@app/hooks/tools/reorganizePages/us
|
||||
|
||||
const ReorganizePages = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const params = useReorganizePagesParameters();
|
||||
const operation = useReorganizePagesOperation();
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Truncates text from the centre, preserving the start and end.
|
||||
* e.g. "very-long-filename.pdf" -> "very-lo...ame.pdf"
|
||||
*/
|
||||
export function truncateCenter(text: string, maxLength: number = 25): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const ellipsis = '...';
|
||||
const charsToShow = maxLength - ellipsis.length;
|
||||
const frontChars = Math.ceil(charsToShow / 2);
|
||||
const backChars = Math.floor(charsToShow / 2);
|
||||
return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out emoji characters from a text string
|
||||
* @param text - The input text string
|
||||
|
||||
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: '2.9.0',
|
||||
appVersion: '2.9.2',
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
enableDesktopInstallSlide: true,
|
||||
|
||||
@@ -192,7 +192,11 @@ run_as_runtime_user() {
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
"$@"
|
||||
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
|
||||
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "$@"
|
||||
# Set HOME/USER/LOGNAME to match gosu behavior (setpriv does not touch env vars)
|
||||
env HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \
|
||||
USER="$RUNTIME_USER" \
|
||||
LOGNAME="$RUNTIME_USER" \
|
||||
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "$@"
|
||||
else
|
||||
warn_switch_user_once
|
||||
"$@"
|
||||
@@ -868,6 +872,21 @@ for p in "${CHOWN_PATHS[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify write access to critical directories; repair if chown failed on bind mounts
|
||||
CRITICAL_DIRS=("/configs" "/logs" "/customFiles" "/pipeline")
|
||||
for dir in "${CRITICAL_DIRS[@]}"; do
|
||||
if [ -d "$dir" ]; then
|
||||
# Test write access as the runtime user
|
||||
if ! run_as_runtime_user test -w "$dir" 2>/dev/null; then
|
||||
log "WARNING: ${RUNTIME_USER} cannot write to $dir — attempting to fix permissions"
|
||||
# Try adding group-write and world-write as fallbacks
|
||||
chmod -R o+rwX "$dir" 2>/dev/null \
|
||||
|| chmod -R a+rwX "$dir" 2>/dev/null \
|
||||
|| log "ERROR: Could not grant ${RUNTIME_USER} write access to $dir. Check your volume mount permissions (e.g. set PUID/PGID or fix host directory ownership)."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# ---------- Xvfb ----------
|
||||
# Start a virtual framebuffer for GUI-based LibreOffice interactions.
|
||||
if command_exists Xvfb; then
|
||||
@@ -920,7 +939,11 @@ fi
|
||||
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
|
||||
"${JAVA_CMD[@]}" &
|
||||
elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then
|
||||
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${JAVA_CMD[@]}" &
|
||||
# Set HOME/USER/LOGNAME to match gosu behavior (setpriv does not touch env vars)
|
||||
env HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \
|
||||
USER="$RUNTIME_USER" \
|
||||
LOGNAME="$RUNTIME_USER" \
|
||||
setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${JAVA_CMD[@]}" &
|
||||
else
|
||||
warn_switch_user_once
|
||||
"${JAVA_CMD[@]}" &
|
||||
|
||||
@@ -251,34 +251,6 @@ Feature: API Validation
|
||||
And the response ZIP should contain 3 files
|
||||
|
||||
|
||||
@ffmpeg @positive @pdftovideo
|
||||
Scenario: Convert PDF to video (MP4)
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 3 pages with random text
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| videoFormat | mp4 |
|
||||
| fps | 1 |
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/video"
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 1000
|
||||
And the response file should have extension ".mp4"
|
||||
|
||||
|
||||
@ffmpeg @positive @pdftovideo
|
||||
Scenario: Convert PDF to video (WebM)
|
||||
Given I generate a PDF file as "fileInput"
|
||||
And the pdf contains 2 pages with random text
|
||||
And the request data includes
|
||||
| parameter | value |
|
||||
| videoFormat | webm |
|
||||
| fps | 2 |
|
||||
When I send the API request to the endpoint "/api/v1/convert/pdf/video"
|
||||
Then the response status code should be 200
|
||||
And the response file should have size greater than 1000
|
||||
And the response file should have extension ".webm"
|
||||
|
||||
|
||||
@positive @pdftojson
|
||||
Scenario: Convert PDF to JSON (text editor format)
|
||||
Given I generate a PDF file as "fileInput"
|
||||
|
||||
+36
-2
@@ -340,6 +340,8 @@ capture_file_list() {
|
||||
-not -path '*/tmp/hsperfdata_stirlingpdfuser/*' \
|
||||
-not -path '*/tmp/hsperfdata_root/*' \
|
||||
-not -path '*/tmp/stirling-pdf/jetty-*/*' \
|
||||
-not -path '*/tmp/stirling-pdf/lu*' \
|
||||
-not -path '*/tmp/stirling-pdf/tmp*' \
|
||||
-not -path '/tmp/lu*' \
|
||||
-not -path '*/tmp/*/user/registrymodifications.xcu' \
|
||||
-not -path '/app/stirling.aot' \
|
||||
@@ -369,8 +371,10 @@ capture_file_list() {
|
||||
-not -path '*/tmp/hsperfdata_root/*' \
|
||||
-not -path '*/tmp/stirling-pdf/hsperfdata_stirlingpdfuser/*' \
|
||||
-not -path '*/tmp/stirling-pdf/jetty-*/*' \
|
||||
-not -path '/tmp/lu*' \
|
||||
-not -path '/tmp/tmp*' \
|
||||
-not -path '*/tmp/stirling-pdf/lu*' \
|
||||
-not -path '*/tmp/stirling-pdf/tmp*' \
|
||||
-not -path '*/tmp/lu*' \
|
||||
-not -path '*/tmp/tmp*' \
|
||||
-not -path '/app/stirling.aot' \
|
||||
-not -path '*/tmp/stirling.aotconf' \
|
||||
-not -path '*/tmp/aot-*.log' \
|
||||
@@ -898,6 +902,36 @@ main() {
|
||||
passed_tests+=("Stirling-PDF-Regression $CONTAINER_NAME")
|
||||
else
|
||||
echo "WARNING: Unexpected temporary files detected after behave tests!"
|
||||
|
||||
# Save temp file failure details to a log for the test report
|
||||
local tempfile_log="$REPORT_DIR/temp-files-failure.log"
|
||||
{
|
||||
echo "=== Temp File Regression Failure ==="
|
||||
echo "Container: $CONTAINER_NAME"
|
||||
echo ""
|
||||
echo "=== Before snapshot ==="
|
||||
cat "$BEFORE_FILE" 2>/dev/null || echo "(empty)"
|
||||
echo ""
|
||||
echo "=== After snapshot ==="
|
||||
cat "$AFTER_FILE" 2>/dev/null || echo "(empty)"
|
||||
echo ""
|
||||
echo "=== Diff (new/changed files) ==="
|
||||
cat "$DIFF_FILE" 2>/dev/null || echo "(empty)"
|
||||
echo ""
|
||||
echo "=== Leftover temp files ==="
|
||||
cat "${DIFF_FILE}.tmp" 2>/dev/null || echo "(none found)"
|
||||
echo ""
|
||||
echo "=== Docker logs ==="
|
||||
docker logs "$CONTAINER_NAME" 2>&1 | tail -200
|
||||
} > "$tempfile_log" 2>/dev/null || true
|
||||
|
||||
# Copy snapshots to report dir for artifact upload
|
||||
cp "$BEFORE_FILE" "$REPORT_DIR/" 2>/dev/null || true
|
||||
cp "$AFTER_FILE" "$REPORT_DIR/" 2>/dev/null || true
|
||||
cp "$DIFF_FILE" "$REPORT_DIR/" 2>/dev/null || true
|
||||
cp "${DIFF_FILE}.tmp" "$REPORT_DIR/files_diff_tmp_matches.txt" 2>/dev/null || true
|
||||
|
||||
test_failure_logs["Stirling-PDF-Regression-Temp-Files"]="$tempfile_log"
|
||||
failed_tests+=("Stirling-PDF-Regression-Temp-Files")
|
||||
fi
|
||||
passed_tests+=("Stirling-PDF-Regression $CONTAINER_NAME")
|
||||
|
||||
Reference in New Issue
Block a user