Compare commits

..
Author SHA1 Message Date
aandClaude Opus 4.6 d86afba440 Add workflow to clear all GitHub Actions caches
Runs on push to this branch and via manual workflow_dispatch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:52:16 +01:00
1427 changed files with 57768 additions and 60762 deletions
-104
View File
@@ -50,7 +50,6 @@ jobs:
permissions:
actions: read
security-events: write
pull-requests: write
strategy:
fail-fast: false
matrix:
@@ -85,60 +84,6 @@ 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'
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: exit 1
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew build -PnoSpotless
env:
@@ -242,9 +187,6 @@ 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
@@ -260,52 +202,6 @@ 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'
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: exit 1
- name: Type-check frontend
run: cd frontend && npm run prep && npm run typecheck:all
- name: Lint frontend
+56
View File
@@ -0,0 +1,56 @@
name: Clear GitHub Actions Cache
on:
workflow_dispatch:
push:
branches:
- clear-github-cache
jobs:
clear-cache:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Clear all caches
uses: actions/github-script@v7
with:
script: |
const caches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
let deleted = 0;
for (const cache of caches.data.actions_caches) {
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted++;
}
// Handle pagination if more than 100 caches
let totalCount = caches.data.total_count;
while (deleted < totalCount) {
const moreCaches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
if (moreCaches.data.actions_caches.length === 0) break;
for (const cache of moreCaches.data.actions_caches) {
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted++;
}
}
console.log(`Successfully deleted ${deleted} caches.`);
+55 -2
View File
@@ -2,7 +2,7 @@ name: Pre-commit
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
@@ -16,6 +16,9 @@ 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
@@ -28,6 +31,13 @@ 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:
@@ -47,4 +57,47 @@ 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
git diff --exit-code
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
-4
View File
@@ -181,7 +181,6 @@ venv.bak/
.idea/
*.iml
out/
.junie/
# Ignore Mac DS_Store files
.DS_Store
@@ -255,6 +254,3 @@ docs/type3/signatures/
# Type3 sample PDFs (development only)
**/type3/samples/
# Claude
.claude/
+1 -1
View File
@@ -187,7 +187,7 @@ services:
limits:
memory: 4G
healthcheck:
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'"]
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'"]
interval: 5s
timeout: 10s
retries: 16
-2
View File
@@ -14,8 +14,6 @@ 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,7 +75,6 @@ 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)
@@ -232,13 +231,6 @@ 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;
@@ -236,9 +236,7 @@ public class PipelineDirectoryProcessor {
operation.getOperation(),
inputExtensions);
// If inputExtensions is null, the API doc has no specific input type constraint,
// so allow all file types (e.g. /api/v1/convert/file/pdf accepts many office formats).
boolean allowAllFiles = inputExtensions == null || inputExtensions.contains("ALL");
boolean allowAllFiles = inputExtensions.contains("ALL");
try (Stream<Path> paths = Files.list(dir)) {
File[] files =
@@ -325,11 +325,6 @@ 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:
@@ -1,83 +0,0 @@
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");
}
}
}
@@ -1,57 +0,0 @@
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);
}
}
@@ -1,22 +0,0 @@
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;
}
@@ -1,22 +0,0 @@
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<>();
}
@@ -1,43 +0,0 @@
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);
}
}
@@ -1,23 +0,0 @@
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;
}
@@ -1,58 +0,0 @@
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;
}
@@ -1,16 +0,0 @@
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;
}
@@ -1,108 +0,0 @@
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());
}
}
}
@@ -1,181 +0,0 @@
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;
}
}
@@ -1,279 +0,0 @@
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
View File
@@ -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*(['"]))(.*?)(\2)/)
def matcher = (content =~ /(appVersion:\s*')([^']*)(')/)
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(4)}")
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}")
if (content != updatedContent) {
f.write(updatedContent, 'UTF-8')
}
+1 -1
View File
@@ -135,7 +135,7 @@ services:
limits:
memory: 4G
healthcheck:
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'"]
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'"]
interval: 5s
timeout: 10s
retries: 16
@@ -41,7 +41,7 @@ services:
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
+1 -1
View File
@@ -10,7 +10,7 @@ services:
limits:
memory: 6G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+1 -1
View File
@@ -10,7 +10,7 @@ services:
limits:
memory: 2G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+1 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: stirling-pdf
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+1 -1
View File
@@ -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${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
CMD curl -fs --max-time 10 http://localhost:8080/api/v1/info/status || exit 1
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD []
+1 -1
View File
@@ -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${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
CMD curl -fs --max-time 10 http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080/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$${SYSTEM_ROOTURIPATH:-''}/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/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -qv 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
+1 -1
View File
@@ -10,7 +10,7 @@ services:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
-5
View File
@@ -7,8 +7,3 @@ STIRLING_FAST_MODEL=anthropic:claude-haiku-4-5
# Default output token limits applied by the engine for each model tier.
STIRLING_SMART_MODEL_MAX_TOKENS=8192
STIRLING_FAST_MODEL_MAX_TOKENS=2048
# PostHog analytics. Set STIRLING_POSTHOG_ENABLED=true and provide an API key to enable.
STIRLING_POSTHOG_ENABLED=false
STIRLING_POSTHOG_API_KEY=phc_VOdeYnlevc2T63m3myFGjeBlRcIusRgmhfx6XL5a1iz
STIRLING_POSTHOG_HOST=https://eu.i.posthog.com
+3 -5
View File
@@ -10,8 +10,6 @@ dependencies = [
"pydantic-settings>=2.0.0",
"python-dotenv>=1.2.1",
"uvicorn>=0.35.0",
"opentelemetry-sdk>=1.39.0",
"posthog>=3.0.0",
]
[dependency-groups]
@@ -44,8 +42,9 @@ select = [
"W",
"RUF100",
"UP",
"PYI", # flake8-pyi: flags deprecated typing constructs
"FA", # flake8-future-annotations: flags missing future annotations imports
]
ignore = [
"E501", # Temporarily disable line length limit until codebase conformat
]
[tool.pyright]
@@ -56,7 +55,6 @@ reportUnnecessaryCast = "warning"
reportUnnecessaryTypeIgnoreComment = "warning"
reportUnusedImport = "warning"
reportUnknownParameterType = "warning"
reportDeprecated = "warning"
[tool.pytest.ini_options]
testpaths = ["tests"]
+11 -68
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import assert_never
from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
@@ -13,14 +12,12 @@ 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
@@ -64,7 +61,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 PDF contents. "
"Use delegate_pdf_question for questions about the contents of a PDF. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use unsupported_capability only when none of the other outputs fit."
),
@@ -72,56 +69,27 @@ 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(
self._build_prompt(request),
request.user_message,
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:
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))
request = ctx.deps.request
return await PdfEditAgent(ctx.deps.runtime).handle(
PdfEditRequest(user_message=request.user_message, conversation_id=request.conversation_id)
)
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
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 [],
)
request = ctx.deps.request
return await PdfQuestionAgent(ctx.deps.runtime).handle(
PdfQuestionRequest(question=request.user_message, conversation_id=request.conversation_id)
)
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
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))
request = ctx.deps.request
return await UserSpecAgent(ctx.deps.runtime).draft(AgentDraftRequest(user_message=request.user_message))
async def unsupported_capability(
self,
@@ -130,28 +98,3 @@ 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)
+8 -32
View File
@@ -4,11 +4,8 @@ from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import (
ExtractedFileText,
NeedContentFileRequest,
PdfContentType,
PdfQuestionAnswerResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfQuestionResponse,
@@ -17,9 +14,6 @@ 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(
@@ -31,27 +25,18 @@ class PdfQuestionAgent:
]
),
system_prompt=(
"Answer questions about PDFs using only the extracted page text provided in the prompt. "
"Answer questions about a PDF using only the extracted 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 with their page numbers."
"When answering, include a short list of evidence snippets copied from the provided text."
),
model_settings=runtime.smart_model_settings,
)
async def handle(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
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,
if not request.extracted_text.strip():
return PdfQuestionNeedTextResponse(
reason="No extracted PDF text was provided, so the question cannot be answered yet."
)
return await self._run_answer_agent(request)
@@ -60,14 +45,5 @@ class PdfQuestionAgent:
return result.output
def _build_prompt(self, request: PdfQuestionRequest) -> str:
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)
file_name = request.file_name or "Unknown file"
return f"File: {file_name}\nQuestion: {request.question}\nExtracted text:\n{request.extracted_text}"
+1 -10
View File
@@ -4,11 +4,8 @@ from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI
from pydantic_ai import Agent
from pydantic_ai.models.instrumented import InstrumentationSettings
from stirling.agents import ExecutionPlanningAgent, OrchestratorAgent, PdfEditAgent, PdfQuestionAgent, UserSpecAgent
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_draft_router,
execution_router,
@@ -18,7 +15,7 @@ from stirling.api.routes import (
)
from stirling.config import AppSettings, load_settings
from stirling.contracts import HealthResponse
from stirling.services import build_runtime, setup_posthog_tracking
from stirling.services import build_runtime
def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
@@ -40,16 +37,10 @@ async def lifespan(fast_api: FastAPI):
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
yield
if tracer_provider:
tracer_provider.shutdown()
app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0")
app.add_middleware(UserIdMiddleware)
app.include_router(orchestrator_router)
app.include_router(pdf_edit_router)
app.include_router(pdf_question_router)
-23
View File
@@ -1,23 +0,0 @@
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from stirling.services.tracking import current_user_id
_USER_ID_HEADER = "X-User-Id"
class UserIdMiddleware(BaseHTTPMiddleware):
"""Extract X-User-Id header and set it as the current user for PostHog tracking."""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
user_id = request.headers.get(_USER_ID_HEADER)
if user_id:
token = current_user_id.set(user_id)
try:
return await call_next(request)
finally:
current_user_id.reset(token)
return await call_next(request)
-4
View File
@@ -19,10 +19,6 @@ class AppSettings(BaseSettings):
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
posthog_enabled: bool = Field(validation_alias="STIRLING_POSTHOG_ENABLED")
posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY")
posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST")
@lru_cache(maxsize=1)
def load_settings() -> AppSettings:
+4 -29
View File
@@ -8,17 +8,7 @@ from .agent_drafts import (
AgentRevisionWorkflowResponse,
)
from .agent_specs import AgentSpec, AgentSpecStep, AiToolAgentStep
from .common import (
ArtifactKind,
ConversationMessage,
ExtractedFileText,
PdfContentType,
PdfTextSelection,
StepKind,
SupportedCapability,
ToolOperationStep,
WorkflowOutcome,
)
from .common import ConversationMessage, PdfTextSelection, ToolOperationStep
from .execution import (
AgentExecutionRequest,
CannotContinueExecutionAction,
@@ -29,13 +19,7 @@ from .execution import (
ToolCallExecutionAction,
)
from .health import HealthResponse
from .orchestrator import (
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
UnsupportedCapabilityResponse,
WorkflowArtifact,
)
from .orchestrator import OrchestratorRequest, OrchestratorResponse, SupportedCapability, UnsupportedCapabilityResponse
from .pdf_edit import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -44,16 +28,14 @@ from .pdf_edit import (
PdfEditResponse,
)
from .pdf_questions import (
NeedContentFileRequest,
PdfQuestionAnswerResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfQuestionResponse,
)
__all__ = [
"ArtifactKind",
"AgentDraft",
"AgentDraftRequest",
"AgentDraftResponse",
@@ -67,7 +49,6 @@ __all__ = [
"AiToolAgentStep",
"CannotContinueExecutionAction",
"ConversationMessage",
"ExtractedFileText",
"CompletedExecutionAction",
"EditCannotDoResponse",
"EditClarificationRequest",
@@ -75,25 +56,19 @@ __all__ = [
"ExecutionContext",
"ExecutionStepResult",
"HealthResponse",
"NeedContentFileRequest",
"NextExecutionAction",
"ExtractedTextArtifact",
"OrchestratorRequest",
"OrchestratorResponse",
"PdfEditRequest",
"PdfEditResponse",
"PdfQuestionAnswerResponse",
"PdfQuestionNotFoundResponse",
"PdfContentType",
"PdfQuestionNeedContentResponse",
"PdfQuestionNeedTextResponse",
"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, StepKind, WorkflowOutcome
from .common import ConversationMessage
from .pdf_edit import EditCannotDoResponse, EditClarificationRequest
class AgentDraftStep(ApiModel):
kind: Literal[StepKind.TOOL, StepKind.AI_TOOL]
kind: Literal["tool", "ai_tool"]
title: str
description: str
@@ -30,7 +30,7 @@ class AgentDraftRequest(ApiModel):
class AgentDraftResponse(ApiModel):
outcome: Literal[WorkflowOutcome.DRAFT] = WorkflowOutcome.DRAFT
outcome: Literal["draft"] = "draft"
draft: AgentDraft
@@ -41,7 +41,7 @@ class AgentRevisionRequest(ApiModel):
class AgentRevisionResponse(ApiModel):
outcome: Literal[WorkflowOutcome.DRAFT] = WorkflowOutcome.DRAFT
outcome: Literal["draft"] = "draft"
draft: AgentDraft
+2 -2
View File
@@ -6,11 +6,11 @@ from pydantic import Field
from stirling.models import ApiModel, OperationId
from .common import StepKind, ToolOperationStep
from .common import ToolOperationStep
class AiToolAgentStep(ApiModel):
kind: Literal[StepKind.AI_TOOL] = StepKind.AI_TOOL
kind: Literal["ai_tool"] = "ai_tool"
title: str
description: str
tool: OperationId
+2 -84
View File
@@ -1,89 +1,12 @@
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import Field, model_validator
from pydantic import 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
@@ -94,13 +17,8 @@ class PdfTextSelection(ApiModel):
text: str
class ExtractedFileText(ApiModel):
file_name: str
pages: list[PdfTextSelection] = Field(default_factory=list)
class ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
kind: Literal["tool"] = "tool"
tool: OperationId
parameters: ParamToolModel
+3 -4
View File
@@ -7,7 +7,6 @@ from pydantic import Field
from stirling.models import ApiModel, OperationId, ParamToolModel
from .agent_specs import AgentSpec
from .common import WorkflowOutcome
class ExecutionStepResult(ApiModel):
@@ -32,19 +31,19 @@ class AgentExecutionRequest(ApiModel):
class ToolCallExecutionAction(ApiModel):
outcome: Literal[WorkflowOutcome.TOOL_CALL] = WorkflowOutcome.TOOL_CALL
outcome: Literal["tool_call"] = "tool_call"
tool: OperationId
parameters: ParamToolModel
rationale: str | None = None
class CompletedExecutionAction(ApiModel):
outcome: Literal[WorkflowOutcome.COMPLETED] = WorkflowOutcome.COMPLETED
outcome: Literal["completed"] = "completed"
summary: str
class CannotContinueExecutionAction(ApiModel):
outcome: Literal[WorkflowOutcome.CANNOT_CONTINUE] = WorkflowOutcome.CANNOT_CONTINUE
outcome: Literal["cannot_continue"] = "cannot_continue"
reason: str
+10 -11
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import Field
@@ -7,29 +8,27 @@ 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 ExtractedTextArtifact(ApiModel):
kind: Literal[ArtifactKind.EXTRACTED_TEXT] = ArtifactKind.EXTRACTED_TEXT
files: list[ExtractedFileText] = Field(default_factory=list)
WorkflowArtifact = Annotated[ExtractedTextArtifact, Field(discriminator="kind")]
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 OrchestratorRequest(ApiModel):
user_message: str
file_names: list[str]
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
resume_with: SupportedCapability | None = None
conversation_id: str | None = None
class UnsupportedCapabilityResponse(ApiModel):
outcome: Literal[WorkflowOutcome.UNSUPPORTED_CAPABILITY] = WorkflowOutcome.UNSUPPORTED_CAPABILITY
outcome: Literal["unsupported_capability"] = "unsupported_capability"
capability: str
message: str
+5 -4
View File
@@ -6,29 +6,30 @@ from pydantic import Field
from stirling.models import ApiModel
from .common import ToolOperationStep, WorkflowOutcome
from .common import ToolOperationStep
class PdfEditRequest(ApiModel):
user_message: str
conversation_id: str | None = None
file_names: list[str] = Field(default_factory=list)
class EditPlanResponse(ApiModel):
outcome: Literal[WorkflowOutcome.PLAN] = WorkflowOutcome.PLAN
outcome: Literal["plan"] = "plan"
summary: str
rationale: str | None = None
steps: list[ToolOperationStep]
class EditClarificationRequest(ApiModel):
outcome: Literal[WorkflowOutcome.NEED_CLARIFICATION] = WorkflowOutcome.NEED_CLARIFICATION
outcome: Literal["need_clarification"] = "need_clarification"
question: str
reason: str
class EditCannotDoResponse(ApiModel):
outcome: Literal[WorkflowOutcome.CANNOT_DO] = WorkflowOutcome.CANNOT_DO
outcome: Literal["cannot_do"] = "cannot_do"
reason: str
+9 -20
View File
@@ -6,42 +6,31 @@ from pydantic import Field
from stirling.models import ApiModel
from .common import ExtractedFileText, PdfContentType, SupportedCapability, WorkflowOutcome
class PdfQuestionRequest(ApiModel):
question: str
page_text: list[ExtractedFileText] = Field(default_factory=list)
file_names: list[str]
conversation_id: str | None = None
extracted_text: str = ""
file_name: str | None = None
class PdfQuestionAnswerResponse(ApiModel):
outcome: Literal[WorkflowOutcome.ANSWER] = WorkflowOutcome.ANSWER
outcome: Literal["answer"] = "answer"
answer: str
evidence: list[ExtractedFileText] = Field(default_factory=list)
evidence: list[str] = Field(default_factory=list)
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
class PdfQuestionNeedTextResponse(ApiModel):
outcome: Literal["need_text"] = "need_text"
reason: str
files: list[NeedContentFileRequest] = Field(default_factory=list)
max_pages: int
max_characters: int
class PdfQuestionNotFoundResponse(ApiModel):
outcome: Literal[WorkflowOutcome.NOT_FOUND] = WorkflowOutcome.NOT_FOUND
outcome: Literal["not_found"] = "not_found"
reason: str
PdfQuestionResponse = Annotated[
PdfQuestionAnswerResponse | PdfQuestionNeedContentResponse | PdfQuestionNotFoundResponse,
PdfQuestionAnswerResponse | PdfQuestionNeedTextResponse | PdfQuestionNotFoundResponse,
Field(discriminator="outcome"),
]
-2
View File
@@ -1,11 +1,9 @@
"""Shared services used by the Stirling AI runtime."""
from .runtime import AppRuntime, build_model_settings, build_runtime
from .tracking import setup_posthog_tracking
__all__ = [
"AppRuntime",
"build_model_settings",
"build_runtime",
"setup_posthog_tracking",
]
-233
View File
@@ -1,233 +0,0 @@
from __future__ import annotations
import json
from collections import OrderedDict
from collections.abc import Mapping
from contextvars import ContextVar
from typing import Any
from opentelemetry.context import Context
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import ( # No public import for these constants yet
GEN_AI_INPUT_MESSAGES,
GEN_AI_OPERATION_NAME,
GEN_AI_OUTPUT_MESSAGES,
GEN_AI_REQUEST_MAX_TOKENS,
GEN_AI_REQUEST_MODEL,
GEN_AI_REQUEST_TEMPERATURE,
GEN_AI_RESPONSE_MODEL,
GEN_AI_SYSTEM,
GEN_AI_TOOL_DEFINITIONS,
GEN_AI_USAGE_INPUT_TOKENS,
GEN_AI_USAGE_OUTPUT_TOKENS,
GenAiOperationNameValues,
)
from opentelemetry.semconv.attributes.server_attributes import SERVER_ADDRESS, SERVER_PORT
from opentelemetry.trace import Span
from posthog.client import Client as PostHogClient
from stirling.config import AppSettings
# Per-request user ID, set by middleware from the X-User-Id header.
# When not set, PostHog generates a random ID and marks the event as personless.
current_user_id: ContextVar[str | None] = ContextVar("current_user_id", default=None)
class LRUSet:
"""Least Recently Used Set: a set with a maximum size that evicts the oldest entries first."""
def __init__(self, max_size: int) -> None:
self._max_size = max_size
self._data: OrderedDict[str, None] = OrderedDict()
def __contains__(self, key: str) -> bool:
return key in self._data
def add(self, key: str) -> None:
self._data[key] = None
if len(self._data) > self._max_size:
self._data.popitem(last=False)
def _parse_json_attr(attrs: Mapping[str, Any], key: str) -> Any | None:
"""Parse a JSON string span attribute, returning None on failure."""
raw = attrs.get(key)
if raw is None:
return None
try:
return json.loads(str(raw))
except (json.JSONDecodeError, TypeError):
return None
def _transform_output_choices(choices: list[Any]) -> list[Any]:
"""Transform Pydantic AI's parts-based output format to PostHog-compatible format.
Pydantic AI emits: ``[{"role": "assistant", "parts": [{"type": "tool_call", "name": "..."}]}]``
PostHog expects: ``[{"role": "assistant", "tool_calls": [{"type": "function", "function": {"name": "..."}}]}]``
"""
for choice in choices:
if not isinstance(choice, dict) or "parts" not in choice:
continue
tool_calls = []
for part in choice.get("parts", []):
if isinstance(part, dict) and part.get("type") == "tool_call":
tool_calls.append(
{
"type": "function",
"id": part.get("id", ""),
"function": {"name": part.get("name", "")},
}
)
if tool_calls:
choice["tool_calls"] = tool_calls
choice["content"] = choice.pop("parts")
return choices
def _extract_user_message(attrs: Mapping[str, Any]) -> str:
"""Extract the last user message text from the input messages span attribute."""
messages = _parse_json_attr(attrs, GEN_AI_INPUT_MESSAGES)
if not isinstance(messages, list):
return ""
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if msg.get("role") == "user":
for part in msg.get("parts", []):
if isinstance(part, dict) and part.get("type") == "text":
return str(part.get("content", ""))
return ""
# TODO: Replace with an official PostHog integration if one ever exists
class PostHogSpanProcessor(SpanProcessor):
"""Translates Pydantic AI OpenTelemetry spans into PostHog $ai_generation events."""
def __init__(self, client: PostHogClient) -> None:
self._client = client
self._seen_traces = LRUSet(max_size=10_000)
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
pass
def on_end(self, span: ReadableSpan) -> None:
attrs = dict(span.attributes or {})
if attrs.get(GEN_AI_OPERATION_NAME) != GenAiOperationNameValues.CHAT.value:
return
properties = self._build_generation_properties(span, attrs)
self._maybe_emit_trace_event(span, attrs, properties)
self._client.capture(
distinct_id=current_user_id.get(),
event="$ai_generation",
properties=properties,
)
def _build_generation_properties(self, span: ReadableSpan, attrs: Mapping[str, Any]) -> dict[str, object]:
"""Build the $ai_generation event properties from span data."""
properties: dict[str, object] = {
"$ai_provider": attrs.get(GEN_AI_SYSTEM, ""),
"$ai_model": attrs.get(GEN_AI_RESPONSE_MODEL) or attrs.get(GEN_AI_REQUEST_MODEL, ""),
"$ai_input_tokens": attrs.get(GEN_AI_USAGE_INPUT_TOKENS, 0),
"$ai_output_tokens": attrs.get(GEN_AI_USAGE_OUTPUT_TOKENS, 0),
}
if span.context:
properties["$ai_trace_id"] = format(span.context.trace_id, "032x")
properties["$ai_span_id"] = format(span.context.span_id, "016x")
if span.parent and span.parent.span_id:
properties["$ai_parent_id"] = format(span.parent.span_id, "016x")
if span.start_time and span.end_time:
properties["$ai_latency"] = (span.end_time - span.start_time) / 1e9
self._add_message_properties(properties, attrs)
self._add_model_parameters(properties, attrs)
self._add_tool_definitions(properties, attrs)
self._add_base_url(properties, attrs)
return properties
def _maybe_emit_trace_event(
self, span: ReadableSpan, attrs: Mapping[str, Any], properties: dict[str, object]
) -> None:
"""Emit an $ai_trace event for the first span seen per trace ID."""
trace_id = str(properties.get("$ai_trace_id", ""))
if not trace_id or trace_id in self._seen_traces:
return
self._seen_traces.add(trace_id)
trace_properties: dict[str, object] = {
"$ai_trace_id": trace_id,
"$ai_trace_name": _extract_user_message(attrs),
"$ai_provider": attrs.get(GEN_AI_SYSTEM, ""),
}
if span.start_time and span.end_time:
trace_properties["$ai_latency"] = (span.end_time - span.start_time) / 1e9
self._client.capture(
distinct_id=current_user_id.get(),
event="$ai_trace",
properties=trace_properties,
)
@staticmethod
def _add_message_properties(properties: dict[str, object], attrs: Mapping[str, Any]) -> None:
input_messages = _parse_json_attr(attrs, GEN_AI_INPUT_MESSAGES)
if input_messages is not None:
properties["$ai_input"] = input_messages
output_messages = _parse_json_attr(attrs, GEN_AI_OUTPUT_MESSAGES)
if isinstance(output_messages, list):
properties["$ai_output_choices"] = _transform_output_choices(output_messages)
elif output_messages is not None:
properties["$ai_output_choices"] = output_messages
@staticmethod
def _add_model_parameters(properties: dict[str, object], attrs: Mapping[str, Any]) -> None:
model_parameters: dict[str, object] = {}
if GEN_AI_REQUEST_TEMPERATURE in attrs:
model_parameters["temperature"] = attrs[GEN_AI_REQUEST_TEMPERATURE]
if GEN_AI_REQUEST_MAX_TOKENS in attrs:
model_parameters["max_tokens"] = attrs[GEN_AI_REQUEST_MAX_TOKENS]
if model_parameters:
properties["$ai_model_parameters"] = model_parameters
@staticmethod
def _add_tool_definitions(properties: dict[str, object], attrs: Mapping[str, Any]) -> None:
tools = _parse_json_attr(attrs, GEN_AI_TOOL_DEFINITIONS)
if tools is not None:
properties["$ai_tools"] = tools
@staticmethod
def _add_base_url(properties: dict[str, object], attrs: Mapping[str, Any]) -> None:
parts: list[str] = []
if host := attrs.get(SERVER_ADDRESS):
parts.append(str(host))
if port := attrs.get(SERVER_PORT):
parts.append(str(port))
if parts:
properties["$ai_base_url"] = ":".join(parts)
def shutdown(self) -> None:
self._client.shutdown()
def force_flush(self, timeout_millis: int = 30000) -> bool:
self._client.flush()
return True
def setup_posthog_tracking(settings: AppSettings) -> TracerProvider | None:
"""Configure OpenTelemetry with a PostHog span processor for LLM analytics.
Returns the TracerProvider so it can be shut down on app exit,
or None when tracking is disabled.
"""
if not settings.posthog_enabled or not settings.posthog_api_key:
return None
client = PostHogClient(project_api_key=settings.posthog_api_key, host=settings.posthog_host)
processor = PostHogSpanProcessor(client)
provider = TracerProvider()
provider.add_span_processor(processor)
return provider
-38
View File
@@ -1,38 +0,0 @@
from __future__ import annotations
from collections.abc import Iterator
import pytest
from stirling.config import AppSettings, load_settings
from stirling.services import build_runtime
from stirling.services.runtime import AppRuntime
@pytest.fixture(autouse=True)
def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
yield
load_settings.cache_clear()
def build_app_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
posthog_enabled=False,
posthog_api_key="",
posthog_host="https://eu.i.posthog.com",
)
@pytest.fixture
def app_settings() -> AppSettings:
return build_app_settings()
@pytest.fixture
def runtime(app_settings: AppSettings) -> AppRuntime:
return build_runtime(app_settings)
+18 -13
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
import pytest
from stirling.agents import PdfEditAgent, PdfEditParameterSelector, PdfEditPlanSelection
from stirling.config import AppSettings
from stirling.contracts import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -13,7 +14,16 @@ from stirling.contracts import (
ToolOperationStep,
)
from stirling.models.tool_models import CompressParams, OperationId, RotateParams
from stirling.services.runtime import AppRuntime
from stirling.services import build_runtime
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
@dataclass(frozen=True)
@@ -51,11 +61,10 @@ class RecordingParameterSelector:
class StubPdfEditAgent(PdfEditAgent):
def __init__(
self,
runtime: AppRuntime,
selection: PdfEditPlanSelection | EditClarificationRequest | EditCannotDoResponse,
parameter_selector: RecordingParameterSelector | PdfEditParameterSelector | None = None,
) -> None:
super().__init__(runtime)
super().__init__(build_runtime(build_test_settings()))
self.selection = selection
if parameter_selector is not None:
self.parameter_selector = parameter_selector
@@ -68,10 +77,9 @@ class StubPdfEditAgent(PdfEditAgent):
@pytest.mark.anyio
async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> None:
async def test_pdf_edit_agent_builds_multi_step_plan() -> None:
parameter_selector = RecordingParameterSelector()
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[OperationId.ROTATE, OperationId.COMPRESS],
summary="Rotate the PDF, then compress it.",
@@ -96,10 +104,9 @@ async def test_pdf_edit_agent_builds_multi_step_plan(runtime: AppRuntime) -> Non
@pytest.mark.anyio
async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtime: AppRuntime) -> None:
async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector() -> None:
parameter_selector = RecordingParameterSelector()
agent = StubPdfEditAgent(
runtime,
PdfEditPlanSelection(
operations=[OperationId.ROTATE, OperationId.COMPRESS],
summary="Rotate the PDF, then compress it.",
@@ -127,13 +134,12 @@ async def test_pdf_edit_agent_passes_previous_steps_to_parameter_selector(runtim
@pytest.mark.anyio
async def test_pdf_edit_agent_returns_clarification_without_partial_plan(runtime: AppRuntime) -> None:
async def test_pdf_edit_agent_returns_clarification_without_partial_plan() -> None:
agent = StubPdfEditAgent(
runtime,
EditClarificationRequest(
question="Which pages should be rotated?",
reason="The request does not say which pages to change.",
),
)
)
response = await agent.handle(PdfEditRequest(user_message="Rotate some pages."))
@@ -142,12 +148,11 @@ async def test_pdf_edit_agent_returns_clarification_without_partial_plan(runtime
@pytest.mark.anyio
async def test_pdf_edit_agent_returns_cannot_do_without_partial_plan(runtime: AppRuntime) -> None:
async def test_pdf_edit_agent_returns_cannot_do_without_partial_plan() -> None:
agent = StubPdfEditAgent(
runtime,
EditCannotDoResponse(
reason="This request requires OCR, which is not part of PDF edit planning.",
),
)
)
response = await agent.handle(PdfEditRequest(user_message="Read this scan and summarize it."))
+24 -31
View File
@@ -3,20 +3,19 @@ from __future__ import annotations
import pytest
from stirling.agents import PdfQuestionAgent
from stirling.config import AppSettings
from stirling.contracts import (
ExtractedFileText,
PdfQuestionAnswerResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfTextSelection,
)
from stirling.services.runtime import AppRuntime
from stirling.services import build_runtime
class StubPdfQuestionAgent(PdfQuestionAgent):
def __init__(self, runtime: AppRuntime, response: PdfQuestionAnswerResponse | PdfQuestionNotFoundResponse) -> None:
super().__init__(runtime)
def __init__(self, response: PdfQuestionAnswerResponse | PdfQuestionNotFoundResponse) -> None:
super().__init__(build_runtime(build_test_settings()))
self.response = response
async def _run_answer_agent(
@@ -26,39 +25,38 @@ class StubPdfQuestionAgent(PdfQuestionAgent):
return self.response
def invoice_page() -> ExtractedFileText:
return ExtractedFileText(
file_name="invoice.pdf",
pages=[PdfTextSelection(page_number=1, text="Invoice total: 120.00")],
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
@pytest.mark.anyio
async def test_pdf_question_agent_requires_extracted_text(runtime: AppRuntime) -> None:
agent = PdfQuestionAgent(runtime)
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?", page_text=[], file_names=["test.pdf"])
)
response = await agent.handle(PdfQuestionRequest(question="What is the total?", extracted_text=""))
assert isinstance(response, PdfQuestionNeedContentResponse)
assert isinstance(response, PdfQuestionNeedTextResponse)
@pytest.mark.anyio
async def test_pdf_question_agent_returns_grounded_answer(runtime: AppRuntime) -> None:
async def test_pdf_question_agent_returns_grounded_answer() -> None:
agent = StubPdfQuestionAgent(
runtime,
PdfQuestionAnswerResponse(
answer="The invoice total is 120.00.",
evidence=[invoice_page()],
),
evidence=["Invoice total: 120.00"],
)
)
response = await agent.handle(
PdfQuestionRequest(
question="What is the total?",
page_text=[invoice_page()],
file_names=["invoice.pdf"],
extracted_text="Invoice total: 120.00",
file_name="invoice.pdf",
)
)
@@ -67,19 +65,14 @@ async def test_pdf_question_agent_returns_grounded_answer(runtime: AppRuntime) -
@pytest.mark.anyio
async def test_pdf_question_agent_returns_not_found_when_text_is_insufficient(runtime: AppRuntime) -> None:
agent = StubPdfQuestionAgent(runtime, PdfQuestionNotFoundResponse(reason="The answer is not present in the text."))
async def test_pdf_question_agent_returns_not_found_when_text_is_insufficient() -> None:
agent = StubPdfQuestionAgent(PdfQuestionNotFoundResponse(reason="The answer is not present in the text."))
response = await agent.handle(
PdfQuestionRequest(
question="What is the total?",
page_text=[
ExtractedFileText(
file_name="invoice.pdf",
pages=[PdfTextSelection(page_number=1, text="This page contains only a shipping address.")],
)
],
file_names=["invoice.pdf"],
extracted_text="This page contains only a shipping address.",
file_name="invoice.pdf",
)
)
+49 -22
View File
@@ -1,4 +1,3 @@
from conftest import build_app_settings
from fastapi.testclient import TestClient
from stirling.api import app
@@ -9,7 +8,7 @@ from stirling.api.dependencies import (
get_pdf_question_agent,
get_user_spec_agent,
)
from stirling.config import load_settings
from stirling.config import AppSettings, load_settings
from stirling.contracts import (
AgentDraft,
AgentDraftRequest,
@@ -21,16 +20,26 @@ from stirling.contracts import (
EditCannotDoResponse,
OrchestratorRequest,
PdfEditRequest,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
UnsupportedCapabilityResponse,
)
from stirling.models.tool_models import RotateParams
class StubSettingsProvider:
def __call__(self) -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
class StubOrchestratorAgent:
async def handle(self, request: OrchestratorRequest) -> PdfQuestionNeedContentResponse:
return PdfQuestionNeedContentResponse(reason=request.user_message, files=[], max_pages=1, max_characters=1000)
async def handle(self, request: OrchestratorRequest) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability="pdf_edit", message=request.user_message)
class StubPdfEditAgent:
@@ -63,16 +72,41 @@ class StubExecutionPlanningAgent:
return CannotContinueExecutionAction(reason=str(request.current_step_index))
app.dependency_overrides[load_settings] = build_app_settings
app.dependency_overrides[get_orchestrator_agent] = lambda: StubOrchestratorAgent()
app.dependency_overrides[get_pdf_edit_agent] = lambda: StubPdfEditAgent()
app.dependency_overrides[get_pdf_question_agent] = lambda: StubPdfQuestionAgent()
app.dependency_overrides[get_user_spec_agent] = lambda: StubUserSpecAgent()
app.dependency_overrides[get_execution_planning_agent] = lambda: StubExecutionPlanningAgent()
client: TestClient = TestClient(app)
def override_settings() -> AppSettings:
return StubSettingsProvider()()
def override_orchestrator_agent() -> StubOrchestratorAgent:
return StubOrchestratorAgent()
def override_pdf_edit_agent() -> StubPdfEditAgent:
return StubPdfEditAgent()
def override_pdf_question_agent() -> StubPdfQuestionAgent:
return StubPdfQuestionAgent()
def override_user_spec_agent() -> StubUserSpecAgent:
return StubUserSpecAgent()
def override_execution_agent() -> StubExecutionPlanningAgent:
return StubExecutionPlanningAgent()
app.dependency_overrides[load_settings] = override_settings
app.dependency_overrides[get_orchestrator_agent] = override_orchestrator_agent
app.dependency_overrides[get_pdf_edit_agent] = override_pdf_edit_agent
app.dependency_overrides[get_pdf_question_agent] = override_pdf_question_agent
app.dependency_overrides[get_user_spec_agent] = override_user_spec_agent
app.dependency_overrides[get_execution_planning_agent] = override_execution_agent
def test_health_route() -> None:
response = client.get("/health")
@@ -81,10 +115,10 @@ def test_health_route() -> None:
def test_orchestrator_route() -> None:
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this", "fileNames": ["test.pdf"]})
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this"})
assert response.status_code == 200
assert response.json()["outcome"] == "need_content"
assert response.json()["outcome"] == "unsupported_capability"
def test_pdf_edit_route() -> None:
@@ -95,14 +129,7 @@ def test_pdf_edit_route() -> None:
def test_pdf_questions_route() -> None:
response = client.post(
"/api/v1/pdf/questions",
json={
"question": "what is this?",
"fileNames": ["test.pdf"],
"pageText": [{"fileName": "test.pdf", "pages": [{"pageNumber": 1, "text": "Example"}]}],
},
)
response = client.post("/api/v1/pdf/questions", json={"question": "what is this?"})
assert response.status_code == 200
assert response.json()["outcome"] == "not_found"
+13 -22
View File
@@ -1,38 +1,25 @@
from stirling.config import AppSettings
from collections.abc import Iterator
import pytest
from stirling.config import AppSettings, load_settings
from stirling.contracts import (
AgentExecutionRequest,
AgentSpec,
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",
file_names=["test.pdf"],
artifacts=[
ExtractedTextArtifact(
files=[
ExtractedFileText(
file_name="test.pdf",
pages=[PdfTextSelection(page_number=1, text="Hello")],
)
]
)
],
)
request = OrchestratorRequest(user_message="Rotate the PDF")
assert request.user_message == "Rotate the PDF"
assert len(request.artifacts) == 1
def test_agent_execution_request_uses_typed_agent_spec() -> None:
@@ -72,15 +59,19 @@ def test_pdf_question_answer_defaults_evidence_list() -> None:
assert response.evidence == []
@pytest.fixture(autouse=True)
def clear_settings_cache() -> Iterator[None]:
load_settings.cache_clear()
yield
load_settings.cache_clear()
def test_app_settings_accepts_model_configuration() -> None:
settings = AppSettings(
smart_model_name="claude-sonnet-4-5-20250929",
fast_model_name="claude-haiku-4-5-20251001",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
posthog_enabled=False,
posthog_api_key="",
posthog_host="https://eu.i.posthog.com",
)
assert settings.smart_model_name
+19 -11
View File
@@ -4,6 +4,7 @@ import pytest
from pydantic import ValidationError
from stirling.agents import UserSpecAgent
from stirling.config import AppSettings
from stirling.contracts import (
AgentDraft,
AgentDraftRequest,
@@ -14,12 +15,21 @@ from stirling.contracts import (
ToolOperationStep,
)
from stirling.models.tool_models import CompressParams, OperationId, RotateParams
from stirling.services.runtime import AppRuntime
from stirling.services import build_runtime
def build_test_settings() -> AppSettings:
return AppSettings(
smart_model_name="test",
fast_model_name="test",
smart_model_max_tokens=8192,
fast_model_max_tokens=2048,
)
class StubUserSpecAgent(UserSpecAgent):
def __init__(self, runtime: AppRuntime, draft_result: AgentDraft, revision_result: AgentDraft) -> None:
super().__init__(runtime)
def __init__(self, draft_result: AgentDraft, revision_result: AgentDraft) -> None:
super().__init__(build_runtime(build_test_settings()))
self.draft_result = draft_result
self.revision_result = revision_result
self.edit_plan = EditPlanResponse(
@@ -43,8 +53,8 @@ class StubUserSpecAgent(UserSpecAgent):
class ClarifyingUserSpecAgent(UserSpecAgent):
def __init__(self, runtime: AppRuntime) -> None:
super().__init__(runtime)
def __init__(self) -> None:
super().__init__(build_runtime(build_test_settings()))
async def _build_edit_plan(self, user_message: str) -> EditClarificationRequest:
return EditClarificationRequest(
@@ -54,9 +64,8 @@ class ClarifyingUserSpecAgent(UserSpecAgent):
@pytest.mark.anyio
async def test_user_spec_agent_drafts_agent_spec(runtime: AppRuntime) -> None:
async def test_user_spec_agent_drafts_agent_spec() -> None:
agent = StubUserSpecAgent(
runtime,
AgentDraft(
name="Invoice Cleanup",
description="Prepare invoices for review.",
@@ -91,7 +100,7 @@ async def test_user_spec_agent_drafts_agent_spec(runtime: AppRuntime) -> None:
@pytest.mark.anyio
async def test_user_spec_agent_revises_existing_draft(runtime: AppRuntime) -> None:
async def test_user_spec_agent_revises_existing_draft() -> None:
current_draft = AgentDraft(
name="Invoice Cleanup",
description="Prepare invoices for review.",
@@ -104,7 +113,6 @@ async def test_user_spec_agent_revises_existing_draft(runtime: AppRuntime) -> No
],
)
agent = StubUserSpecAgent(
runtime,
draft_result=current_draft,
revision_result=AgentDraft(
name="Invoice Cleanup",
@@ -144,8 +152,8 @@ def test_tool_operation_step_rejects_mismatched_parameters() -> None:
@pytest.mark.anyio
async def test_user_spec_agent_propagates_edit_clarification(runtime: AppRuntime) -> None:
agent = ClarifyingUserSpecAgent(runtime)
async def test_user_spec_agent_propagates_edit_clarification() -> None:
agent = ClarifyingUserSpecAgent()
response = await agent.draft(AgentDraftRequest(user_message="Build an agent to rotate some pages."))
+103 -133
View File
@@ -37,7 +37,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.5"
version = "3.13.3"
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/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -135,7 +135,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.93.0"
version = "0.86.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/c2/70/2429d6f7c2516db99fb342c3ad89575ab3e0cd31d3d2f6cba5fdf5e9c65b/anthropic-0.93.0.tar.gz", hash = "sha256:fea8376f7d5cdf99d5e8e85a48fe7a7bd8ab307cdfee4b1e8283a18b1c0ce1b5", size = 654155, upload-time = "2026-04-09T18:13:53.522Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -194,15 +194,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "beartype"
version = "0.22.9"
@@ -419,55 +410,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -540,8 +531,6 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "opentelemetry-sdk" },
{ name = "posthog" },
{ name = "pydantic" },
{ name = "pydantic-ai" },
{ name = "pydantic-settings" },
@@ -559,8 +548,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
{ name = "posthog", specifier = ">=3.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" },
{ name = "pydantic-ai", specifier = ">=1.67.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" },
@@ -649,7 +636,7 @@ wheels = [
[[package]]
name = "fastmcp"
version = "3.2.3"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
@@ -674,9 +661,9 @@ dependencies = [
{ name = "watchfiles" },
{ name = "websockets" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1594,23 +1581,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "posthog"
version = "7.9.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "six" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/a7/2865487853061fbd62383492237b546d2d8f7c1846272350d2b9e14138cd/posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec", size = 176828, upload-time = "2026-03-12T09:01:15.184Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/a9/7a803aed5a5649cf78ea7b31e90d0080181ba21f739243e1741a1e607f1f/posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0", size = 202469, upload-time = "2026-03-12T09:01:13.38Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
-10
View File
@@ -1,10 +0,0 @@
dist/
node_modules/
public/vendor/
public/pdfjs*/
public/js/thirdParty/
public/css/cookieconsent.css
*.min.*
*.md
*.wxs
src/output.css
+47 -38
View File
@@ -1,52 +1,62 @@
// @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)
},
],
},
@@ -55,15 +65,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.",
},
],
@@ -74,9 +84,8 @@ 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/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
],
languageOptions: {
parserOptions: {
@@ -85,8 +94,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
@@ -95,8 +104,8 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.browser,
},
},
}
}
},
// Config for node scripts
{
@@ -104,7 +113,7 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.node,
},
},
}
}
},
);
+5 -2
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
@@ -6,7 +6,10 @@
<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" />
+5 -32
View File
@@ -59,7 +59,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@userback/widget": "^0.3.12",
"autoprefixer": "^10.4.21",
"axios": "^1.15.0",
"axios": "^1.13.2",
"d3": "^7.9.0",
"globals": "^17.1.0",
"i18next": "^25.5.2",
@@ -114,7 +114,6 @@
"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",
@@ -5842,23 +5841,14 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"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"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
@@ -11289,22 +11279,6 @@
"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",
@@ -11457,7 +11431,6 @@
"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": {
+2 -10
View File
@@ -55,7 +55,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@userback/widget": "^0.3.12",
"autoprefixer": "^10.4.21",
"axios": "^1.15.0",
"axios": "^1.13.2",
"d3": "^7.9.0",
"globals": "^17.1.0",
"i18next": "^25.5.2",
@@ -88,20 +88,14 @@
"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",
@@ -116,9 +110,8 @@
"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:prototypes && 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: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",
@@ -180,7 +173,6 @@
"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",
+17 -17
View File
@@ -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,
},
});
});
+4 -1
View File
@@ -1,3 +1,6 @@
module.exports = {
plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
plugins: [
require('@tailwindcss/postcss'),
require('autoprefixer'),
],
};
+121 -120
View File
@@ -1,205 +1,206 @@
/* 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-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-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;
--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-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-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;
--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,8 +44,6 @@ 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"
@@ -3343,9 +3341,6 @@ 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]
@@ -4292,8 +4287,6 @@ 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"
@@ -6645,13 +6638,9 @@ 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"
@@ -6674,16 +6663,6 @@ 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"
+1
View File
@@ -23,3 +23,4 @@
"theme_color": "#000000",
"background_color": "#ffffff"
}
+14 -10
View File
@@ -1,24 +1,28 @@
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);
+29 -31
View File
@@ -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,20 +118,18 @@ 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
@@ -140,34 +138,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 });
}
@@ -184,7 +182,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;
@@ -198,7 +196,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}`);
@@ -206,7 +204,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);
});
+358 -384
View File
@@ -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,434 +16,408 @@ 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);
}
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";
// 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);
}
// Handle empty string licenses
if (licenseType === "") {
licenseType = "Unknown";
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);
}
}
// Handle array licenses (rare but possible)
if (Array.isArray(licenseType)) {
licenseType = licenseType.join(" AND ");
if (!Array.isArray(licenseData)) {
console.error('❌ Invalid license data structure');
process.exit(1);
}
// Handle object licenses (fallback)
if (typeof licenseType === "object" && licenseType !== null) {
licenseType = "Unknown";
}
// Convert license-checker format to array
const licenseArray = licenseData.map(dep => {
let licenseType = dep.licenseType;
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";
}
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
licenseType = 'Unknown';
}
return {
name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown",
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link,
// 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
};
})
};
});
// 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);
// 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;
}, {});
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}`);
console.log('📊 License types found:');
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// 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,
),
// 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')
)
);
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");
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, 4));
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
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);
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 case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
}
}
// 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]];
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
}
}
// For non-standard licenses, return empty string (will use package link if available)
return "";
// 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 '';
}
/**
* 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();
}
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
// 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 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;
// 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
});
}
});
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;
return warnings;
}
+26 -25
View File
@@ -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,65 +40,66 @@ 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);
});
+2 -3
View File
@@ -20,9 +20,8 @@
--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;
}
* {
+204 -214
View File
@@ -1,244 +1,234 @@
<!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="" />
<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">
</div>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
<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>
<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>
<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>
</div>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
</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 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>
</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>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<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.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"
/>
<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>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
<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="value-prop">
<div class="value-icon">
<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="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"
/>
<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>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>
</div>
</div>
</div>
<!-- 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>
<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="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<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="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"
/>
<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>Plus Many More</h3>
<h3>Automation</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>
<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>
</body>
</div>
</body>
</html>
+21 -20
View File
@@ -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,28 +59,29 @@ 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.'
);
}
+3 -1
View File
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"windows": [
"main"
],
"permissions": [
"core:default",
"core:window:allow-destroy",
+325 -325
View File
@@ -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"
}
]
}
+3 -1
View File
@@ -21,7 +21,9 @@ import "@app/utils/fileIdSafety";
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
+7 -5
View File
@@ -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,9 +21,11 @@ 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 />
</>
+44 -44
View File
@@ -8,12 +8,7 @@ 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";
@@ -25,8 +20,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";
@@ -46,14 +41,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);
}
};
@@ -67,7 +62,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;
@@ -103,44 +98,49 @@ 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>
+74 -88
View File
@@ -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,59 +32,47 @@ 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[]) => {
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) {
try {
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFileUpload(files);
await refreshRecentFiles();
} catch (error) {
console.error("Failed to process selected files:", error);
console.error('Failed to process dropped files:', error);
}
},
[onRecentFileSelect],
);
}
}, [onFileUpload, refreshRecentFiles]);
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],
);
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(() => {
@@ -101,7 +89,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');
};
}, []);
@@ -109,7 +97,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(() => {
@@ -117,29 +105,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
@@ -153,25 +141,23 @@ 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)}
@@ -179,14 +165,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,7 +14,12 @@ 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;
@@ -54,7 +59,12 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
{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>
@@ -63,4 +73,4 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
);
};
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,16 +72,20 @@ 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,13 +25,17 @@ 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 });
@@ -90,12 +94,14 @@ 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,9 +24,7 @@ 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;
@@ -52,13 +50,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)',
},
},
}}
@@ -75,16 +73,8 @@ 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"
@@ -92,13 +82,7 @@ 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,42 +26,48 @@ 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>
@@ -74,6 +80,18 @@ 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,16 +92,13 @@ 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++) {
@@ -120,21 +117,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();
@@ -156,7 +153,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);
@@ -179,7 +176,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);
}
@@ -212,7 +209,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) {
@@ -230,45 +227,42 @@ 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}
@@ -278,9 +272,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>
@@ -292,24 +286,26 @@ 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")}
{t('sign.canvas.clear', 'Clear canvas')}
</Button>
<Button onClick={closeModal}>
{t('common.done', 'Done')}
</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,7 +67,13 @@ 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,18 +36,15 @@ 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 {
@@ -55,7 +52,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
@@ -72,8 +69,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;
}
@@ -81,10 +78,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);
@@ -101,7 +98,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
@@ -119,33 +116,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) {
@@ -153,73 +150,68 @@ 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);
@@ -239,7 +231,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}
@@ -247,7 +239,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}
@@ -260,7 +252,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,9 +44,15 @@ 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,7 +18,12 @@ 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);
@@ -36,17 +41,17 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
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);
@@ -58,7 +63,7 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t("annotation.fontSize", "Font size")}
{t('annotation.fontSize', 'Font size')}
</Text>
<Slider
value={fontSize}
@@ -72,7 +77,7 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
{/* 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)}
@@ -86,25 +91,25 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
{/* 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"
>
@@ -120,7 +125,7 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t("annotation.opacity", "Opacity")}
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={opacity}
@@ -143,7 +148,7 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
<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}
@@ -161,7 +166,7 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
</div>
<Button
size="xs"
variant={!borderVisible ? "filled" : "light"}
variant={!borderVisible ? 'filled' : 'light'}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
@@ -171,7 +176,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
});
}}
>
{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>
@@ -181,7 +188,7 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
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"
@@ -191,13 +198,13 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
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)',
},
},
}}
@@ -207,8 +214,8 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
</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,37 +61,14 @@ 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 => {
@@ -117,7 +94,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || "Helvetica");
onFontFamilyChange(value || 'Helvetica');
onAnyChange?.();
}}
data={fontOptions}
@@ -210,7 +187,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
setColorInput(textColor);
}
}}
style={{ width: "100%" }}
style={{ width: '100%' }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
@@ -218,9 +195,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'
}}
/>
}
@@ -247,14 +224,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,9 +46,15 @@ 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,26 +1,33 @@
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}
@@ -35,4 +42,4 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disab
</Stack>
</BaseAnnotationTool>
);
};
};
@@ -1,14 +1,17 @@
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) => {
@@ -20,7 +23,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
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);
@@ -30,7 +33,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error("Error reading file:", error);
console.error('Error reading file:', error);
}
} else if (!file) {
setImageData(null);
@@ -41,11 +44,15 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
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}
@@ -57,4 +64,4 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
</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,7 +16,11 @@ 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();
@@ -34,7 +38,7 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
e.stopPropagation();
const files = await openFilesFromDisk({
multiple,
onFallbackOpen: () => fileInputRef.current?.click(),
onFallbackOpen: () => fileInputRef.current?.click()
});
if (files.length > 0) {
onFileSelect(files);
@@ -52,7 +56,7 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = "";
event.target.value = '';
};
return (
@@ -63,17 +67,17 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
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();
}
@@ -82,9 +86,11 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
</div>
<div className={styles.headerIndex}>
{t('fileEditor.addFiles', 'Add Files')}
</div>
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
<div className={styles.kebab} />
</div>
@@ -93,81 +99,84 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
{/* 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,10 +6,7 @@
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;
@@ -48,8 +45,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);
}
@@ -69,7 +66,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);
}
@@ -106,7 +103,7 @@
}
.headerIconButton {
color: #ffffff !important;
color: #FFFFFF !important;
}
/* Menu dropdown */
@@ -229,13 +226,14 @@
}
.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;
@@ -266,8 +264,7 @@
/* Animations */
@keyframes pulse {
0%,
100% {
0%, 100% {
opacity: 1;
}
50% {
@@ -291,15 +288,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 */
}
@@ -311,16 +308,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,19 +1,22 @@
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;
@@ -22,15 +25,16 @@ 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();
@@ -58,11 +62,11 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
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);
@@ -72,7 +76,7 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
// 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
@@ -100,8 +104,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
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]);
@@ -111,8 +115,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
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]);
@@ -133,75 +137,69 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
// 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);
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
},
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
} 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 toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
if (!targetRecord) return;
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);
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
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 {
// 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];
// 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 {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
newSelection = [...currentSelectedIds, contextFileId];
}
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
},
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, 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(() => {
@@ -210,174 +208,154 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
}
}, [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;
}
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];
// Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1
? selectedFileIds.filter(id => currentIds.includes(id))
: [sourceFileId];
// Create new order
const newOrder = [...currentIds];
// 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
// 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);
});
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)
}
// 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 {
// Target was moved, insert at end
insertIndex = newOrder.length;
// 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);
// 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]);
// 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],
);
// 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,
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
});
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
});
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 {
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: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500,
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
},
[activeStirlingFileStubs, selectors, 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;
@@ -387,85 +365,91 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
// 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>
{/* 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}
/>
{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}
/>
</Box>
</Dropzone>
);

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