Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae86eeb42e | ||
|
|
b59c4e1021 | ||
|
|
4ada46ca56 | ||
|
|
76aa5c7e2f | ||
|
|
d53beb9bce | ||
|
|
3d17f0409f | ||
|
|
a3e45bc182 | ||
|
|
33b2b5827a | ||
|
|
60cc749e6a | ||
|
|
11b26755a4 | ||
|
|
a5b259b453 | ||
|
|
cc1604a802 | ||
|
|
b130242688 | ||
|
|
fbae819d7c |
@@ -50,6 +50,7 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
security-events: write
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -84,6 +85,73 @@ jobs:
|
||||
gradle-version: 9.3.1
|
||||
cache-disabled: true
|
||||
|
||||
- name: Check Java formatting (Spotless)
|
||||
if: matrix.jdk-version == 25 && matrix.spring-security == false
|
||||
id: spotless-check
|
||||
run: ./gradlew spotlessCheck
|
||||
continue-on-error: true
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Comment on Java formatting failure
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Java Formatting Check Failed',
|
||||
'',
|
||||
'Your code has formatting issues. Run the following command to fix them:',
|
||||
'',
|
||||
'```bash',
|
||||
'./gradlew spotlessApply',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if Java formatting issues found
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Java Formatting Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Your code has formatting issues."
|
||||
echo "Run the following command to fix them:"
|
||||
echo ""
|
||||
echo " ./gradlew spotlessApply"
|
||||
echo ""
|
||||
echo "Then commit and push the changes."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: ./gradlew build -PnoSpotless
|
||||
env:
|
||||
@@ -187,6 +255,9 @@ jobs:
|
||||
if: needs.files-changed.outputs.frontend == 'true'
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
@@ -202,6 +273,65 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
- name: Check TypeScript formatting (Prettier)
|
||||
id: prettier-check
|
||||
run: cd frontend && npm run format:check
|
||||
continue-on-error: true
|
||||
- name: Comment on TypeScript formatting failure
|
||||
if: steps.prettier-check.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- typescript-formatting-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### TypeScript Formatting Check Failed',
|
||||
'',
|
||||
'Your code has formatting issues. Run the following command to fix them:',
|
||||
'',
|
||||
'```bash',
|
||||
'cd frontend && npm run fix',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
- name: Fail if TypeScript formatting issues found
|
||||
if: steps.prettier-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " TypeScript Formatting Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Your code has formatting issues."
|
||||
echo "Run the following command to fix them:"
|
||||
echo ""
|
||||
echo " cd frontend && npm run fix"
|
||||
echo ""
|
||||
echo "Then commit and push the changes."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
- name: Type-check frontend
|
||||
run: cd frontend && npm run prep && npm run typecheck:all
|
||||
- name: Lint frontend
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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.`);
|
||||
@@ -2,7 +2,7 @@ name: Pre-commit
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -16,9 +16,6 @@ jobs:
|
||||
# Prevents sdist builds → no tar extraction
|
||||
PIP_ONLY_BINARY: ":all:"
|
||||
PIP_DISABLE_PIP_VERSION_CHECK: "1"
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
@@ -31,13 +28,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
uses: ./.github/actions/setup-bot
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
@@ -57,47 +47,4 @@ jobs:
|
||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 9.3.1
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
env:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: git add
|
||||
run: |
|
||||
git add .
|
||||
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
committer: ${{ steps.setup-bot.outputs.committer }}
|
||||
author: ${{ steps.setup-bot.outputs.committer }}
|
||||
signoff: true
|
||||
branch: pre-commit
|
||||
title: "🤖 format everything with pre-commit by ${{ steps.setup-bot.outputs.app-slug }}"
|
||||
body: |
|
||||
Auto-generated by [create-pull-request][1] with **${{ steps.setup-bot.outputs.app-slug }}**
|
||||
|
||||
[1]: https://github.com/peter-evans/create-pull-request
|
||||
draft: false
|
||||
delete-branch: true
|
||||
labels: github-actions
|
||||
sign-commits: true
|
||||
git diff --exit-code
|
||||
|
||||
@@ -181,6 +181,7 @@ venv.bak/
|
||||
.idea/
|
||||
*.iml
|
||||
out/
|
||||
.junie/
|
||||
|
||||
# Ignore Mac DS_Store files
|
||||
.DS_Store
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ services:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -14,6 +14,8 @@ if that directory exists, is licensed under the license defined in "frontend/src
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
|
||||
* All content that resides under the "frontend/src/saas/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
|
||||
* All content that resides under the "frontend/src/prototypes/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is
|
||||
available under the MIT License as defined below.
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ public class ApplicationProperties {
|
||||
private AutoPipeline autoPipeline = new AutoPipeline();
|
||||
private ProcessExecutor processExecutor = new ProcessExecutor();
|
||||
private PdfEditor pdfEditor = new PdfEditor();
|
||||
private AiEngine aiEngine = new AiEngine();
|
||||
|
||||
@Bean
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
@@ -231,6 +232,13 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AiEngine {
|
||||
private boolean enabled = false;
|
||||
private String url = "http://localhost:5001";
|
||||
private int timeoutSeconds = 120;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Legal {
|
||||
private String termsAndConditions;
|
||||
|
||||
@@ -325,6 +325,11 @@ processExecutor:
|
||||
ghostscriptTimeoutMinutes: 30
|
||||
ocrMyPdfTimeoutMinutes: 30
|
||||
|
||||
aiEngine:
|
||||
enabled: false # Set to 'true' to enable the AI engine integration
|
||||
url: http://localhost:5001 # URL of the Python AI engine
|
||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
||||
|
||||
pdfEditor:
|
||||
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
|
||||
cache:
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiWorkflowService;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "AI Engine", description = "Endpoints for AI-powered PDF workflows")
|
||||
public class AiEngineController {
|
||||
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final AiWorkflowService aiWorkflowService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@GetMapping("/health")
|
||||
@Operation(
|
||||
summary = "AI engine health check",
|
||||
description = "Returns the health status of the AI engine including configured models")
|
||||
public ResponseEntity<String> health() throws IOException {
|
||||
String response = aiEngineClient.get("/health");
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/orchestrate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Run an AI workflow against a PDF",
|
||||
description =
|
||||
"Accepts a PDF upload and a user message and returns an AI workflow result")
|
||||
public ResponseEntity<AiWorkflowResponse> orchestrate(
|
||||
@Valid @ModelAttribute AiWorkflowRequest request) throws IOException {
|
||||
return ResponseEntity.ok(aiWorkflowService.orchestrate(request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/pdf/edit", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Generate a PDF edit plan",
|
||||
description =
|
||||
"Sends a user message to the PDF edit agent which returns a structured plan"
|
||||
+ " of tool operations to perform")
|
||||
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
||||
validateJson(requestBody);
|
||||
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
|
||||
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
|
||||
}
|
||||
|
||||
private void validateJson(String body) {
|
||||
try {
|
||||
objectMapper.readValue(body, JsonNode.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Types of content that can be extracted from a PDF and sent to the AI.
|
||||
*
|
||||
* <p>Values MUST match {@code PdfContentType} in {@code engine/src/stirling/contracts/common.py}.
|
||||
*/
|
||||
public enum AiPdfContentType {
|
||||
// Document-level structured data
|
||||
PAGE_LAYOUT("page_layout"),
|
||||
DOCUMENT_METADATA("document_metadata"),
|
||||
ENCRYPTION_INFO("encryption_info"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
LAYERS("layers"),
|
||||
EMBEDDED_FILES("embedded_files"),
|
||||
JAVASCRIPT("javascript"),
|
||||
LINKS("links"),
|
||||
IMAGE_INFO("image_info"),
|
||||
FONTS("fonts"),
|
||||
|
||||
// Text and content
|
||||
PAGE_TEXT("page_text"),
|
||||
FULL_TEXT("full_text"),
|
||||
FORM_FIELDS("form_fields"),
|
||||
ANNOTATIONS("annotations"),
|
||||
SIGNATURES("signatures"),
|
||||
STRUCTURE_TREE("structure_tree"),
|
||||
XMP_METADATA("xmp_metadata"),
|
||||
|
||||
// Heavy content
|
||||
COMPLIANCE("compliance"),
|
||||
IMAGES("images");
|
||||
|
||||
private final String value;
|
||||
|
||||
AiPdfContentType(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static AiPdfContentType fromValue(String value) {
|
||||
for (AiPdfContentType type : values()) {
|
||||
if (type.value.equals(value)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown PDF content type: " + value);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "A single PDF file input")
|
||||
public class AiWorkflowFileInput {
|
||||
|
||||
@NotNull
|
||||
@Schema(
|
||||
description = "The input PDF file",
|
||||
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Per-file content extraction request from the AI engine")
|
||||
public class AiWorkflowFileRequest {
|
||||
|
||||
@Schema(description = "Original filename of the requested file", example = "contract.pdf")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "Specific 1-based page numbers to extract from this file")
|
||||
private List<Integer> pageNumbers = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Content types to extract from this file")
|
||||
private List<AiPdfContentType> contentTypes = new ArrayList<>();
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/**
|
||||
* Discriminator values for AI workflow responses.
|
||||
*
|
||||
* <p>Values MUST match {@code WorkflowOutcome} in {@code engine/src/stirling/contracts/common.py}.
|
||||
*/
|
||||
public enum AiWorkflowOutcome {
|
||||
ANSWER("answer"),
|
||||
NOT_FOUND("not_found"),
|
||||
NEED_CONTENT("need_content"),
|
||||
PLAN("plan"),
|
||||
NEED_CLARIFICATION("need_clarification"),
|
||||
CANNOT_DO("cannot_do"),
|
||||
TOOL_CALL("tool_call"),
|
||||
COMPLETED("completed"),
|
||||
UNSUPPORTED_CAPABILITY("unsupported_capability"),
|
||||
CANNOT_CONTINUE("cannot_continue");
|
||||
|
||||
private final String value;
|
||||
|
||||
AiWorkflowOutcome(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static AiWorkflowOutcome fromValue(String value) {
|
||||
for (AiWorkflowOutcome outcome : values()) {
|
||||
if (outcome.value.equals(value)) {
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown AI workflow outcome: " + value);
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Run an AI workflow against one or more PDF files")
|
||||
public class AiWorkflowRequest {
|
||||
|
||||
@NotNull
|
||||
@Schema(description = "The input PDF files")
|
||||
private List<AiWorkflowFileInput> fileInputs;
|
||||
|
||||
@NotBlank
|
||||
@Schema(description = "The user message to orchestrate", example = "Summarise these documents")
|
||||
private String userMessage;
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Structured AI workflow result")
|
||||
public class AiWorkflowResponse {
|
||||
|
||||
@Schema(description = "Workflow outcome")
|
||||
private AiWorkflowOutcome outcome;
|
||||
|
||||
@Schema(description = "Answer returned by the AI workflow when applicable")
|
||||
private String answer;
|
||||
|
||||
@Schema(description = "Summary returned by the AI workflow when applicable")
|
||||
private String summary;
|
||||
|
||||
@Schema(description = "Rationale returned by the AI workflow when applicable")
|
||||
private String rationale;
|
||||
|
||||
@Schema(description = "Reason when the AI workflow cannot proceed")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "Clarification question for the user when more input is required")
|
||||
private String question;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Unsupported capability identifier when the workflow cannot route the request")
|
||||
private String capability;
|
||||
|
||||
@Schema(description = "Message returned for unsupported capability outcomes")
|
||||
private String message;
|
||||
|
||||
@Schema(description = "Supporting evidence snippets from extracted PDF text")
|
||||
private List<AiWorkflowTextSelection> evidence = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Structured tool steps when the workflow returns a plan")
|
||||
private List<Map<String, Object>> steps = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Per-file text extraction requests from the AI engine")
|
||||
private List<AiWorkflowFileRequest> files = new ArrayList<>();
|
||||
|
||||
@Schema(description = "Maximum number of pages the AI engine wants text extracted from")
|
||||
private Integer maxPages;
|
||||
|
||||
@Schema(description = "Maximum number of characters the AI engine wants extracted")
|
||||
private Integer maxCharacters;
|
||||
|
||||
@Schema(description = "AI engine capability to resume with on the next turn")
|
||||
private String resumeWith;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.proprietary.model.api.ai;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Page-scoped extracted text selection")
|
||||
public class AiWorkflowTextSelection {
|
||||
|
||||
@Schema(description = "1-based page number", example = "2")
|
||||
private Integer pageNumber;
|
||||
|
||||
@Schema(description = "Extracted text or evidence snippet")
|
||||
private String text;
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AiEngineClient {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public AiEngineClient(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.httpClient =
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(
|
||||
Duration.ofSeconds(
|
||||
applicationProperties.getAiEngine().getTimeoutSeconds()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public String post(String path, String jsonBody) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
if (!config.isEnabled()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
|
||||
}
|
||||
|
||||
String url = config.getUrl().stripTrailing() + path;
|
||||
log.debug("Proxying AI engine request to {}", url);
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = sendRequest(request);
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
checkResponseStatus(response);
|
||||
return response.body();
|
||||
}
|
||||
|
||||
public String get(String path) throws IOException {
|
||||
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
|
||||
if (!config.isEnabled()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
|
||||
}
|
||||
|
||||
String url = config.getUrl().stripTrailing() + path;
|
||||
log.debug("Proxying AI engine GET request to {}", url);
|
||||
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Accept", "application/json")
|
||||
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = sendRequest(request);
|
||||
|
||||
log.debug("AI engine responded with status {}", response.statusCode());
|
||||
checkResponseStatus(response);
|
||||
return response.body();
|
||||
}
|
||||
|
||||
private HttpResponse<String> sendRequest(HttpRequest request) throws IOException {
|
||||
try {
|
||||
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.SERVICE_UNAVAILABLE, "AI engine request was interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkResponseStatus(HttpResponse<String> response) {
|
||||
int status = response.statusCode();
|
||||
if (status >= 500) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_GATEWAY, "AI engine returned error: " + status);
|
||||
}
|
||||
if (status >= 400) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.valueOf(status),
|
||||
"AI engine returned client error: " + response.body());
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AiWorkflowService {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private sealed interface WorkflowState {
|
||||
record Pending(WorkflowTurnRequest request) implements WorkflowState {}
|
||||
|
||||
record Terminal(AiWorkflowResponse response) implements WorkflowState {}
|
||||
}
|
||||
|
||||
public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException {
|
||||
validateRequest(request);
|
||||
|
||||
Map<String, MultipartFile> filesByName = new LinkedHashMap<>();
|
||||
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
|
||||
filesByName.put(
|
||||
fileInput.getFileInput().getOriginalFilename(), fileInput.getFileInput());
|
||||
}
|
||||
|
||||
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
|
||||
initialRequest.setUserMessage(request.getUserMessage().trim());
|
||||
initialRequest.setFileNames(new ArrayList<>(filesByName.keySet()));
|
||||
|
||||
WorkflowState state = new WorkflowState.Pending(initialRequest);
|
||||
while (state instanceof WorkflowState.Pending pending) {
|
||||
state = advance(pending.request(), filesByName);
|
||||
}
|
||||
return ((WorkflowState.Terminal) state).response();
|
||||
}
|
||||
|
||||
private WorkflowState advance(
|
||||
WorkflowTurnRequest request, Map<String, MultipartFile> filesByName)
|
||||
throws IOException {
|
||||
AiWorkflowResponse response = invokeOrchestrator(request);
|
||||
return switch (response.getOutcome()) {
|
||||
case NEED_CONTENT -> onNeedContent(response, filesByName, request);
|
||||
case ANSWER,
|
||||
NOT_FOUND,
|
||||
PLAN,
|
||||
NEED_CLARIFICATION,
|
||||
CANNOT_DO,
|
||||
TOOL_CALL,
|
||||
COMPLETED,
|
||||
UNSUPPORTED_CAPABILITY,
|
||||
CANNOT_CONTINUE ->
|
||||
new WorkflowState.Terminal(response);
|
||||
};
|
||||
}
|
||||
|
||||
private WorkflowState onNeedContent(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesByName,
|
||||
WorkflowTurnRequest request)
|
||||
throws IOException {
|
||||
if (!request.getArtifacts().isEmpty()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue("AI engine requested content extraction more than once."));
|
||||
}
|
||||
|
||||
List<AiWorkflowFileRequest> requestedFiles = response.getFiles();
|
||||
|
||||
// Validate requested file names before loading anything
|
||||
if (requestedFiles != null && !requestedFiles.isEmpty()) {
|
||||
for (AiWorkflowFileRequest fileReq : requestedFiles) {
|
||||
if (!filesByName.containsKey(fileReq.getFileName())) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested unknown file: " + fileReq.getFileName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> fileNamesToLoad =
|
||||
(requestedFiles == null || requestedFiles.isEmpty())
|
||||
? new ArrayList<>(filesByName.keySet())
|
||||
: requestedFiles.stream().map(AiWorkflowFileRequest::getFileName).toList();
|
||||
|
||||
Map<String, AiWorkflowFileRequest> requestedByName =
|
||||
requestedFiles == null || requestedFiles.isEmpty()
|
||||
? Map.of()
|
||||
: requestedFiles.stream()
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
AiWorkflowFileRequest::getFileName, r -> r));
|
||||
|
||||
List<LoadedFile> loadedFiles = new ArrayList<>();
|
||||
try {
|
||||
for (String fileName : fileNamesToLoad) {
|
||||
PDDocument doc = pdfDocumentFactory.load(filesByName.get(fileName), true);
|
||||
loadedFiles.add(new LoadedFile(fileName, doc));
|
||||
}
|
||||
|
||||
List<PdfContentResult> contentResults =
|
||||
pdfContentExtractor.extractContent(
|
||||
loadedFiles,
|
||||
requestedByName,
|
||||
response.getMaxPages(),
|
||||
response.getMaxCharacters());
|
||||
|
||||
WorkflowTurnRequest nextRequest = new WorkflowTurnRequest();
|
||||
nextRequest.setUserMessage(request.getUserMessage());
|
||||
nextRequest.setFileNames(request.getFileNames());
|
||||
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
|
||||
nextRequest.setResumeWith(response.getResumeWith());
|
||||
return new WorkflowState.Pending(nextRequest);
|
||||
} finally {
|
||||
for (LoadedFile lf : loadedFiles) {
|
||||
try {
|
||||
lf.document().close();
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to close PDF document: {}", lf.fileName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRequest(AiWorkflowRequest request) {
|
||||
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
|
||||
if (fileInput.getFileInput().isEmpty()) {
|
||||
throw ExceptionUtils.createFileNullOrEmptyException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AiWorkflowResponse cannotContinue(String reason) {
|
||||
AiWorkflowResponse response = new AiWorkflowResponse();
|
||||
response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE);
|
||||
response.setReason(reason);
|
||||
return response;
|
||||
}
|
||||
|
||||
private AiWorkflowResponse invokeOrchestrator(WorkflowTurnRequest request) throws IOException {
|
||||
String requestBody = objectMapper.writeValueAsString(request);
|
||||
String responseBody = aiEngineClient.post("/api/v1/orchestrator", requestBody);
|
||||
return objectMapper.readValue(responseBody, AiWorkflowResponse.class);
|
||||
}
|
||||
|
||||
@Data
|
||||
private static class WorkflowTurnRequest {
|
||||
private String userMessage;
|
||||
private List<String> fileNames = new ArrayList<>();
|
||||
private List<WorkflowArtifact> artifacts = new ArrayList<>();
|
||||
private String resumeWith;
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
|
||||
import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PdfContentExtractor {
|
||||
|
||||
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
|
||||
|
||||
record LoadedFile(String fileName, PDDocument document) {}
|
||||
|
||||
/**
|
||||
* Extracts content from the loaded files according to the requested content types and budget
|
||||
* constraints.
|
||||
*/
|
||||
List<PdfContentResult> extractContent(
|
||||
List<LoadedFile> loadedFiles,
|
||||
Map<String, AiWorkflowFileRequest> requestedByName,
|
||||
int maxPages,
|
||||
int maxCharacters)
|
||||
throws IOException {
|
||||
List<PdfContentResult> contentResults = new ArrayList<>();
|
||||
int remainingPages = maxPages;
|
||||
int remainingCharacters = maxCharacters;
|
||||
|
||||
for (LoadedFile lf : loadedFiles) {
|
||||
if (remainingPages <= 0 || remainingCharacters <= 0) break;
|
||||
AiWorkflowFileRequest fileReq = requestedByName.get(lf.fileName());
|
||||
List<AiPdfContentType> contentTypes =
|
||||
fileReq != null && !fileReq.getContentTypes().isEmpty()
|
||||
? fileReq.getContentTypes()
|
||||
: List.of(AiPdfContentType.PAGE_TEXT);
|
||||
|
||||
for (AiPdfContentType contentType : contentTypes) {
|
||||
Optional<PdfContentResult> result =
|
||||
dispatchContentType(
|
||||
contentType, lf, fileReq, remainingPages, remainingCharacters);
|
||||
if (result.isPresent()) {
|
||||
PdfContentResult content = result.get();
|
||||
contentResults.add(content);
|
||||
remainingPages -= content.pagesConsumed();
|
||||
remainingCharacters -= content.charactersConsumed();
|
||||
}
|
||||
}
|
||||
}
|
||||
return contentResults;
|
||||
}
|
||||
|
||||
/** Groups content results by artifact kind and builds the corresponding workflow artifacts. */
|
||||
List<WorkflowArtifact> buildArtifacts(List<PdfContentResult> results) {
|
||||
List<WorkflowArtifact> artifacts = new ArrayList<>();
|
||||
Map<ArtifactKind, List<PdfContentResult>> byKind =
|
||||
results.stream().collect(Collectors.groupingBy(PdfContentResult::getArtifactKind));
|
||||
for (var entry : byKind.entrySet()) {
|
||||
artifacts.add(buildArtifact(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
private Optional<PdfContentResult> dispatchContentType(
|
||||
AiPdfContentType contentType,
|
||||
LoadedFile lf,
|
||||
AiWorkflowFileRequest fileReq,
|
||||
int remainingPages,
|
||||
int remainingCharacters)
|
||||
throws IOException {
|
||||
return switch (contentType) {
|
||||
case PAGE_TEXT, FULL_TEXT ->
|
||||
Optional.<PdfContentResult>ofNullable(
|
||||
extractText(lf, fileReq, remainingPages, remainingCharacters));
|
||||
default -> {
|
||||
log.warn(
|
||||
"Content type {} not yet implemented, skipping for {}",
|
||||
contentType,
|
||||
lf.fileName());
|
||||
yield Optional.empty();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ExtractedFileText extractText(
|
||||
LoadedFile lf,
|
||||
AiWorkflowFileRequest fileReq,
|
||||
int remainingPages,
|
||||
int remainingCharacters)
|
||||
throws IOException {
|
||||
List<Integer> requestedPages = fileReq != null ? fileReq.getPageNumbers() : null;
|
||||
List<Integer> pages =
|
||||
selectPages(lf.document().getNumberOfPages(), requestedPages, remainingPages);
|
||||
List<AiWorkflowTextSelection> extracted =
|
||||
extractPageText(lf.document(), pages, remainingCharacters);
|
||||
return extracted.isEmpty() ? null : buildExtractedFileText(lf.fileName(), extracted);
|
||||
}
|
||||
|
||||
private WorkflowArtifact buildArtifact(ArtifactKind kind, List<PdfContentResult> results) {
|
||||
return switch (kind) {
|
||||
case EXTRACTED_TEXT -> {
|
||||
ExtractedTextArtifact artifact = new ExtractedTextArtifact();
|
||||
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
|
||||
yield artifact;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private List<Integer> selectPages(
|
||||
int totalPages, List<Integer> requestedPageNumbers, int maxPages) {
|
||||
if (totalPages <= 0) {
|
||||
throw ExceptionUtils.createPdfNoPages();
|
||||
}
|
||||
|
||||
List<Integer> pages = new ArrayList<>();
|
||||
|
||||
if (requestedPageNumbers == null || requestedPageNumbers.isEmpty()) {
|
||||
for (int p = 1; p <= totalPages && pages.size() < maxPages; p++) {
|
||||
pages.add(p);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
Set<Integer> deduplicatedPages = new LinkedHashSet<>(requestedPageNumbers);
|
||||
for (Integer pageNumber : deduplicatedPages) {
|
||||
if (pageNumber == null || pageNumber < 1 || pageNumber > totalPages) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidPageNumber",
|
||||
"Requested page number %s is outside the PDF page range.",
|
||||
pageNumber);
|
||||
}
|
||||
pages.add(pageNumber);
|
||||
if (pages.size() >= maxPages) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
private List<AiWorkflowTextSelection> extractPageText(
|
||||
PDDocument document, List<Integer> selectedPages, int maxCharacters)
|
||||
throws IOException {
|
||||
PDFTextStripper textStripper = new PDFTextStripper();
|
||||
List<AiWorkflowTextSelection> pages = new ArrayList<>();
|
||||
int remainingCharacters = maxCharacters;
|
||||
|
||||
for (Integer pageNumber : selectedPages) {
|
||||
if (remainingCharacters <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
textStripper.setStartPage(pageNumber);
|
||||
textStripper.setEndPage(pageNumber);
|
||||
|
||||
String pageText = textStripper.getText(document).trim();
|
||||
if (pageText.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int allowedCharacters = Math.min(remainingCharacters, MAX_CHARACTERS_PER_PAGE);
|
||||
String clippedText = clip(pageText, allowedCharacters);
|
||||
if (clippedText.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AiWorkflowTextSelection selection = new AiWorkflowTextSelection();
|
||||
selection.setPageNumber(pageNumber);
|
||||
selection.setText(clippedText);
|
||||
pages.add(selection);
|
||||
remainingCharacters -= clippedText.length();
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
private ExtractedFileText buildExtractedFileText(
|
||||
String fileName, List<AiWorkflowTextSelection> pages) {
|
||||
ExtractedFileText fileText = new ExtractedFileText();
|
||||
fileText.setFileName(fileName);
|
||||
fileText.setPages(pages);
|
||||
return fileText;
|
||||
}
|
||||
|
||||
private String clip(String text, int maxLength) {
|
||||
if (text.length() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
// Avoid splitting a surrogate pair at the boundary
|
||||
int end = maxLength;
|
||||
if (Character.isHighSurrogate(text.charAt(end - 1))) {
|
||||
end--;
|
||||
}
|
||||
return text.substring(0, end);
|
||||
}
|
||||
|
||||
// --- Types shared with AiWorkflowService (package-private) ---
|
||||
|
||||
interface PdfContentResult {
|
||||
@JsonIgnore
|
||||
ArtifactKind getArtifactKind();
|
||||
|
||||
@JsonIgnore
|
||||
default int pagesConsumed() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
default int charactersConsumed() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Values MUST match {@code ArtifactKind} in {@code engine/src/stirling/contracts/common.py}.
|
||||
*/
|
||||
enum ArtifactKind {
|
||||
EXTRACTED_TEXT("extracted_text");
|
||||
|
||||
private final String value;
|
||||
|
||||
ArtifactKind(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkflowArtifact {
|
||||
ArtifactKind getKind();
|
||||
}
|
||||
|
||||
@Data
|
||||
static class ExtractedFileText implements PdfContentResult {
|
||||
private String fileName;
|
||||
private List<AiWorkflowTextSelection> pages = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public ArtifactKind getArtifactKind() {
|
||||
return ArtifactKind.EXTRACTED_TEXT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int pagesConsumed() {
|
||||
return pages.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int charactersConsumed() {
|
||||
return pages.stream().mapToInt(p -> p.getText().length()).sum();
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
static final class ExtractedTextArtifact implements WorkflowArtifact {
|
||||
private final ArtifactKind kind = ArtifactKind.EXTRACTED_TEXT;
|
||||
private List<ExtractedFileText> files = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -110,11 +110,11 @@ tasks.register('syncAppVersion') {
|
||||
[new File(sim1Path), new File(sim2Path)].each { f ->
|
||||
if (f.exists()) {
|
||||
def content = f.getText('UTF-8')
|
||||
def matcher = (content =~ /(appVersion:\s*')([^']*)(')/)
|
||||
def matcher = (content =~ /(appVersion:\s*(['"]))(.*?)(\2)/)
|
||||
if (!matcher.find()) {
|
||||
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
|
||||
}
|
||||
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}")
|
||||
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(4)}")
|
||||
if (content != updatedContent) {
|
||||
f.write(updatedContent, 'UTF-8')
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ services:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
UMASK: "022"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
limits:
|
||||
memory: 6G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
limits:
|
||||
memory: 2G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
container_name: stirling-pdf
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -119,7 +119,7 @@ EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
|
||||
CMD curl -fs --max-time 10 http://localhost:8080/api/v1/info/status || exit 1
|
||||
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD []
|
||||
|
||||
@@ -121,7 +121,7 @@ EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
|
||||
CMD curl -fs --max-time 10 http://localhost:8080/api/v1/info/status || exit 1
|
||||
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
|
||||
CMD []
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
context: ../../..
|
||||
dockerfile: docker/embedded/Dockerfile
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
context: ../../..
|
||||
dockerfile: docker/embedded/Dockerfile
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
limits:
|
||||
memory: 1G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -qv 'Please sign in'"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -qv 'Please sign in'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 16
|
||||
|
||||
@@ -42,9 +42,8 @@ select = [
|
||||
"W",
|
||||
"RUF100",
|
||||
"UP",
|
||||
]
|
||||
ignore = [
|
||||
"E501", # Temporarily disable line length limit until codebase conformat
|
||||
"PYI", # flake8-pyi: flags deprecated typing constructs
|
||||
"FA", # flake8-future-annotations: flags missing future annotations imports
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
@@ -55,6 +54,7 @@ reportUnnecessaryCast = "warning"
|
||||
reportUnnecessaryTypeIgnoreComment = "warning"
|
||||
reportUnusedImport = "warning"
|
||||
reportUnknownParameterType = "warning"
|
||||
reportDeprecated = "warning"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import assert_never
|
||||
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.output import ToolOutput
|
||||
@@ -12,12 +13,14 @@ from stirling.agents.user_spec import UserSpecAgent
|
||||
from stirling.contracts import (
|
||||
AgentDraftRequest,
|
||||
AgentDraftWorkflowResponse,
|
||||
ExtractedTextArtifact,
|
||||
OrchestratorRequest,
|
||||
OrchestratorResponse,
|
||||
PdfEditRequest,
|
||||
PdfEditResponse,
|
||||
PdfQuestionRequest,
|
||||
PdfQuestionResponse,
|
||||
SupportedCapability,
|
||||
UnsupportedCapabilityResponse,
|
||||
)
|
||||
from stirling.services import AppRuntime
|
||||
@@ -61,7 +64,7 @@ class OrchestratorAgent:
|
||||
"You are the top-level orchestrator. "
|
||||
"Choose exactly one output function that best handles the request. "
|
||||
"Use delegate_pdf_edit for requested PDF modifications. "
|
||||
"Use delegate_pdf_question for questions about the contents of a PDF. "
|
||||
"Use delegate_pdf_question for questions about PDF contents. "
|
||||
"Use delegate_user_spec for requests to create or define an agent spec. "
|
||||
"Use unsupported_capability only when none of the other outputs fit."
|
||||
),
|
||||
@@ -69,27 +72,56 @@ class OrchestratorAgent:
|
||||
)
|
||||
|
||||
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
|
||||
if request.resume_with is not None:
|
||||
return await self._resume(request, request.resume_with)
|
||||
result = await self.agent.run(
|
||||
request.user_message,
|
||||
self._build_prompt(request),
|
||||
deps=OrchestratorDeps(runtime=self.runtime, request=request),
|
||||
)
|
||||
return result.output
|
||||
|
||||
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
|
||||
"""Fast-path to get back to the correct endpoint without having to call AI."""
|
||||
match capability:
|
||||
case SupportedCapability.PDF_QUESTION:
|
||||
return await self._run_pdf_question(request)
|
||||
case SupportedCapability.PDF_EDIT:
|
||||
return await self._run_pdf_edit(request)
|
||||
case SupportedCapability.AGENT_DRAFT:
|
||||
return await self._run_agent_draft(request)
|
||||
case (
|
||||
SupportedCapability.ORCHESTRATE
|
||||
| SupportedCapability.AGENT_REVISE
|
||||
| SupportedCapability.AGENT_NEXT_ACTION
|
||||
):
|
||||
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
|
||||
case _ as unreachable:
|
||||
assert_never(unreachable)
|
||||
|
||||
async def delegate_pdf_edit(self, ctx: RunContext[OrchestratorDeps]) -> PdfEditResponse:
|
||||
request = ctx.deps.request
|
||||
return await PdfEditAgent(ctx.deps.runtime).handle(
|
||||
PdfEditRequest(user_message=request.user_message, conversation_id=request.conversation_id)
|
||||
)
|
||||
return await self._run_pdf_edit(ctx.deps.request)
|
||||
|
||||
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
|
||||
return await PdfEditAgent(self.runtime).handle(PdfEditRequest(user_message=request.user_message))
|
||||
|
||||
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
|
||||
request = ctx.deps.request
|
||||
return await PdfQuestionAgent(ctx.deps.runtime).handle(
|
||||
PdfQuestionRequest(question=request.user_message, conversation_id=request.conversation_id)
|
||||
return await self._run_pdf_question(ctx.deps.request)
|
||||
|
||||
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionResponse:
|
||||
extracted_text = self._get_extracted_text_artifact(request)
|
||||
return await PdfQuestionAgent(self.runtime).handle(
|
||||
PdfQuestionRequest(
|
||||
question=request.user_message,
|
||||
file_names=request.file_names,
|
||||
page_text=extracted_text.files if extracted_text is not None else [],
|
||||
)
|
||||
)
|
||||
|
||||
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
|
||||
request = ctx.deps.request
|
||||
return await UserSpecAgent(ctx.deps.runtime).draft(AgentDraftRequest(user_message=request.user_message))
|
||||
return await self._run_agent_draft(ctx.deps.request)
|
||||
|
||||
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
|
||||
return await UserSpecAgent(self.runtime).draft(AgentDraftRequest(user_message=request.user_message))
|
||||
|
||||
async def unsupported_capability(
|
||||
self,
|
||||
@@ -98,3 +130,28 @@ class OrchestratorAgent:
|
||||
message: str,
|
||||
) -> UnsupportedCapabilityResponse:
|
||||
return UnsupportedCapabilityResponse(capability=capability, message=message)
|
||||
|
||||
def _get_extracted_text_artifact(self, request: OrchestratorRequest) -> ExtractedTextArtifact | None:
|
||||
for artifact in request.artifacts:
|
||||
if isinstance(artifact, ExtractedTextArtifact):
|
||||
return artifact
|
||||
return None
|
||||
|
||||
def _build_prompt(self, request: OrchestratorRequest) -> str:
|
||||
artifact_summary = self._describe_artifacts(request)
|
||||
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
|
||||
return f"User message: {request.user_message}\nFiles: {file_names}\nAvailable artifacts:\n{artifact_summary}"
|
||||
|
||||
def _describe_artifacts(self, request: OrchestratorRequest) -> str:
|
||||
if not request.artifacts:
|
||||
return "- none"
|
||||
|
||||
descriptions: list[str] = []
|
||||
for artifact in request.artifacts:
|
||||
if isinstance(artifact, ExtractedTextArtifact):
|
||||
total_pages = sum(len(f.pages) for f in artifact.files)
|
||||
file_names = [f.file_name for f in artifact.files]
|
||||
descriptions.append(f"- extracted_text: {total_pages} pages from {file_names}")
|
||||
continue
|
||||
descriptions.append("- unknown artifact")
|
||||
return "\n".join(descriptions)
|
||||
|
||||
@@ -4,8 +4,11 @@ from pydantic_ai import Agent
|
||||
from pydantic_ai.output import NativeOutput
|
||||
|
||||
from stirling.contracts import (
|
||||
ExtractedFileText,
|
||||
NeedContentFileRequest,
|
||||
PdfContentType,
|
||||
PdfQuestionAnswerResponse,
|
||||
PdfQuestionNeedTextResponse,
|
||||
PdfQuestionNeedContentResponse,
|
||||
PdfQuestionNotFoundResponse,
|
||||
PdfQuestionRequest,
|
||||
PdfQuestionResponse,
|
||||
@@ -14,6 +17,9 @@ from stirling.services import AppRuntime
|
||||
|
||||
|
||||
class PdfQuestionAgent:
|
||||
DEFAULT_MAX_PAGES = 12
|
||||
DEFAULT_MAX_CHARACTERS = 24_000
|
||||
|
||||
def __init__(self, runtime: AppRuntime) -> None:
|
||||
self.runtime = runtime
|
||||
self.agent = Agent(
|
||||
@@ -25,18 +31,27 @@ class PdfQuestionAgent:
|
||||
]
|
||||
),
|
||||
system_prompt=(
|
||||
"Answer questions about a PDF using only the extracted text provided in the prompt. "
|
||||
"Answer questions about PDFs using only the extracted page text provided in the prompt. "
|
||||
"Do not guess or use outside knowledge. "
|
||||
"If the answer is not supported by the provided text, return not_found. "
|
||||
"When answering, include a short list of evidence snippets copied from the provided text."
|
||||
"When answering, include a short list of evidence snippets with their page numbers."
|
||||
),
|
||||
model_settings=runtime.smart_model_settings,
|
||||
)
|
||||
|
||||
async def handle(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
|
||||
if not request.extracted_text.strip():
|
||||
return PdfQuestionNeedTextResponse(
|
||||
reason="No extracted PDF text was provided, so the question cannot be answered yet."
|
||||
if not self._has_page_text(request.page_text):
|
||||
return PdfQuestionNeedContentResponse(
|
||||
reason="No extracted PDF page text was provided, so the question cannot be answered yet.",
|
||||
files=[
|
||||
NeedContentFileRequest(
|
||||
file_name=file_name,
|
||||
content_types=[PdfContentType.PAGE_TEXT],
|
||||
)
|
||||
for file_name in request.file_names
|
||||
],
|
||||
max_pages=self.DEFAULT_MAX_PAGES,
|
||||
max_characters=self.DEFAULT_MAX_CHARACTERS,
|
||||
)
|
||||
return await self._run_answer_agent(request)
|
||||
|
||||
@@ -45,5 +60,14 @@ class PdfQuestionAgent:
|
||||
return result.output
|
||||
|
||||
def _build_prompt(self, request: PdfQuestionRequest) -> str:
|
||||
file_name = request.file_name or "Unknown file"
|
||||
return f"File: {file_name}\nQuestion: {request.question}\nExtracted text:\n{request.extracted_text}"
|
||||
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
|
||||
sections = [
|
||||
f"[File: {file_text.file_name}, Page {selection.page_number or '?'}]\n{selection.text}"
|
||||
for file_text in request.page_text
|
||||
for selection in file_text.pages
|
||||
]
|
||||
pages = "\n\n".join(sections)
|
||||
return f"Files: {file_names}\nQuestion: {request.question}\nExtracted page text:\n{pages}"
|
||||
|
||||
def _has_page_text(self, page_text: list[ExtractedFileText]) -> bool:
|
||||
return any(selection.text.strip() for file_text in page_text for selection in file_text.pages)
|
||||
|
||||
@@ -8,7 +8,17 @@ from .agent_drafts import (
|
||||
AgentRevisionWorkflowResponse,
|
||||
)
|
||||
from .agent_specs import AgentSpec, AgentSpecStep, AiToolAgentStep
|
||||
from .common import ConversationMessage, PdfTextSelection, ToolOperationStep
|
||||
from .common import (
|
||||
ArtifactKind,
|
||||
ConversationMessage,
|
||||
ExtractedFileText,
|
||||
PdfContentType,
|
||||
PdfTextSelection,
|
||||
StepKind,
|
||||
SupportedCapability,
|
||||
ToolOperationStep,
|
||||
WorkflowOutcome,
|
||||
)
|
||||
from .execution import (
|
||||
AgentExecutionRequest,
|
||||
CannotContinueExecutionAction,
|
||||
@@ -19,7 +29,13 @@ from .execution import (
|
||||
ToolCallExecutionAction,
|
||||
)
|
||||
from .health import HealthResponse
|
||||
from .orchestrator import OrchestratorRequest, OrchestratorResponse, SupportedCapability, UnsupportedCapabilityResponse
|
||||
from .orchestrator import (
|
||||
ExtractedTextArtifact,
|
||||
OrchestratorRequest,
|
||||
OrchestratorResponse,
|
||||
UnsupportedCapabilityResponse,
|
||||
WorkflowArtifact,
|
||||
)
|
||||
from .pdf_edit import (
|
||||
EditCannotDoResponse,
|
||||
EditClarificationRequest,
|
||||
@@ -28,14 +44,16 @@ from .pdf_edit import (
|
||||
PdfEditResponse,
|
||||
)
|
||||
from .pdf_questions import (
|
||||
NeedContentFileRequest,
|
||||
PdfQuestionAnswerResponse,
|
||||
PdfQuestionNeedTextResponse,
|
||||
PdfQuestionNeedContentResponse,
|
||||
PdfQuestionNotFoundResponse,
|
||||
PdfQuestionRequest,
|
||||
PdfQuestionResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ArtifactKind",
|
||||
"AgentDraft",
|
||||
"AgentDraftRequest",
|
||||
"AgentDraftResponse",
|
||||
@@ -49,6 +67,7 @@ __all__ = [
|
||||
"AiToolAgentStep",
|
||||
"CannotContinueExecutionAction",
|
||||
"ConversationMessage",
|
||||
"ExtractedFileText",
|
||||
"CompletedExecutionAction",
|
||||
"EditCannotDoResponse",
|
||||
"EditClarificationRequest",
|
||||
@@ -56,19 +75,25 @@ __all__ = [
|
||||
"ExecutionContext",
|
||||
"ExecutionStepResult",
|
||||
"HealthResponse",
|
||||
"NeedContentFileRequest",
|
||||
"NextExecutionAction",
|
||||
"ExtractedTextArtifact",
|
||||
"OrchestratorRequest",
|
||||
"OrchestratorResponse",
|
||||
"PdfEditRequest",
|
||||
"PdfEditResponse",
|
||||
"PdfQuestionAnswerResponse",
|
||||
"PdfQuestionNotFoundResponse",
|
||||
"PdfQuestionNeedTextResponse",
|
||||
"PdfContentType",
|
||||
"PdfQuestionNeedContentResponse",
|
||||
"PdfQuestionRequest",
|
||||
"PdfQuestionResponse",
|
||||
"PdfTextSelection",
|
||||
"StepKind",
|
||||
"SupportedCapability",
|
||||
"ToolOperationStep",
|
||||
"ToolCallExecutionAction",
|
||||
"WorkflowOutcome",
|
||||
"UnsupportedCapabilityResponse",
|
||||
"WorkflowArtifact",
|
||||
]
|
||||
|
||||
@@ -7,12 +7,12 @@ from pydantic import Field
|
||||
from stirling.models import ApiModel
|
||||
|
||||
from .agent_specs import AgentSpecStep
|
||||
from .common import ConversationMessage
|
||||
from .common import ConversationMessage, StepKind, WorkflowOutcome
|
||||
from .pdf_edit import EditCannotDoResponse, EditClarificationRequest
|
||||
|
||||
|
||||
class AgentDraftStep(ApiModel):
|
||||
kind: Literal["tool", "ai_tool"]
|
||||
kind: Literal[StepKind.TOOL, StepKind.AI_TOOL]
|
||||
title: str
|
||||
description: str
|
||||
|
||||
@@ -30,7 +30,7 @@ class AgentDraftRequest(ApiModel):
|
||||
|
||||
|
||||
class AgentDraftResponse(ApiModel):
|
||||
outcome: Literal["draft"] = "draft"
|
||||
outcome: Literal[WorkflowOutcome.DRAFT] = WorkflowOutcome.DRAFT
|
||||
draft: AgentDraft
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class AgentRevisionRequest(ApiModel):
|
||||
|
||||
|
||||
class AgentRevisionResponse(ApiModel):
|
||||
outcome: Literal["draft"] = "draft"
|
||||
outcome: Literal[WorkflowOutcome.DRAFT] = WorkflowOutcome.DRAFT
|
||||
draft: AgentDraft
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ from pydantic import Field
|
||||
|
||||
from stirling.models import ApiModel, OperationId
|
||||
|
||||
from .common import ToolOperationStep
|
||||
from .common import StepKind, ToolOperationStep
|
||||
|
||||
|
||||
class AiToolAgentStep(ApiModel):
|
||||
kind: Literal["ai_tool"] = "ai_tool"
|
||||
kind: Literal[StepKind.AI_TOOL] = StepKind.AI_TOOL
|
||||
title: str
|
||||
description: str
|
||||
tool: OperationId
|
||||
|
||||
@@ -1,12 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from stirling.models import OPERATIONS, ApiModel, OperationId, ParamToolModel
|
||||
|
||||
|
||||
class PdfContentType(StrEnum):
|
||||
"""Types of content that can be extracted from a PDF and sent to the AI.
|
||||
|
||||
Java counterpart: AiPdfContentType.java - values must stay in sync.
|
||||
"""
|
||||
|
||||
# Document-level structured data
|
||||
PAGE_LAYOUT = "page_layout"
|
||||
DOCUMENT_METADATA = "document_metadata"
|
||||
ENCRYPTION_INFO = "encryption_info"
|
||||
BOOKMARKS = "bookmarks"
|
||||
LAYERS = "layers"
|
||||
EMBEDDED_FILES = "embedded_files"
|
||||
JAVASCRIPT = "javascript"
|
||||
LINKS = "links"
|
||||
IMAGE_INFO = "image_info"
|
||||
FONTS = "fonts"
|
||||
|
||||
# Text and content
|
||||
PAGE_TEXT = "page_text"
|
||||
FULL_TEXT = "full_text"
|
||||
FORM_FIELDS = "form_fields"
|
||||
ANNOTATIONS = "annotations"
|
||||
SIGNATURES = "signatures"
|
||||
STRUCTURE_TREE = "structure_tree"
|
||||
XMP_METADATA = "xmp_metadata"
|
||||
|
||||
# Heavy content
|
||||
COMPLIANCE = "compliance"
|
||||
IMAGES = "images"
|
||||
|
||||
|
||||
class WorkflowOutcome(StrEnum):
|
||||
"""Discriminator values for all workflow response unions (outcome field).
|
||||
|
||||
Java counterpart: AiWorkflowOutcome.java - values must stay in sync.
|
||||
"""
|
||||
|
||||
ANSWER = "answer"
|
||||
NEED_CONTENT = "need_content"
|
||||
NOT_FOUND = "not_found"
|
||||
PLAN = "plan"
|
||||
NEED_CLARIFICATION = "need_clarification"
|
||||
CANNOT_DO = "cannot_do"
|
||||
DRAFT = "draft"
|
||||
TOOL_CALL = "tool_call"
|
||||
COMPLETED = "completed"
|
||||
CANNOT_CONTINUE = "cannot_continue"
|
||||
UNSUPPORTED_CAPABILITY = "unsupported_capability"
|
||||
|
||||
|
||||
class ArtifactKind(StrEnum):
|
||||
"""Discriminator values for WorkflowArtifact unions (kind field).
|
||||
|
||||
Java counterpart: PdfContentExtractor.ArtifactKind - values must stay in sync.
|
||||
"""
|
||||
|
||||
EXTRACTED_TEXT = "extracted_text"
|
||||
|
||||
|
||||
class StepKind(StrEnum):
|
||||
"""Discriminator values for AgentSpecStep unions (kind field)."""
|
||||
|
||||
TOOL = "tool"
|
||||
AI_TOOL = "ai_tool"
|
||||
|
||||
|
||||
class SupportedCapability(StrEnum):
|
||||
ORCHESTRATE = "orchestrate"
|
||||
PDF_EDIT = "pdf_edit"
|
||||
PDF_QUESTION = "pdf_question"
|
||||
AGENT_DRAFT = "agent_draft"
|
||||
AGENT_REVISE = "agent_revise"
|
||||
AGENT_NEXT_ACTION = "agent_next_action"
|
||||
|
||||
|
||||
class ConversationMessage(ApiModel):
|
||||
role: str
|
||||
content: str
|
||||
@@ -17,8 +94,13 @@ class PdfTextSelection(ApiModel):
|
||||
text: str
|
||||
|
||||
|
||||
class ExtractedFileText(ApiModel):
|
||||
file_name: str
|
||||
pages: list[PdfTextSelection] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ToolOperationStep(ApiModel):
|
||||
kind: Literal["tool"] = "tool"
|
||||
kind: Literal[StepKind.TOOL] = StepKind.TOOL
|
||||
tool: OperationId
|
||||
parameters: ParamToolModel
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from pydantic import Field
|
||||
from stirling.models import ApiModel, OperationId, ParamToolModel
|
||||
|
||||
from .agent_specs import AgentSpec
|
||||
from .common import WorkflowOutcome
|
||||
|
||||
|
||||
class ExecutionStepResult(ApiModel):
|
||||
@@ -31,19 +32,19 @@ class AgentExecutionRequest(ApiModel):
|
||||
|
||||
|
||||
class ToolCallExecutionAction(ApiModel):
|
||||
outcome: Literal["tool_call"] = "tool_call"
|
||||
outcome: Literal[WorkflowOutcome.TOOL_CALL] = WorkflowOutcome.TOOL_CALL
|
||||
tool: OperationId
|
||||
parameters: ParamToolModel
|
||||
rationale: str | None = None
|
||||
|
||||
|
||||
class CompletedExecutionAction(ApiModel):
|
||||
outcome: Literal["completed"] = "completed"
|
||||
outcome: Literal[WorkflowOutcome.COMPLETED] = WorkflowOutcome.COMPLETED
|
||||
summary: str
|
||||
|
||||
|
||||
class CannotContinueExecutionAction(ApiModel):
|
||||
outcome: Literal["cannot_continue"] = "cannot_continue"
|
||||
outcome: Literal[WorkflowOutcome.CANNOT_CONTINUE] = WorkflowOutcome.CANNOT_CONTINUE
|
||||
reason: str
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field
|
||||
@@ -8,27 +7,29 @@ from pydantic import Field
|
||||
from stirling.models import ApiModel
|
||||
|
||||
from .agent_drafts import AgentDraftResponse
|
||||
from .common import ArtifactKind, ExtractedFileText, SupportedCapability, WorkflowOutcome
|
||||
from .execution import NextExecutionAction
|
||||
from .pdf_edit import PdfEditResponse
|
||||
from .pdf_questions import PdfQuestionResponse
|
||||
|
||||
|
||||
class SupportedCapability(StrEnum):
|
||||
ORCHESTRATE = "orchestrate"
|
||||
PDF_EDIT = "pdf_edit"
|
||||
PDF_QUESTION = "pdf_question"
|
||||
AGENT_DRAFT = "agent_draft"
|
||||
AGENT_REVISE = "agent_revise"
|
||||
AGENT_NEXT_ACTION = "agent_next_action"
|
||||
class ExtractedTextArtifact(ApiModel):
|
||||
kind: Literal[ArtifactKind.EXTRACTED_TEXT] = ArtifactKind.EXTRACTED_TEXT
|
||||
files: list[ExtractedFileText] = Field(default_factory=list)
|
||||
|
||||
|
||||
WorkflowArtifact = Annotated[ExtractedTextArtifact, Field(discriminator="kind")]
|
||||
|
||||
|
||||
class OrchestratorRequest(ApiModel):
|
||||
user_message: str
|
||||
conversation_id: str | None = None
|
||||
file_names: list[str]
|
||||
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
|
||||
resume_with: SupportedCapability | None = None
|
||||
|
||||
|
||||
class UnsupportedCapabilityResponse(ApiModel):
|
||||
outcome: Literal["unsupported_capability"] = "unsupported_capability"
|
||||
outcome: Literal[WorkflowOutcome.UNSUPPORTED_CAPABILITY] = WorkflowOutcome.UNSUPPORTED_CAPABILITY
|
||||
capability: str
|
||||
message: str
|
||||
|
||||
|
||||
@@ -6,30 +6,29 @@ from pydantic import Field
|
||||
|
||||
from stirling.models import ApiModel
|
||||
|
||||
from .common import ToolOperationStep
|
||||
from .common import ToolOperationStep, WorkflowOutcome
|
||||
|
||||
|
||||
class PdfEditRequest(ApiModel):
|
||||
user_message: str
|
||||
conversation_id: str | None = None
|
||||
file_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EditPlanResponse(ApiModel):
|
||||
outcome: Literal["plan"] = "plan"
|
||||
outcome: Literal[WorkflowOutcome.PLAN] = WorkflowOutcome.PLAN
|
||||
summary: str
|
||||
rationale: str | None = None
|
||||
steps: list[ToolOperationStep]
|
||||
|
||||
|
||||
class EditClarificationRequest(ApiModel):
|
||||
outcome: Literal["need_clarification"] = "need_clarification"
|
||||
outcome: Literal[WorkflowOutcome.NEED_CLARIFICATION] = WorkflowOutcome.NEED_CLARIFICATION
|
||||
question: str
|
||||
reason: str
|
||||
|
||||
|
||||
class EditCannotDoResponse(ApiModel):
|
||||
outcome: Literal["cannot_do"] = "cannot_do"
|
||||
outcome: Literal[WorkflowOutcome.CANNOT_DO] = WorkflowOutcome.CANNOT_DO
|
||||
reason: str
|
||||
|
||||
|
||||
|
||||
@@ -6,31 +6,42 @@ from pydantic import Field
|
||||
|
||||
from stirling.models import ApiModel
|
||||
|
||||
from .common import ExtractedFileText, PdfContentType, SupportedCapability, WorkflowOutcome
|
||||
|
||||
|
||||
class PdfQuestionRequest(ApiModel):
|
||||
question: str
|
||||
conversation_id: str | None = None
|
||||
extracted_text: str = ""
|
||||
file_name: str | None = None
|
||||
page_text: list[ExtractedFileText] = Field(default_factory=list)
|
||||
file_names: list[str]
|
||||
|
||||
|
||||
class PdfQuestionAnswerResponse(ApiModel):
|
||||
outcome: Literal["answer"] = "answer"
|
||||
outcome: Literal[WorkflowOutcome.ANSWER] = WorkflowOutcome.ANSWER
|
||||
answer: str
|
||||
evidence: list[str] = Field(default_factory=list)
|
||||
evidence: list[ExtractedFileText] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PdfQuestionNeedTextResponse(ApiModel):
|
||||
outcome: Literal["need_text"] = "need_text"
|
||||
class NeedContentFileRequest(ApiModel):
|
||||
file_name: str
|
||||
page_numbers: list[int] = Field(default_factory=list)
|
||||
content_types: list[PdfContentType]
|
||||
|
||||
|
||||
class PdfQuestionNeedContentResponse(ApiModel):
|
||||
outcome: Literal[WorkflowOutcome.NEED_CONTENT] = WorkflowOutcome.NEED_CONTENT
|
||||
resume_with: SupportedCapability = SupportedCapability.PDF_QUESTION
|
||||
reason: str
|
||||
files: list[NeedContentFileRequest] = Field(default_factory=list)
|
||||
max_pages: int
|
||||
max_characters: int
|
||||
|
||||
|
||||
class PdfQuestionNotFoundResponse(ApiModel):
|
||||
outcome: Literal["not_found"] = "not_found"
|
||||
outcome: Literal[WorkflowOutcome.NOT_FOUND] = WorkflowOutcome.NOT_FOUND
|
||||
reason: str
|
||||
|
||||
|
||||
PdfQuestionResponse = Annotated[
|
||||
PdfQuestionAnswerResponse | PdfQuestionNeedTextResponse | PdfQuestionNotFoundResponse,
|
||||
PdfQuestionAnswerResponse | PdfQuestionNeedContentResponse | PdfQuestionNotFoundResponse,
|
||||
Field(discriminator="outcome"),
|
||||
]
|
||||
|
||||
@@ -5,10 +5,12 @@ import pytest
|
||||
from stirling.agents import PdfQuestionAgent
|
||||
from stirling.config import AppSettings
|
||||
from stirling.contracts import (
|
||||
ExtractedFileText,
|
||||
PdfQuestionAnswerResponse,
|
||||
PdfQuestionNeedTextResponse,
|
||||
PdfQuestionNeedContentResponse,
|
||||
PdfQuestionNotFoundResponse,
|
||||
PdfQuestionRequest,
|
||||
PdfTextSelection,
|
||||
)
|
||||
from stirling.services import build_runtime
|
||||
|
||||
@@ -34,13 +36,22 @@ def build_test_settings() -> AppSettings:
|
||||
)
|
||||
|
||||
|
||||
def invoice_page() -> ExtractedFileText:
|
||||
return ExtractedFileText(
|
||||
file_name="invoice.pdf",
|
||||
pages=[PdfTextSelection(page_number=1, text="Invoice total: 120.00")],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pdf_question_agent_requires_extracted_text() -> None:
|
||||
agent = PdfQuestionAgent(build_runtime(build_test_settings()))
|
||||
|
||||
response = await agent.handle(PdfQuestionRequest(question="What is the total?", extracted_text=""))
|
||||
response = await agent.handle(
|
||||
PdfQuestionRequest(question="What is the total?", page_text=[], file_names=["test.pdf"])
|
||||
)
|
||||
|
||||
assert isinstance(response, PdfQuestionNeedTextResponse)
|
||||
assert isinstance(response, PdfQuestionNeedContentResponse)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -48,15 +59,15 @@ async def test_pdf_question_agent_returns_grounded_answer() -> None:
|
||||
agent = StubPdfQuestionAgent(
|
||||
PdfQuestionAnswerResponse(
|
||||
answer="The invoice total is 120.00.",
|
||||
evidence=["Invoice total: 120.00"],
|
||||
evidence=[invoice_page()],
|
||||
)
|
||||
)
|
||||
|
||||
response = await agent.handle(
|
||||
PdfQuestionRequest(
|
||||
question="What is the total?",
|
||||
extracted_text="Invoice total: 120.00",
|
||||
file_name="invoice.pdf",
|
||||
page_text=[invoice_page()],
|
||||
file_names=["invoice.pdf"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -71,8 +82,13 @@ async def test_pdf_question_agent_returns_not_found_when_text_is_insufficient()
|
||||
response = await agent.handle(
|
||||
PdfQuestionRequest(
|
||||
question="What is the total?",
|
||||
extracted_text="This page contains only a shipping address.",
|
||||
file_name="invoice.pdf",
|
||||
page_text=[
|
||||
ExtractedFileText(
|
||||
file_name="invoice.pdf",
|
||||
pages=[PdfTextSelection(page_number=1, text="This page contains only a shipping address.")],
|
||||
)
|
||||
],
|
||||
file_names=["invoice.pdf"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ from stirling.contracts import (
|
||||
EditCannotDoResponse,
|
||||
OrchestratorRequest,
|
||||
PdfEditRequest,
|
||||
PdfQuestionNeedContentResponse,
|
||||
PdfQuestionNotFoundResponse,
|
||||
PdfQuestionRequest,
|
||||
UnsupportedCapabilityResponse,
|
||||
)
|
||||
from stirling.models.tool_models import RotateParams
|
||||
|
||||
@@ -38,8 +38,8 @@ class StubSettingsProvider:
|
||||
|
||||
|
||||
class StubOrchestratorAgent:
|
||||
async def handle(self, request: OrchestratorRequest) -> UnsupportedCapabilityResponse:
|
||||
return UnsupportedCapabilityResponse(capability="pdf_edit", message=request.user_message)
|
||||
async def handle(self, request: OrchestratorRequest) -> PdfQuestionNeedContentResponse:
|
||||
return PdfQuestionNeedContentResponse(reason=request.user_message, files=[], max_pages=1, max_characters=1000)
|
||||
|
||||
|
||||
class StubPdfEditAgent:
|
||||
@@ -115,10 +115,10 @@ def test_health_route() -> None:
|
||||
|
||||
|
||||
def test_orchestrator_route() -> None:
|
||||
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this"})
|
||||
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this", "fileNames": ["test.pdf"]})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["outcome"] == "unsupported_capability"
|
||||
assert response.json()["outcome"] == "need_content"
|
||||
|
||||
|
||||
def test_pdf_edit_route() -> None:
|
||||
@@ -129,7 +129,14 @@ def test_pdf_edit_route() -> None:
|
||||
|
||||
|
||||
def test_pdf_questions_route() -> None:
|
||||
response = client.post("/api/v1/pdf/questions", json={"question": "what is this?"})
|
||||
response = client.post(
|
||||
"/api/v1/pdf/questions",
|
||||
json={
|
||||
"question": "what is this?",
|
||||
"fileNames": ["test.pdf"],
|
||||
"pageText": [{"fileName": "test.pdf", "pages": [{"pageNumber": 1, "text": "Example"}]}],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["outcome"] == "not_found"
|
||||
|
||||
@@ -9,17 +9,34 @@ from stirling.contracts import (
|
||||
AgentSpecStep,
|
||||
EditPlanResponse,
|
||||
ExecutionContext,
|
||||
ExtractedFileText,
|
||||
ExtractedTextArtifact,
|
||||
OrchestratorRequest,
|
||||
PdfQuestionAnswerResponse,
|
||||
PdfTextSelection,
|
||||
ToolOperationStep,
|
||||
)
|
||||
from stirling.models.tool_models import OperationId, RotateParams
|
||||
|
||||
|
||||
def test_orchestrator_request_accepts_user_message() -> None:
|
||||
request = OrchestratorRequest(user_message="Rotate the PDF")
|
||||
request = OrchestratorRequest(
|
||||
user_message="Rotate the PDF",
|
||||
file_names=["test.pdf"],
|
||||
artifacts=[
|
||||
ExtractedTextArtifact(
|
||||
files=[
|
||||
ExtractedFileText(
|
||||
file_name="test.pdf",
|
||||
pages=[PdfTextSelection(page_number=1, text="Hello")],
|
||||
)
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert request.user_message == "Rotate the PDF"
|
||||
assert len(request.artifacts) == 1
|
||||
|
||||
|
||||
def test_agent_execution_request_uses_typed_agent_spec() -> None:
|
||||
|
||||
Generated
+103
-103
@@ -37,7 +37,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.3"
|
||||
version = "3.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -48,59 +48,59 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -135,7 +135,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.86.0"
|
||||
version = "0.93.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -147,9 +147,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/70/2429d6f7c2516db99fb342c3ad89575ab3e0cd31d3d2f6cba5fdf5e9c65b/anthropic-0.93.0.tar.gz", hash = "sha256:fea8376f7d5cdf99d5e8e85a48fe7a7bd8ab307cdfee4b1e8283a18b1c0ce1b5", size = 654155, upload-time = "2026-04-09T18:13:53.522Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/7b/5b2c11902707c49c7a99418eb027ed3eb63876193fee5c80b5c878e3a673/anthropic-0.93.0-py3-none-any.whl", hash = "sha256:2c20b2ce6d305564c66a6cbaedddee8efdd3b9753098bf314093fcf4c662d04c", size = 627482, upload-time = "2026-04-09T18:13:51.606Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -410,55 +410,55 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
version = "46.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -636,7 +636,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "3.1.1"
|
||||
version = "3.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
@@ -661,9 +661,9 @@ dependencies = [
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/42/7eed0a38e3b7a386805fecacf8a5a9353a2b3040395ef9e30e585d8549ac/fastmcp-3.2.3.tar.gz", hash = "sha256:4f02ae8b00227285a0cf6544dea1db29b022c8cdd8d3dfdec7118540210ae60a", size = 26328743, upload-time = "2026-04-09T22:05:03.402Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/48/84b6dcba793178a44b9d99b4def6cd62f870dcfc5bb7b9153ac390135812/fastmcp-3.2.3-py3-none-any.whl", hash = "sha256:cc50af6eed1f62ed8b6ebf4987286d8d1d006f08d5bec739d5c7fb76160e0911", size = 707260, upload-time = "2026-04-09T22:05:01.225Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
dist/
|
||||
node_modules/
|
||||
public/vendor/
|
||||
public/pdfjs*/
|
||||
public/js/thirdParty/
|
||||
public/css/cookieconsent.css
|
||||
*.min.*
|
||||
*.md
|
||||
*.wxs
|
||||
src/output.css
|
||||
+38
-47
@@ -1,62 +1,52 @@
|
||||
// @ts-check
|
||||
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import eslint from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const srcGlobs = [
|
||||
'src/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
];
|
||||
const nodeGlobs = [
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'*.config.{js,ts,mjs}',
|
||||
];
|
||||
const srcGlobs = ["src/**/*.{js,mjs,jsx,ts,tsx}"];
|
||||
const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"];
|
||||
|
||||
const baseRestrictedImportPatterns = [
|
||||
{ regex: '^\\.', message: "Use @app/* imports instead of relative imports." },
|
||||
{ regex: '^src/', message: "Use @app/* imports instead of absolute src/ imports." },
|
||||
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
|
||||
{ regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." },
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
// Everything that contains 3rd party code that we don't want to lint
|
||||
ignores: [
|
||||
'dist',
|
||||
'node_modules',
|
||||
'public',
|
||||
'src-tauri',
|
||||
],
|
||||
ignores: ["dist", "node_modules", "public", "src-tauri"],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: baseRestrictedImportPatterns,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'error',
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
|
||||
allowInterfaces: 'with-single-extends',
|
||||
allowInterfaces: "with-single-extends",
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
|
||||
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
'args': 'all', // All function args must be used (or explicitly ignored)
|
||||
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
|
||||
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
|
||||
'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
|
||||
args: "all", // All function args must be used (or explicitly ignored)
|
||||
argsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||
caughtErrors: "all", // Caught errors must be used (or explicitly ignored)
|
||||
caughtErrorsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||
destructuredArrayIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||
varsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
|
||||
ignoreRestSiblings: true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -65,15 +55,15 @@ export default defineConfig(
|
||||
// Use the stub/shadow pattern instead: define a stub in src/core/ and override in src/desktop/.
|
||||
{
|
||||
files: srcGlobs,
|
||||
ignores: ['src/desktop/**'],
|
||||
ignores: ["src/desktop/**"],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
...baseRestrictedImportPatterns,
|
||||
{
|
||||
regex: '^@tauri-apps/',
|
||||
regex: "^@tauri-apps/",
|
||||
message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
|
||||
},
|
||||
],
|
||||
@@ -84,8 +74,9 @@ export default defineConfig(
|
||||
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
|
||||
{
|
||||
files: [
|
||||
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
|
||||
"src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"src/saas/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
"src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
@@ -94,8 +85,8 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
},
|
||||
},
|
||||
// Config for browser scripts
|
||||
@@ -104,8 +95,8 @@ export default defineConfig(
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
// Config for node scripts
|
||||
{
|
||||
@@ -113,7 +104,7 @@ export default defineConfig(
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+2
-5
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -6,10 +6,7 @@
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<meta name="description" content="The Free Adobe Acrobat alternative (10M+ Downloads)" />
|
||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
|
||||
Generated
+32
-5
@@ -59,7 +59,7 @@
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@userback/widget": "^0.3.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.15.0",
|
||||
"d3": "^7.9.0",
|
||||
"globals": "^17.1.0",
|
||||
"i18next": "^25.5.2",
|
||||
@@ -114,6 +114,7 @@
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.25.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.2",
|
||||
@@ -5841,14 +5842,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
@@ -11279,6 +11289,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@@ -11431,6 +11457,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
|
||||
+10
-2
@@ -55,7 +55,7 @@
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@userback/widget": "^0.3.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.15.0",
|
||||
"d3": "^7.9.0",
|
||||
"globals": "^17.1.0",
|
||||
"i18next": "^25.5.2",
|
||||
@@ -88,14 +88,20 @@
|
||||
"dev:proprietary": "npm run prep && vite --mode proprietary",
|
||||
"dev:saas": "npm run prep:saas && vite --mode saas",
|
||||
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
|
||||
"dev:prototypes": "npm run prep && vite --mode prototypes",
|
||||
"fix": "npm run format && npm run lint:fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "npm run lint:eslint && npm run lint:cycles",
|
||||
"lint:eslint": "eslint --max-warnings=0",
|
||||
"lint:fix": "eslint --fix",
|
||||
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
|
||||
"build": "npm run prep && vite build",
|
||||
"build:core": "npm run prep && vite build --mode core",
|
||||
"build:proprietary": "npm run prep && vite build --mode proprietary",
|
||||
"build:saas": "npm run prep:saas && vite build --mode saas",
|
||||
"build:desktop": "npm run prep:desktop && vite build --mode desktop",
|
||||
"build:prototypes": "npm run prep && vite build --mode prototypes",
|
||||
"preview": "vite preview",
|
||||
"tauri-dev": "npm run prep:desktop && tauri dev --no-watch",
|
||||
"tauri-build": "npm run prep:desktop-build && tauri build",
|
||||
@@ -110,8 +116,9 @@
|
||||
"typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
|
||||
"typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
|
||||
"typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json",
|
||||
"typecheck:prototypes": "tsc --noEmit --project src/prototypes/tsconfig.json",
|
||||
"typecheck:scripts": "tsc --noEmit --project scripts/tsconfig.json",
|
||||
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:scripts",
|
||||
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:prototypes && npm run typecheck:scripts",
|
||||
"check": "npm run typecheck && npm run lint && npm run test:run",
|
||||
"generate-licenses": "node scripts/generate-licenses.js",
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
@@ -173,6 +180,7 @@
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"puppeteer": "^24.25.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.2",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './src/core/tests',
|
||||
testMatch: '**/*.spec.ts',
|
||||
testDir: "./src/core/tests",
|
||||
testMatch: "**/*.spec.ts",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
@@ -15,34 +15,34 @@ export default defineConfig({
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:5173',
|
||||
baseURL: "http://localhost:5173",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
@@ -68,8 +68,8 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
command: "npm run dev",
|
||||
url: "http://localhost:5173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('@tailwindcss/postcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
|
||||
};
|
||||
|
||||
@@ -1,206 +1,205 @@
|
||||
/* Light theme variables */
|
||||
:root {
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
|
||||
--cc-btn-primary-bg: #007BFF;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007BFF;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
--cc-btn-primary-bg: #007bff;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007bff;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007BFF;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007bff;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007bff;
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007BFF;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
--cc-toggle-on-bg: #007bff;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747E;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747e;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme variables */
|
||||
.cc--darkmode{
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
.cc--darkmode {
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #ffffff;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: #555555;
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
|
||||
.cm__body {
|
||||
max-width: 90% !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.cm__desc{
|
||||
max-width: 70rem !important;
|
||||
.cm__desc {
|
||||
max-width: 70rem !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
flex-direction: row-reverse !important;
|
||||
gap:10px !important;
|
||||
padding-top: 3.4rem !important;
|
||||
.cm__btns {
|
||||
flex-direction: row-reverse !important;
|
||||
gap: 10px !important;
|
||||
padding-top: 3.4rem !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1400px) {
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: normal !important;
|
||||
}
|
||||
.cm__body {
|
||||
max-width: 90% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: normal !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
.cm__btns {
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility fixes */
|
||||
#cc-main .section__toggle {
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-circle {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Ensure toggles are visible in both themes */
|
||||
#cc-main .toggle__icon {
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
}
|
||||
|
||||
/* Ensure toggle text is visible */
|
||||
#cc-main .pm__section-title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .pm__section-desc {
|
||||
color: var(--cc-secondary-color) !important;
|
||||
color: var(--cc-secondary-color) !important;
|
||||
}
|
||||
|
||||
/* Make sure the modal has proper contrast */
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
/* Lower z-index so cookie banner appears behind onboarding modals */
|
||||
#cc-main {
|
||||
z-index: 100 !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Ensure consent modal text is visible in both themes */
|
||||
#cc-main .cm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__desc {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer {
|
||||
color: var(--cc-primary-color) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .cm__footer-links a,
|
||||
#cc-main .cm__link {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ downloadPdf = "Download PDF"
|
||||
downloadUnavailable = "Download unavailable for this item"
|
||||
edit = "Edit"
|
||||
editYourNewFiles = "Edit your new file(s)"
|
||||
encryptedFileBlocked = "File is password-protected. Unlock it first."
|
||||
encryptedFilesBlocked = "{{count}} files are password-protected. Unlock them first."
|
||||
exportAndContinue = "Export & Continue"
|
||||
false = "False"
|
||||
fileSavedToDisk = "File saved to disk"
|
||||
@@ -3341,6 +3343,9 @@ successBodyWithName = "Password removed from {{fileName}}"
|
||||
successTitle = "Password removed"
|
||||
title = "Remove password to continue"
|
||||
unlock = "Unlock & Continue"
|
||||
unlockAll = "Use for all ({{count}})"
|
||||
unlockAllPartialFail = "Wrong password for: {{names}}"
|
||||
unlockAllSuccess = "Unlocked {{count}} file(s)."
|
||||
unlockPrompt = "Unlock PDF to continue"
|
||||
|
||||
[encryptedPdfUnlock.password]
|
||||
@@ -4287,6 +4292,8 @@ welcomeTitle = "You've been invited!"
|
||||
|
||||
[landing]
|
||||
addFiles = "Add Files"
|
||||
heroSubtitle = "Drop in or add an existing PDF to get started."
|
||||
heroTitle = "Stirling PDF"
|
||||
mobileUpload = "Upload from Mobile"
|
||||
openFromComputer = "Open from computer"
|
||||
uploadFromComputer = "Upload from computer"
|
||||
@@ -6638,9 +6645,13 @@ defaultPdfEditorActive = "Stirling PDF is your default PDF editor"
|
||||
defaultPdfEditorChecking = "Checking..."
|
||||
defaultPdfEditorInactive = "Another application is set as default"
|
||||
defaultPdfEditorSet = "Already Default"
|
||||
defaultStartupView = "Default view on launch"
|
||||
defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts"
|
||||
defaultToolPickerMode = "Default tool picker mode"
|
||||
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
|
||||
description = "Configure general application preferences."
|
||||
defaultViewerZoom = "Default reader zoom"
|
||||
defaultViewerZoomDescription = "Set the default zoom level when opening PDFs in the reader"
|
||||
hideUnavailableConversions = "Hide unavailable conversions"
|
||||
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
hideUnavailableTools = "Hide unavailable tools"
|
||||
@@ -6663,6 +6674,16 @@ title = "For System Administrators"
|
||||
fullscreen = "Fullscreen"
|
||||
sidebar = "Sidebar"
|
||||
|
||||
[settings.general.startupView]
|
||||
automate = "Automate"
|
||||
read = "Reader"
|
||||
tools = "Tools"
|
||||
|
||||
[settings.general.zoomLevel]
|
||||
auto = "Auto"
|
||||
fitPage = "Fit page"
|
||||
fitWidth = "Fit width"
|
||||
|
||||
[settings.general.updates]
|
||||
checkForUpdates = "Check for Updates"
|
||||
currentBackendVersion = "Current Backend Version"
|
||||
|
||||
@@ -23,4 +23,3 @@
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.platform !== "win32") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const frontendDir = process.cwd();
|
||||
const tauriDir = resolve(frontendDir, 'src-tauri');
|
||||
const provisionerManifest = join(tauriDir, 'provisioner', 'Cargo.toml');
|
||||
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
|
||||
|
||||
execFileSync(
|
||||
'cargo',
|
||||
['build', '--release', '--manifest-path', provisionerManifest],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" });
|
||||
|
||||
const provisionerExe = join(tauriDir, 'provisioner', 'target', 'release', 'stirling-provisioner.exe');
|
||||
const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe");
|
||||
if (!existsSync(provisionerExe)) {
|
||||
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
|
||||
}
|
||||
|
||||
const wixDir = join(tauriDir, 'windows', 'wix');
|
||||
const wixDir = join(tauriDir, "windows", "wix");
|
||||
mkdirSync(wixDir, { recursive: true });
|
||||
|
||||
const destExe = join(wixDir, 'stirling-provision.exe');
|
||||
const destExe = join(wixDir, "stirling-provision.exe");
|
||||
copyFileSync(provisionerExe, destExe);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { icons } = require('@iconify-json/material-symbols');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { icons } = require("@iconify-json/material-symbols");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Check for verbose flag
|
||||
const isVerbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
||||
const isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
||||
|
||||
// Logging functions
|
||||
const info = (message) => console.log(message);
|
||||
@@ -18,12 +18,12 @@ const debug = (message) => {
|
||||
// Function to scan codebase for LocalIcon usage
|
||||
function scanForUsedIcons() {
|
||||
const usedIcons = new Set();
|
||||
const srcDir = path.join(__dirname, '..', 'src');
|
||||
const srcDir = path.join(__dirname, "..", "src");
|
||||
|
||||
info('🔍 Scanning codebase for LocalIcon usage...');
|
||||
info("🔍 Scanning codebase for LocalIcon usage...");
|
||||
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.error('❌ Source directory not found:', srcDir);
|
||||
console.error("❌ Source directory not found:", srcDir);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -31,19 +31,19 @@ function scanForUsedIcons() {
|
||||
function scanDirectory(dir) {
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
scanDirectory(filePath);
|
||||
} else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
} else if (file.endsWith(".tsx") || file.endsWith(".ts")) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
||||
if (localIconMatches) {
|
||||
localIconMatches.forEach(match => {
|
||||
localIconMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon="([^"]+)"/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
@@ -55,7 +55,7 @@ function scanForUsedIcons() {
|
||||
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
|
||||
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
|
||||
if (localIconSingleQuoteMatches) {
|
||||
localIconSingleQuoteMatches.forEach(match => {
|
||||
localIconSingleQuoteMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon='([^']+)'/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
@@ -67,7 +67,7 @@ function scanForUsedIcons() {
|
||||
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
||||
if (spanMatches) {
|
||||
spanMatches.forEach(match => {
|
||||
spanMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
||||
if (iconMatch && iconMatch[1].trim()) {
|
||||
const iconName = iconMatch[1].trim();
|
||||
@@ -80,7 +80,7 @@ function scanForUsedIcons() {
|
||||
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
||||
if (iconMatches) {
|
||||
iconMatches.forEach(match => {
|
||||
iconMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
@@ -92,7 +92,7 @@ function scanForUsedIcons() {
|
||||
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
|
||||
const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g);
|
||||
if (iconPropertyMatches) {
|
||||
iconPropertyMatches.forEach(match => {
|
||||
iconPropertyMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[2]);
|
||||
@@ -118,18 +118,20 @@ async function main() {
|
||||
const usedIcons = scanForUsedIcons();
|
||||
|
||||
// Check if we need to regenerate (compare with existing)
|
||||
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
|
||||
const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json");
|
||||
let needsRegeneration = true;
|
||||
|
||||
if (fs.existsSync(outputPath)) {
|
||||
try {
|
||||
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||
const existingSet = JSON.parse(fs.readFileSync(outputPath, "utf8"));
|
||||
const existingIcons = Object.keys(existingSet.icons || {}).sort();
|
||||
const currentIcons = [...usedIcons].sort();
|
||||
|
||||
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
|
||||
needsRegeneration = false;
|
||||
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
|
||||
info(
|
||||
`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse existing file, regenerate
|
||||
@@ -138,34 +140,34 @@ async function main() {
|
||||
}
|
||||
|
||||
if (!needsRegeneration) {
|
||||
info('🎉 No regeneration needed!');
|
||||
info("🎉 No regeneration needed!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
|
||||
|
||||
// Dynamic import of ES module
|
||||
const { getIcons } = await import('@iconify/utils');
|
||||
const { getIcons } = await import("@iconify/utils");
|
||||
|
||||
// Extract only our used icons from the full set
|
||||
const extractedIcons = getIcons(icons, usedIcons);
|
||||
|
||||
if (!extractedIcons) {
|
||||
console.error('❌ Failed to extract icons');
|
||||
console.error("❌ Failed to extract icons");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for missing icons
|
||||
const extractedIconNames = Object.keys(extractedIcons.icons || {});
|
||||
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
|
||||
const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon));
|
||||
|
||||
if (missingIcons.length > 0) {
|
||||
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
|
||||
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
|
||||
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`);
|
||||
info("💡 These icons don't exist in Material Symbols. Please use available alternatives.");
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
const outputDir = path.join(__dirname, '..', 'src', 'assets');
|
||||
const outputDir = path.join(__dirname, "..", "src", "assets");
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
@@ -182,7 +184,7 @@ async function main() {
|
||||
// This file is automatically generated by scripts/generate-icons.js
|
||||
// Do not edit manually - changes will be overwritten
|
||||
|
||||
export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')};
|
||||
export type MaterialSymbolIcon = ${usedIcons.map((icon) => `'${icon}'`).join(" | ")};
|
||||
|
||||
export interface IconSet {
|
||||
prefix: string;
|
||||
@@ -196,7 +198,7 @@ declare const iconSet: IconSet;
|
||||
export default iconSet;
|
||||
`;
|
||||
|
||||
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
|
||||
const typesPath = path.join(outputDir, "material-symbols-icons.d.ts");
|
||||
fs.writeFileSync(typesPath, typesContent);
|
||||
|
||||
info(`📝 Generated types: ${typesPath}`);
|
||||
@@ -204,7 +206,7 @@ export default iconSet;
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
main().catch(error => {
|
||||
console.error('❌ Script failed:', error);
|
||||
main().catch((error) => {
|
||||
console.error("❌ Script failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require('node:child_process');
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execSync } = require("node:child_process");
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { argv } = require('node:process');
|
||||
const inputIdx = argv.indexOf('--input');
|
||||
const { argv } = require("node:process");
|
||||
const inputIdx = argv.indexOf("--input");
|
||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
|
||||
@@ -16,408 +16,434 @@ const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
|
||||
*/
|
||||
|
||||
const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'assets', '3rdPartyLicenses.json');
|
||||
const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
|
||||
const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json");
|
||||
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
|
||||
|
||||
// Ensure the output directory exists
|
||||
const outputDir = path.dirname(OUTPUT_FILE);
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log('🔍 Generating frontend license report...');
|
||||
console.log("🔍 Generating frontend license report...");
|
||||
|
||||
try {
|
||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
|
||||
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
|
||||
process.exit(2);
|
||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
|
||||
console.error("Fork PR detected: only --input (postprocess-only) mode is allowed.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let licenseData;
|
||||
// Generate license report using pinned license-checker; disable lifecycle scripts
|
||||
if (POSTPROCESS_ONLY) {
|
||||
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
|
||||
console.error("❌ --input file missing or not found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let licenseData;
|
||||
// Generate license report using pinned license-checker; disable lifecycle scripts
|
||||
if (POSTPROCESS_ONLY) {
|
||||
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
|
||||
console.error('❌ --input file missing or not found');
|
||||
process.exit(1);
|
||||
}
|
||||
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
|
||||
} else {
|
||||
const licenseReport = execSync(
|
||||
// 'npx --yes license-checker@25.0.1 --production --json',
|
||||
'npx --yes license-report --only=prod --output=json',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON),
|
||||
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
|
||||
}
|
||||
);
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error('❌ Failed to parse license data:', parseError.message);
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(licenseData)) {
|
||||
console.error('❌ Invalid license data structure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Convert license-checker format to array
|
||||
const licenseArray = licenseData.map(dep => {
|
||||
let licenseType = dep.licenseType;
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
// Handle empty string licenses
|
||||
if (licenseType === '') {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
// Handle array licenses (rare but possible)
|
||||
if (Array.isArray(licenseType)) {
|
||||
licenseType = licenseType.join(' AND ');
|
||||
}
|
||||
|
||||
// Handle object licenses (fallback)
|
||||
if (typeof licenseType === 'object' && licenseType !== null) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||
}
|
||||
|
||||
return {
|
||||
name: dep.name,
|
||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
|
||||
licenseType: licenseType,
|
||||
repository: dep.link,
|
||||
url: dep.link,
|
||||
link: dep.link
|
||||
};
|
||||
});
|
||||
|
||||
// Transform to match Java backend format
|
||||
const transformedData = {
|
||||
dependencies: licenseArray.map(dep => {
|
||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
||||
|
||||
return {
|
||||
moduleName: dep.name,
|
||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
moduleVersion: dep.version,
|
||||
moduleLicense: licenseType,
|
||||
moduleLicenseUrl: licenseUrl
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
// Log summary of license types found
|
||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
||||
acc[license] = (acc[license] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log('📊 License types found:');
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
console.log(` ${license}: ${count} packages`);
|
||||
});
|
||||
|
||||
// Log any complex or unusual license formats for debugging
|
||||
const complexLicenses = licenseArray.filter(dep =>
|
||||
dep.licenseType && (
|
||||
dep.licenseType.includes('AND') ||
|
||||
dep.licenseType.includes('OR') ||
|
||||
dep.licenseType === 'Unknown' ||
|
||||
dep.licenseType.includes('SEE LICENSE')
|
||||
)
|
||||
licenseData = JSON.parse(readFileSync(INPUT_FILE, "utf8"));
|
||||
} else {
|
||||
const licenseReport = execSync(
|
||||
// 'npx --yes license-checker@25.0.1 --production --json',
|
||||
"npx --yes license-report --only=prod --output=json",
|
||||
{
|
||||
encoding: "utf8",
|
||||
cwd: path.dirname(PACKAGE_JSON),
|
||||
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" },
|
||||
},
|
||||
);
|
||||
|
||||
if (complexLicenses.length > 0) {
|
||||
console.log('\n🔍 Complex/Edge case licenses detected:');
|
||||
complexLicenses.forEach(dep => {
|
||||
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
|
||||
});
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
} catch (parseError) {
|
||||
console.error("❌ Failed to parse license data:", parseError.message);
|
||||
console.error("Raw output:", licenseReport.substring(0, 500) + "...");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for potentially problematic licenses
|
||||
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
|
||||
if (problematicLicenses.length > 0) {
|
||||
console.log('\n⚠️ License compatibility warnings:');
|
||||
problematicLicenses.forEach(warning => {
|
||||
console.log(` ${warning.message}`);
|
||||
});
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
|
||||
writeFileSync(warningsFile, JSON.stringify({
|
||||
warnings: problematicLicenses,
|
||||
generated: new Date().toISOString()
|
||||
}, null, 2));
|
||||
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
|
||||
} else {
|
||||
console.log('\n✅ All licenses appear to be corporate-friendly');
|
||||
}
|
||||
|
||||
// Write to file
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
|
||||
console.log(`✅ License report generated successfully!`);
|
||||
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
|
||||
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating license report:', error.message);
|
||||
if (!Array.isArray(licenseData)) {
|
||||
console.error("❌ Invalid license data structure");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Convert license-checker format to array
|
||||
const licenseArray = licenseData.map((dep) => {
|
||||
let licenseType = dep.licenseType;
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
licenseType = "Unknown";
|
||||
}
|
||||
|
||||
// Handle empty string licenses
|
||||
if (licenseType === "") {
|
||||
licenseType = "Unknown";
|
||||
}
|
||||
|
||||
// Handle array licenses (rare but possible)
|
||||
if (Array.isArray(licenseType)) {
|
||||
licenseType = licenseType.join(" AND ");
|
||||
}
|
||||
|
||||
// Handle object licenses (fallback)
|
||||
if (typeof licenseType === "object" && licenseType !== null) {
|
||||
licenseType = "Unknown";
|
||||
}
|
||||
|
||||
if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||
}
|
||||
|
||||
return {
|
||||
name: dep.name,
|
||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown",
|
||||
licenseType: licenseType,
|
||||
repository: dep.link,
|
||||
url: dep.link,
|
||||
link: dep.link,
|
||||
};
|
||||
});
|
||||
|
||||
// Transform to match Java backend format
|
||||
const transformedData = {
|
||||
dependencies: licenseArray.map((dep) => {
|
||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
|
||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
||||
|
||||
return {
|
||||
moduleName: dep.name,
|
||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
moduleVersion: dep.version,
|
||||
moduleLicense: licenseType,
|
||||
moduleLicenseUrl: licenseUrl,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Log summary of license types found
|
||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
|
||||
acc[license] = (acc[license] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log("📊 License types found:");
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
console.log(` ${license}: ${count} packages`);
|
||||
});
|
||||
|
||||
// Log any complex or unusual license formats for debugging
|
||||
const complexLicenses = licenseArray.filter(
|
||||
(dep) =>
|
||||
dep.licenseType &&
|
||||
(dep.licenseType.includes("AND") ||
|
||||
dep.licenseType.includes("OR") ||
|
||||
dep.licenseType === "Unknown" ||
|
||||
dep.licenseType.includes("SEE LICENSE")),
|
||||
);
|
||||
|
||||
if (complexLicenses.length > 0) {
|
||||
console.log("\n🔍 Complex/Edge case licenses detected:");
|
||||
complexLicenses.forEach((dep) => {
|
||||
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
|
||||
});
|
||||
}
|
||||
|
||||
// Check for potentially problematic licenses
|
||||
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
|
||||
if (problematicLicenses.length > 0) {
|
||||
console.log("\n⚠️ License compatibility warnings:");
|
||||
problematicLicenses.forEach((warning) => {
|
||||
console.log(` ${warning.message}`);
|
||||
});
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json");
|
||||
writeFileSync(
|
||||
warningsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
warnings: problematicLicenses,
|
||||
generated: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
|
||||
} else {
|
||||
console.log("\n✅ All licenses appear to be corporate-friendly");
|
||||
}
|
||||
|
||||
// Write to file
|
||||
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 2) + "\n");
|
||||
|
||||
console.log(`✅ License report generated successfully!`);
|
||||
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
|
||||
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
|
||||
} catch (error) {
|
||||
console.error("❌ Error generating license report:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get standard license URLs for common licenses
|
||||
*/
|
||||
function getLicenseUrl(licenseType) {
|
||||
if (!licenseType || licenseType === 'Unknown') return '';
|
||||
if (!licenseType || licenseType === "Unknown") return "";
|
||||
|
||||
const licenseUrls = {
|
||||
'MIT': 'https://opensource.org/licenses/MIT',
|
||||
'MIT*': 'https://opensource.org/licenses/MIT',
|
||||
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause',
|
||||
'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
|
||||
'BSD': 'https://opensource.org/licenses/BSD-3-Clause',
|
||||
'GPL-3.0': 'https://www.gnu.org/licenses/gpl-3.0.html',
|
||||
'GPL-2.0': 'https://www.gnu.org/licenses/gpl-2.0.html',
|
||||
'LGPL-2.1': 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html',
|
||||
'LGPL-3.0': 'https://www.gnu.org/licenses/lgpl-3.0.html',
|
||||
'ISC': 'https://opensource.org/licenses/ISC',
|
||||
'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||
'Unlicense': 'https://unlicense.org/',
|
||||
'MPL-2.0': 'https://www.mozilla.org/en-US/MPL/2.0/',
|
||||
'WTFPL': 'http://www.wtfpl.net/',
|
||||
'Zlib': 'https://opensource.org/licenses/Zlib',
|
||||
'Artistic-2.0': 'https://opensource.org/licenses/Artistic-2.0',
|
||||
'EPL-1.0': 'https://www.eclipse.org/legal/epl-v10.html',
|
||||
'EPL-2.0': 'https://www.eclipse.org/legal/epl-2.0/',
|
||||
'CDDL-1.0': 'https://opensource.org/licenses/CDDL-1.0',
|
||||
'Ruby': 'https://www.ruby-lang.org/en/about/license.txt',
|
||||
'Python-2.0': 'https://www.python.org/download/releases/2.0/license/',
|
||||
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||
'UNLICENSED': ''
|
||||
};
|
||||
const licenseUrls = {
|
||||
MIT: "https://opensource.org/licenses/MIT",
|
||||
"MIT*": "https://opensource.org/licenses/MIT",
|
||||
"Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"Apache License 2.0": "https://www.apache.org/licenses/LICENSE-2.0",
|
||||
"BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause",
|
||||
"BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause",
|
||||
BSD: "https://opensource.org/licenses/BSD-3-Clause",
|
||||
"GPL-3.0": "https://www.gnu.org/licenses/gpl-3.0.html",
|
||||
"GPL-2.0": "https://www.gnu.org/licenses/gpl-2.0.html",
|
||||
"LGPL-2.1": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html",
|
||||
"LGPL-3.0": "https://www.gnu.org/licenses/lgpl-3.0.html",
|
||||
ISC: "https://opensource.org/licenses/ISC",
|
||||
"CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||
Unlicense: "https://unlicense.org/",
|
||||
"MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
|
||||
WTFPL: "http://www.wtfpl.net/",
|
||||
Zlib: "https://opensource.org/licenses/Zlib",
|
||||
"Artistic-2.0": "https://opensource.org/licenses/Artistic-2.0",
|
||||
"EPL-1.0": "https://www.eclipse.org/legal/epl-v10.html",
|
||||
"EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
|
||||
"CDDL-1.0": "https://opensource.org/licenses/CDDL-1.0",
|
||||
Ruby: "https://www.ruby-lang.org/en/about/license.txt",
|
||||
"Python-2.0": "https://www.python.org/download/releases/2.0/license/",
|
||||
"Public Domain": "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||
UNLICENSED: "",
|
||||
};
|
||||
|
||||
// Try exact match first
|
||||
if (licenseUrls[licenseType]) {
|
||||
return licenseUrls[licenseType];
|
||||
// Try exact match first
|
||||
if (licenseUrls[licenseType]) {
|
||||
return licenseUrls[licenseType];
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const lowerType = licenseType.toLowerCase();
|
||||
for (const [key, url] of Object.entries(licenseUrls)) {
|
||||
if (key.toLowerCase() === lowerType) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Try case-insensitive match
|
||||
const lowerType = licenseType.toLowerCase();
|
||||
for (const [key, url] of Object.entries(licenseUrls)) {
|
||||
if (key.toLowerCase() === lowerType) {
|
||||
return url;
|
||||
}
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes("AND") || licenseType.includes("OR")) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
|
||||
if (match && licenseUrls[match[1]]) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
|
||||
if (match && licenseUrls[match[1]]) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
}
|
||||
|
||||
// For non-standard licenses, return empty string (will use package link if available)
|
||||
return '';
|
||||
// For non-standard licenses, return empty string (will use package link if available)
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for potentially problematic licenses that may not be MIT/corporate compatible
|
||||
*/
|
||||
function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
const warnings = [];
|
||||
const warnings = [];
|
||||
|
||||
// Define problematic license patterns
|
||||
const problematicLicenses = {
|
||||
// Copyleft licenses
|
||||
'GPL-2.0': 'Strong copyleft license - requires derivative works to be GPL',
|
||||
'GPL-3.0': 'Strong copyleft license - requires derivative works to be GPL',
|
||||
'LGPL-2.1': 'Weak copyleft license - may require source disclosure for modifications',
|
||||
'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications',
|
||||
'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use',
|
||||
'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use',
|
||||
// Define problematic license patterns
|
||||
const problematicLicenses = {
|
||||
// Copyleft licenses
|
||||
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||
"LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications",
|
||||
"LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications",
|
||||
"AGPL-3.0": "Network copyleft license - requires source disclosure for network use",
|
||||
"AGPL-1.0": "Network copyleft license - requires source disclosure for network use",
|
||||
|
||||
// Other potentially problematic licenses
|
||||
'WTFPL': 'Potentially problematic license - legal uncertainty',
|
||||
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
|
||||
'CC-BY-SA-3.0': 'ShareAlike license - requires derivative works to use same license',
|
||||
'CC-BY-NC-4.0': 'Non-commercial license - prohibits commercial use',
|
||||
'CC-BY-NC-3.0': 'Non-commercial license - prohibits commercial use',
|
||||
'OSL-3.0': 'Copyleft license - requires derivative works to be OSL',
|
||||
'EPL-1.0': 'Weak copyleft license - may require source disclosure',
|
||||
'EPL-2.0': 'Weak copyleft license - may require source disclosure',
|
||||
'CDDL-1.0': 'Weak copyleft license - may require source disclosure',
|
||||
'CDDL-1.1': 'Weak copyleft license - may require source disclosure',
|
||||
'CPL-1.0': 'Weak copyleft license - may require source disclosure',
|
||||
'MPL-1.1': 'Weak copyleft license - may require source disclosure',
|
||||
'EUPL-1.1': 'Copyleft license - requires derivative works to be EUPL',
|
||||
'EUPL-1.2': 'Copyleft license - requires derivative works to be EUPL',
|
||||
'UNLICENSED': 'No license specified - usage rights unclear',
|
||||
'Unknown': 'License not detected - manual review required'
|
||||
};
|
||||
// Other potentially problematic licenses
|
||||
WTFPL: "Potentially problematic license - legal uncertainty",
|
||||
"CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license",
|
||||
"CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license",
|
||||
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
|
||||
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
|
||||
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
|
||||
"EPL-1.0": "Weak copyleft license - may require source disclosure",
|
||||
"EPL-2.0": "Weak copyleft license - may require source disclosure",
|
||||
"CDDL-1.0": "Weak copyleft license - may require source disclosure",
|
||||
"CDDL-1.1": "Weak copyleft license - may require source disclosure",
|
||||
"CPL-1.0": "Weak copyleft license - may require source disclosure",
|
||||
"MPL-1.1": "Weak copyleft license - may require source disclosure",
|
||||
"EUPL-1.1": "Copyleft license - requires derivative works to be EUPL",
|
||||
"EUPL-1.2": "Copyleft license - requires derivative works to be EUPL",
|
||||
UNLICENSED: "No license specified - usage rights unclear",
|
||||
Unknown: "License not detected - manual review required",
|
||||
};
|
||||
|
||||
// Known good licenses (no warnings needed)
|
||||
const goodLicenses = new Set([
|
||||
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
|
||||
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
|
||||
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
|
||||
]);
|
||||
// Known good licenses (no warnings needed)
|
||||
const goodLicenses = new Set([
|
||||
"MIT",
|
||||
"MIT*",
|
||||
"Apache-2.0",
|
||||
"Apache License 2.0",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSD",
|
||||
"ISC",
|
||||
"CC0-1.0",
|
||||
"Public Domain",
|
||||
"Unlicense",
|
||||
"0BSD",
|
||||
"BlueOak-1.0.0",
|
||||
"Zlib",
|
||||
"Artistic-2.0",
|
||||
"Python-2.0",
|
||||
"Ruby",
|
||||
"MPL-2.0",
|
||||
"CC-BY-4.0",
|
||||
"SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||
]);
|
||||
|
||||
// Helper function to normalize license names for comparison
|
||||
function normalizeLicense(license) {
|
||||
return license
|
||||
.replace(/-or-later$/, '') // Remove -or-later suffix
|
||||
.replace(/\+$/, '') // Remove + suffix
|
||||
.trim();
|
||||
// Helper function to normalize license names for comparison
|
||||
function normalizeLicense(license) {
|
||||
return license
|
||||
.replace(/-or-later$/, "") // Remove -or-later suffix
|
||||
.replace(/\+$/, "") // Remove + suffix
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Check each license type
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
// Skip known good licenses
|
||||
if (goodLicenses.has(license)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check each license type
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
// Skip known good licenses
|
||||
if (goodLicenses.has(license)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this license only affects our own packages
|
||||
const affectedPackages = licenseArray.filter(dep => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
|
||||
return depLicense === license;
|
||||
});
|
||||
|
||||
const isOnlyOurPackages = affectedPackages.every(dep =>
|
||||
dep.name === 'frontend' ||
|
||||
dep.name.toLowerCase().includes('stirling-pdf') ||
|
||||
dep.name.toLowerCase().includes('stirling_pdf') ||
|
||||
dep.name.toLowerCase().includes('stirlingpdf')
|
||||
);
|
||||
|
||||
if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) {
|
||||
return; // Skip warnings for our own Stirling-PDF packages
|
||||
}
|
||||
|
||||
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (license.includes('AND') || license.includes('OR')) {
|
||||
// For OR licenses, check if there's at least one acceptable license option
|
||||
if (license.includes('OR')) {
|
||||
// Extract license components from OR expression
|
||||
const orComponents = license
|
||||
.replace(/[()]/g, '') // Remove parentheses
|
||||
.split(' OR ')
|
||||
.map(component => component.trim());
|
||||
|
||||
// Check if any component is in the goodLicenses set (with normalization)
|
||||
const hasGoodLicense = orComponents.some(component => {
|
||||
const normalized = normalizeLicense(component);
|
||||
return goodLicenses.has(component) || goodLicenses.has(normalized);
|
||||
});
|
||||
|
||||
if (hasGoodLicense) {
|
||||
return; // Skip warning - can use the good license option
|
||||
}
|
||||
}
|
||||
|
||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
|
||||
license.includes(problematic)
|
||||
);
|
||||
|
||||
if (hasProblematicComponent) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter(dep => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map(dep => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
const licenseType = license.includes('AND') ? 'AND' : 'OR';
|
||||
const reason = licenseType === 'AND'
|
||||
? 'Compound license with AND requirement - all components must be compatible'
|
||||
: 'Compound license with potentially problematic components and no good fallback options';
|
||||
|
||||
warnings.push({
|
||||
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
|
||||
licenseType: license,
|
||||
licenseUrl: '',
|
||||
reason: reason,
|
||||
packageCount: count,
|
||||
affectedDependencies: affectedPackages
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for exact matches with problematic licenses
|
||||
if (problematicLicenses[license]) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter(dep => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map(dep => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : '');
|
||||
const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses';
|
||||
|
||||
warnings.push({
|
||||
message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
||||
licenseType: license,
|
||||
licenseUrl: licenseUrl,
|
||||
reason: problematicLicenses[license],
|
||||
packageCount: count,
|
||||
affectedDependencies: affectedPackages
|
||||
});
|
||||
} else {
|
||||
// Unknown license type - flag for manual review
|
||||
const affectedPackages = licenseArray
|
||||
.filter(dep => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map(dep => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
warnings.push({
|
||||
message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`,
|
||||
licenseType: license,
|
||||
licenseUrl: '',
|
||||
reason: 'Unknown license type',
|
||||
packageCount: count,
|
||||
affectedDependencies: affectedPackages
|
||||
});
|
||||
}
|
||||
// Check if this license only affects our own packages
|
||||
const affectedPackages = licenseArray.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
return depLicense === license;
|
||||
});
|
||||
|
||||
return warnings;
|
||||
const isOnlyOurPackages = affectedPackages.every(
|
||||
(dep) =>
|
||||
dep.name === "frontend" ||
|
||||
dep.name.toLowerCase().includes("stirling-pdf") ||
|
||||
dep.name.toLowerCase().includes("stirling_pdf") ||
|
||||
dep.name.toLowerCase().includes("stirlingpdf"),
|
||||
);
|
||||
|
||||
if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) {
|
||||
return; // Skip warnings for our own Stirling-PDF packages
|
||||
}
|
||||
|
||||
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (license.includes("AND") || license.includes("OR")) {
|
||||
// For OR licenses, check if there's at least one acceptable license option
|
||||
if (license.includes("OR")) {
|
||||
// Extract license components from OR expression
|
||||
const orComponents = license
|
||||
.replace(/[()]/g, "") // Remove parentheses
|
||||
.split(" OR ")
|
||||
.map((component) => component.trim());
|
||||
|
||||
// Check if any component is in the goodLicenses set (with normalization)
|
||||
const hasGoodLicense = orComponents.some((component) => {
|
||||
const normalized = normalizeLicense(component);
|
||||
return goodLicenses.has(component) || goodLicenses.has(normalized);
|
||||
});
|
||||
|
||||
if (hasGoodLicense) {
|
||||
return; // Skip warning - can use the good license option
|
||||
}
|
||||
}
|
||||
|
||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic));
|
||||
|
||||
if (hasProblematicComponent) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map((dep) => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
}));
|
||||
|
||||
const licenseType = license.includes("AND") ? "AND" : "OR";
|
||||
const reason =
|
||||
licenseType === "AND"
|
||||
? "Compound license with AND requirement - all components must be compatible"
|
||||
: "Compound license with potentially problematic components and no good fallback options";
|
||||
|
||||
warnings.push({
|
||||
message: `📋 This PR contains ${count} package${count > 1 ? "s" : ""} with compound license "${license}" - manual review recommended`,
|
||||
licenseType: license,
|
||||
licenseUrl: "",
|
||||
reason: reason,
|
||||
packageCount: count,
|
||||
affectedDependencies: affectedPackages,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for exact matches with problematic licenses
|
||||
if (problematicLicenses[license]) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map((dep) => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
}));
|
||||
|
||||
const packageList =
|
||||
affectedPackages
|
||||
.map((pkg) => pkg.name)
|
||||
.slice(0, 5)
|
||||
.join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : "");
|
||||
const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses";
|
||||
|
||||
warnings.push({
|
||||
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
||||
licenseType: license,
|
||||
licenseUrl: licenseUrl,
|
||||
reason: problematicLicenses[license],
|
||||
packageCount: count,
|
||||
affectedDependencies: affectedPackages,
|
||||
});
|
||||
} else {
|
||||
// Unknown license type - flag for manual review
|
||||
const affectedPackages = licenseArray
|
||||
.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map((dep) => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
}));
|
||||
|
||||
warnings.push({
|
||||
message: `❓ This PR contains ${count} package${count > 1 ? "s" : ""} with unknown license type "${license}" - manual review required`,
|
||||
licenseType: license,
|
||||
licenseUrl: "",
|
||||
reason: "Unknown license type",
|
||||
packageCount: count,
|
||||
affectedDependencies: affectedPackages,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -8,20 +8,20 @@
|
||||
* for users to experiment with Stirling PDF's features.
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { existsSync, mkdirSync, statSync } from 'fs';
|
||||
import puppeteer from "puppeteer";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { existsSync, mkdirSync, statSync } from "fs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const TEMPLATE_PATH = join(__dirname, 'template.html');
|
||||
const OUTPUT_DIR = join(__dirname, '../../public/samples');
|
||||
const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf');
|
||||
const TEMPLATE_PATH = join(__dirname, "template.html");
|
||||
const OUTPUT_DIR = join(__dirname, "../../public/samples");
|
||||
const OUTPUT_PATH = join(OUTPUT_DIR, "Sample.pdf");
|
||||
|
||||
async function generatePDF() {
|
||||
console.log('🚀 Starting Stirling PDF sample document generation...\n');
|
||||
console.log("🚀 Starting Stirling PDF sample document generation...\n");
|
||||
|
||||
// Ensure output directory exists
|
||||
if (!existsSync(OUTPUT_DIR)) {
|
||||
@@ -40,66 +40,65 @@ async function generatePDF() {
|
||||
let browser;
|
||||
try {
|
||||
// Launch Puppeteer
|
||||
console.log('🌐 Launching browser...');
|
||||
console.log("🌐 Launching browser...");
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
headless: "new",
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set viewport to match A4 proportions
|
||||
await page.setViewport({
|
||||
width: 794, // A4 width in pixels at 96 DPI
|
||||
width: 794, // A4 width in pixels at 96 DPI
|
||||
height: 1123, // A4 height in pixels at 96 DPI
|
||||
deviceScaleFactor: 2 // Higher quality rendering
|
||||
deviceScaleFactor: 2, // Higher quality rendering
|
||||
});
|
||||
|
||||
// Navigate to the template file
|
||||
const fileUrl = `file://${TEMPLATE_PATH}`;
|
||||
console.log('📖 Loading HTML template...');
|
||||
console.log("📖 Loading HTML template...");
|
||||
await page.goto(fileUrl, {
|
||||
waitUntil: 'networkidle0' // Wait for all resources to load
|
||||
waitUntil: "networkidle0", // Wait for all resources to load
|
||||
});
|
||||
|
||||
// Generate PDF with A4 dimensions
|
||||
console.log('📝 Generating PDF...');
|
||||
console.log("📝 Generating PDF...");
|
||||
await page.pdf({
|
||||
path: OUTPUT_PATH,
|
||||
format: 'A4',
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
left: 0,
|
||||
},
|
||||
preferCSSPageSize: true
|
||||
preferCSSPageSize: true,
|
||||
});
|
||||
|
||||
console.log('\n✅ PDF generated successfully!');
|
||||
console.log("\n✅ PDF generated successfully!");
|
||||
console.log(`📦 Output: ${OUTPUT_PATH}`);
|
||||
|
||||
// Get file size
|
||||
const stats = statSync(OUTPUT_PATH);
|
||||
const fileSizeInKB = (stats.size / 1024).toFixed(2);
|
||||
console.log(`📊 File size: ${fileSizeInKB} KB`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error generating PDF:', error.message);
|
||||
console.error("\n❌ Error generating PDF:", error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
console.log('🔒 Browser closed.');
|
||||
console.log("🔒 Browser closed.");
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n');
|
||||
console.log("\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n");
|
||||
}
|
||||
|
||||
// Run the generator
|
||||
generatePDF().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
generatePDF().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
--color-white: #ffffff;
|
||||
|
||||
/* Font Stack */
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -1,234 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stirling PDF - Sample Document</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Page 1: Hero / Cover Page -->
|
||||
<div class="page page-1">
|
||||
<div class="decorative-shapes">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="">
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="logo-container">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Stirling PDF - Sample Document</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Page 1: Hero / Cover Page -->
|
||||
<div class="page page-1">
|
||||
<div class="decorative-shapes">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
|
||||
</div>
|
||||
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-badge">
|
||||
<span class="stat-number">10M+</span>
|
||||
<span class="stat-label">Downloads</span>
|
||||
<div class="hero-content">
|
||||
<div class="logo-container">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-features">
|
||||
<div class="feature-pill">Open Source</div>
|
||||
<div class="feature-pill">Privacy First</div>
|
||||
<div class="feature-pill">Self-Hosted</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page 2: What is Stirling PDF -->
|
||||
<div class="page page-2">
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">What is Stirling PDF?</h2>
|
||||
<p class="intro-text">
|
||||
Stirling PDF is a robust, web-based PDF manipulation tool.
|
||||
It enables you to carry out various operations on PDF files, including splitting,
|
||||
merging, converting, rearranging, adding images, rotating, compressing, and more.
|
||||
</p>
|
||||
|
||||
<div class="value-props">
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
||||
<div class="hero-stats">
|
||||
<div class="stat-badge">
|
||||
<span class="stat-number">10M+</span>
|
||||
<span class="stat-label">Downloads</span>
|
||||
</div>
|
||||
<h3>50+ PDF Operations</h3>
|
||||
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Workflow Automation</h3>
|
||||
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Multi-Language Support</h3>
|
||||
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Privacy First</h3>
|
||||
<p>Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Open Source</h3>
|
||||
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>API Access</h3>
|
||||
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
||||
<div class="hero-features">
|
||||
<div class="feature-pill">Open Source</div>
|
||||
<div class="feature-pill">Privacy First</div>
|
||||
<div class="feature-pill">Self-Hosted</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page 3: Key Features -->
|
||||
<div class="page page-3">
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">Key Features</h2>
|
||||
<!-- Page 2: What is Stirling PDF -->
|
||||
<div class="page page-2">
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">What is Stirling PDF?</h2>
|
||||
<p class="intro-text">
|
||||
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF
|
||||
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more.
|
||||
</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card" data-category="general">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<div class="value-props">
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
<path
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Page Operations</h3>
|
||||
<h3>50+ PDF Operations</h3>
|
||||
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Merge & split PDFs</li>
|
||||
<li>Rearrange pages</li>
|
||||
<li>Rotate & crop</li>
|
||||
<li>Extract pages</li>
|
||||
<li>Multi-page layout</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="security">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Workflow Automation</h3>
|
||||
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Security & Signing</h3>
|
||||
<h3>Multi-Language Support</h3>
|
||||
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Password protection</li>
|
||||
<li>Digital signatures</li>
|
||||
<li>Watermarks</li>
|
||||
<li>Permission controls</li>
|
||||
<li>Redaction tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="formatting">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z" />
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>File Conversions</h3>
|
||||
<h3>Privacy First</h3>
|
||||
<p>
|
||||
Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>PDF to/from images</li>
|
||||
<li>Office documents</li>
|
||||
<li>HTML to PDF</li>
|
||||
<li>Markdown to PDF</li>
|
||||
<li>PDF to Word/Excel</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="automation">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Automation</h3>
|
||||
<h3>Open Source</h3>
|
||||
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Multi-step workflows</li>
|
||||
<li>Chain PDF operations</li>
|
||||
<li>Save recurring tasks</li>
|
||||
<li>Batch file processing</li>
|
||||
<li>API integration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-features">
|
||||
<div class="additional-features-header">
|
||||
<div class="additional-features-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8" />
|
||||
</svg>
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>API Access</h3>
|
||||
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
||||
</div>
|
||||
<h3>Plus Many More</h3>
|
||||
</div>
|
||||
<div class="additional-features-grid">
|
||||
<ul>
|
||||
<li>OCR text recognition</li>
|
||||
<li>Compress PDFs</li>
|
||||
<li>Add images & stamps</li>
|
||||
<li>Detect blank pages</li>
|
||||
<li>Extract images</li>
|
||||
<li>Edit metadata</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Flatten forms</li>
|
||||
<li>PDF/A conversion</li>
|
||||
<li>Add page numbers</li>
|
||||
<li>Remove pages</li>
|
||||
<li>Repair PDFs</li>
|
||||
<li>And 40+ more tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
<!-- Page 3: Key Features -->
|
||||
<div class="page page-3">
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">Key Features</h2>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card" data-category="general">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Page Operations</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Merge & split PDFs</li>
|
||||
<li>Rearrange pages</li>
|
||||
<li>Rotate & crop</li>
|
||||
<li>Extract pages</li>
|
||||
<li>Multi-page layout</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="security">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Security & Signing</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Password protection</li>
|
||||
<li>Digital signatures</li>
|
||||
<li>Watermarks</li>
|
||||
<li>Permission controls</li>
|
||||
<li>Redaction tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="formatting">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>File Conversions</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>PDF to/from images</li>
|
||||
<li>Office documents</li>
|
||||
<li>HTML to PDF</li>
|
||||
<li>Markdown to PDF</li>
|
||||
<li>PDF to Word/Excel</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card" data-category="automation">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Automation</h3>
|
||||
</div>
|
||||
<ul class="feature-list">
|
||||
<li>Multi-step workflows</li>
|
||||
<li>Chain PDF operations</li>
|
||||
<li>Save recurring tasks</li>
|
||||
<li>Batch file processing</li>
|
||||
<li>API integration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="additional-features">
|
||||
<div class="additional-features-header">
|
||||
<div class="additional-features-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Plus Many More</h3>
|
||||
</div>
|
||||
<div class="additional-features-grid">
|
||||
<ul>
|
||||
<li>OCR text recognition</li>
|
||||
<li>Compress PDFs</li>
|
||||
<li>Add images & stamps</li>
|
||||
<li>Detect blank pages</li>
|
||||
<li>Extract images</li>
|
||||
<li>Edit metadata</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Flatten forms</li>
|
||||
<li>PDF/A conversion</li>
|
||||
<li>Add page numbers</li>
|
||||
<li>Remove pages</li>
|
||||
<li>Repair PDFs</li>
|
||||
<li>And 40+ more tools</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,22 +10,22 @@
|
||||
* tsx scripts/setup-env.ts --saas # also checks .env.saas
|
||||
*/
|
||||
|
||||
import { existsSync, copyFileSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { config, parse } from 'dotenv';
|
||||
import { existsSync, copyFileSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { config, parse } from "dotenv";
|
||||
|
||||
// npm scripts run from the directory containing package.json (frontend/)
|
||||
const root = process.cwd();
|
||||
const args = process.argv.slice(2);
|
||||
const isDesktop = args.includes('--desktop');
|
||||
const isSaas = args.includes('--saas');
|
||||
const isDesktop = args.includes("--desktop");
|
||||
const isSaas = args.includes("--saas");
|
||||
|
||||
console.log('setup-env: see frontend/README.md#environment-variables for documentation');
|
||||
console.log("setup-env: see frontend/README.md#environment-variables for documentation");
|
||||
|
||||
function getExampleKeys(exampleFile: string): string[] {
|
||||
const examplePath = join(root, exampleFile);
|
||||
if (!existsSync(examplePath)) return [];
|
||||
return Object.keys(parse(readFileSync(examplePath, 'utf-8')));
|
||||
return Object.keys(parse(readFileSync(examplePath, "utf-8")));
|
||||
}
|
||||
|
||||
function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
||||
@@ -44,13 +44,13 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
||||
|
||||
config({ path: envPath });
|
||||
|
||||
const missing = getExampleKeys(exampleFile).filter(k => !(k in process.env));
|
||||
const missing = getExampleKeys(exampleFile).filter((k) => !(k in process.env));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`setup-env: ${envFile} is missing keys from ${exampleFile}:\n` +
|
||||
missing.map(k => ` ${k}`).join('\n') +
|
||||
'\n Add them manually or delete your local file to re-copy from the example.'
|
||||
missing.map((k) => ` ${k}`).join("\n") +
|
||||
"\n Add them manually or delete your local file to re-copy from the example.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -59,29 +59,28 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
failed = ensureEnvFile('.env', 'config/.env.example') || failed;
|
||||
failed = ensureEnvFile(".env", "config/.env.example") || failed;
|
||||
|
||||
if (isDesktop) {
|
||||
failed = ensureEnvFile('.env.desktop', 'config/.env.desktop.example') || failed;
|
||||
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
|
||||
}
|
||||
|
||||
if (isSaas) {
|
||||
failed = ensureEnvFile('.env.saas', 'config/.env.saas.example') || failed;
|
||||
failed = ensureEnvFile(".env.saas", "config/.env.saas.example") || failed;
|
||||
}
|
||||
|
||||
// Warn about any VITE_ vars set in the environment that aren't listed in any example file.
|
||||
const allExampleKeys = new Set([
|
||||
...getExampleKeys('config/.env.example'),
|
||||
...getExampleKeys('config/.env.desktop.example'),
|
||||
...getExampleKeys('config/.env.saas.example'),
|
||||
...getExampleKeys("config/.env.example"),
|
||||
...getExampleKeys("config/.env.desktop.example"),
|
||||
...getExampleKeys("config/.env.saas.example"),
|
||||
]);
|
||||
const unknownViteVars = Object.keys(process.env)
|
||||
.filter(k => k.startsWith('VITE_') && !allExampleKeys.has(k));
|
||||
const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k));
|
||||
if (unknownViteVars.length > 0) {
|
||||
console.warn(
|
||||
'setup-env: the following VITE_ vars are set but not listed in any example file:\n' +
|
||||
unknownViteVars.map(k => ` ${k}`).join('\n') +
|
||||
'\n Add them to the appropriate config/.env.*.example file if they are required.'
|
||||
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
|
||||
unknownViteVars.map((k) => ` ${k}`).join("\n") +
|
||||
"\n Add them to the appropriate config/.env.*.example file if they are required.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
|
||||
@@ -1,98 +1,82 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.9.2",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop"
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.9.2",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "npm run dev -- --mode desktop",
|
||||
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"publisher": "Stirling PDF Inc.",
|
||||
"targets": ["deb", "rpm", "dmg", "msi"],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
],
|
||||
"resources": ["libs/*.jar", "runtime/jre/**/*"],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["pdf"],
|
||||
"name": "PDF Document",
|
||||
"role": "Editor",
|
||||
"mimeType": "application/pdf"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Stirling-PDF",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
|
||||
}
|
||||
]
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com",
|
||||
"wix": {
|
||||
"fragmentPaths": ["windows/wix/provisioning.wxs"],
|
||||
"componentGroupRefs": ["ProvisioningComponentGroup"]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"publisher": "Stirling PDF Inc.",
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
"dmg",
|
||||
"msi"
|
||||
],
|
||||
"icon": [
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/16x16.png",
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/192x192.png"
|
||||
],
|
||||
"resources": [
|
||||
"libs/*.jar",
|
||||
"runtime/jre/**/*"
|
||||
],
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": [
|
||||
"pdf"
|
||||
],
|
||||
"name": "PDF Document",
|
||||
"role": "Editor",
|
||||
"mimeType": "application/pdf"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com",
|
||||
"wix": {
|
||||
"fragmentPaths": [
|
||||
"windows/wix/provisioning.wxs"
|
||||
],
|
||||
"componentGroupRefs": [
|
||||
"ProvisioningComponentGroup"
|
||||
]
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null,
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"stirlingpdf"
|
||||
]
|
||||
}
|
||||
}
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null,
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": ["stirlingpdf"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,326 +1,326 @@
|
||||
{
|
||||
"dependencies": [
|
||||
{
|
||||
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
||||
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
|
||||
"moduleVersion": "1.7.7",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/core",
|
||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/engines",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-annotation",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-export",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-history",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-loader",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-pan",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-render",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-rotate",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-scroll",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-search",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-selection",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-spread",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-tiling",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-viewport",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-zoom",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@emotion/react",
|
||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||
"moduleVersion": "11.14.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
||||
},
|
||||
{
|
||||
"moduleName": "@emotion/styled",
|
||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||
"moduleVersion": "11.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
||||
},
|
||||
{
|
||||
"moduleName": "@iconify/react",
|
||||
"moduleUrl": "git+https://github.com/iconify/iconify.git",
|
||||
"moduleVersion": "6.0.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/core",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dates",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dropzone",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/hooks",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mui/icons-material",
|
||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||
"moduleVersion": "7.3.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mui/material",
|
||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||
"moduleVersion": "7.3.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tailwindcss/postcss",
|
||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.1.13",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tanstack/react-virtual",
|
||||
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
|
||||
"moduleVersion": "3.13.12",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "autoprefixer",
|
||||
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
|
||||
"moduleVersion": "10.4.21",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "axios",
|
||||
"moduleUrl": "git+https://github.com/axios/axios.git",
|
||||
"moduleVersion": "1.12.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next",
|
||||
"moduleUrl": "git+https://github.com/i18next/i18next.git",
|
||||
"moduleVersion": "25.5.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next-browser-languagedetector",
|
||||
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
|
||||
"moduleVersion": "8.2.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next-http-backend",
|
||||
"moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git",
|
||||
"moduleVersion": "3.0.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "jszip",
|
||||
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
|
||||
"moduleVersion": "3.10.1",
|
||||
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
|
||||
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "license-report",
|
||||
"moduleUrl": "git+https://github.com/kessler/license-report.git",
|
||||
"moduleVersion": "6.8.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pdf-lib",
|
||||
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
|
||||
"moduleVersion": "1.17.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pdfjs-dist",
|
||||
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
|
||||
"moduleVersion": "5.4.149",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "posthog-js",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.268.0",
|
||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react",
|
||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||
"moduleVersion": "19.1.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-dom",
|
||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||
"moduleVersion": "19.1.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-i18next",
|
||||
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
|
||||
"moduleVersion": "15.7.3",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-router-dom",
|
||||
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
|
||||
"moduleVersion": "7.9.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "tailwindcss",
|
||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.1.13",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "web-vitals",
|
||||
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
|
||||
"moduleVersion": "5.1.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
|
||||
}
|
||||
]
|
||||
}
|
||||
"dependencies": [
|
||||
{
|
||||
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
|
||||
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
|
||||
"moduleVersion": "1.7.7",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/core",
|
||||
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/engines",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-annotation",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-export",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-history",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-loader",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-pan",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-render",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-rotate",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-scroll",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-search",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-selection",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-spread",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-tiling",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-viewport",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@embedpdf/plugin-zoom",
|
||||
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||
"moduleVersion": "1.3.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@emotion/react",
|
||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||
"moduleVersion": "11.14.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
||||
},
|
||||
{
|
||||
"moduleName": "@emotion/styled",
|
||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||
"moduleVersion": "11.14.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
|
||||
},
|
||||
{
|
||||
"moduleName": "@iconify/react",
|
||||
"moduleUrl": "git+https://github.com/iconify/iconify.git",
|
||||
"moduleVersion": "6.0.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/core",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dates",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/dropzone",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mantine/hooks",
|
||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||
"moduleVersion": "8.3.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mui/icons-material",
|
||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||
"moduleVersion": "7.3.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@mui/material",
|
||||
"moduleUrl": "git+https://github.com/mui/material-ui.git",
|
||||
"moduleVersion": "7.3.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tailwindcss/postcss",
|
||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.1.13",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "@tanstack/react-virtual",
|
||||
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
|
||||
"moduleVersion": "3.13.12",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "autoprefixer",
|
||||
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
|
||||
"moduleVersion": "10.4.21",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "axios",
|
||||
"moduleUrl": "git+https://github.com/axios/axios.git",
|
||||
"moduleVersion": "1.12.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next",
|
||||
"moduleUrl": "git+https://github.com/i18next/i18next.git",
|
||||
"moduleVersion": "25.5.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next-browser-languagedetector",
|
||||
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
|
||||
"moduleVersion": "8.2.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "i18next-http-backend",
|
||||
"moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git",
|
||||
"moduleVersion": "3.0.2",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "jszip",
|
||||
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
|
||||
"moduleVersion": "3.10.1",
|
||||
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
|
||||
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "license-report",
|
||||
"moduleUrl": "git+https://github.com/kessler/license-report.git",
|
||||
"moduleVersion": "6.8.0",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pdf-lib",
|
||||
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
|
||||
"moduleVersion": "1.17.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "pdfjs-dist",
|
||||
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
|
||||
"moduleVersion": "5.4.149",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "posthog-js",
|
||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||
"moduleVersion": "1.268.0",
|
||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react",
|
||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||
"moduleVersion": "19.1.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-dom",
|
||||
"moduleUrl": "git+https://github.com/facebook/react.git",
|
||||
"moduleVersion": "19.1.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-i18next",
|
||||
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
|
||||
"moduleVersion": "15.7.3",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "react-router-dom",
|
||||
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
|
||||
"moduleVersion": "7.9.1",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "tailwindcss",
|
||||
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
|
||||
"moduleVersion": "4.1.13",
|
||||
"moduleLicense": "MIT",
|
||||
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
|
||||
},
|
||||
{
|
||||
"moduleName": "web-vitals",
|
||||
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
|
||||
"moduleVersion": "5.1.0",
|
||||
"moduleLicense": "Apache-2.0",
|
||||
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ import "@app/utils/fileIdSafety";
|
||||
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
{children}
|
||||
</RainbowThemeProvider>
|
||||
<RainbowThemeProvider>{children}</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
|
||||
import { ReactNode } from "react";
|
||||
import { useBanner } from "@app/contexts/BannerContext";
|
||||
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -21,11 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
height: 100% !important;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
{banner}
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||
</div>
|
||||
<NavigationWarningModal />
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,12 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import {
|
||||
AppConfigProvider,
|
||||
AppConfigProviderProps,
|
||||
AppConfigRetryOptions,
|
||||
useAppConfig,
|
||||
} from "@app/contexts/AppConfigContext";
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
@@ -20,8 +25,8 @@ import { BannerProvider } from "@app/contexts/BannerContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
|
||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
|
||||
@@ -41,14 +46,14 @@ function BrandingAssetManager() {
|
||||
const { favicon, logo192, manifestHref } = useLogoAssets();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const setLinkHref = (selector: string, href: string) => {
|
||||
const link = document.querySelector<HTMLLinkElement>(selector);
|
||||
if (link && link.getAttribute('href') !== href) {
|
||||
link.setAttribute('href', href);
|
||||
if (link && link.getAttribute("href") !== href) {
|
||||
link.setAttribute("href", href);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +67,7 @@ function BrandingAssetManager() {
|
||||
}
|
||||
|
||||
// Avoid requirement to have props which are required in app providers anyway
|
||||
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
|
||||
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, "children" | "retryOptions">;
|
||||
|
||||
export interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
@@ -98,49 +103,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BannerProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Modal } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { Tool } from '@app/types/tool';
|
||||
import MobileLayout from '@app/components/fileManager/MobileLayout';
|
||||
import DesktopLayout from '@app/components/fileManager/DesktopLayout';
|
||||
import DragOverlay from '@app/components/fileManager/DragOverlay';
|
||||
import { FileManagerProvider } from '@app/contexts/FileManagerContext';
|
||||
import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from '@app/services/googleDrivePickerService';
|
||||
import { loadScript } from '@app/utils/scriptLoader';
|
||||
import { useAllFiles } from '@app/contexts/FileContext';
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { Tool } from "@app/types/tool";
|
||||
import MobileLayout from "@app/components/fileManager/MobileLayout";
|
||||
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
||||
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService";
|
||||
import { loadScript } from "@app/utils/scriptLoader";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@@ -32,47 +32,59 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { fileIds: activeFileIds } = useAllFiles();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
if (!selectedTool?.supportedFormats) return true;
|
||||
const extension = fileName.split('.').pop()?.toLowerCase();
|
||||
return selectedTool.supportedFormats.includes(extension || '');
|
||||
}, [selectedTool?.supportedFormats]);
|
||||
const isFileSupported = useCallback(
|
||||
(fileName: string) => {
|
||||
if (!selectedTool?.supportedFormats) return true;
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
return selectedTool.supportedFormats.includes(extension || "");
|
||||
},
|
||||
[selectedTool?.supportedFormats],
|
||||
);
|
||||
|
||||
const refreshRecentFiles = useCallback(async () => {
|
||||
const files = await loadRecentFiles();
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
|
||||
try {
|
||||
// Use StirlingFileStubs directly - preserves all metadata!
|
||||
onRecentFileSelect(files);
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
}, [onRecentFileSelect]);
|
||||
|
||||
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
const handleRecentFilesSelected = useCallback(
|
||||
async (files: StirlingFileStub[]) => {
|
||||
try {
|
||||
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||
onFileUpload(files);
|
||||
await refreshRecentFiles();
|
||||
// Use StirlingFileStubs directly - preserves all metadata!
|
||||
onRecentFileSelect(files);
|
||||
} catch (error) {
|
||||
console.error('Failed to process dropped files:', error);
|
||||
console.error("Failed to process selected files:", error);
|
||||
}
|
||||
}
|
||||
}, [onFileUpload, refreshRecentFiles]);
|
||||
},
|
||||
[onRecentFileSelect],
|
||||
);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
}, [handleRemoveFile, recentFiles]);
|
||||
const handleNewFileUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||
onFileUpload(files);
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error("Failed to process dropped files:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onFileUpload, refreshRecentFiles],
|
||||
);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(
|
||||
async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
},
|
||||
[handleRemoveFile, recentFiles],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +101,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
return () => {
|
||||
// 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');
|
||||
console.log("FileManager unmounting - FileContext handles blob URL cleanup");
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -97,7 +109,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
// Use useMemo to only track Google Drive config changes, not all config updates
|
||||
const googleDriveBackendConfig = useMemo(
|
||||
() => extractGoogleDriveBackendConfig(config),
|
||||
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId]
|
||||
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,29 +117,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
// Load scripts in parallel without blocking
|
||||
Promise.all([
|
||||
loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
src: "https://apis.google.com/js/api.js",
|
||||
id: "gapi-script",
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
src: "https://accounts.google.com/gsi/client",
|
||||
id: "gis-script",
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
]).catch((error) => {
|
||||
console.warn('Failed to preload Google Drive scripts:', error);
|
||||
console.warn("Failed to preload Google Drive scripts:", error);
|
||||
});
|
||||
}
|
||||
}, [googleDriveBackendConfig]);
|
||||
|
||||
// Modal size constants for consistent scaling
|
||||
const modalHeight = '80vh';
|
||||
const modalWidth = isMobile ? '100%' : '80vw';
|
||||
const modalMaxWidth = isMobile ? '100%' : '1200px';
|
||||
const modalMaxHeight = '1200px';
|
||||
const modalMinWidth = isMobile ? '320px' : '800px';
|
||||
const modalHeight = "80vh";
|
||||
const modalWidth = isMobile ? "100%" : "80vw";
|
||||
const modalMaxWidth = isMobile ? "100%" : "1200px";
|
||||
const modalMaxHeight = "1200px";
|
||||
const modalMinWidth = isMobile ? "320px" : "800px";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -141,23 +153,25 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
|
||||
styles={{
|
||||
content: {
|
||||
position: 'relative',
|
||||
margin: isMobile ? '1rem' : '2rem'
|
||||
position: "relative",
|
||||
margin: isMobile ? "1rem" : "2rem",
|
||||
},
|
||||
body: { padding: 0 },
|
||||
header: { display: 'none' }
|
||||
header: { display: "none" },
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: modalHeight,
|
||||
width: modalWidth,
|
||||
maxWidth: modalMaxWidth,
|
||||
maxHeight: modalMaxHeight,
|
||||
minWidth: modalMinWidth,
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
height: modalHeight,
|
||||
width: modalWidth,
|
||||
maxWidth: modalMaxWidth,
|
||||
maxHeight: modalMaxHeight,
|
||||
minWidth: modalMinWidth,
|
||||
margin: "0 auto",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Dropzone
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
@@ -165,14 +179,14 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--bg-file-manager)'
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius-md)",
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
}}
|
||||
styles={{
|
||||
inner: { pointerEvents: 'all' }
|
||||
inner: { pointerEvents: "all" },
|
||||
}}
|
||||
>
|
||||
<FileManagerProvider
|
||||
|
||||
@@ -14,12 +14,7 @@ interface StorageStatsCardProps {
|
||||
onReloadFiles: () => void;
|
||||
}
|
||||
|
||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
}) => {
|
||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
@@ -59,12 +54,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
|
||||
Reload Files
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -73,4 +63,4 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageStatsCard;
|
||||
export default StorageStatsCard;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, ReactNode } from "react";
|
||||
|
||||
interface PDFAnnotationContextValue {
|
||||
// Drawing mode management
|
||||
@@ -58,7 +58,7 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
setSignatureConfig,
|
||||
}) => {
|
||||
const contextValue: PDFAnnotationContextValue = {
|
||||
activateDrawMode,
|
||||
@@ -72,20 +72,16 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
getImageData,
|
||||
isPlacementMode,
|
||||
signatureConfig,
|
||||
setSignatureConfig
|
||||
setSignatureConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
<PDFAnnotationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PDFAnnotationContext.Provider>
|
||||
);
|
||||
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
|
||||
};
|
||||
|
||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||
const context = useContext(PDFAnnotationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
|
||||
throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Stack, Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Stack, Alert, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
|
||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||
import { usePDFAnnotation } from "@app/components/annotation/providers/PDFAnnotationProvider";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
|
||||
export interface AnnotationToolConfig {
|
||||
enableDrawing?: boolean;
|
||||
@@ -25,17 +25,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
config,
|
||||
children,
|
||||
onSignatureDataChange,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
activateSignaturePlacementMode,
|
||||
undo,
|
||||
redo
|
||||
} = usePDFAnnotation();
|
||||
const { activateSignaturePlacementMode, undo, redo } = usePDFAnnotation();
|
||||
const { historyApiRef } = useSignature();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [selectedColor, setSelectedColor] = useState("#000000");
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
@@ -94,14 +90,12 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
signatureData,
|
||||
onSignatureDataChange: handleSignatureDataChange,
|
||||
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||
disabled
|
||||
disabled,
|
||||
})}
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||
<Text size="sm">
|
||||
Click anywhere on the PDF to place your annotation.
|
||||
</Text>
|
||||
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
|
||||
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import ColorizeIcon from '@mui/icons-material/Colorize';
|
||||
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||
|
||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
||||
// the button is hidden in the UI if the API is not supported.
|
||||
const supportsEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
|
||||
const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
|
||||
|
||||
interface EyeDropper {
|
||||
open(): Promise<{ sRGBHex: string }>;
|
||||
}
|
||||
declare const EyeDropper: { new(): EyeDropper };
|
||||
declare const EyeDropper: { new (): EyeDropper };
|
||||
|
||||
interface ColorControlProps {
|
||||
value: string;
|
||||
@@ -24,7 +24,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
||||
// Only propagate to the parent (which triggers expensive annotation updates)
|
||||
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
|
||||
const [localColor, setLocalColor] = useState(value);
|
||||
useEffect(() => { setLocalColor(value); }, [value]);
|
||||
useEffect(() => {
|
||||
setLocalColor(value);
|
||||
}, [value]);
|
||||
|
||||
const handleEyeDropper = useCallback(async () => {
|
||||
if (!supportsEyeDropper) return;
|
||||
@@ -50,13 +52,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'var(--bg-raised)',
|
||||
border: '1px solid var(--border-default)',
|
||||
color: 'var(--text-secondary)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
borderColor: 'var(--border-strong)',
|
||||
color: 'var(--text-primary)',
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -73,8 +75,16 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
||||
onChange={setLocalColor}
|
||||
onChangeEnd={onChange}
|
||||
swatches={[
|
||||
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
|
||||
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
|
||||
"#000000",
|
||||
"#ffffff",
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#0000ff",
|
||||
"#ffff00",
|
||||
"#ff00ff",
|
||||
"#00ffff",
|
||||
"#ffa500",
|
||||
"transparent",
|
||||
]}
|
||||
swatchesPerRow={5}
|
||||
size="sm"
|
||||
@@ -82,7 +92,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
||||
{supportsEyeDropper && (
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label="Pick colour from screen">
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper} style={{ color: 'var(--text-primary)' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={handleEyeDropper}
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
<ColorizeIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from "react";
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ColorPickerProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,48 +26,42 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
opacityLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
|
||||
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
|
||||
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
|
||||
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={resolvedTitle}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
|
||||
<Stack gap="md">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
|
||||
swatchesPerRow={6}
|
||||
size="lg"
|
||||
fullWidth
|
||||
/>
|
||||
{showOpacity && onOpacityChange && opacity !== undefined && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{resolvedOpacityLabel}
|
||||
</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={onOpacityChange}
|
||||
marks={[
|
||||
{ value: 25, label: '25%' },
|
||||
{ value: 50, label: '50%' },
|
||||
{ value: 75, label: '75%' },
|
||||
{ value: 100, label: '100%' },
|
||||
{ value: 25, label: "25%" },
|
||||
{ value: 50, label: "50%" },
|
||||
{ value: 75, label: "75%" },
|
||||
{ value: 100, label: "100%" },
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={onClose}>
|
||||
{t('common.done', 'Done')}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{t("common.done", "Done")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
@@ -80,18 +74,6 @@ interface ColorSwatchButtonProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
color,
|
||||
onClick,
|
||||
size = 24
|
||||
}) => {
|
||||
return (
|
||||
<ColorSwatch
|
||||
color={color}
|
||||
size={size}
|
||||
radius={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
|
||||
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
|
||||
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
|
||||
import SignaturePad from 'signature_pad';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
|
||||
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
|
||||
import SignaturePad from "signature_pad";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
interface DrawingCanvasProps {
|
||||
selectedColor: string;
|
||||
@@ -68,7 +68,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
if (savedSignatureData) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
@@ -92,13 +92,16 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
}, [autoOpen]);
|
||||
|
||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas.toDataURL('image/png');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return canvas.toDataURL("image/png");
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
|
||||
let minX = canvas.width,
|
||||
minY = canvas.height,
|
||||
maxX = 0,
|
||||
maxY = 0;
|
||||
|
||||
// Find bounds of non-transparent pixels
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
@@ -117,21 +120,21 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
const trimHeight = maxY - minY + 1;
|
||||
|
||||
// Create trimmed canvas
|
||||
const trimmedCanvas = document.createElement('canvas');
|
||||
const trimmedCanvas = document.createElement("canvas");
|
||||
trimmedCanvas.width = trimWidth;
|
||||
trimmedCanvas.height = trimHeight;
|
||||
const trimmedCtx = trimmedCanvas.getContext('2d');
|
||||
const trimmedCtx = trimmedCanvas.getContext("2d");
|
||||
if (trimmedCtx) {
|
||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
||||
}
|
||||
|
||||
return trimmedCanvas.toDataURL('image/png');
|
||||
return trimmedCanvas.toDataURL("image/png");
|
||||
};
|
||||
|
||||
const renderPreview = (dataUrl: string) => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
@@ -153,7 +156,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
const canvas = modalCanvasRef.current;
|
||||
if (canvas) {
|
||||
const trimmedPng = trimCanvas(canvas);
|
||||
const untrimmedPng = canvas.toDataURL('image/png');
|
||||
const untrimmedPng = canvas.toDataURL("image/png");
|
||||
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
|
||||
onSignatureDataChange(trimmedPng);
|
||||
renderPreview(trimmedPng);
|
||||
@@ -176,7 +179,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
padRef.current.clear();
|
||||
}
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext('2d');
|
||||
const ctx = previewCanvasRef.current.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
}
|
||||
@@ -209,7 +212,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
useEffect(() => {
|
||||
const canvas = previewCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!initialSignatureData) {
|
||||
@@ -227,42 +230,45 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<PrivateContent>
|
||||
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: '#ffffff',
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
backgroundColor: "#ffffff",
|
||||
width: "100%",
|
||||
}}
|
||||
onClick={disabled ? undefined : openModal}
|
||||
/>
|
||||
</PrivateContent>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
|
||||
{t("sign.canvas.clickToOpen", "Click to open the drawing canvas")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={t("sign.canvas.modalTitle", "Draw your signature")}
|
||||
size="auto"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.colorLabel', 'Colour')}
|
||||
{t("sign.canvas.colorLabel", "Colour")}
|
||||
</Text>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
|
||||
</Stack>
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('sign.canvas.penSizeLabel', 'Pen size')}
|
||||
{t("sign.canvas.penSizeLabel", "Pen size")}
|
||||
</Text>
|
||||
<PenSizeSelector
|
||||
value={penSize}
|
||||
@@ -272,9 +278,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
updatePenSize(size);
|
||||
}}
|
||||
onInputChange={onPenSizeInputChange}
|
||||
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
|
||||
placeholder={t("sign.canvas.penSizePlaceholder", "Size")}
|
||||
size="compact-sm"
|
||||
style={{ width: '80px' }}
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -286,26 +292,24 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
if (el) initPad(el);
|
||||
}}
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
display: 'block',
|
||||
touchAction: 'none',
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '50rem',
|
||||
height: '25rem',
|
||||
cursor: 'crosshair',
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: "4px",
|
||||
display: "block",
|
||||
touchAction: "none",
|
||||
backgroundColor: "white",
|
||||
width: "100%",
|
||||
maxWidth: "50rem",
|
||||
height: "25rem",
|
||||
cursor: "crosshair",
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Button variant="subtle" color="red" onClick={clear}>
|
||||
{t('sign.canvas.clear', 'Clear canvas')}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
{t('common.done', 'Done')}
|
||||
{t("sign.canvas.clear", "Clear canvas")}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import React from "react";
|
||||
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface DrawingControlsProps {
|
||||
onUndo?: () => void;
|
||||
@@ -35,30 +35,30 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap" align="center">
|
||||
{onUndo && (
|
||||
<Tooltip label={t('sign.undo', 'Undo')}>
|
||||
<Tooltip label={t("sign.undo", "Undo")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.undo', 'Undo')}
|
||||
aria-label={t("sign.undo", "Undo")}
|
||||
onClick={onUndo}
|
||||
disabled={undoDisabled}
|
||||
color={undoDisabled ? 'gray' : 'blue'}
|
||||
color={undoDisabled ? "gray" : "blue"}
|
||||
>
|
||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onRedo && (
|
||||
<Tooltip label={t('sign.redo', 'Redo')}>
|
||||
<Tooltip label={t("sign.redo", "Redo")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t('sign.redo', 'Redo')}
|
||||
aria-label={t("sign.redo", "Redo")}
|
||||
onClick={onRedo}
|
||||
disabled={redoDisabled}
|
||||
color={redoDisabled ? 'gray' : 'blue'}
|
||||
color={redoDisabled ? "gray" : "blue"}
|
||||
>
|
||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
|
||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -67,13 +67,7 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
ml="auto"
|
||||
>
|
||||
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FileInput, Text, Stack, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { removeWhiteBackground } from '@app/utils/imageTransparency';
|
||||
import { alert } from '@app/components/toast';
|
||||
import React, { useState } from "react";
|
||||
import { FileInput, Text, Stack, Checkbox } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { removeWhiteBackground } from "@app/utils/imageTransparency";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
interface ImageUploaderProps {
|
||||
onImageChange: (file: File | null) => void;
|
||||
@@ -22,7 +22,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
placeholder,
|
||||
hint,
|
||||
allowBackgroundRemoval = false,
|
||||
onProcessedImageData
|
||||
onProcessedImageData,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [removeBackground, setRemoveBackground] = useState(false);
|
||||
@@ -36,15 +36,18 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
try {
|
||||
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
|
||||
autoDetectCorner: true,
|
||||
tolerance: 15
|
||||
tolerance: 15,
|
||||
});
|
||||
onProcessedImageData?.(transparentImageDataUrl);
|
||||
} catch (error) {
|
||||
console.error('Error removing background:', error);
|
||||
console.error("Error removing background:", error);
|
||||
alert({
|
||||
title: t('sign.image.backgroundRemovalFailedTitle', 'Background removal failed'),
|
||||
body: t('sign.image.backgroundRemovalFailedMessage', 'Could not remove the background from the image. Using original image instead.'),
|
||||
alertType: 'error'
|
||||
title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"),
|
||||
body: t(
|
||||
"sign.image.backgroundRemovalFailedMessage",
|
||||
"Could not remove the background from the image. Using original image instead.",
|
||||
),
|
||||
alertType: "error",
|
||||
});
|
||||
onProcessedImageData?.(null);
|
||||
} finally {
|
||||
@@ -52,7 +55,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
}
|
||||
} else {
|
||||
// When background removal is disabled, return the original image data
|
||||
if (typeof imageSource === 'string') {
|
||||
if (typeof imageSource === "string") {
|
||||
onProcessedImageData?.(imageSource);
|
||||
} else {
|
||||
// Convert File to data URL if needed
|
||||
@@ -69,8 +72,8 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
// Validate that it's actually an image file or SVG
|
||||
if (!file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.svg')) {
|
||||
console.error('Selected file is not an image or SVG');
|
||||
if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) {
|
||||
console.error("Selected file is not an image or SVG");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,10 +81,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
onImageChange(file);
|
||||
|
||||
let dataUrlToProcess: string;
|
||||
|
||||
|
||||
// Check if file is SVG
|
||||
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
|
||||
|
||||
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
|
||||
|
||||
if (isSvg) {
|
||||
// For SVG, convert to PNG so it can be embedded in PDF
|
||||
dataUrlToProcess = await convertSvgToPng(file);
|
||||
@@ -98,7 +101,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
setOriginalImageData(dataUrlToProcess);
|
||||
await processImage(dataUrlToProcess, removeBackground);
|
||||
} catch (error) {
|
||||
console.error('Error processing image file:', error);
|
||||
console.error("Error processing image file:", error);
|
||||
}
|
||||
} else if (!file) {
|
||||
// Clear image data when no file is selected
|
||||
@@ -116,33 +119,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const svgText = e.target?.result as string;
|
||||
|
||||
|
||||
// Parse SVG to get dimensions
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
|
||||
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
|
||||
const svgElement = svgDoc.documentElement;
|
||||
|
||||
|
||||
// Get SVG dimensions
|
||||
let width = 800; // Default width
|
||||
let width = 800; // Default width
|
||||
let height = 600; // Default height
|
||||
|
||||
if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) {
|
||||
width = parseFloat(svgElement.getAttribute('width') || '800');
|
||||
height = parseFloat(svgElement.getAttribute('height') || '600');
|
||||
} else if (svgElement.hasAttribute('viewBox')) {
|
||||
const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/);
|
||||
|
||||
if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) {
|
||||
width = parseFloat(svgElement.getAttribute("width") || "800");
|
||||
height = parseFloat(svgElement.getAttribute("height") || "600");
|
||||
} else if (svgElement.hasAttribute("viewBox")) {
|
||||
const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+|,/);
|
||||
if (viewBox && viewBox.length === 4) {
|
||||
width = parseFloat(viewBox[2]);
|
||||
height = parseFloat(viewBox[3]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Ensure reasonable dimensions
|
||||
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
|
||||
width = 800;
|
||||
height = 600;
|
||||
}
|
||||
|
||||
|
||||
// Scale large SVGs down
|
||||
const maxDimension = 2048;
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
@@ -150,68 +153,73 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
width *= scale;
|
||||
height *= scale;
|
||||
}
|
||||
|
||||
console.log('Converting SVG to PNG:', { width, height });
|
||||
|
||||
|
||||
console.log("Converting SVG to PNG:", { width, height });
|
||||
|
||||
// Create an image element to render SVG
|
||||
const img = new Image();
|
||||
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
// Use computed dimensions or image natural dimensions
|
||||
const finalWidth = img.naturalWidth || img.width || width;
|
||||
const finalHeight = img.naturalHeight || img.height || height;
|
||||
|
||||
console.log('Image loaded:', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, finalWidth, finalHeight });
|
||||
|
||||
|
||||
console.log("Image loaded:", {
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight,
|
||||
finalWidth,
|
||||
finalHeight,
|
||||
});
|
||||
|
||||
// Create canvas to convert to PNG
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = finalWidth;
|
||||
canvas.height = finalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
reject(new Error("Failed to get canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fill with white background (optional, for transparency support)
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, finalWidth, finalHeight);
|
||||
|
||||
|
||||
// Draw SVG
|
||||
ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
|
||||
// Convert canvas to PNG data URL
|
||||
const pngDataUrl = canvas.toDataURL('image/png');
|
||||
console.log('SVG converted to PNG successfully');
|
||||
const pngDataUrl = canvas.toDataURL("image/png");
|
||||
console.log("SVG converted to PNG successfully");
|
||||
resolve(pngDataUrl);
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('Error during canvas rendering:', error);
|
||||
console.error("Error during canvas rendering:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
img.onerror = (error) => {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('Failed to load SVG image:', error);
|
||||
reject(new Error('Failed to load SVG image'));
|
||||
console.error("Failed to load SVG image:", error);
|
||||
reject(new Error("Failed to load SVG image"));
|
||||
};
|
||||
|
||||
|
||||
img.src = url;
|
||||
} catch (error) {
|
||||
console.error('Error parsing SVG:', error);
|
||||
console.error("Error parsing SVG:", error);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('Error reading file:', reader.error);
|
||||
console.error("Error reading file:", reader.error);
|
||||
reject(reader.error);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
@@ -231,7 +239,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
<PrivateContent>
|
||||
<FileInput
|
||||
label={label}
|
||||
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
|
||||
accept="image/*,.svg"
|
||||
onChange={handleImageChange}
|
||||
disabled={disabled || isProcessing}
|
||||
@@ -239,7 +247,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
</PrivateContent>
|
||||
{allowBackgroundRemoval && (
|
||||
<Checkbox
|
||||
label={t('sign.image.removeBackground', 'Remove white background (make transparent)')}
|
||||
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
|
||||
checked={removeBackground}
|
||||
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
|
||||
disabled={disabled || !currentFile || isProcessing}
|
||||
@@ -252,7 +260,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
)}
|
||||
{isProcessing && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('sign.image.processing', 'Processing image...')}
|
||||
{t("sign.image.processing", "Processing image...")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import OpacityIcon from '@mui/icons-material/Opacity';
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import OpacityIcon from "@mui/icons-material/Opacity";
|
||||
|
||||
interface OpacityControlProps {
|
||||
value: number; // 0-100
|
||||
@@ -16,7 +16,7 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t('annotation.opacity', 'Opacity')}>
|
||||
<Tooltip label={t("annotation.opacity", "Opacity")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -26,13 +26,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'var(--bg-raised)',
|
||||
border: '1px solid var(--border-default)',
|
||||
color: 'var(--text-secondary)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
borderColor: 'var(--border-strong)',
|
||||
color: 'var(--text-primary)',
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -44,15 +44,9 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs" style={{ minWidth: 150 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t('annotation.opacity', 'Opacity')}
|
||||
{t("annotation.opacity", "Opacity")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
/>
|
||||
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
|
||||
import type { PdfAnnotationObject } from '@embedpdf/models';
|
||||
import type { AnnotationPatch } from '@app/components/viewer/viewerTypes';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
|
||||
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
|
||||
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
|
||||
import type { PdfAnnotationObject } from "@embedpdf/models";
|
||||
import type { AnnotationPatch } from "@app/components/viewer/viewerTypes";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
|
||||
import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
|
||||
import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
|
||||
|
||||
export type PropertiesAnnotationType = 'text' | 'note' | 'shape';
|
||||
export type PropertiesAnnotationType = "text" | "note" | "shape";
|
||||
|
||||
interface PropertiesPopoverProps {
|
||||
annotationType: PropertiesAnnotationType;
|
||||
@@ -18,12 +18,7 @@ interface PropertiesPopoverProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PropertiesPopover({
|
||||
annotationType,
|
||||
annotation,
|
||||
onUpdate,
|
||||
disabled = false,
|
||||
}: PropertiesPopoverProps) {
|
||||
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
@@ -41,17 +36,17 @@ export function PropertiesPopover({
|
||||
const fontSize = obj?.fontSize ?? 14;
|
||||
const textAlign = obj?.textAlign;
|
||||
const currentAlign =
|
||||
typeof textAlign === 'number'
|
||||
typeof textAlign === "number"
|
||||
? textAlign === 1
|
||||
? 'center'
|
||||
? "center"
|
||||
: textAlign === 2
|
||||
? 'right'
|
||||
: 'left'
|
||||
: textAlign === 'center'
|
||||
? 'center'
|
||||
: textAlign === 'right'
|
||||
? 'right'
|
||||
: 'left';
|
||||
? "right"
|
||||
: "left"
|
||||
: textAlign === "center"
|
||||
? "center"
|
||||
: textAlign === "right"
|
||||
? "right"
|
||||
: "left";
|
||||
|
||||
// For shapes
|
||||
const opacity = Math.round((obj?.opacity ?? 1) * 100);
|
||||
@@ -63,7 +58,7 @@ export function PropertiesPopover({
|
||||
{/* Font Size */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.fontSize', 'Font size')}
|
||||
{t("annotation.fontSize", "Font size")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={fontSize}
|
||||
@@ -77,7 +72,7 @@ export function PropertiesPopover({
|
||||
{/* Opacity */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.opacity', 'Opacity')}
|
||||
{t("annotation.opacity", "Opacity")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={Math.round((obj?.opacity ?? 1) * 100)}
|
||||
@@ -91,25 +86,25 @@ export function PropertiesPopover({
|
||||
{/* Text Alignment */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.textAlignment', 'Text Alignment')}
|
||||
{t("annotation.textAlignment", "Text Alignment")}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'left' ? 'filled' : 'default'}
|
||||
variant={currentAlign === "left" ? "filled" : "default"}
|
||||
onClick={() => onUpdate({ textAlign: 0 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'center' ? 'filled' : 'default'}
|
||||
variant={currentAlign === "center" ? "filled" : "default"}
|
||||
onClick={() => onUpdate({ textAlign: 1 })}
|
||||
size="md"
|
||||
>
|
||||
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={currentAlign === 'right' ? 'filled' : 'default'}
|
||||
variant={currentAlign === "right" ? "filled" : "default"}
|
||||
onClick={() => onUpdate({ textAlign: 2 })}
|
||||
size="md"
|
||||
>
|
||||
@@ -125,7 +120,7 @@ export function PropertiesPopover({
|
||||
{/* Opacity */}
|
||||
<div>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.opacity', 'Opacity')}
|
||||
{t("annotation.opacity", "Opacity")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={opacity}
|
||||
@@ -148,7 +143,7 @@ export function PropertiesPopover({
|
||||
<Group gap="xs" align="flex-end">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={500} mb={4}>
|
||||
{t('annotation.strokeWidth', 'Stroke')}
|
||||
{t("annotation.strokeWidth", "Stroke")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={strokeWidth}
|
||||
@@ -166,7 +161,7 @@ export function PropertiesPopover({
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={!borderVisible ? 'filled' : 'light'}
|
||||
variant={!borderVisible ? "filled" : "light"}
|
||||
onClick={() => {
|
||||
const newValue = borderVisible ? 0 : 1;
|
||||
onUpdate({
|
||||
@@ -176,9 +171,7 @@ export function PropertiesPopover({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{borderVisible
|
||||
? t('annotation.borderOn', 'Border: On')
|
||||
: t('annotation.borderOff', 'Border: Off')}
|
||||
{borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -188,7 +181,7 @@ export function PropertiesPopover({
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t('annotation.properties', 'Properties')}>
|
||||
<Tooltip label={t("annotation.properties", "Properties")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -198,13 +191,13 @@ export function PropertiesPopover({
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'var(--bg-raised)',
|
||||
border: '1px solid var(--border-default)',
|
||||
color: 'var(--text-secondary)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
borderColor: 'var(--border-strong)',
|
||||
color: 'var(--text-primary)',
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -214,8 +207,8 @@ export function PropertiesPopover({
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
|
||||
{annotationType === 'shape' && renderShapeControls()}
|
||||
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
|
||||
{annotationType === "shape" && renderShapeControls()}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||
|
||||
interface TextInputWithFontProps {
|
||||
text: string;
|
||||
@@ -12,8 +12,8 @@ interface TextInputWithFontProps {
|
||||
onFontFamilyChange: (family: string) => void;
|
||||
textColor?: string;
|
||||
onTextColorChange?: (color: string) => void;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
onTextAlignChange?: (align: "left" | "center" | "right") => void;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
@@ -31,9 +31,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
onFontSizeChange,
|
||||
fontFamily,
|
||||
onFontFamilyChange,
|
||||
textColor = '#000000',
|
||||
textColor = "#000000",
|
||||
onTextColorChange,
|
||||
textAlign = 'left',
|
||||
textAlign = "left",
|
||||
onTextAlignChange,
|
||||
disabled = false,
|
||||
label,
|
||||
@@ -42,7 +42,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
fontSizeLabel,
|
||||
fontSizePlaceholder,
|
||||
colorLabel,
|
||||
onAnyChange
|
||||
onAnyChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||
@@ -61,14 +61,37 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
}, [textColor]);
|
||||
|
||||
const fontOptions = [
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Times-Roman', label: 'Times' },
|
||||
{ value: 'Courier', label: 'Courier' },
|
||||
{ value: 'Arial', label: 'Arial' },
|
||||
{ value: 'Georgia', label: 'Georgia' },
|
||||
{ value: "Helvetica", label: "Helvetica" },
|
||||
{ value: "Times-Roman", label: "Times" },
|
||||
{ value: "Courier", label: "Courier" },
|
||||
{ value: "Arial", label: "Arial" },
|
||||
{ value: "Georgia", label: "Georgia" },
|
||||
];
|
||||
|
||||
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
|
||||
const fontSizeOptions = [
|
||||
"8",
|
||||
"12",
|
||||
"16",
|
||||
"20",
|
||||
"24",
|
||||
"28",
|
||||
"32",
|
||||
"36",
|
||||
"40",
|
||||
"48",
|
||||
"56",
|
||||
"64",
|
||||
"72",
|
||||
"80",
|
||||
"96",
|
||||
"112",
|
||||
"128",
|
||||
"144",
|
||||
"160",
|
||||
"176",
|
||||
"192",
|
||||
"200",
|
||||
];
|
||||
|
||||
// Validate hex color
|
||||
const isValidHexColor = (color: string): boolean => {
|
||||
@@ -94,7 +117,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
label={fontLabel}
|
||||
value={fontFamily}
|
||||
onChange={(value) => {
|
||||
onFontFamilyChange(value || 'Helvetica');
|
||||
onFontFamilyChange(value || "Helvetica");
|
||||
onAnyChange?.();
|
||||
}}
|
||||
data={fontOptions}
|
||||
@@ -187,7 +210,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
setColorInput(textColor);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: "100%" }}
|
||||
rightSection={
|
||||
<Box
|
||||
onClick={() => !disabled && setIsColorPickerOpen(true)}
|
||||
@@ -195,9 +218,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
width: 24,
|
||||
height: 24,
|
||||
backgroundColor: textColor,
|
||||
border: '1px solid #ccc',
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 4,
|
||||
cursor: disabled ? 'default' : 'pointer'
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -224,14 +247,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||
<SegmentedControl
|
||||
value={textAlign}
|
||||
onChange={(value: string) => {
|
||||
onTextAlignChange(value as 'left' | 'center' | 'right');
|
||||
onTextAlignChange(value as "left" | "center" | "right");
|
||||
onAnyChange?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ label: t('textAlign.left', 'Left'), value: 'left' },
|
||||
{ label: t('textAlign.center', 'Center'), value: 'center' },
|
||||
{ label: t('textAlign.right', 'Right'), value: 'right' },
|
||||
{ label: t("textAlign.left", "Left"), value: "left" },
|
||||
{ label: t("textAlign.center", "Center"), value: "center" },
|
||||
{ label: t("textAlign.right", "Right"), value: "right" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState } from 'react';
|
||||
import LineWeightIcon from '@mui/icons-material/LineWeight';
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
||||
|
||||
interface WidthControlProps {
|
||||
value: number;
|
||||
@@ -18,7 +18,7 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t('annotation.width', 'Width')}>
|
||||
<Tooltip label={t("annotation.width", "Width")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -28,13 +28,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
||||
styles={{
|
||||
root: {
|
||||
flexShrink: 0,
|
||||
backgroundColor: 'var(--bg-raised)',
|
||||
border: '1px solid var(--border-default)',
|
||||
color: 'var(--text-secondary)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
borderColor: 'var(--border-strong)',
|
||||
color: 'var(--text-primary)',
|
||||
backgroundColor: "var(--bg-raised)",
|
||||
border: "1px solid var(--border-default)",
|
||||
color: "var(--text-secondary)",
|
||||
"&:hover": {
|
||||
backgroundColor: "var(--hover-bg)",
|
||||
borderColor: "var(--border-strong)",
|
||||
color: "var(--text-primary)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -46,15 +46,9 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs" style={{ minWidth: 150 }}>
|
||||
<Text size="xs" fw={500}>
|
||||
{t('annotation.width', 'Width')}
|
||||
{t("annotation.width", "Width")}
|
||||
</Text>
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={min}
|
||||
max={max}
|
||||
label={(val) => `${val}pt`}
|
||||
/>
|
||||
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
||||
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
|
||||
import React, { useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
|
||||
|
||||
interface DrawingToolProps {
|
||||
onDrawingChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
onDrawingChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const [selectedColor] = useState('#000000');
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
|
||||
const [selectedColor] = useState("#000000");
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||
const [penSizeInput, setPenSizeInput] = useState("2");
|
||||
|
||||
const toolConfig = {
|
||||
enableDrawing: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Drawing"
|
||||
placeButtonText: "Place Drawing",
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onDrawingChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
|
||||
<Stack gap="sm">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
@@ -42,4 +35,4 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
|
||||
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
|
||||
import React, { useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||
|
||||
interface ImageToolProps {
|
||||
onImageChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
@@ -23,7 +20,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
if (e.target?.result) {
|
||||
resolve(e.target.result as string);
|
||||
} else {
|
||||
reject(new Error('Failed to read file'));
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
@@ -33,7 +30,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
setImageData(result);
|
||||
onImageChange?.(result);
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
console.error("Error reading file:", error);
|
||||
}
|
||||
} else if (!file) {
|
||||
setImageData(null);
|
||||
@@ -44,15 +41,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
const toolConfig = {
|
||||
enableImageUpload: true,
|
||||
showPlaceButton: true,
|
||||
placeButtonText: "Place Image"
|
||||
placeButtonText: "Place Image",
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onImageChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
|
||||
<Stack gap="sm">
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
@@ -64,4 +57,4 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
</Stack>
|
||||
</BaseAnnotationTool>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button, Group, useMantineColorScheme } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
import styles from "@app/components/fileEditor/FileEditor.module.css";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
@@ -16,11 +16,7 @@ interface AddFileCardProps {
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
@@ -38,7 +34,7 @@ const AddFileCard = ({
|
||||
e.stopPropagation();
|
||||
const files = await openFilesFromDisk({
|
||||
multiple,
|
||||
onFallbackOpen: () => fileInputRef.current?.click()
|
||||
onFallbackOpen: () => fileInputRef.current?.click(),
|
||||
});
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
@@ -56,7 +52,7 @@ const AddFileCard = ({
|
||||
onFileSelect(files);
|
||||
}
|
||||
// Reset input so same files can be selected again
|
||||
event.target.value = '';
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -67,17 +63,17 @@ const AddFileCard = ({
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
||||
aria-label={t("fileEditor.addFiles", "Add files")}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
@@ -86,11 +82,9 @@ const AddFileCard = ({
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t('fileEditor.addFiles', 'Add Files')}
|
||||
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
@@ -99,84 +93,81 @@ const AddFileCard = ({
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
|
||||
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
style={{ height: "2.2rem", width: "auto" }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '100%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.6rem",
|
||||
width: "100%",
|
||||
marginTop: "0.8rem",
|
||||
marginBottom: "0.8rem",
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "2rem",
|
||||
height: "38px",
|
||||
paddingLeft: isUploadHover ? 0 : "1rem",
|
||||
paddingRight: isUploadHover ? 0 : "1rem",
|
||||
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
|
||||
minWidth: isUploadHover ? "58px" : undefined,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
backgroundColor: "var(--landing-button-bg)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: "1rem",
|
||||
height: "38px",
|
||||
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
|
||||
minWidth: "58px",
|
||||
paddingLeft: isUploadHover ? "1rem" : 0,
|
||||
paddingRight: isUploadHover ? "1rem" : 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease",
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
<LocalIcon
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--accent-interactive)" }}
|
||||
/>
|
||||
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
|
||||
>
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
background: var(--file-card-bg);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
outline-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: visible;
|
||||
@@ -45,8 +48,8 @@
|
||||
}
|
||||
|
||||
.headerResting {
|
||||
background: #3B4B6E; /* dark blue for unselected in light mode */
|
||||
color: #FFFFFF;
|
||||
background: #3b4b6e; /* dark blue for unselected in light mode */
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
@@ -66,7 +69,7 @@
|
||||
/* Unsupported (but not errored) header appearance */
|
||||
.headerUnsupported {
|
||||
background: var(--unsupported-bar-bg); /* neutral gray */
|
||||
color: #FFFFFF;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid var(--unsupported-bar-border);
|
||||
}
|
||||
|
||||
@@ -103,7 +106,7 @@
|
||||
}
|
||||
|
||||
.headerIconButton {
|
||||
color: #FFFFFF !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Menu dropdown */
|
||||
@@ -226,14 +229,13 @@
|
||||
}
|
||||
|
||||
.pinned {
|
||||
color: #FFC107 !important;
|
||||
color: #ffc107 !important;
|
||||
}
|
||||
|
||||
|
||||
/* Unsupported file indicator */
|
||||
.unsupportedPill {
|
||||
margin-left: 1.75rem;
|
||||
background: #6B7280;
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
@@ -264,7 +266,8 @@
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@@ -288,15 +291,15 @@
|
||||
DARK MODE OVERRIDES
|
||||
========================= */
|
||||
:global([data-mantine-color-scheme="dark"]) .card {
|
||||
outline-color: #3A4047; /* deselected stroke */
|
||||
outline-color: #3a4047; /* deselected stroke */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
|
||||
outline-color: #4B525A; /* selected stroke (subtle grey) */
|
||||
outline-color: #4b525a; /* selected stroke (subtle grey) */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .headerResting {
|
||||
background: #1F2329; /* requested default unselected color */
|
||||
background: #1f2329; /* requested default unselected color */
|
||||
color: var(--tool-header-text); /* #D0D6DC */
|
||||
border-bottom-color: var(--tool-header-border); /* #3A4047 */
|
||||
}
|
||||
@@ -308,16 +311,16 @@
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .title {
|
||||
color: #D0D6DC; /* title text */
|
||||
color: #d0d6dc; /* title text */
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .meta {
|
||||
color: #6B7280; /* subtitle text */
|
||||
color: #6b7280; /* subtitle text */
|
||||
}
|
||||
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
outline-color: #3b4b6e;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Text, Center, Box, LoadingOverlay, Stack
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
|
||||
import { useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
import { detectFileExtension } from '@app/utils/fileUtils';
|
||||
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
|
||||
import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
|
||||
import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
import { FileId, StirlingFile } from "@app/types/fileContext";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: () => void;
|
||||
@@ -25,16 +22,15 @@ interface FileEditorProps {
|
||||
supportedExtensions?: string[];
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
toolMode = false,
|
||||
supportedExtensions = ["pdf"]
|
||||
}: FileEditorProps) => {
|
||||
|
||||
const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => {
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback((fileName: string): boolean => {
|
||||
const extension = detectFileExtension(fileName);
|
||||
return extension ? supportedExtensions.includes(extension) : false;
|
||||
}, [supportedExtensions]);
|
||||
const isFileSupported = useCallback(
|
||||
(fileName: string): boolean => {
|
||||
const extension = detectFileExtension(fileName);
|
||||
return extension ? supportedExtensions.includes(extension) : false;
|
||||
},
|
||||
[supportedExtensions],
|
||||
);
|
||||
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
@@ -62,11 +58,11 @@ const FileEditor = ({
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
|
||||
const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
|
||||
alert({ alertType: "error", title: "Error", body: message, expandable: true });
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
@@ -76,7 +72,7 @@ const FileEditor = ({
|
||||
// Compute effective max allowed files based on the active tool and mode
|
||||
const maxAllowed = useMemo<number>(() => {
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
|
||||
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
}, [selectedTool?.maxFiles, toolMode]);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
@@ -104,8 +100,8 @@ const FileEditor = ({
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on select all:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Failed to clear file errors on select all:", error);
|
||||
}
|
||||
}
|
||||
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
|
||||
@@ -115,8 +111,8 @@ const FileEditor = ({
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Failed to clear file errors on deselect:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Failed to clear file errors on deselect:", error);
|
||||
}
|
||||
}
|
||||
}, [setSelectedFiles, clearAllFileErrors]);
|
||||
@@ -137,69 +133,75 @@ const FileEditor = ({
|
||||
|
||||
// Process uploaded files using context
|
||||
// ZIP extraction is now handled automatically in FileContext based on user preferences
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
_setError(null);
|
||||
const handleFileUpload = useCallback(
|
||||
async (uploadedFiles: File[]) => {
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
if (uploadedFiles.length > 0) {
|
||||
// FileContext will automatically handle ZIP extraction based on user preferences
|
||||
// - Respects autoUnzip setting
|
||||
// - Respects autoUnzipFileLimit
|
||||
// - HTML ZIPs stay intact
|
||||
// - Non-ZIP files pass through unchanged
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
// After auto-selection, enforce maxAllowed if needed
|
||||
if (Number.isFinite(maxAllowed)) {
|
||||
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
|
||||
if (nowSelectedIds.length > maxAllowed) {
|
||||
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
|
||||
try {
|
||||
if (uploadedFiles.length > 0) {
|
||||
// FileContext will automatically handle ZIP extraction based on user preferences
|
||||
// - Respects autoUnzip setting
|
||||
// - Respects autoUnzipFileLimit
|
||||
// - HTML ZIPs stay intact
|
||||
// - Non-ZIP files pass through unchanged
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
// After auto-selection, enforce maxAllowed if needed
|
||||
if (Number.isFinite(maxAllowed)) {
|
||||
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id);
|
||||
if (nowSelectedIds.length > maxAllowed) {
|
||||
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
|
||||
}
|
||||
}
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to process files";
|
||||
showError(errorMessage);
|
||||
console.error("File processing error:", err);
|
||||
}
|
||||
},
|
||||
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
|
||||
);
|
||||
|
||||
const toggleFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: FileId[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
|
||||
} else {
|
||||
// Add file to selection
|
||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
// Only one file allowed -> replace selection with the new file
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// If at capacity, drop the oldest selected and append the new one
|
||||
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
|
||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||
} else {
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
}
|
||||
}, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: FileId[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
|
||||
} else {
|
||||
// Add file to selection
|
||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
// Only one file allowed -> replace selection with the new file
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// If at capacity, drop the oldest selected and append the new one
|
||||
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
|
||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||
} else {
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]);
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
},
|
||||
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
|
||||
);
|
||||
|
||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||
useEffect(() => {
|
||||
@@ -208,154 +210,174 @@ const FileEditor = ({
|
||||
}
|
||||
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const currentIds = activeStirlingFileStubs.map(r => r.id);
|
||||
const handleReorderFiles = useCallback(
|
||||
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const currentIds = activeStirlingFileStubs.map((r) => r.id);
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||
const targetIndex = currentIds.findIndex(id => id === targetFileId);
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
|
||||
const targetIndex = currentIds.findIndex((id) => id === targetFileId);
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
console.warn('Could not find source or target file for reordering');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
? selectedFileIds.filter(id => currentIds.includes(id))
|
||||
: [sourceFileId];
|
||||
|
||||
// Create new order
|
||||
const newOrder = [...currentIds];
|
||||
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
|
||||
.sort((a, b) => b - a); // Sort descending
|
||||
|
||||
sourceIndices.forEach(index => {
|
||||
newOrder.splice(index, 1);
|
||||
});
|
||||
|
||||
// Calculate insertion index after removals
|
||||
let insertIndex = newOrder.findIndex(id => id === targetFileId);
|
||||
if (insertIndex !== -1) {
|
||||
// Determine if moving forward or backward
|
||||
const isMovingForward = sourceIndex < targetIndex;
|
||||
if (isMovingForward) {
|
||||
// Moving forward: insert after target
|
||||
insertIndex += 1;
|
||||
} else {
|
||||
// Moving backward: insert before target (insertIndex already correct)
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
console.warn("Could not find source or target file for reordering");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Target was moved, insert at end
|
||||
insertIndex = newOrder.length;
|
||||
}
|
||||
|
||||
// Insert files at the calculated position
|
||||
newOrder.splice(insertIndex, 0, ...filesToMove);
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove =
|
||||
selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId];
|
||||
|
||||
// Update file order
|
||||
reorderFiles(newOrder);
|
||||
// Create new order
|
||||
const newOrder = [...currentIds];
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending
|
||||
|
||||
sourceIndices.forEach((index) => {
|
||||
newOrder.splice(index, 1);
|
||||
});
|
||||
|
||||
// Calculate insertion index after removals
|
||||
let insertIndex = newOrder.findIndex((id) => id === targetFileId);
|
||||
if (insertIndex !== -1) {
|
||||
// Determine if moving forward or backward
|
||||
const isMovingForward = sourceIndex < targetIndex;
|
||||
if (isMovingForward) {
|
||||
// Moving forward: insert after target
|
||||
insertIndex += 1;
|
||||
} else {
|
||||
// Moving backward: insert before target (insertIndex already correct)
|
||||
}
|
||||
} else {
|
||||
// Target was moved, insert at end
|
||||
insertIndex = newOrder.length;
|
||||
}
|
||||
|
||||
// Insert files at the calculated position
|
||||
newOrder.splice(insertIndex, 0, ...filesToMove);
|
||||
|
||||
// Update file order
|
||||
reorderFiles(newOrder);
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
|
||||
},
|
||||
[activeStirlingFileStubs, reorderFiles, _setStatus],
|
||||
);
|
||||
|
||||
// File operations using context
|
||||
const handleCloseFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
const contextFileId = record.id;
|
||||
removeFiles([contextFileId], false);
|
||||
const handleCloseFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
const contextFileId = record.id;
|
||||
removeFiles([contextFileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleDownloadFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
|
||||
if (record && file) {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath
|
||||
});
|
||||
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
|
||||
// Mark file as clean after successful save to disk
|
||||
if (result.savedPath) {
|
||||
console.log('[FileEditor] Marking file as clean:', fileId);
|
||||
fileActions.updateStirlingFileStub(fileId, {
|
||||
localFilePath: record.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
} else {
|
||||
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, fileActions]);
|
||||
},
|
||||
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds],
|
||||
);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await fileActions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
const handleDownloadFile = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
console.log("[FileEditor] handleDownloadFile called:", {
|
||||
fileId,
|
||||
hasRecord: !!record,
|
||||
hasFile: !!file,
|
||||
localFilePath: record?.localFilePath,
|
||||
isDirty: record?.isDirty,
|
||||
});
|
||||
if (record && file) {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath,
|
||||
});
|
||||
console.log("[FileEditor] Download complete, checking dirty state:", {
|
||||
localFilePath: record.localFilePath,
|
||||
isDirty: record.isDirty,
|
||||
savedPath: result.savedPath,
|
||||
});
|
||||
// Mark file as clean after successful save to disk
|
||||
if (result.savedPath) {
|
||||
console.log("[FileEditor] Marking file as clean:", fileId);
|
||||
fileActions.updateStirlingFileStub(fileId, {
|
||||
localFilePath: record.localFilePath ?? result.savedPath,
|
||||
isDirty: false,
|
||||
});
|
||||
} else {
|
||||
console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty });
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeStirlingFileStubs, selectors, fileActions],
|
||||
);
|
||||
|
||||
const handleUnzipFile = useCallback(
|
||||
async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
await fileActions.addStirlingFileStubs(result.extractedStubs);
|
||||
|
||||
// Remove the original ZIP file
|
||||
removeFiles([fileId], false);
|
||||
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500,
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
body: result.errors.join("\n"),
|
||||
expandable: true,
|
||||
durationMs: 3500,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to unzip file:", error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Failed to extract files from ${file.name}`,
|
||||
body: result.errors.join('\n'),
|
||||
expandable: true,
|
||||
durationMs: 3500
|
||||
alertType: "error",
|
||||
title: `Error unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to unzip file:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: `Error unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
|
||||
},
|
||||
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
|
||||
);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
|
||||
if (index !== -1) {
|
||||
setActiveFileId(fileId as string);
|
||||
setActiveFileIndex(index);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]);
|
||||
const handleViewFile = useCallback(
|
||||
(fileId: FileId) => {
|
||||
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
|
||||
if (index !== -1) {
|
||||
setActiveFileId(fileId as string);
|
||||
setActiveFileIndex(index);
|
||||
navActions.setWorkbench("viewer");
|
||||
}
|
||||
},
|
||||
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
|
||||
);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
@@ -365,91 +387,85 @@ const FileEditor = ({
|
||||
// The files are already in FileContext, just need to add them to active files
|
||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
showError('Failed to load some files from storage');
|
||||
console.error("Error loading files from storage:", err);
|
||||
showError("Failed to load some files from storage");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{
|
||||
border: 'none',
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent'
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
activateOnClick={false}
|
||||
activateOnDrag={true}
|
||||
>
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<Box pos="relative" style={{ overflow: "auto" }}>
|
||||
<LoadingOverlay visible={state.ui.isProcessing} />
|
||||
|
||||
<Box p="md">
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
📁
|
||||
</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Upload PDF files, ZIP archives, or load from storage to get started
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
|
||||
rowGap: "1.5rem",
|
||||
padding: "1rem",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{activeStirlingFileStubs.length === 0 ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
rowGap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
onToggleFile={toggleFile}
|
||||
onCloseFile={handleCloseFile}
|
||||
onViewFile={handleViewFile}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
onUnzipFile={handleUnzipFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import React from "react";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
interface FileEditorFileNameProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
);
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
|
||||
|
||||
export default FileEditorFileName;
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from '@mantine/core';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { zipFileService } from '@app/services/zipFileService';
|
||||
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
import { useFileContext } from '@app/contexts/FileContext';
|
||||
import { useFileState } from '@app/contexts/file/fileHooks';
|
||||
import { FileId } from '@app/types/file';
|
||||
import { formatFileSize } from '@app/utils/fileUtils';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
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';
|
||||
|
||||
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import UnarchiveIcon from "@mui/icons-material/Unarchive";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
|
||||
import styles from "@app/components/fileEditor/FileEditor.module.css";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/file/fileHooks";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
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";
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -69,14 +67,7 @@ const FileEditorThumbnail = ({
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadOutlinedIcon = icons.download;
|
||||
const {
|
||||
pinFile,
|
||||
unpinFile,
|
||||
isFilePinned,
|
||||
activeFiles,
|
||||
actions: fileActions,
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
|
||||
const { state, selectors } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
@@ -93,7 +84,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
const actualFile = useMemo(() => {
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
return activeFiles.find((f) => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
@@ -114,17 +105,17 @@ const FileEditorThumbnail = ({
|
||||
}, [file.size]);
|
||||
|
||||
const extUpper = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
||||
return (m?.[1] || '').toUpperCase();
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||
return (m?.[1] || "").toUpperCase();
|
||||
}, [file.name]);
|
||||
|
||||
const extLower = useMemo(() => {
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
|
||||
return (m?.[1] || '').toLowerCase();
|
||||
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
|
||||
return (m?.[1] || "").toLowerCase();
|
||||
}, [file.name]);
|
||||
|
||||
const isCBZ = extLower === 'cbz';
|
||||
const isCBR = extLower === 'cbr';
|
||||
const isCBZ = extLower === "cbz";
|
||||
const isCBR = extLower === "cbr";
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
@@ -137,71 +128,68 @@ const FileEditorThumbnail = ({
|
||||
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
||||
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
pageCount > 0
|
||||
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
: '',
|
||||
[pageCount]
|
||||
);
|
||||
const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
const d = new Date(file.lastModified);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}, [file.lastModified]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
const fileElementRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
|
||||
dragElementRef.current = element;
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: "file",
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: [file.id], // Always drag only this file, ignore selection state
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id
|
||||
}),
|
||||
canDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: "file",
|
||||
fileId: file.id,
|
||||
}),
|
||||
canDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === "file" && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === "file" && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
}, [file.id, file.name, selectedFiles, onReorderFiles]);
|
||||
return () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
},
|
||||
[file.id, file.name, selectedFiles, onReorderFiles],
|
||||
);
|
||||
|
||||
// Handle close with confirmation
|
||||
const handleCloseWithConfirmation = useCallback(() => {
|
||||
@@ -210,7 +198,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, onCloseFile]);
|
||||
|
||||
@@ -221,12 +209,12 @@ const FileEditorThumbnail = ({
|
||||
const result = await downloadFile({
|
||||
data: fileToSave,
|
||||
filename: file.name,
|
||||
localPath: file.localFilePath
|
||||
localPath: file.localFilePath,
|
||||
});
|
||||
if (!result.cancelled && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(file.id, {
|
||||
localFilePath: file.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
isDirty: false,
|
||||
});
|
||||
} else if (result.cancelled) {
|
||||
setShowCloseModal(false);
|
||||
@@ -234,14 +222,14 @@ const FileEditorThumbnail = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${file.name}:`, error);
|
||||
alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
|
||||
alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true });
|
||||
setShowCloseModal(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Then close
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
|
||||
|
||||
@@ -250,95 +238,110 @@ const FileEditorThumbnail = ({
|
||||
}, []);
|
||||
|
||||
// Build hover menu actions
|
||||
const hoverActions = useMemo<HoverAction[]>(() => [
|
||||
{
|
||||
id: 'view',
|
||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||
label: t('openInViewer', 'Open in Viewer'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
const hoverActions = useMemo<HoverAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "view",
|
||||
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
|
||||
label: t("openInViewer", "Open in Viewer"),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: terminology.download,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
{
|
||||
id: "download",
|
||||
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
|
||||
label: terminology.download,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
},
|
||||
},
|
||||
},
|
||||
...(canUpload || canShare
|
||||
? [
|
||||
...(canUpload ? [{
|
||||
id: 'upload',
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t('fileManager.updateOnServer', 'Update on Server')
|
||||
: t('fileManager.uploadToServer', 'Upload to Server'),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
}] : []),
|
||||
...(canShare ? [{
|
||||
id: 'share',
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t('fileManager.share', 'Share'),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
}] : []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'unzip',
|
||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||
label: t('fileManager.unzip', 'Unzip'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (onUnzipFile) {
|
||||
onUnzipFile(file.id);
|
||||
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}
|
||||
...(canUpload || canShare
|
||||
? [
|
||||
...(canUpload
|
||||
? [
|
||||
{
|
||||
id: "upload",
|
||||
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||
label: isUploaded
|
||||
? t("fileManager.updateOnServer", "Update on Server")
|
||||
: t("fileManager.uploadToServer", "Upload to Server"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowUploadModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canShare
|
||||
? [
|
||||
{
|
||||
id: "share",
|
||||
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.share", "Share"),
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowShareModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "unzip",
|
||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||
label: t("fileManager.unzip", "Unzip"),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (onUnzipFile) {
|
||||
onUnzipFile(file.id);
|
||||
alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
}
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||
label: t('close', 'Close'),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseWithConfirmation();
|
||||
{
|
||||
id: "close",
|
||||
icon: <CloseIcon style={{ fontSize: 20 }} />,
|
||||
label: t("close", "Close"),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
handleCloseWithConfirmation();
|
||||
},
|
||||
color: "red",
|
||||
},
|
||||
color: 'red',
|
||||
}
|
||||
], [
|
||||
t,
|
||||
file.id,
|
||||
file.name,
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
terminology,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
handleCloseWithConfirmation,
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded
|
||||
]);
|
||||
],
|
||||
[
|
||||
t,
|
||||
file.id,
|
||||
file.name,
|
||||
isZipFile,
|
||||
isCBZ,
|
||||
isCBR,
|
||||
terminology,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
onUnzipFile,
|
||||
handleCloseWithConfirmation,
|
||||
canUpload,
|
||||
canShare,
|
||||
isUploaded,
|
||||
],
|
||||
);
|
||||
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
try {
|
||||
fileActions.clearFileError(file.id);
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
}
|
||||
if (isSharedFile && !sharedEditNoticeShownRef.current) {
|
||||
sharedEditNoticeShownRef.current = true;
|
||||
@@ -359,7 +362,6 @@ const FileEditorThumbnail = ({
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileElementRef}
|
||||
@@ -369,7 +371,7 @@ const FileEditorThumbnail = ({
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{opacity: isDragging ? 0.9 : 1}}
|
||||
style={{ opacity: isDragging ? 0.9 : 1 }}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
@@ -379,15 +381,12 @@ const FileEditorThumbnail = ({
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t('error._value', 'Error')}</span>
|
||||
<span>{t("error._value", "Error")}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
@@ -397,9 +396,7 @@ const FileEditorThumbnail = ({
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.unsupportedPill}>
|
||||
<span>
|
||||
{t('unsupported', 'Unsupported')}
|
||||
</span>
|
||||
<span>{t("unsupported", "Unsupported")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -412,9 +409,9 @@ const FileEditorThumbnail = ({
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
|
||||
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
||||
<ActionIcon
|
||||
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
|
||||
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
@@ -427,9 +424,17 @@ const FileEditorThumbnail = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
||||
<Tooltip
|
||||
label={
|
||||
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
|
||||
aria-label={
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
@@ -438,10 +443,10 @@ const FileEditorThumbnail = ({
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -454,35 +459,30 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
textAlign: 'center',
|
||||
background: 'var(--file-card-bg)',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
textAlign: "center",
|
||||
background: "var(--file-card-bg)",
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={styles.meta}
|
||||
lineClamp={3}
|
||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||
>
|
||||
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"} • ${prettySize}`}>
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ''}
|
||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||
{extUpper ? ` - ${extUpper} file` : ""}
|
||||
{pageLabel ? ` - ${pageLabel}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Preview area */}
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
||||
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl ? (
|
||||
@@ -495,27 +495,29 @@ const FileEditorThumbnail = ({
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
img.style.display = 'none';
|
||||
img.parentElement?.setAttribute('data-thumb-missing', 'true');
|
||||
img.style.display = "none";
|
||||
img.parentElement?.setAttribute("data-thumb-missing", "true");
|
||||
}}
|
||||
style={{
|
||||
maxWidth: '80%',
|
||||
maxHeight: '80%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 0,
|
||||
background: '#ffffff',
|
||||
border: '1px solid var(--border-default)',
|
||||
display: 'block',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
alignSelf: 'start'
|
||||
}}
|
||||
/>
|
||||
maxWidth: "80%",
|
||||
maxHeight: "80%",
|
||||
objectFit: "contain",
|
||||
borderRadius: 0,
|
||||
background: "#ffffff",
|
||||
border: "1px solid var(--border-default)",
|
||||
display: "block",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
alignSelf: "start",
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith('application/pdf') ? (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ height: '100%' }}>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">Loading thumbnail...</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -527,74 +529,72 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* 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'
|
||||
}}>
|
||||
<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)'
|
||||
maxWidth={"100%"}
|
||||
color="var(--mantine-color-gray-7)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
opened={showCloseModal}
|
||||
onClose={handleCancelClose}
|
||||
title={t('confirmClose', 'Confirm Close')}
|
||||
title={t("confirmClose", "Confirm Close")}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{file.isDirty && file.localFilePath ? (
|
||||
<>
|
||||
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
|
||||
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
{t("confirmCloseCancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseDiscard', 'Discard changes and close')}
|
||||
{t("confirmCloseDiscard", "Discard changes and close")}
|
||||
</Button>
|
||||
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||
{t('confirmCloseSave', 'Save and close')}
|
||||
{t("confirmCloseSave", "Save and close")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
{t("confirmCloseCancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseConfirm', 'Close File')}
|
||||
{t("confirmCloseConfirm", "Close File")}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
@@ -604,39 +604,27 @@ const FileEditorThumbnail = ({
|
||||
<Modal
|
||||
opened={showSharedEditNotice}
|
||||
onClose={() => setShowSharedEditNotice(false)}
|
||||
title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')}
|
||||
title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")}
|
||||
centered
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'fileManager.sharedEditNoticeBody',
|
||||
'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.'
|
||||
"fileManager.sharedEditNoticeBody",
|
||||
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button onClick={() => setShowSharedEditNotice(false)}>
|
||||
{t('fileManager.sharedEditNoticeConfirm', 'Got it')}
|
||||
{t("fileManager.sharedEditNoticeConfirm", "Got it")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{canUpload && (
|
||||
<UploadToServerModal
|
||||
opened={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ShareFileModal
|
||||
opened={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
|
||||
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
totalItems: number;
|
||||
@@ -20,41 +20,44 @@ export function useFileEditorRightRailButtons({
|
||||
}: FileEditorRightRailButtonsParams) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'file-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.selectAll', 'Select All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-deselect-all',
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.deselectAll', 'Deselect All'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: 'file-close-selected',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
|
||||
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
], [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
|
||||
const buttons = useMemo<RightRailButtonWithAction[]>(
|
||||
() => [
|
||||
{
|
||||
id: "file-select-all",
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.selectAll", "Select All"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
|
||||
section: "top" as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
visible: totalItems > 0,
|
||||
onClick: onSelectAll,
|
||||
},
|
||||
{
|
||||
id: "file-deselect-all",
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
|
||||
section: "top" as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onDeselectAll,
|
||||
},
|
||||
{
|
||||
id: "file-close-selected",
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
|
||||
section: "top" as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
visible: totalItems > 0,
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
],
|
||||
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
|
||||
);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize } from '@app/utils/fileUtils';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import React from "react";
|
||||
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileSize } from "@app/utils/fileUtils";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
interface CompactFileDetailsProps {
|
||||
currentFile: StirlingFileStub | null;
|
||||
@@ -29,47 +29,56 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
isAnimating,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onOpenFiles
|
||||
onOpenFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasMultipleFiles = numberOfFiles > 1;
|
||||
const showOwner = Boolean(
|
||||
currentFile &&
|
||||
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
|
||||
currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink),
|
||||
);
|
||||
const ownerLabel = currentFile
|
||||
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
|
||||
: '';
|
||||
const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : "";
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: '100%' }}>
|
||||
<Stack gap="xs" style={{ height: "100%" }}>
|
||||
{/* Compact mobile layout */}
|
||||
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<Box style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
{/* Small preview */}
|
||||
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "7.5rem",
|
||||
height: "9.375rem",
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{currentFile && thumbnail ? (
|
||||
<PrivateContent>
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={currentFile.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: "0.25rem",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
|
||||
}}
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : currentFile ? (
|
||||
<Center style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 4
|
||||
}}>
|
||||
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Center
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
|
||||
</Center>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -77,10 +86,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<PrivateContent>{currentFile ? currentFile.name : 'No file selected'}</PrivateContent>
|
||||
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ''}
|
||||
{currentFile ? getFileSize(currentFile) : ""}
|
||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
@@ -92,33 +101,23 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* Compact tool chain for mobile */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
|
||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")}
|
||||
</Text>
|
||||
)}
|
||||
{currentFile && showOwner && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('fileManager.owner', 'Owner')}: {ownerLabel}
|
||||
{t("fileManager.owner", "Owner")}: {ownerLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation arrows for multiple files */}
|
||||
{hasMultipleFiles && (
|
||||
<Box style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
<Box style={{ display: "flex", gap: "0.25rem" }}>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
|
||||
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
|
||||
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
@@ -132,14 +131,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||
color: 'white'
|
||||
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||
: t('fileManager.openFile', 'Open File')
|
||||
}
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Grid } from '@mantine/core';
|
||||
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
|
||||
import FileDetails from '@app/components/fileManager/FileDetails';
|
||||
import SearchInput from '@app/components/fileManager/SearchInput';
|
||||
import FileListArea from '@app/components/fileManager/FileListArea';
|
||||
import FileActions from '@app/components/fileManager/FileActions';
|
||||
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import React from "react";
|
||||
import { Grid } from "@mantine/core";
|
||||
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
|
||||
import FileDetails from "@app/components/fileManager/FileDetails";
|
||||
import SearchInput from "@app/components/fileManager/SearchInput";
|
||||
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||
import FileActions from "@app/components/fileManager/FileActions";
|
||||
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
|
||||
const DesktopLayout: React.FC = () => {
|
||||
const {
|
||||
activeSource,
|
||||
recentFiles,
|
||||
modalHeight,
|
||||
} = useFileManagerContext();
|
||||
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
|
||||
{/* Column 1: File Sources */}
|
||||
<Grid.Col span="content" p="lg" style={{
|
||||
minWidth: '13.625rem',
|
||||
width: '13.625rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
}} data-tour="file-sources">
|
||||
<Grid.Col
|
||||
span="content"
|
||||
p="lg"
|
||||
style={{
|
||||
minWidth: "13.625rem",
|
||||
width: "13.625rem",
|
||||
flexShrink: 0,
|
||||
height: "100%",
|
||||
}}
|
||||
data-tour="file-sources"
|
||||
>
|
||||
<FileSourceButtons />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Column 2: File List */}
|
||||
<Grid.Col span="auto" style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
flex: '1 1 0px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'var(--bg-file-list)',
|
||||
border: '1px solid var(--mantine-color-gray-2)',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{activeSource === 'recent' && (
|
||||
<Grid.Col
|
||||
span="auto"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
flex: "1 1 0px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "var(--bg-file-list)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{activeSource === "recent" && (
|
||||
<>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<div style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<FileActions />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<FileListArea
|
||||
scrollAreaHeight={activeSource === 'recent' && recentFiles.length > 0
|
||||
? `calc(${modalHeight} - 7rem)`
|
||||
: '100%'}
|
||||
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"}
|
||||
scrollAreaStyle={{
|
||||
height: activeSource === 'recent' && recentFiles.length > 0
|
||||
? `calc(${modalHeight} - 7rem)`
|
||||
: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 0
|
||||
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -81,14 +87,18 @@ const DesktopLayout: React.FC = () => {
|
||||
</Grid.Col>
|
||||
|
||||
{/* Column 3: File Details */}
|
||||
<Grid.Col p="xl" span="content" style={{
|
||||
minWidth: '25rem',
|
||||
width: '25rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
maxWidth: '18rem'
|
||||
}}>
|
||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||
<Grid.Col
|
||||
p="xl"
|
||||
span="content"
|
||||
style={{
|
||||
minWidth: "25rem",
|
||||
width: "25rem",
|
||||
flexShrink: 0,
|
||||
height: "100%",
|
||||
maxWidth: "18rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: "100%", overflow: "hidden" }}>
|
||||
<FileDetails />
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from "react";
|
||||
import { Stack, Text, useMantineTheme, alpha } from "@mantine/core";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DragOverlayProps {
|
||||
isVisible: boolean;
|
||||
@@ -16,29 +16,29 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: alpha(theme.colors.blue[6], 0.1),
|
||||
border: `0.125rem dashed ${theme.colors.blue[6]}`,
|
||||
borderRadius: '1.875rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: "1.875rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'none'
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="md">
|
||||
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
|
||||
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
|
||||
<Text size="xl" fw={500} c="blue.6">
|
||||
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
|
||||
{t("fileManager.dropFilesHere", "Drop files here to upload")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragOverlay;
|
||||
export default DragOverlay;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import React, { useState } from "react";
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,92 +24,90 @@ const EmptyFilesState: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
{/* Container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '3rem 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
minWidth: '20rem',
|
||||
maxWidth: '28rem',
|
||||
width: '100%'
|
||||
backgroundColor: "transparent",
|
||||
padding: "3rem 2rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "1.5rem",
|
||||
minWidth: "20rem",
|
||||
maxWidth: "28rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
||||
{t("fileManager.noRecentFiles", "No recent files")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Stirling PDF Logo */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
|
||||
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
style={{ height: "2.2rem", width: "auto" }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem'
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-file-manager)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? '100%' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
||||
backgroundColor: "var(--bg-file-manager)",
|
||||
color: "var(--landing-button-color)",
|
||||
border: "1px solid var(--landing-button-border)",
|
||||
borderRadius: isUploadHover ? "2rem" : "1rem",
|
||||
height: "38px",
|
||||
width: isUploadHover ? "100%" : "58px",
|
||||
minWidth: "58px",
|
||||
paddingLeft: isUploadHover ? "1rem" : 0,
|
||||
paddingRight: isUploadHover ? "1rem" : 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease, border-radius .5s ease",
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
<LocalIcon
|
||||
icon={icons.uploadIconName}
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--accent-interactive)" }}
|
||||
/>
|
||||
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -30,9 +30,8 @@ const FileActions: React.FC = () => {
|
||||
onDownloadSelected,
|
||||
refreshRecentFiles,
|
||||
storageFilter,
|
||||
onStorageFilterChange
|
||||
} =
|
||||
useFileManagerContext();
|
||||
onStorageFilterChange,
|
||||
} = useFileManagerContext();
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
@@ -42,11 +41,11 @@ const FileActions: React.FC = () => {
|
||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
|
||||
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }
|
||||
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") },
|
||||
]
|
||||
: [
|
||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") }
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||
];
|
||||
useEffect(() => {
|
||||
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
||||
@@ -56,10 +55,8 @@ const FileActions: React.FC = () => {
|
||||
const hasSelection = selectedFileIds.length > 0;
|
||||
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
||||
const hasDownloadAccess = selectedFiles.every((file) => {
|
||||
const role = (file.remoteOwnedByCurrentUser !== false
|
||||
? 'editor'
|
||||
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
||||
return role === 'editor' || role === 'commenter' || role === 'viewer';
|
||||
const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
||||
return role === "editor" || role === "commenter" || role === "viewer";
|
||||
});
|
||||
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
@@ -80,7 +77,6 @@ const FileActions: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Only show actions if there are files
|
||||
if (recentFiles.length === 0) {
|
||||
return null;
|
||||
@@ -120,9 +116,7 @@ const FileActions: React.FC = () => {
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={storageFilter}
|
||||
onChange={(value) =>
|
||||
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
|
||||
}
|
||||
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")}
|
||||
data={storageFilterOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Stack, Button, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail';
|
||||
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||
import FilePreview from '@app/components/shared/FilePreview';
|
||||
import FileInfoCard from '@app/components/fileManager/FileInfoCard';
|
||||
import CompactFileDetails from '@app/components/fileManager/CompactFileDetails';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Stack, Button, Box } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
import FilePreview from "@app/components/shared/FilePreview";
|
||||
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
|
||||
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
|
||||
|
||||
interface FileDetailsProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
compact = false
|
||||
}) => {
|
||||
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0);
|
||||
@@ -35,7 +33,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
|
||||
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1));
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
@@ -44,7 +42,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
|
||||
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0));
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
@@ -75,7 +73,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
return (
|
||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
|
||||
{/* Section 1: Thumbnail Preview */}
|
||||
<Box style={{ width: '100%', height: 'min(35vh, 280px)', textAlign: 'center', flexShrink: 0 }}>
|
||||
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
|
||||
<FilePreview
|
||||
file={currentFile}
|
||||
thumbnail={getCurrentThumbnail()}
|
||||
@@ -89,10 +87,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
</Box>
|
||||
|
||||
{/* Section 2: File Details */}
|
||||
<FileInfoCard
|
||||
currentFile={currentFile}
|
||||
modalHeight={modalHeight}
|
||||
/>
|
||||
<FileInfoCard currentFile={currentFile} modalHeight={modalHeight} />
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
@@ -100,14 +95,13 @@ const FileDetails: React.FC<FileDetailsProps> = ({
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
|
||||
color: 'white'
|
||||
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 1
|
||||
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
|
||||
: t('fileManager.openFile', 'Open File')
|
||||
}
|
||||
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
|
||||
: t("fileManager.openFile", "Open File")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user