Compare commits

..
Author SHA1 Message Date
James Brunton ae86eeb42e Add comments in the console to tell the user how to fix their issues in case the PR comment fails 2026-04-14 09:41:45 +01:00
James Brunton b59c4e1021 Continue on error if CI comments fail 2026-04-14 09:29:28 +01:00
plindandGitHub 4ada46ca56 Fix encrypted PDF unlock modal missing on IndexedDB restore and large files (#6099) 2026-04-14 00:38:42 +01:00
Reece BrowneandGitHub 76aa5c7e2f Fix encrypted pdf handling (#6088)
Fix and improve encrypted pdf handling
2026-04-13 13:20:43 +01:00
d53beb9bce Remove duplicate isPanning state (#6086)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:49:09 +01:00
unlairandGitHub 3d17f0409f Fix healthcheck in Docker files when SYSTEM_ROOTURIPATH is specified (#5954) 2026-04-12 22:44:04 +01:00
James BruntonandGitHub a3e45bc182 Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
2026-04-10 17:41:19 +01:00
aikido-autofix[bot]GitHubaikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
33b2b5827a [Aikido] Fix 16 security issues in fastmcp, aiohttp, cryptography and 1 more (#6091)
Upgrade fastmcp, aiohttp, cryptography, and anthropic to fix critical
SSRF/path traversal, header injection, OAuth confused deputy, and DoS
vulnerabilities.

<details>
<summary> 16 CVEs resolved by this upgrade, including 2 critical 🚨
CVEs</summary>

<br>


This PR will resolve the following CVEs:
| Issue |
Severity&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; |
Description |
| --- | --- | --- |
|
<pre>[CVE-2026-32871](https://app.aikido.dev/issues/25944204/detail?groupId=70007#CVE-2026-32871)</pre>
| <pre>🚨 CRITICAL</pre> | [fastmcp] Path traversal vulnerability in URL
construction allows attackers to bypass API prefix restrictions and
access arbitrary backend endpoints using unencoded path parameters,
enabling authenticated SSRF attacks. |
|
<pre>[CVE-2026-27124](https://app.aikido.dev/issues/25944204/detail?groupId=70007#CVE-2026-27124)</pre>
| <pre>HIGH</pre> | [fastmcp] OAuthProxy fails to validate user consent
when receiving authorization codes from GitHub, allowing attackers to
exploit GitHub's consent-skipping behavior to gain unauthorized access
to FastMCP servers through a Confused Deputy attack. |
|
<pre>[CVE-2025-64340](https://app.aikido.dev/issues/25944204/detail?groupId=70007#CVE-2025-64340)</pre>
| <pre>MEDIUM</pre> | [fastmcp] Server names with shell metacharacters
can cause command injection on Windows when passed to install commands,
allowing arbitrary code execution through cmd.exe interpretation of .cmd
wrapper files. |
|
<pre>[CVE-2026-34520](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34520)</pre>
| <pre>🚨 CRITICAL</pre> | [aiohttp] is an asynchronous HTTP
client/server framework for asyncio and Python. Prior to version 3.13.4,
the C parser (the default for most installs) accepted null bytes and
control characters in response headers. This issue has been patched in
version 3.13.4. |
|
<pre>[CVE-2026-34516](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34516)</pre>
| <pre>HIGH</pre> | [aiohttp] A response with an excessive number of
multipart headers can consume more memory than intended, leading to a
denial of service (DoS) vulnerability through resource exhaustion. |
|
<pre>[CVE-2026-22815](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-22815)</pre>
| <pre>MEDIUM</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, insufficient
restrictions in header/trailer handling could cause uncapped memory
usage. This issue has been patched in version 3.13.4. |
|
<pre>[CVE-2026-34515](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34515)</pre>
| <pre>MEDIUM</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, on Windows
the static resource handler may expose information about a NTLMv2 remote
path. This issue has been patched in version 3.13.4. |
|
<pre>[CVE-2026-34525](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34525)</pre>
| <pre>MEDIUM</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, multiple Host
headers were allowed in aiohttp. This issue has been patched in version
3.13.4. |
|
<pre>[CVE-2026-34513](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34513)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, an unbounded
DNS cache could result in excessive memory usage possibly resulting in a
DoS situation. This issue has been patched in version 3.13.4. |
|
<pre>[CVE-2026-34514](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34514)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, an attacker
who controls the content_type parameter in aiohttp could use this to
inject extra headers or similar exploits. This issue has been patched in
version 3.13.4. |
|
<pre>[CVE-2026-34517](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34517)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, for some
multipart form fields, aiohttp read the entire field into memory before
checking client_max_size. This issue has been patched in version 3.13.4.
|
|
<pre>[CVE-2026-34518](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34518)</pre>
| <pre>LOW</pre> | [aiohttp] When following redirects to a different
origin, the framework fails to drop the Cookie and Proxy-Authorization
headers alongside the Authorization header, potentially leaking
sensitive authentication credentials to untrusted domains. |
|
<pre>[CVE-2026-34519](https://app.aikido.dev/issues/25944198/detail?groupId=70007#CVE-2026-34519)</pre>
| <pre>LOW</pre> | [aiohttp] is an asynchronous HTTP client/server
framework for asyncio and Python. Prior to version 3.13.4, an attacker
who controls the reason parameter when creating a Response may be able
to inject extra headers or similar exploits. This issue has been patched
in version 3.13.4. |
|
<pre>[CVE-2026-39892](https://app.aikido.dev/issues/25637201/detail?groupId=70007#CVE-2026-39892)</pre>
| <pre>MEDIUM</pre> | [cryptography] Non-contiguous buffers passed to
cryptographic APIs can cause buffer overflows, potentially leading to
memory corruption and arbitrary code execution. |
|
<pre>[CVE-2026-34452](https://app.aikido.dev/issues/25944200/detail?groupId=70007#CVE-2026-34452)</pre>
| <pre>MEDIUM</pre> | [anthropic] A time-of-check-time-of-use (TOCTOU)
vulnerability in the async filesystem memory tool allows local attackers
to escape the sandbox directory via symlink manipulation, enabling
arbitrary file read/write operations outside the intended memory
directory. |
|
<pre>[CVE-2026-34450](https://app.aikido.dev/issues/25944200/detail?groupId=70007#CVE-2026-34450)</pre>
| <pre>MEDIUM</pre> | [anthropic] The local filesystem memory tool
created world-readable and potentially world-writable files, allowing
local attackers to read persisted agent state or modify memory files to
influence model behavior. |


</details>

Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-04-10 08:54:53 +00:00
aikido-autofix[bot]GitHubaikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
60cc749e6a [Aikido] Fix critical issue in axios via minor version upgrade from 1.13.6 to 1.15.0 in frontend (#6092)
Upgrade axios to fix critical proxy bypass and SSRF vulnerabilities in
hostname normalization that could allow attackers to reach protected
internal services.

 There are no breaking changes

<details>
<summary> 1 CVE resolved by this upgrade, including 1 critical 🚨
CVE</summary>

<br>


This PR will resolve the following CVEs:
| Issue |
Severity&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; |
Description |
| --- | --- | --- |
|
<pre>[CVE-2025-62718](https://app.aikido.dev/issues/26490690/detail?groupId=70007#CVE-2025-62718)</pre>
| <pre>🚨 CRITICAL</pre> | [axios] Axios fails to properly normalize
hostnames when checking NO_PROXY rules, allowing requests to loopback
addresses (localhost., [::1]) to bypass proxy protections and reach
internal services. This enables proxy bypass and SSRF attacks against
protected loopback or internal endpoints. |


</details>

Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-04-10 09:50:05 +01:00
EthanHealy01andGitHub 11b26755a4 use clean 3 card design for landing page (#6084)
<img width="2056" height="1080" alt="Screenshot 2026-04-08 at 1 26
58 PM"
src="https://github.com/user-attachments/assets/e834988b-c3ab-4633-bf15-9fe0457d0029"
/>

<img width="2056" height="1080" alt="Screenshot 2026-04-08 at 1 27
12 PM"
src="https://github.com/user-attachments/assets/adfebd95-ca59-4de0-9336-b1e2dc1dc5fe"
/>
2026-04-09 12:38:46 +00:00
Vibe StackandGitHub a5b259b453 feat(settings): add default startup view and reader zoom preferences (#6073)
## Description of Changes

Adds two new user preferences to the General settings panel, addressing
#5908.

**Default view on launch** - a segmented control (Tools / Reader /
Automate) that controls which left-column tab is active when the app
starts. Previously the app always opened on the Tools tab with no way to
change this. Users who spend most of their time reading PDFs had to
manually switch to the Reader tab on every launch.

**Default reader zoom** - a dropdown (Auto / Fit width / Fit page /
50%–200%) that sets the initial zoom level whenever a PDF is opened in
the reader. Previously the app always applied an automatic
fit-to-viewport calculation.

Both settings are non-breaking. The defaults (`Tools` and `Auto`)
reproduce the existing behaviour exactly, so existing users see no
difference until they change a preference.

### What changed
- `preferencesService.ts` - added `StartupView` and `ViewerZoomSetting`
types plus the two new fields to `UserPreferences` with safe defaults
- `ToolWorkflowContext.tsx` - one-time startup effect that navigates to
the preferred tab on first render (mirrors the existing
`defaultToolPanelMode` sync pattern)
- `ZoomAPIBridge.tsx` - respects the zoom preference before falling back
to auto-zoom logic when a document loads
- `GeneralSection.tsx` - two new controls added below "Default tool
picker mode"; the Select uses `comboboxProps={{ withinPortal: true }}`
so the dropdown renders above the settings modal
- `en-GB/translation.toml` - new keys for labels, descriptions, and
option values

Closes #5908 

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [x] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
<img width="1023" height="747" alt="Screenshot 2026-04-05 185718"
src="https://github.com/user-attachments/assets/6a8bc35a-d813-4ab8-b303-55bdce747a6a"
/>
<img width="1026" height="755" alt="Screenshot 2026-04-05 185620"
src="https://github.com/user-attachments/assets/d2c45134-ed32-4332-a193-1a96837ba2a3"
/>


### Testing (if applicable)

- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-04-09 10:30:19 +00:00
James BruntonandGitHub cc1604a802 Add prototypes folder to test new functionality in (#6081)
# Description of Changes
Add prototypes folder to test new functionality in. This build of the
app is spawnable with `npm run dev:prototypes`.

Currently just contains a very developer-y chat interface to help us
develop & explore the AI backend before we make the frontend for it for
real.
2026-04-09 08:21:07 +00:00
James BruntonandGitHub b130242688 Add Java orchestrator to connect to the AI engine (#6003)
# Description of Changes
Add Java orchestration layer which can connect and go back and forth
with the AI engine to get results for the user. It's expected that the
AI engine will not be publicly available and this Java layer will always
be in front of it, to manage sessions and auth etc.
2026-04-09 08:04:38 +00:00
fbae819d7c Fix/desktop open with tool access (#6056)
## Description

Fixes #6029 - Additional selection in windows client no longer necessary

## Problem
When opening PDF files in the Windows desktop client using "Open with",
the file displays properly but users had to manually select it again in
the workbench before any PDF tools (merge, compress, crop, compare,
etc.) become functional.

## Root Cause
Files opened via "Open with" were added to FileContext but **not
selected** (missing `selectFiles: true`). Without selection, the file
wasn't marked as active, preventing tool access.

Additionally, `AppInitializer` was placed outside
`ToolWorkflowProvider`, causing a context error.

## Solution

### Changes:
1. **frontend/src/desktop/hooks/useAppInitialization.ts**
   - Added `{ selectFiles: true }` when calling `addFiles()`
   - Files now immediately marked as active in FileContext

2. **frontend/src/core/components/AppProviders.tsx**
   - Moved `AppInitializer` inside `ToolWorkflowProvider`
   - Ensures context availability for initialization

## Testing
- Open PDF via "Open with" on Windows
- File now immediately usable with all tools
- No manual reselection needed

## Screenshot
<img width="1920" height="1080" alt="Screenshot (3)"
src="https://github.com/user-attachments/assets/9ceacadf-eb12-42a6-86f9-bca6188bfbb9"
/>

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2026-04-08 20:39:20 +05:30
1418 changed files with 60563 additions and 57846 deletions
+130
View File
@@ -50,6 +50,7 @@ jobs:
permissions:
actions: read
security-events: write
pull-requests: write
strategy:
fail-fast: false
matrix:
@@ -84,6 +85,73 @@ jobs:
gradle-version: 9.3.1
cache-disabled: true
- name: Check Java formatting (Spotless)
if: matrix.jdk-version == 25 && matrix.spring-security == false
id: spotless-check
run: ./gradlew spotlessCheck
continue-on-error: true
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Comment on Java formatting failure
if: steps.spotless-check.outcome == 'failure'
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const marker = '<!-- java-formatting-check -->';
const body = [
marker,
'### Java Formatting Check Failed',
'',
'Your code has formatting issues. Run the following command to fix them:',
'',
'```bash',
'./gradlew spotlessApply',
'```',
'',
'Then commit and push the changes.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if Java formatting issues found
if: steps.spotless-check.outcome == 'failure'
run: |
echo "============================================"
echo " Java Formatting Check Failed"
echo "============================================"
echo ""
echo "Your code has formatting issues."
echo "Run the following command to fix them:"
echo ""
echo " ./gradlew spotlessApply"
echo ""
echo "Then commit and push the changes."
echo "============================================"
exit 1
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
run: ./gradlew build -PnoSpotless
env:
@@ -187,6 +255,9 @@ jobs:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
@@ -202,6 +273,65 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Check TypeScript formatting (Prettier)
id: prettier-check
run: cd frontend && npm run format:check
continue-on-error: true
- name: Comment on TypeScript formatting failure
if: steps.prettier-check.outcome == 'failure'
continue-on-error: true
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const marker = '<!-- typescript-formatting-check -->';
const body = [
marker,
'### TypeScript Formatting Check Failed',
'',
'Your code has formatting issues. Run the following command to fix them:',
'',
'```bash',
'cd frontend && npm run fix',
'```',
'',
'Then commit and push the changes.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Fail if TypeScript formatting issues found
if: steps.prettier-check.outcome == 'failure'
run: |
echo "============================================"
echo " TypeScript Formatting Check Failed"
echo "============================================"
echo ""
echo "Your code has formatting issues."
echo "Run the following command to fix them:"
echo ""
echo " cd frontend && npm run fix"
echo ""
echo "Then commit and push the changes."
echo "============================================"
exit 1
- name: Type-check frontend
run: cd frontend && npm run prep && npm run typecheck:all
- name: Lint frontend
-56
View File
@@ -1,56 +0,0 @@
name: Clear GitHub Actions Cache
on:
workflow_dispatch:
push:
branches:
- clear-github-cache
jobs:
clear-cache:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Clear all caches
uses: actions/github-script@v7
with:
script: |
const caches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
let deleted = 0;
for (const cache of caches.data.actions_caches) {
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted++;
}
// Handle pagination if more than 100 caches
let totalCount = caches.data.total_count;
while (deleted < totalCount) {
const moreCaches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
if (moreCaches.data.actions_caches.length === 0) break;
for (const cache of moreCaches.data.actions_caches) {
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted++;
}
}
console.log(`Successfully deleted ${deleted} caches.`);
+2 -55
View File
@@ -2,7 +2,7 @@ name: Pre-commit
on:
workflow_dispatch:
push:
pull_request:
branches:
- main
@@ -16,9 +16,6 @@ jobs:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
@@ -31,13 +28,6 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -57,47 +47,4 @@ jobs:
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
continue-on-error: true
- name: Set up JDK 25
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
with:
gradle-version: 9.3.1
- name: Build with Gradle
run: ./gradlew build
env:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: git add
run: |
git add .
git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV
- name: Create Pull Request
if: env.CHANGES_DETECTED == 'true'
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: ":file_folder: pre-commit"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: pre-commit
title: "🤖 format everything with pre-commit by ${{ steps.setup-bot.outputs.app-slug }}"
body: |
Auto-generated by [create-pull-request][1] with **${{ steps.setup-bot.outputs.app-slug }}**
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
git diff --exit-code
+1
View File
@@ -181,6 +181,7 @@ venv.bak/
.idea/
*.iml
out/
.junie/
# Ignore Mac DS_Store files
.DS_Store
+1 -1
View File
@@ -187,7 +187,7 @@ services:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
+2
View File
@@ -14,6 +14,8 @@ if that directory exists, is licensed under the license defined in "frontend/src
if that directory exists, is licensed under the license defined in "frontend/src/desktop/LICENSE".
* All content that resides under the "frontend/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/saas/LICENSE".
* All content that resides under the "frontend/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/prototypes/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
@@ -75,6 +75,7 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -231,6 +232,13 @@ public class ApplicationProperties {
}
}
@Data
public static class AiEngine {
private boolean enabled = false;
private String url = "http://localhost:5001";
private int timeoutSeconds = 120;
}
@Data
public static class Legal {
private String termsAndConditions;
@@ -325,6 +325,11 @@ processExecutor:
ghostscriptTimeoutMinutes: 30
ocrMyPdfTimeoutMinutes: 30
aiEngine:
enabled: false # Set to 'true' to enable the AI engine integration
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
pdfEditor:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
cache:
@@ -0,0 +1,83 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiWorkflowService;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@RestController
@RequestMapping("/api/v1/ai")
@RequiredArgsConstructor
@Tag(name = "AI Engine", description = "Endpoints for AI-powered PDF workflows")
public class AiEngineController {
private final AiEngineClient aiEngineClient;
private final AiWorkflowService aiWorkflowService;
private final ObjectMapper objectMapper;
@GetMapping("/health")
@Operation(
summary = "AI engine health check",
description = "Returns the health status of the AI engine including configured models")
public ResponseEntity<String> health() throws IOException {
String response = aiEngineClient.get("/health");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
@PostMapping(value = "/orchestrate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Run an AI workflow against a PDF",
description =
"Accepts a PDF upload and a user message and returns an AI workflow result")
public ResponseEntity<AiWorkflowResponse> orchestrate(
@Valid @ModelAttribute AiWorkflowRequest request) throws IOException {
return ResponseEntity.ok(aiWorkflowService.orchestrate(request));
}
@PostMapping(value = "/pdf/edit", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Generate a PDF edit plan",
description =
"Sends a user message to the PDF edit agent which returns a structured plan"
+ " of tool operations to perform")
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
validateJson(requestBody);
String response = aiEngineClient.post("/api/v1/pdf/edit", requestBody);
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
}
private void validateJson(String body) {
try {
objectMapper.readValue(body, JsonNode.class);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Request body is not valid JSON");
}
}
}
@@ -0,0 +1,57 @@
package stirling.software.proprietary.model.api.ai;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* Types of content that can be extracted from a PDF and sent to the AI.
*
* <p>Values MUST match {@code PdfContentType} in {@code engine/src/stirling/contracts/common.py}.
*/
public enum AiPdfContentType {
// Document-level structured data
PAGE_LAYOUT("page_layout"),
DOCUMENT_METADATA("document_metadata"),
ENCRYPTION_INFO("encryption_info"),
BOOKMARKS("bookmarks"),
LAYERS("layers"),
EMBEDDED_FILES("embedded_files"),
JAVASCRIPT("javascript"),
LINKS("links"),
IMAGE_INFO("image_info"),
FONTS("fonts"),
// Text and content
PAGE_TEXT("page_text"),
FULL_TEXT("full_text"),
FORM_FIELDS("form_fields"),
ANNOTATIONS("annotations"),
SIGNATURES("signatures"),
STRUCTURE_TREE("structure_tree"),
XMP_METADATA("xmp_metadata"),
// Heavy content
COMPLIANCE("compliance"),
IMAGES("images");
private final String value;
AiPdfContentType(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
@JsonCreator
public static AiPdfContentType fromValue(String value) {
for (AiPdfContentType type : values()) {
if (type.value.equals(value)) {
return type;
}
}
throw new IllegalArgumentException("Unknown PDF content type: " + value);
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.model.api.ai;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "A single PDF file input")
public class AiWorkflowFileInput {
@NotNull
@Schema(
description = "The input PDF file",
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
format = "binary")
private MultipartFile fileInput;
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.model.api.ai;
import java.util.ArrayList;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Per-file content extraction request from the AI engine")
public class AiWorkflowFileRequest {
@Schema(description = "Original filename of the requested file", example = "contract.pdf")
private String fileName;
@Schema(description = "Specific 1-based page numbers to extract from this file")
private List<Integer> pageNumbers = new ArrayList<>();
@Schema(description = "Content types to extract from this file")
private List<AiPdfContentType> contentTypes = new ArrayList<>();
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.model.api.ai;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* Discriminator values for AI workflow responses.
*
* <p>Values MUST match {@code WorkflowOutcome} in {@code engine/src/stirling/contracts/common.py}.
*/
public enum AiWorkflowOutcome {
ANSWER("answer"),
NOT_FOUND("not_found"),
NEED_CONTENT("need_content"),
PLAN("plan"),
NEED_CLARIFICATION("need_clarification"),
CANNOT_DO("cannot_do"),
TOOL_CALL("tool_call"),
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
CANNOT_CONTINUE("cannot_continue");
private final String value;
AiWorkflowOutcome(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
@JsonCreator
public static AiWorkflowOutcome fromValue(String value) {
for (AiWorkflowOutcome outcome : values()) {
if (outcome.value.equals(value)) {
return outcome;
}
}
throw new IllegalArgumentException("Unknown AI workflow outcome: " + value);
}
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.model.api.ai;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "Run an AI workflow against one or more PDF files")
public class AiWorkflowRequest {
@NotNull
@Schema(description = "The input PDF files")
private List<AiWorkflowFileInput> fileInputs;
@NotBlank
@Schema(description = "The user message to orchestrate", example = "Summarise these documents")
private String userMessage;
}
@@ -0,0 +1,58 @@
package stirling.software.proprietary.model.api.ai;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Structured AI workflow result")
public class AiWorkflowResponse {
@Schema(description = "Workflow outcome")
private AiWorkflowOutcome outcome;
@Schema(description = "Answer returned by the AI workflow when applicable")
private String answer;
@Schema(description = "Summary returned by the AI workflow when applicable")
private String summary;
@Schema(description = "Rationale returned by the AI workflow when applicable")
private String rationale;
@Schema(description = "Reason when the AI workflow cannot proceed")
private String reason;
@Schema(description = "Clarification question for the user when more input is required")
private String question;
@Schema(
description =
"Unsupported capability identifier when the workflow cannot route the request")
private String capability;
@Schema(description = "Message returned for unsupported capability outcomes")
private String message;
@Schema(description = "Supporting evidence snippets from extracted PDF text")
private List<AiWorkflowTextSelection> evidence = new ArrayList<>();
@Schema(description = "Structured tool steps when the workflow returns a plan")
private List<Map<String, Object>> steps = new ArrayList<>();
@Schema(description = "Per-file text extraction requests from the AI engine")
private List<AiWorkflowFileRequest> files = new ArrayList<>();
@Schema(description = "Maximum number of pages the AI engine wants text extracted from")
private Integer maxPages;
@Schema(description = "Maximum number of characters the AI engine wants extracted")
private Integer maxCharacters;
@Schema(description = "AI engine capability to resume with on the next turn")
private String resumeWith;
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.model.api.ai;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "Page-scoped extracted text selection")
public class AiWorkflowTextSelection {
@Schema(description = "1-based page number", example = "2")
private Integer pageNumber;
@Schema(description = "Extracted text or evidence snippet")
private String text;
}
@@ -0,0 +1,108 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
@Service
public class AiEngineClient {
private final ApplicationProperties applicationProperties;
private final HttpClient httpClient;
public AiEngineClient(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
this.httpClient =
HttpClient.newBuilder()
.connectTimeout(
Duration.ofSeconds(
applicationProperties.getAiEngine().getTimeoutSeconds()))
.build();
}
public String post(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
}
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine request to {}", url);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = sendRequest(request);
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
return response.body();
}
public String get(String path) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
if (!config.isEnabled()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine is not enabled");
}
String url = config.getUrl().stripTrailing() + path;
log.debug("Proxying AI engine GET request to {}", url);
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.GET()
.build();
HttpResponse<String> response = sendRequest(request);
log.debug("AI engine responded with status {}", response.statusCode());
checkResponseStatus(response);
return response.body();
}
private HttpResponse<String> sendRequest(HttpRequest request) throws IOException {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine request was interrupted");
}
}
private void checkResponseStatus(HttpResponse<String> response) {
int status = response.statusCode();
if (status >= 500) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY, "AI engine returned error: " + status);
}
if (status >= 400) {
throw new ResponseStatusException(
HttpStatus.valueOf(status),
"AI engine returned client error: " + response.body());
}
}
}
@@ -0,0 +1,181 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome;
import stirling.software.proprietary.model.api.ai.AiWorkflowRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile;
import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult;
import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiWorkflowService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final AiEngineClient aiEngineClient;
private final PdfContentExtractor pdfContentExtractor;
private final ObjectMapper objectMapper;
private sealed interface WorkflowState {
record Pending(WorkflowTurnRequest request) implements WorkflowState {}
record Terminal(AiWorkflowResponse response) implements WorkflowState {}
}
public AiWorkflowResponse orchestrate(AiWorkflowRequest request) throws IOException {
validateRequest(request);
Map<String, MultipartFile> filesByName = new LinkedHashMap<>();
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
filesByName.put(
fileInput.getFileInput().getOriginalFilename(), fileInput.getFileInput());
}
WorkflowTurnRequest initialRequest = new WorkflowTurnRequest();
initialRequest.setUserMessage(request.getUserMessage().trim());
initialRequest.setFileNames(new ArrayList<>(filesByName.keySet()));
WorkflowState state = new WorkflowState.Pending(initialRequest);
while (state instanceof WorkflowState.Pending pending) {
state = advance(pending.request(), filesByName);
}
return ((WorkflowState.Terminal) state).response();
}
private WorkflowState advance(
WorkflowTurnRequest request, Map<String, MultipartFile> filesByName)
throws IOException {
AiWorkflowResponse response = invokeOrchestrator(request);
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesByName, request);
case ANSWER,
NOT_FOUND,
PLAN,
NEED_CLARIFICATION,
CANNOT_DO,
TOOL_CALL,
COMPLETED,
UNSUPPORTED_CAPABILITY,
CANNOT_CONTINUE ->
new WorkflowState.Terminal(response);
};
}
private WorkflowState onNeedContent(
AiWorkflowResponse response,
Map<String, MultipartFile> filesByName,
WorkflowTurnRequest request)
throws IOException {
if (!request.getArtifacts().isEmpty()) {
return new WorkflowState.Terminal(
cannotContinue("AI engine requested content extraction more than once."));
}
List<AiWorkflowFileRequest> requestedFiles = response.getFiles();
// Validate requested file names before loading anything
if (requestedFiles != null && !requestedFiles.isEmpty()) {
for (AiWorkflowFileRequest fileReq : requestedFiles) {
if (!filesByName.containsKey(fileReq.getFileName())) {
return new WorkflowState.Terminal(
cannotContinue(
"AI engine requested unknown file: " + fileReq.getFileName()));
}
}
}
List<String> fileNamesToLoad =
(requestedFiles == null || requestedFiles.isEmpty())
? new ArrayList<>(filesByName.keySet())
: requestedFiles.stream().map(AiWorkflowFileRequest::getFileName).toList();
Map<String, AiWorkflowFileRequest> requestedByName =
requestedFiles == null || requestedFiles.isEmpty()
? Map.of()
: requestedFiles.stream()
.collect(
Collectors.toMap(
AiWorkflowFileRequest::getFileName, r -> r));
List<LoadedFile> loadedFiles = new ArrayList<>();
try {
for (String fileName : fileNamesToLoad) {
PDDocument doc = pdfDocumentFactory.load(filesByName.get(fileName), true);
loadedFiles.add(new LoadedFile(fileName, doc));
}
List<PdfContentResult> contentResults =
pdfContentExtractor.extractContent(
loadedFiles,
requestedByName,
response.getMaxPages(),
response.getMaxCharacters());
WorkflowTurnRequest nextRequest = new WorkflowTurnRequest();
nextRequest.setUserMessage(request.getUserMessage());
nextRequest.setFileNames(request.getFileNames());
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
try {
lf.document().close();
} catch (IOException e) {
log.warn("Failed to close PDF document: {}", lf.fileName(), e);
}
}
}
}
private void validateRequest(AiWorkflowRequest request) {
for (AiWorkflowFileInput fileInput : request.getFileInputs()) {
if (fileInput.getFileInput().isEmpty()) {
throw ExceptionUtils.createFileNullOrEmptyException();
}
}
}
private AiWorkflowResponse cannotContinue(String reason) {
AiWorkflowResponse response = new AiWorkflowResponse();
response.setOutcome(AiWorkflowOutcome.CANNOT_CONTINUE);
response.setReason(reason);
return response;
}
private AiWorkflowResponse invokeOrchestrator(WorkflowTurnRequest request) throws IOException {
String requestBody = objectMapper.writeValueAsString(request);
String responseBody = aiEngineClient.post("/api/v1/orchestrator", requestBody);
return objectMapper.readValue(responseBody, AiWorkflowResponse.class);
}
@Data
private static class WorkflowTurnRequest {
private String userMessage;
private List<String> fileNames = new ArrayList<>();
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
}
}
@@ -0,0 +1,279 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest;
import stirling.software.proprietary.model.api.ai.AiWorkflowTextSelection;
@Slf4j
@Service
public class PdfContentExtractor {
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
record LoadedFile(String fileName, PDDocument document) {}
/**
* Extracts content from the loaded files according to the requested content types and budget
* constraints.
*/
List<PdfContentResult> extractContent(
List<LoadedFile> loadedFiles,
Map<String, AiWorkflowFileRequest> requestedByName,
int maxPages,
int maxCharacters)
throws IOException {
List<PdfContentResult> contentResults = new ArrayList<>();
int remainingPages = maxPages;
int remainingCharacters = maxCharacters;
for (LoadedFile lf : loadedFiles) {
if (remainingPages <= 0 || remainingCharacters <= 0) break;
AiWorkflowFileRequest fileReq = requestedByName.get(lf.fileName());
List<AiPdfContentType> contentTypes =
fileReq != null && !fileReq.getContentTypes().isEmpty()
? fileReq.getContentTypes()
: List.of(AiPdfContentType.PAGE_TEXT);
for (AiPdfContentType contentType : contentTypes) {
Optional<PdfContentResult> result =
dispatchContentType(
contentType, lf, fileReq, remainingPages, remainingCharacters);
if (result.isPresent()) {
PdfContentResult content = result.get();
contentResults.add(content);
remainingPages -= content.pagesConsumed();
remainingCharacters -= content.charactersConsumed();
}
}
}
return contentResults;
}
/** Groups content results by artifact kind and builds the corresponding workflow artifacts. */
List<WorkflowArtifact> buildArtifacts(List<PdfContentResult> results) {
List<WorkflowArtifact> artifacts = new ArrayList<>();
Map<ArtifactKind, List<PdfContentResult>> byKind =
results.stream().collect(Collectors.groupingBy(PdfContentResult::getArtifactKind));
for (var entry : byKind.entrySet()) {
artifacts.add(buildArtifact(entry.getKey(), entry.getValue()));
}
return artifacts;
}
private Optional<PdfContentResult> dispatchContentType(
AiPdfContentType contentType,
LoadedFile lf,
AiWorkflowFileRequest fileReq,
int remainingPages,
int remainingCharacters)
throws IOException {
return switch (contentType) {
case PAGE_TEXT, FULL_TEXT ->
Optional.<PdfContentResult>ofNullable(
extractText(lf, fileReq, remainingPages, remainingCharacters));
default -> {
log.warn(
"Content type {} not yet implemented, skipping for {}",
contentType,
lf.fileName());
yield Optional.empty();
}
};
}
private ExtractedFileText extractText(
LoadedFile lf,
AiWorkflowFileRequest fileReq,
int remainingPages,
int remainingCharacters)
throws IOException {
List<Integer> requestedPages = fileReq != null ? fileReq.getPageNumbers() : null;
List<Integer> pages =
selectPages(lf.document().getNumberOfPages(), requestedPages, remainingPages);
List<AiWorkflowTextSelection> extracted =
extractPageText(lf.document(), pages, remainingCharacters);
return extracted.isEmpty() ? null : buildExtractedFileText(lf.fileName(), extracted);
}
private WorkflowArtifact buildArtifact(ArtifactKind kind, List<PdfContentResult> results) {
return switch (kind) {
case EXTRACTED_TEXT -> {
ExtractedTextArtifact artifact = new ExtractedTextArtifact();
artifact.setFiles(results.stream().map(ExtractedFileText.class::cast).toList());
yield artifact;
}
};
}
private List<Integer> selectPages(
int totalPages, List<Integer> requestedPageNumbers, int maxPages) {
if (totalPages <= 0) {
throw ExceptionUtils.createPdfNoPages();
}
List<Integer> pages = new ArrayList<>();
if (requestedPageNumbers == null || requestedPageNumbers.isEmpty()) {
for (int p = 1; p <= totalPages && pages.size() < maxPages; p++) {
pages.add(p);
}
return pages;
}
Set<Integer> deduplicatedPages = new LinkedHashSet<>(requestedPageNumbers);
for (Integer pageNumber : deduplicatedPages) {
if (pageNumber == null || pageNumber < 1 || pageNumber > totalPages) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidPageNumber",
"Requested page number %s is outside the PDF page range.",
pageNumber);
}
pages.add(pageNumber);
if (pages.size() >= maxPages) {
break;
}
}
return pages;
}
private List<AiWorkflowTextSelection> extractPageText(
PDDocument document, List<Integer> selectedPages, int maxCharacters)
throws IOException {
PDFTextStripper textStripper = new PDFTextStripper();
List<AiWorkflowTextSelection> pages = new ArrayList<>();
int remainingCharacters = maxCharacters;
for (Integer pageNumber : selectedPages) {
if (remainingCharacters <= 0) {
break;
}
textStripper.setStartPage(pageNumber);
textStripper.setEndPage(pageNumber);
String pageText = textStripper.getText(document).trim();
if (pageText.isBlank()) {
continue;
}
int allowedCharacters = Math.min(remainingCharacters, MAX_CHARACTERS_PER_PAGE);
String clippedText = clip(pageText, allowedCharacters);
if (clippedText.isBlank()) {
continue;
}
AiWorkflowTextSelection selection = new AiWorkflowTextSelection();
selection.setPageNumber(pageNumber);
selection.setText(clippedText);
pages.add(selection);
remainingCharacters -= clippedText.length();
}
return pages;
}
private ExtractedFileText buildExtractedFileText(
String fileName, List<AiWorkflowTextSelection> pages) {
ExtractedFileText fileText = new ExtractedFileText();
fileText.setFileName(fileName);
fileText.setPages(pages);
return fileText;
}
private String clip(String text, int maxLength) {
if (text.length() <= maxLength) {
return text;
}
// Avoid splitting a surrogate pair at the boundary
int end = maxLength;
if (Character.isHighSurrogate(text.charAt(end - 1))) {
end--;
}
return text.substring(0, end);
}
// --- Types shared with AiWorkflowService (package-private) ---
interface PdfContentResult {
@JsonIgnore
ArtifactKind getArtifactKind();
@JsonIgnore
default int pagesConsumed() {
return 0;
}
@JsonIgnore
default int charactersConsumed() {
return 0;
}
}
/**
* Values MUST match {@code ArtifactKind} in {@code engine/src/stirling/contracts/common.py}.
*/
enum ArtifactKind {
EXTRACTED_TEXT("extracted_text");
private final String value;
ArtifactKind(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
}
interface WorkflowArtifact {
ArtifactKind getKind();
}
@Data
static class ExtractedFileText implements PdfContentResult {
private String fileName;
private List<AiWorkflowTextSelection> pages = new ArrayList<>();
@Override
public ArtifactKind getArtifactKind() {
return ArtifactKind.EXTRACTED_TEXT;
}
@Override
public int pagesConsumed() {
return pages.size();
}
@Override
public int charactersConsumed() {
return pages.stream().mapToInt(p -> p.getText().length()).sum();
}
}
@Data
static final class ExtractedTextArtifact implements WorkflowArtifact {
private final ArtifactKind kind = ArtifactKind.EXTRACTED_TEXT;
private List<ExtractedFileText> files = new ArrayList<>();
}
}
+2 -2
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*')([^']*)(')/)
def matcher = (content =~ /(appVersion:\s*(['"]))(.*?)(\2)/)
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${f} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}")
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(4)}")
if (content != updatedContent) {
f.write(updatedContent, 'UTF-8')
}
+1 -1
View File
@@ -135,7 +135,7 @@ services:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
@@ -41,7 +41,7 @@ services:
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -45,7 +45,7 @@ services:
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -24,7 +24,7 @@ services:
UMASK: "022"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status || exit 1"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1"]
interval: 30s
timeout: 10s
retries: 3
+1 -1
View File
@@ -10,7 +10,7 @@ services:
limits:
memory: 6G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+1 -1
View File
@@ -10,7 +10,7 @@ services:
limits:
memory: 2G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+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/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+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/api/v1/info/status || exit 1
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD []
+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/api/v1/info/status || exit 1
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
ENTRYPOINT ["tini", "--", "/scripts/init.sh"]
CMD []
@@ -11,7 +11,7 @@ services:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
@@ -10,7 +10,7 @@ services:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
@@ -6,7 +6,7 @@ services:
context: ../../..
dockerfile: docker/embedded/Dockerfile
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
@@ -6,7 +6,7 @@ services:
context: ../../..
dockerfile: docker/embedded/Dockerfile
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
@@ -10,7 +10,7 @@ services:
limits:
memory: 1G
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -qv 'Please sign in'"]
test: ["CMD-SHELL", "curl -f http://localhost:8080$${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -qv 'Please sign in'"]
interval: 5s
timeout: 10s
retries: 16
+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/api/v1/info/status | grep -q 'UP'"]
test: ["CMD-SHELL", "curl -f -H 'X-API-KEY: 123456789' http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status | grep -q 'UP'"]
interval: 5s
timeout: 10s
retries: 16
+3 -3
View File
@@ -42,9 +42,8 @@ select = [
"W",
"RUF100",
"UP",
]
ignore = [
"E501", # Temporarily disable line length limit until codebase conformat
"PYI", # flake8-pyi: flags deprecated typing constructs
"FA", # flake8-future-annotations: flags missing future annotations imports
]
[tool.pyright]
@@ -55,6 +54,7 @@ reportUnnecessaryCast = "warning"
reportUnnecessaryTypeIgnoreComment = "warning"
reportUnusedImport = "warning"
reportUnknownParameterType = "warning"
reportDeprecated = "warning"
[tool.pytest.ini_options]
testpaths = ["tests"]
+68 -11
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import assert_never
from pydantic_ai import Agent
from pydantic_ai.output import ToolOutput
@@ -12,12 +13,14 @@ from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftRequest,
AgentDraftWorkflowResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
PdfEditRequest,
PdfEditResponse,
PdfQuestionRequest,
PdfQuestionResponse,
SupportedCapability,
UnsupportedCapabilityResponse,
)
from stirling.services import AppRuntime
@@ -61,7 +64,7 @@ class OrchestratorAgent:
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
"Use delegate_pdf_edit for requested PDF modifications. "
"Use delegate_pdf_question for questions about the contents of a PDF. "
"Use delegate_pdf_question for questions about PDF contents. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use unsupported_capability only when none of the other outputs fit."
),
@@ -69,27 +72,56 @@ class OrchestratorAgent:
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
if request.resume_with is not None:
return await self._resume(request, request.resume_with)
result = await self.agent.run(
request.user_message,
self._build_prompt(request),
deps=OrchestratorDeps(runtime=self.runtime, request=request),
)
return result.output
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
"""Fast-path to get back to the correct endpoint without having to call AI."""
match capability:
case SupportedCapability.PDF_QUESTION:
return await self._run_pdf_question(request)
case SupportedCapability.PDF_EDIT:
return await self._run_pdf_edit(request)
case SupportedCapability.AGENT_DRAFT:
return await self._run_agent_draft(request)
case (
SupportedCapability.ORCHESTRATE
| SupportedCapability.AGENT_REVISE
| SupportedCapability.AGENT_NEXT_ACTION
):
raise ValueError(f"Cannot resume orchestrator with capability: {capability}")
case _ as unreachable:
assert_never(unreachable)
async def delegate_pdf_edit(self, ctx: RunContext[OrchestratorDeps]) -> PdfEditResponse:
request = ctx.deps.request
return await PdfEditAgent(ctx.deps.runtime).handle(
PdfEditRequest(user_message=request.user_message, conversation_id=request.conversation_id)
)
return await self._run_pdf_edit(ctx.deps.request)
async def _run_pdf_edit(self, request: OrchestratorRequest) -> PdfEditResponse:
return await PdfEditAgent(self.runtime).handle(PdfEditRequest(user_message=request.user_message))
async def delegate_pdf_question(self, ctx: RunContext[OrchestratorDeps]) -> PdfQuestionResponse:
request = ctx.deps.request
return await PdfQuestionAgent(ctx.deps.runtime).handle(
PdfQuestionRequest(question=request.user_message, conversation_id=request.conversation_id)
return await self._run_pdf_question(ctx.deps.request)
async def _run_pdf_question(self, request: OrchestratorRequest) -> PdfQuestionResponse:
extracted_text = self._get_extracted_text_artifact(request)
return await PdfQuestionAgent(self.runtime).handle(
PdfQuestionRequest(
question=request.user_message,
file_names=request.file_names,
page_text=extracted_text.files if extracted_text is not None else [],
)
)
async def delegate_user_spec(self, ctx: RunContext[OrchestratorDeps]) -> AgentDraftWorkflowResponse:
request = ctx.deps.request
return await UserSpecAgent(ctx.deps.runtime).draft(AgentDraftRequest(user_message=request.user_message))
return await self._run_agent_draft(ctx.deps.request)
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).draft(AgentDraftRequest(user_message=request.user_message))
async def unsupported_capability(
self,
@@ -98,3 +130,28 @@ class OrchestratorAgent:
message: str,
) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability=capability, message=message)
def _get_extracted_text_artifact(self, request: OrchestratorRequest) -> ExtractedTextArtifact | None:
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
return artifact
return None
def _build_prompt(self, request: OrchestratorRequest) -> str:
artifact_summary = self._describe_artifacts(request)
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
return f"User message: {request.user_message}\nFiles: {file_names}\nAvailable artifacts:\n{artifact_summary}"
def _describe_artifacts(self, request: OrchestratorRequest) -> str:
if not request.artifacts:
return "- none"
descriptions: list[str] = []
for artifact in request.artifacts:
if isinstance(artifact, ExtractedTextArtifact):
total_pages = sum(len(f.pages) for f in artifact.files)
file_names = [f.file_name for f in artifact.files]
descriptions.append(f"- extracted_text: {total_pages} pages from {file_names}")
continue
descriptions.append("- unknown artifact")
return "\n".join(descriptions)
+32 -8
View File
@@ -4,8 +4,11 @@ from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.contracts import (
ExtractedFileText,
NeedContentFileRequest,
PdfContentType,
PdfQuestionAnswerResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfQuestionResponse,
@@ -14,6 +17,9 @@ from stirling.services import AppRuntime
class PdfQuestionAgent:
DEFAULT_MAX_PAGES = 12
DEFAULT_MAX_CHARACTERS = 24_000
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
self.agent = Agent(
@@ -25,18 +31,27 @@ class PdfQuestionAgent:
]
),
system_prompt=(
"Answer questions about a PDF using only the extracted text provided in the prompt. "
"Answer questions about PDFs using only the extracted page text provided in the prompt. "
"Do not guess or use outside knowledge. "
"If the answer is not supported by the provided text, return not_found. "
"When answering, include a short list of evidence snippets copied from the provided text."
"When answering, include a short list of evidence snippets with their page numbers."
),
model_settings=runtime.smart_model_settings,
)
async def handle(self, request: PdfQuestionRequest) -> PdfQuestionResponse:
if not request.extracted_text.strip():
return PdfQuestionNeedTextResponse(
reason="No extracted PDF text was provided, so the question cannot be answered yet."
if not self._has_page_text(request.page_text):
return PdfQuestionNeedContentResponse(
reason="No extracted PDF page text was provided, so the question cannot be answered yet.",
files=[
NeedContentFileRequest(
file_name=file_name,
content_types=[PdfContentType.PAGE_TEXT],
)
for file_name in request.file_names
],
max_pages=self.DEFAULT_MAX_PAGES,
max_characters=self.DEFAULT_MAX_CHARACTERS,
)
return await self._run_answer_agent(request)
@@ -45,5 +60,14 @@ class PdfQuestionAgent:
return result.output
def _build_prompt(self, request: PdfQuestionRequest) -> str:
file_name = request.file_name or "Unknown file"
return f"File: {file_name}\nQuestion: {request.question}\nExtracted text:\n{request.extracted_text}"
file_names = ", ".join(request.file_names) if request.file_names else "Unknown files"
sections = [
f"[File: {file_text.file_name}, Page {selection.page_number or '?'}]\n{selection.text}"
for file_text in request.page_text
for selection in file_text.pages
]
pages = "\n\n".join(sections)
return f"Files: {file_names}\nQuestion: {request.question}\nExtracted page text:\n{pages}"
def _has_page_text(self, page_text: list[ExtractedFileText]) -> bool:
return any(selection.text.strip() for file_text in page_text for selection in file_text.pages)
+29 -4
View File
@@ -8,7 +8,17 @@ from .agent_drafts import (
AgentRevisionWorkflowResponse,
)
from .agent_specs import AgentSpec, AgentSpecStep, AiToolAgentStep
from .common import ConversationMessage, PdfTextSelection, ToolOperationStep
from .common import (
ArtifactKind,
ConversationMessage,
ExtractedFileText,
PdfContentType,
PdfTextSelection,
StepKind,
SupportedCapability,
ToolOperationStep,
WorkflowOutcome,
)
from .execution import (
AgentExecutionRequest,
CannotContinueExecutionAction,
@@ -19,7 +29,13 @@ from .execution import (
ToolCallExecutionAction,
)
from .health import HealthResponse
from .orchestrator import OrchestratorRequest, OrchestratorResponse, SupportedCapability, UnsupportedCapabilityResponse
from .orchestrator import (
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
UnsupportedCapabilityResponse,
WorkflowArtifact,
)
from .pdf_edit import (
EditCannotDoResponse,
EditClarificationRequest,
@@ -28,14 +44,16 @@ from .pdf_edit import (
PdfEditResponse,
)
from .pdf_questions import (
NeedContentFileRequest,
PdfQuestionAnswerResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfQuestionResponse,
)
__all__ = [
"ArtifactKind",
"AgentDraft",
"AgentDraftRequest",
"AgentDraftResponse",
@@ -49,6 +67,7 @@ __all__ = [
"AiToolAgentStep",
"CannotContinueExecutionAction",
"ConversationMessage",
"ExtractedFileText",
"CompletedExecutionAction",
"EditCannotDoResponse",
"EditClarificationRequest",
@@ -56,19 +75,25 @@ __all__ = [
"ExecutionContext",
"ExecutionStepResult",
"HealthResponse",
"NeedContentFileRequest",
"NextExecutionAction",
"ExtractedTextArtifact",
"OrchestratorRequest",
"OrchestratorResponse",
"PdfEditRequest",
"PdfEditResponse",
"PdfQuestionAnswerResponse",
"PdfQuestionNotFoundResponse",
"PdfQuestionNeedTextResponse",
"PdfContentType",
"PdfQuestionNeedContentResponse",
"PdfQuestionRequest",
"PdfQuestionResponse",
"PdfTextSelection",
"StepKind",
"SupportedCapability",
"ToolOperationStep",
"ToolCallExecutionAction",
"WorkflowOutcome",
"UnsupportedCapabilityResponse",
"WorkflowArtifact",
]
@@ -7,12 +7,12 @@ from pydantic import Field
from stirling.models import ApiModel
from .agent_specs import AgentSpecStep
from .common import ConversationMessage
from .common import ConversationMessage, StepKind, WorkflowOutcome
from .pdf_edit import EditCannotDoResponse, EditClarificationRequest
class AgentDraftStep(ApiModel):
kind: Literal["tool", "ai_tool"]
kind: Literal[StepKind.TOOL, StepKind.AI_TOOL]
title: str
description: str
@@ -30,7 +30,7 @@ class AgentDraftRequest(ApiModel):
class AgentDraftResponse(ApiModel):
outcome: Literal["draft"] = "draft"
outcome: Literal[WorkflowOutcome.DRAFT] = WorkflowOutcome.DRAFT
draft: AgentDraft
@@ -41,7 +41,7 @@ class AgentRevisionRequest(ApiModel):
class AgentRevisionResponse(ApiModel):
outcome: Literal["draft"] = "draft"
outcome: Literal[WorkflowOutcome.DRAFT] = WorkflowOutcome.DRAFT
draft: AgentDraft
+2 -2
View File
@@ -6,11 +6,11 @@ from pydantic import Field
from stirling.models import ApiModel, OperationId
from .common import ToolOperationStep
from .common import StepKind, ToolOperationStep
class AiToolAgentStep(ApiModel):
kind: Literal["ai_tool"] = "ai_tool"
kind: Literal[StepKind.AI_TOOL] = StepKind.AI_TOOL
title: str
description: str
tool: OperationId
+84 -2
View File
@@ -1,12 +1,89 @@
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from stirling.models import OPERATIONS, ApiModel, OperationId, ParamToolModel
class PdfContentType(StrEnum):
"""Types of content that can be extracted from a PDF and sent to the AI.
Java counterpart: AiPdfContentType.java - values must stay in sync.
"""
# Document-level structured data
PAGE_LAYOUT = "page_layout"
DOCUMENT_METADATA = "document_metadata"
ENCRYPTION_INFO = "encryption_info"
BOOKMARKS = "bookmarks"
LAYERS = "layers"
EMBEDDED_FILES = "embedded_files"
JAVASCRIPT = "javascript"
LINKS = "links"
IMAGE_INFO = "image_info"
FONTS = "fonts"
# Text and content
PAGE_TEXT = "page_text"
FULL_TEXT = "full_text"
FORM_FIELDS = "form_fields"
ANNOTATIONS = "annotations"
SIGNATURES = "signatures"
STRUCTURE_TREE = "structure_tree"
XMP_METADATA = "xmp_metadata"
# Heavy content
COMPLIANCE = "compliance"
IMAGES = "images"
class WorkflowOutcome(StrEnum):
"""Discriminator values for all workflow response unions (outcome field).
Java counterpart: AiWorkflowOutcome.java - values must stay in sync.
"""
ANSWER = "answer"
NEED_CONTENT = "need_content"
NOT_FOUND = "not_found"
PLAN = "plan"
NEED_CLARIFICATION = "need_clarification"
CANNOT_DO = "cannot_do"
DRAFT = "draft"
TOOL_CALL = "tool_call"
COMPLETED = "completed"
CANNOT_CONTINUE = "cannot_continue"
UNSUPPORTED_CAPABILITY = "unsupported_capability"
class ArtifactKind(StrEnum):
"""Discriminator values for WorkflowArtifact unions (kind field).
Java counterpart: PdfContentExtractor.ArtifactKind - values must stay in sync.
"""
EXTRACTED_TEXT = "extracted_text"
class StepKind(StrEnum):
"""Discriminator values for AgentSpecStep unions (kind field)."""
TOOL = "tool"
AI_TOOL = "ai_tool"
class SupportedCapability(StrEnum):
ORCHESTRATE = "orchestrate"
PDF_EDIT = "pdf_edit"
PDF_QUESTION = "pdf_question"
AGENT_DRAFT = "agent_draft"
AGENT_REVISE = "agent_revise"
AGENT_NEXT_ACTION = "agent_next_action"
class ConversationMessage(ApiModel):
role: str
content: str
@@ -17,8 +94,13 @@ class PdfTextSelection(ApiModel):
text: str
class ExtractedFileText(ApiModel):
file_name: str
pages: list[PdfTextSelection] = Field(default_factory=list)
class ToolOperationStep(ApiModel):
kind: Literal["tool"] = "tool"
kind: Literal[StepKind.TOOL] = StepKind.TOOL
tool: OperationId
parameters: ParamToolModel
+4 -3
View File
@@ -7,6 +7,7 @@ from pydantic import Field
from stirling.models import ApiModel, OperationId, ParamToolModel
from .agent_specs import AgentSpec
from .common import WorkflowOutcome
class ExecutionStepResult(ApiModel):
@@ -31,19 +32,19 @@ class AgentExecutionRequest(ApiModel):
class ToolCallExecutionAction(ApiModel):
outcome: Literal["tool_call"] = "tool_call"
outcome: Literal[WorkflowOutcome.TOOL_CALL] = WorkflowOutcome.TOOL_CALL
tool: OperationId
parameters: ParamToolModel
rationale: str | None = None
class CompletedExecutionAction(ApiModel):
outcome: Literal["completed"] = "completed"
outcome: Literal[WorkflowOutcome.COMPLETED] = WorkflowOutcome.COMPLETED
summary: str
class CannotContinueExecutionAction(ApiModel):
outcome: Literal["cannot_continue"] = "cannot_continue"
outcome: Literal[WorkflowOutcome.CANNOT_CONTINUE] = WorkflowOutcome.CANNOT_CONTINUE
reason: str
+11 -10
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import Field
@@ -8,27 +7,29 @@ from pydantic import Field
from stirling.models import ApiModel
from .agent_drafts import AgentDraftResponse
from .common import ArtifactKind, ExtractedFileText, SupportedCapability, WorkflowOutcome
from .execution import NextExecutionAction
from .pdf_edit import PdfEditResponse
from .pdf_questions import PdfQuestionResponse
class SupportedCapability(StrEnum):
ORCHESTRATE = "orchestrate"
PDF_EDIT = "pdf_edit"
PDF_QUESTION = "pdf_question"
AGENT_DRAFT = "agent_draft"
AGENT_REVISE = "agent_revise"
AGENT_NEXT_ACTION = "agent_next_action"
class ExtractedTextArtifact(ApiModel):
kind: Literal[ArtifactKind.EXTRACTED_TEXT] = ArtifactKind.EXTRACTED_TEXT
files: list[ExtractedFileText] = Field(default_factory=list)
WorkflowArtifact = Annotated[ExtractedTextArtifact, Field(discriminator="kind")]
class OrchestratorRequest(ApiModel):
user_message: str
conversation_id: str | None = None
file_names: list[str]
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
resume_with: SupportedCapability | None = None
class UnsupportedCapabilityResponse(ApiModel):
outcome: Literal["unsupported_capability"] = "unsupported_capability"
outcome: Literal[WorkflowOutcome.UNSUPPORTED_CAPABILITY] = WorkflowOutcome.UNSUPPORTED_CAPABILITY
capability: str
message: str
+4 -5
View File
@@ -6,30 +6,29 @@ from pydantic import Field
from stirling.models import ApiModel
from .common import ToolOperationStep
from .common import ToolOperationStep, WorkflowOutcome
class PdfEditRequest(ApiModel):
user_message: str
conversation_id: str | None = None
file_names: list[str] = Field(default_factory=list)
class EditPlanResponse(ApiModel):
outcome: Literal["plan"] = "plan"
outcome: Literal[WorkflowOutcome.PLAN] = WorkflowOutcome.PLAN
summary: str
rationale: str | None = None
steps: list[ToolOperationStep]
class EditClarificationRequest(ApiModel):
outcome: Literal["need_clarification"] = "need_clarification"
outcome: Literal[WorkflowOutcome.NEED_CLARIFICATION] = WorkflowOutcome.NEED_CLARIFICATION
question: str
reason: str
class EditCannotDoResponse(ApiModel):
outcome: Literal["cannot_do"] = "cannot_do"
outcome: Literal[WorkflowOutcome.CANNOT_DO] = WorkflowOutcome.CANNOT_DO
reason: str
+20 -9
View File
@@ -6,31 +6,42 @@ from pydantic import Field
from stirling.models import ApiModel
from .common import ExtractedFileText, PdfContentType, SupportedCapability, WorkflowOutcome
class PdfQuestionRequest(ApiModel):
question: str
conversation_id: str | None = None
extracted_text: str = ""
file_name: str | None = None
page_text: list[ExtractedFileText] = Field(default_factory=list)
file_names: list[str]
class PdfQuestionAnswerResponse(ApiModel):
outcome: Literal["answer"] = "answer"
outcome: Literal[WorkflowOutcome.ANSWER] = WorkflowOutcome.ANSWER
answer: str
evidence: list[str] = Field(default_factory=list)
evidence: list[ExtractedFileText] = Field(default_factory=list)
class PdfQuestionNeedTextResponse(ApiModel):
outcome: Literal["need_text"] = "need_text"
class NeedContentFileRequest(ApiModel):
file_name: str
page_numbers: list[int] = Field(default_factory=list)
content_types: list[PdfContentType]
class PdfQuestionNeedContentResponse(ApiModel):
outcome: Literal[WorkflowOutcome.NEED_CONTENT] = WorkflowOutcome.NEED_CONTENT
resume_with: SupportedCapability = SupportedCapability.PDF_QUESTION
reason: str
files: list[NeedContentFileRequest] = Field(default_factory=list)
max_pages: int
max_characters: int
class PdfQuestionNotFoundResponse(ApiModel):
outcome: Literal["not_found"] = "not_found"
outcome: Literal[WorkflowOutcome.NOT_FOUND] = WorkflowOutcome.NOT_FOUND
reason: str
PdfQuestionResponse = Annotated[
PdfQuestionAnswerResponse | PdfQuestionNeedTextResponse | PdfQuestionNotFoundResponse,
PdfQuestionAnswerResponse | PdfQuestionNeedContentResponse | PdfQuestionNotFoundResponse,
Field(discriminator="outcome"),
]
+24 -8
View File
@@ -5,10 +5,12 @@ import pytest
from stirling.agents import PdfQuestionAgent
from stirling.config import AppSettings
from stirling.contracts import (
ExtractedFileText,
PdfQuestionAnswerResponse,
PdfQuestionNeedTextResponse,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
PdfTextSelection,
)
from stirling.services import build_runtime
@@ -34,13 +36,22 @@ def build_test_settings() -> AppSettings:
)
def invoice_page() -> ExtractedFileText:
return ExtractedFileText(
file_name="invoice.pdf",
pages=[PdfTextSelection(page_number=1, text="Invoice total: 120.00")],
)
@pytest.mark.anyio
async def test_pdf_question_agent_requires_extracted_text() -> None:
agent = PdfQuestionAgent(build_runtime(build_test_settings()))
response = await agent.handle(PdfQuestionRequest(question="What is the total?", extracted_text=""))
response = await agent.handle(
PdfQuestionRequest(question="What is the total?", page_text=[], file_names=["test.pdf"])
)
assert isinstance(response, PdfQuestionNeedTextResponse)
assert isinstance(response, PdfQuestionNeedContentResponse)
@pytest.mark.anyio
@@ -48,15 +59,15 @@ async def test_pdf_question_agent_returns_grounded_answer() -> None:
agent = StubPdfQuestionAgent(
PdfQuestionAnswerResponse(
answer="The invoice total is 120.00.",
evidence=["Invoice total: 120.00"],
evidence=[invoice_page()],
)
)
response = await agent.handle(
PdfQuestionRequest(
question="What is the total?",
extracted_text="Invoice total: 120.00",
file_name="invoice.pdf",
page_text=[invoice_page()],
file_names=["invoice.pdf"],
)
)
@@ -71,8 +82,13 @@ async def test_pdf_question_agent_returns_not_found_when_text_is_insufficient()
response = await agent.handle(
PdfQuestionRequest(
question="What is the total?",
extracted_text="This page contains only a shipping address.",
file_name="invoice.pdf",
page_text=[
ExtractedFileText(
file_name="invoice.pdf",
pages=[PdfTextSelection(page_number=1, text="This page contains only a shipping address.")],
)
],
file_names=["invoice.pdf"],
)
)
+13 -6
View File
@@ -20,9 +20,9 @@ from stirling.contracts import (
EditCannotDoResponse,
OrchestratorRequest,
PdfEditRequest,
PdfQuestionNeedContentResponse,
PdfQuestionNotFoundResponse,
PdfQuestionRequest,
UnsupportedCapabilityResponse,
)
from stirling.models.tool_models import RotateParams
@@ -38,8 +38,8 @@ class StubSettingsProvider:
class StubOrchestratorAgent:
async def handle(self, request: OrchestratorRequest) -> UnsupportedCapabilityResponse:
return UnsupportedCapabilityResponse(capability="pdf_edit", message=request.user_message)
async def handle(self, request: OrchestratorRequest) -> PdfQuestionNeedContentResponse:
return PdfQuestionNeedContentResponse(reason=request.user_message, files=[], max_pages=1, max_characters=1000)
class StubPdfEditAgent:
@@ -115,10 +115,10 @@ def test_health_route() -> None:
def test_orchestrator_route() -> None:
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this"})
response = client.post("/api/v1/orchestrator", json={"userMessage": "route this", "fileNames": ["test.pdf"]})
assert response.status_code == 200
assert response.json()["outcome"] == "unsupported_capability"
assert response.json()["outcome"] == "need_content"
def test_pdf_edit_route() -> None:
@@ -129,7 +129,14 @@ def test_pdf_edit_route() -> None:
def test_pdf_questions_route() -> None:
response = client.post("/api/v1/pdf/questions", json={"question": "what is this?"})
response = client.post(
"/api/v1/pdf/questions",
json={
"question": "what is this?",
"fileNames": ["test.pdf"],
"pageText": [{"fileName": "test.pdf", "pages": [{"pageNumber": 1, "text": "Example"}]}],
},
)
assert response.status_code == 200
assert response.json()["outcome"] == "not_found"
+18 -1
View File
@@ -9,17 +9,34 @@ from stirling.contracts import (
AgentSpecStep,
EditPlanResponse,
ExecutionContext,
ExtractedFileText,
ExtractedTextArtifact,
OrchestratorRequest,
PdfQuestionAnswerResponse,
PdfTextSelection,
ToolOperationStep,
)
from stirling.models.tool_models import OperationId, RotateParams
def test_orchestrator_request_accepts_user_message() -> None:
request = OrchestratorRequest(user_message="Rotate the PDF")
request = OrchestratorRequest(
user_message="Rotate the PDF",
file_names=["test.pdf"],
artifacts=[
ExtractedTextArtifact(
files=[
ExtractedFileText(
file_name="test.pdf",
pages=[PdfTextSelection(page_number=1, text="Hello")],
)
]
)
],
)
assert request.user_message == "Rotate the PDF"
assert len(request.artifacts) == 1
def test_agent_execution_request_uses_typed_agent_spec() -> None:
+103 -103
View File
@@ -37,7 +37,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.3"
version = "3.13.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -48,59 +48,59 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
]
[[package]]
@@ -135,7 +135,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.86.0"
version = "0.93.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -147,9 +147,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/70/2429d6f7c2516db99fb342c3ad89575ab3e0cd31d3d2f6cba5fdf5e9c65b/anthropic-0.93.0.tar.gz", hash = "sha256:fea8376f7d5cdf99d5e8e85a48fe7a7bd8ab307cdfee4b1e8283a18b1c0ce1b5", size = 654155, upload-time = "2026-04-09T18:13:53.522Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" },
{ url = "https://files.pythonhosted.org/packages/c9/7b/5b2c11902707c49c7a99418eb027ed3eb63876193fee5c80b5c878e3a673/anthropic-0.93.0-py3-none-any.whl", hash = "sha256:2c20b2ce6d305564c66a6cbaedddee8efdd3b9753098bf314093fcf4c662d04c", size = 627482, upload-time = "2026-04-09T18:13:51.606Z" },
]
[[package]]
@@ -410,55 +410,55 @@ wheels = [
[[package]]
name = "cryptography"
version = "46.0.5"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
@@ -636,7 +636,7 @@ wheels = [
[[package]]
name = "fastmcp"
version = "3.1.1"
version = "3.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
@@ -661,9 +661,9 @@ dependencies = [
{ name = "watchfiles" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644, upload-time = "2026-03-14T19:12:20.235Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/42/7eed0a38e3b7a386805fecacf8a5a9353a2b3040395ef9e30e585d8549ac/fastmcp-3.2.3.tar.gz", hash = "sha256:4f02ae8b00227285a0cf6544dea1db29b022c8cdd8d3dfdec7118540210ae60a", size = 26328743, upload-time = "2026-04-09T22:05:03.402Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754, upload-time = "2026-03-14T19:12:22.736Z" },
{ url = "https://files.pythonhosted.org/packages/f5/48/84b6dcba793178a44b9d99b4def6cd62f870dcfc5bb7b9153ac390135812/fastmcp-3.2.3-py3-none-any.whl", hash = "sha256:cc50af6eed1f62ed8b6ebf4987286d8d1d006f08d5bec739d5c7fb76160e0911", size = 707260, upload-time = "2026-04-09T22:05:01.225Z" },
]
[[package]]
+10
View File
@@ -0,0 +1,10 @@
dist/
node_modules/
public/vendor/
public/pdfjs*/
public/js/thirdParty/
public/css/cookieconsent.css
*.min.*
*.md
*.wxs
src/output.css
+38 -47
View File
@@ -1,62 +1,52 @@
// @ts-check
import eslint from '@eslint/js';
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import eslint from "@eslint/js";
import globals from "globals";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
const srcGlobs = ["src/**/*.{js,mjs,jsx,ts,tsx}"];
const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"];
const baseRestrictedImportPatterns = [
{ regex: '^\\.', message: "Use @app/* imports instead of relative imports." },
{ regex: '^src/', message: "Use @app/* imports instead of absolute src/ imports." },
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
{ regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." },
];
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
ignores: [
'dist',
'node_modules',
'public',
'src-tauri',
],
ignores: ["dist", "node_modules", "public", "src-tauri"],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
'no-restricted-imports': [
'error',
"no-restricted-imports": [
"error",
{
patterns: baseRestrictedImportPatterns,
},
],
'@typescript-eslint/no-empty-object-type': [
'error',
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
allowInterfaces: "with-single-extends",
},
],
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-unused-vars': [
'error',
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
'args': 'all', // All function args must be used (or explicitly ignored)
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
args: "all", // All function args must be used (or explicitly ignored)
argsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
caughtErrors: "all", // Caught errors must be used (or explicitly ignored)
caughtErrorsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
destructuredArrayIgnorePattern: "^_", // Allow unused variables beginning with an underscore
varsIgnorePattern: "^_", // Allow unused variables beginning with an underscore
ignoreRestSiblings: true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
@@ -65,15 +55,15 @@ export default defineConfig(
// Use the stub/shadow pattern instead: define a stub in src/core/ and override in src/desktop/.
{
files: srcGlobs,
ignores: ['src/desktop/**'],
ignores: ["src/desktop/**"],
rules: {
'no-restricted-imports': [
'error',
"no-restricted-imports": [
"error",
{
patterns: [
...baseRestrictedImportPatterns,
{
regex: '^@tauri-apps/',
regex: "^@tauri-apps/",
message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
},
],
@@ -84,8 +74,9 @@ export default defineConfig(
// Folders that have been cleaned up and are now conformant - stricter rules enforced here
{
files: [
'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}',
'src/saas/**/*.{js,mjs,jsx,ts,tsx}',
"src/proprietary/**/*.{js,mjs,jsx,ts,tsx}",
"src/saas/**/*.{js,mjs,jsx,ts,tsx}",
"src/prototypes/**/*.{js,mjs,jsx,ts,tsx}",
],
languageOptions: {
parserOptions: {
@@ -94,8 +85,8 @@ export default defineConfig(
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
},
},
// Config for browser scripts
@@ -104,8 +95,8 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.browser,
}
}
},
},
},
// Config for node scripts
{
@@ -113,7 +104,7 @@ export default defineConfig(
languageOptions: {
globals: {
...globals.node,
}
}
},
},
},
);
+2 -5
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
@@ -6,10 +6,7 @@
<link rel="icon" href="modern-logo/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/>
<meta name="description" content="The Free Adobe Acrobat alternative (10M+ Downloads)" />
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
<link rel="manifest" href="manifest.json" />
+32 -5
View File
@@ -59,7 +59,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@userback/widget": "^0.3.12",
"autoprefixer": "^10.4.21",
"axios": "^1.13.2",
"axios": "^1.15.0",
"d3": "^7.9.0",
"globals": "^17.1.0",
"i18next": "^25.5.2",
@@ -114,6 +114,7 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"puppeteer": "^24.25.0",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
@@ -5841,14 +5842,23 @@
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/axobject-query": {
@@ -11279,6 +11289,22 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -11431,6 +11457,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/pump": {
+10 -2
View File
@@ -55,7 +55,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@userback/widget": "^0.3.12",
"autoprefixer": "^10.4.21",
"axios": "^1.13.2",
"axios": "^1.15.0",
"d3": "^7.9.0",
"globals": "^17.1.0",
"i18next": "^25.5.2",
@@ -88,14 +88,20 @@
"dev:proprietary": "npm run prep && vite --mode proprietary",
"dev:saas": "npm run prep:saas && vite --mode saas",
"dev:desktop": "npm run prep:desktop && vite --mode desktop",
"dev:prototypes": "npm run prep && vite --mode prototypes",
"fix": "npm run format && npm run lint:fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "npm run lint:eslint && npm run lint:cycles",
"lint:eslint": "eslint --max-warnings=0",
"lint:fix": "eslint --fix",
"lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1",
"build": "npm run prep && vite build",
"build:core": "npm run prep && vite build --mode core",
"build:proprietary": "npm run prep && vite build --mode proprietary",
"build:saas": "npm run prep:saas && vite build --mode saas",
"build:desktop": "npm run prep:desktop && vite build --mode desktop",
"build:prototypes": "npm run prep && vite build --mode prototypes",
"preview": "vite preview",
"tauri-dev": "npm run prep:desktop && tauri dev --no-watch",
"tauri-build": "npm run prep:desktop-build && tauri build",
@@ -110,8 +116,9 @@
"typecheck:proprietary": "tsc --noEmit --project src/proprietary/tsconfig.json",
"typecheck:saas": "tsc --noEmit --project src/saas/tsconfig.json",
"typecheck:desktop": "tsc --noEmit --project src/desktop/tsconfig.json",
"typecheck:prototypes": "tsc --noEmit --project src/prototypes/tsconfig.json",
"typecheck:scripts": "tsc --noEmit --project scripts/tsconfig.json",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:scripts",
"typecheck:all": "npm run typecheck:core && npm run typecheck:proprietary && npm run typecheck:saas && npm run typecheck:desktop && npm run typecheck:prototypes && npm run typecheck:scripts",
"check": "npm run typecheck && npm run lint && npm run test:run",
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
@@ -173,6 +180,7 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1",
"puppeteer": "^24.25.0",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
+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,
},
});
});
+1 -4
View File
@@ -1,6 +1,3 @@
module.exports = {
plugins: [
require('@tailwindcss/postcss'),
require('autoprefixer'),
],
plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
};
+120 -121
View File
@@ -1,206 +1,205 @@
/* Light theme variables */
:root {
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-btn-primary-bg: #007BFF;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007BFF;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-primary-bg: #007bff;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007bff;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007BFF;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007BFF;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007bff;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007bff;
--cc-separator-border-color: #e0e0e0;
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007BFF;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-on-bg: #007bff;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747E;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747e;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-section-category-border: #e0e0e0;
--cc-section-category-border: #e0e0e0;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
}
/* Dark theme variables */
.cc--darkmode{
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
.cc--darkmode {
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: #555555;
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-section-category-border: #555555;
--cc-section-category-border: #555555;
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
}
.cm__body{
max-width: 90% !important;
flex-direction: row !important;
align-items: center !important;
.cm__body {
max-width: 90% !important;
flex-direction: row !important;
align-items: center !important;
}
.cm__desc{
max-width: 70rem !important;
.cm__desc {
max-width: 70rem !important;
}
.cm__btns{
flex-direction: row-reverse !important;
gap:10px !important;
padding-top: 3.4rem !important;
.cm__btns {
flex-direction: row-reverse !important;
gap: 10px !important;
padding-top: 3.4rem !important;
}
@media only screen and (max-width: 1400px) {
.cm__body{
max-width: 90% !important;
flex-direction: column !important;
align-items: normal !important;
}
.cm__body {
max-width: 90% !important;
flex-direction: column !important;
align-items: normal !important;
}
.cm__btns{
padding-top: 1rem !important;
}
.cm__btns {
padding-top: 1rem !important;
}
}
/* Toggle visibility fixes */
#cc-main .section__toggle {
opacity: 0 !important; /* Keep invisible but functional */
opacity: 0 !important; /* Keep invisible but functional */
}
#cc-main .toggle__icon {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
}
#cc-main .toggle__icon-circle {
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
}
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
}
/* Ensure toggles are visible in both themes */
#cc-main .toggle__icon {
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
}
/* Ensure toggle text is visible */
#cc-main .pm__section-title {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .pm__section-desc {
color: var(--cc-secondary-color) !important;
color: var(--cc-secondary-color) !important;
}
/* Make sure the modal has proper contrast */
#cc-main .pm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
color: var(--cc-primary-color) !important;
}
@@ -44,6 +44,8 @@ downloadPdf = "Download PDF"
downloadUnavailable = "Download unavailable for this item"
edit = "Edit"
editYourNewFiles = "Edit your new file(s)"
encryptedFileBlocked = "File is password-protected. Unlock it first."
encryptedFilesBlocked = "{{count}} files are password-protected. Unlock them first."
exportAndContinue = "Export & Continue"
false = "False"
fileSavedToDisk = "File saved to disk"
@@ -3341,6 +3343,9 @@ successBodyWithName = "Password removed from {{fileName}}"
successTitle = "Password removed"
title = "Remove password to continue"
unlock = "Unlock & Continue"
unlockAll = "Use for all ({{count}})"
unlockAllPartialFail = "Wrong password for: {{names}}"
unlockAllSuccess = "Unlocked {{count}} file(s)."
unlockPrompt = "Unlock PDF to continue"
[encryptedPdfUnlock.password]
@@ -4287,6 +4292,8 @@ welcomeTitle = "You've been invited!"
[landing]
addFiles = "Add Files"
heroSubtitle = "Drop in or add an existing PDF to get started."
heroTitle = "Stirling PDF"
mobileUpload = "Upload from Mobile"
openFromComputer = "Open from computer"
uploadFromComputer = "Upload from computer"
@@ -6638,9 +6645,13 @@ defaultPdfEditorActive = "Stirling PDF is your default PDF editor"
defaultPdfEditorChecking = "Checking..."
defaultPdfEditorInactive = "Another application is set as default"
defaultPdfEditorSet = "Already Default"
defaultStartupView = "Default view on launch"
defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts"
defaultToolPickerMode = "Default tool picker mode"
defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default"
description = "Configure general application preferences."
defaultViewerZoom = "Default reader zoom"
defaultViewerZoomDescription = "Set the default zoom level when opening PDFs in the reader"
hideUnavailableConversions = "Hide unavailable conversions"
hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
hideUnavailableTools = "Hide unavailable tools"
@@ -6663,6 +6674,16 @@ title = "For System Administrators"
fullscreen = "Fullscreen"
sidebar = "Sidebar"
[settings.general.startupView]
automate = "Automate"
read = "Reader"
tools = "Tools"
[settings.general.zoomLevel]
auto = "Auto"
fitPage = "Fit page"
fitWidth = "Fit width"
[settings.general.updates]
checkForUpdates = "Check for Updates"
currentBackendVersion = "Current Backend Version"
-1
View File
@@ -23,4 +23,3 @@
"theme_color": "#000000",
"background_color": "#ffffff"
}
+10 -14
View File
@@ -1,28 +1,24 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
import { join, resolve } from "node:path";
if (process.platform !== 'win32') {
if (process.platform !== "win32") {
process.exit(0);
}
const frontendDir = process.cwd();
const tauriDir = resolve(frontendDir, 'src-tauri');
const provisionerManifest = join(tauriDir, 'provisioner', 'Cargo.toml');
const tauriDir = resolve(frontendDir, "src-tauri");
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
execFileSync(
'cargo',
['build', '--release', '--manifest-path', provisionerManifest],
{ stdio: 'inherit' }
);
execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" });
const provisionerExe = join(tauriDir, 'provisioner', 'target', 'release', 'stirling-provisioner.exe');
const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe");
if (!existsSync(provisionerExe)) {
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
}
const wixDir = join(tauriDir, 'windows', 'wix');
const wixDir = join(tauriDir, "windows", "wix");
mkdirSync(wixDir, { recursive: true });
const destExe = join(wixDir, 'stirling-provision.exe');
const destExe = join(wixDir, "stirling-provision.exe");
copyFileSync(provisionerExe, destExe);
+31 -29
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,18 +118,20 @@ async function main() {
const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json");
let needsRegeneration = true;
if (fs.existsSync(outputPath)) {
try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingSet = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
info(
`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`,
);
}
} catch {
// If we can't parse existing file, regenerate
@@ -138,34 +140,34 @@ async function main() {
}
if (!needsRegeneration) {
info('🎉 No regeneration needed!');
info("🎉 No regeneration needed!");
process.exit(0);
}
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
// Dynamic import of ES module
const { getIcons } = await import('@iconify/utils');
const { getIcons } = await import("@iconify/utils");
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
if (!extractedIcons) {
console.error('❌ Failed to extract icons');
console.error("❌ Failed to extract icons");
process.exit(1);
}
// Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon));
if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`);
info("💡 These icons don't exist in Material Symbols. Please use available alternatives.");
}
// Create output directory
const outputDir = path.join(__dirname, '..', 'src', 'assets');
const outputDir = path.join(__dirname, "..", "src", "assets");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
@@ -182,7 +184,7 @@ async function main() {
// This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten
export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')};
export type MaterialSymbolIcon = ${usedIcons.map((icon) => `'${icon}'`).join(" | ")};
export interface IconSet {
prefix: string;
@@ -196,7 +198,7 @@ declare const iconSet: IconSet;
export default iconSet;
`;
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
const typesPath = path.join(outputDir, "material-symbols-icons.d.ts");
fs.writeFileSync(typesPath, typesContent);
info(`📝 Generated types: ${typesPath}`);
@@ -204,7 +206,7 @@ export default iconSet;
}
// Run the main function
main().catch(error => {
console.error('❌ Script failed:', error);
main().catch((error) => {
console.error("❌ Script failed:", error);
process.exit(1);
});
+396 -370
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,408 +16,434 @@ const POSTPROCESS_ONLY = !!INPUT_FILE;
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'assets', '3rdPartyLicenses.json');
const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json");
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
mkdirSync(outputDir, { recursive: true });
}
console.log('🔍 Generating frontend license report...');
console.log("🔍 Generating frontend license report...");
try {
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
process.exit(2);
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
console.error("Fork PR detected: only --input (postprocess-only) mode is allowed.");
process.exit(2);
}
let licenseData;
// Generate license report using pinned license-checker; disable lifecycle scripts
if (POSTPROCESS_ONLY) {
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
console.error("❌ --input file missing or not found");
process.exit(1);
}
let licenseData;
// Generate license report using pinned license-checker; disable lifecycle scripts
if (POSTPROCESS_ONLY) {
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
console.error('❌ --input file missing or not found');
process.exit(1);
}
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
} else {
const licenseReport = execSync(
// 'npx --yes license-checker@25.0.1 --production --json',
'npx --yes license-report --only=prod --output=json',
{
encoding: 'utf8',
cwd: path.dirname(PACKAGE_JSON),
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
}
);
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error('❌ Failed to parse license data:', parseError.message);
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
process.exit(1);
}
}
if (!Array.isArray(licenseData)) {
console.error('❌ Invalid license data structure');
process.exit(1);
}
// Convert license-checker format to array
const licenseArray = licenseData.map(dep => {
let licenseType = dep.licenseType;
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
licenseType = 'Unknown';
}
// Handle empty string licenses
if (licenseType === '') {
licenseType = 'Unknown';
}
// Handle array licenses (rare but possible)
if (Array.isArray(licenseType)) {
licenseType = licenseType.join(' AND ');
}
// Handle object licenses (fallback)
if (typeof licenseType === 'object' && licenseType !== null) {
licenseType = 'Unknown';
}
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
}
return {
name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link
};
});
// Transform to match Java backend format
const transformedData = {
dependencies: licenseArray.map(dep => {
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
const licenseUrl = dep.link || getLicenseUrl(licenseType);
return {
moduleName: dep.name,
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
moduleVersion: dep.version,
moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl
};
})
};
// Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => {
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
acc[license] = (acc[license] || 0) + 1;
return acc;
}, {});
console.log('📊 License types found:');
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// Log any complex or unusual license formats for debugging
const complexLicenses = licenseArray.filter(dep =>
dep.licenseType && (
dep.licenseType.includes('AND') ||
dep.licenseType.includes('OR') ||
dep.licenseType === 'Unknown' ||
dep.licenseType.includes('SEE LICENSE')
)
licenseData = JSON.parse(readFileSync(INPUT_FILE, "utf8"));
} else {
const licenseReport = execSync(
// 'npx --yes license-checker@25.0.1 --production --json',
"npx --yes license-report --only=prod --output=json",
{
encoding: "utf8",
cwd: path.dirname(PACKAGE_JSON),
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" },
},
);
if (complexLicenses.length > 0) {
console.log('\n🔍 Complex/Edge case licenses detected:');
complexLicenses.forEach(dep => {
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
});
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error("❌ Failed to parse license data:", parseError.message);
console.error("Raw output:", licenseReport.substring(0, 500) + "...");
process.exit(1);
}
}
// Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
if (problematicLicenses.length > 0) {
console.log('\n⚠️ License compatibility warnings:');
problematicLicenses.forEach(warning => {
console.log(` ${warning.message}`);
});
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
writeFileSync(warningsFile, JSON.stringify({
warnings: problematicLicenses,
generated: new Date().toISOString()
}, null, 2));
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
} else {
console.log('\n✅ All licenses appear to be corporate-friendly');
}
// Write to file
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
console.error('❌ Error generating license report:', error.message);
if (!Array.isArray(licenseData)) {
console.error("❌ Invalid license data structure");
process.exit(1);
}
// Convert license-checker format to array
const licenseArray = licenseData.map((dep) => {
let licenseType = dep.licenseType;
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
licenseType = "Unknown";
}
// Handle empty string licenses
if (licenseType === "") {
licenseType = "Unknown";
}
// Handle array licenses (rare but possible)
if (Array.isArray(licenseType)) {
licenseType = licenseType.join(" AND ");
}
// Handle object licenses (fallback)
if (typeof licenseType === "object" && licenseType !== null) {
licenseType = "Unknown";
}
if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
}
return {
name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown",
licenseType: licenseType,
repository: dep.link,
url: dep.link,
link: dep.link,
};
});
// Transform to match Java backend format
const transformedData = {
dependencies: licenseArray.map((dep) => {
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
const licenseUrl = dep.link || getLicenseUrl(licenseType);
return {
moduleName: dep.name,
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
moduleVersion: dep.version,
moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl,
};
}),
};
// Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => {
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
acc[license] = (acc[license] || 0) + 1;
return acc;
}, {});
console.log("📊 License types found:");
Object.entries(licenseSummary).forEach(([license, count]) => {
console.log(` ${license}: ${count} packages`);
});
// Log any complex or unusual license formats for debugging
const complexLicenses = licenseArray.filter(
(dep) =>
dep.licenseType &&
(dep.licenseType.includes("AND") ||
dep.licenseType.includes("OR") ||
dep.licenseType === "Unknown" ||
dep.licenseType.includes("SEE LICENSE")),
);
if (complexLicenses.length > 0) {
console.log("\n🔍 Complex/Edge case licenses detected:");
complexLicenses.forEach((dep) => {
console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`);
});
}
// Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
if (problematicLicenses.length > 0) {
console.log("\n⚠️ License compatibility warnings:");
problematicLicenses.forEach((warning) => {
console.log(` ${warning.message}`);
});
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json");
writeFileSync(
warningsFile,
JSON.stringify(
{
warnings: problematicLicenses,
generated: new Date().toISOString(),
},
null,
2,
),
);
console.log(`⚠️ License warnings saved to: ${warningsFile}`);
} else {
console.log("\n✅ All licenses appear to be corporate-friendly");
}
// Write to file
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 2) + "\n");
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
} catch (error) {
console.error("❌ Error generating license report:", error.message);
process.exit(1);
}
/**
* Get standard license URLs for common licenses
*/
function getLicenseUrl(licenseType) {
if (!licenseType || licenseType === 'Unknown') return '';
if (!licenseType || licenseType === "Unknown") return "";
const licenseUrls = {
'MIT': 'https://opensource.org/licenses/MIT',
'MIT*': 'https://opensource.org/licenses/MIT',
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause',
'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause',
'BSD': 'https://opensource.org/licenses/BSD-3-Clause',
'GPL-3.0': 'https://www.gnu.org/licenses/gpl-3.0.html',
'GPL-2.0': 'https://www.gnu.org/licenses/gpl-2.0.html',
'LGPL-2.1': 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html',
'LGPL-3.0': 'https://www.gnu.org/licenses/lgpl-3.0.html',
'ISC': 'https://opensource.org/licenses/ISC',
'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/',
'Unlicense': 'https://unlicense.org/',
'MPL-2.0': 'https://www.mozilla.org/en-US/MPL/2.0/',
'WTFPL': 'http://www.wtfpl.net/',
'Zlib': 'https://opensource.org/licenses/Zlib',
'Artistic-2.0': 'https://opensource.org/licenses/Artistic-2.0',
'EPL-1.0': 'https://www.eclipse.org/legal/epl-v10.html',
'EPL-2.0': 'https://www.eclipse.org/legal/epl-2.0/',
'CDDL-1.0': 'https://opensource.org/licenses/CDDL-1.0',
'Ruby': 'https://www.ruby-lang.org/en/about/license.txt',
'Python-2.0': 'https://www.python.org/download/releases/2.0/license/',
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
'UNLICENSED': ''
};
const licenseUrls = {
MIT: "https://opensource.org/licenses/MIT",
"MIT*": "https://opensource.org/licenses/MIT",
"Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0",
"Apache License 2.0": "https://www.apache.org/licenses/LICENSE-2.0",
"BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause",
"BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause",
BSD: "https://opensource.org/licenses/BSD-3-Clause",
"GPL-3.0": "https://www.gnu.org/licenses/gpl-3.0.html",
"GPL-2.0": "https://www.gnu.org/licenses/gpl-2.0.html",
"LGPL-2.1": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html",
"LGPL-3.0": "https://www.gnu.org/licenses/lgpl-3.0.html",
ISC: "https://opensource.org/licenses/ISC",
"CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
Unlicense: "https://unlicense.org/",
"MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/",
WTFPL: "http://www.wtfpl.net/",
Zlib: "https://opensource.org/licenses/Zlib",
"Artistic-2.0": "https://opensource.org/licenses/Artistic-2.0",
"EPL-1.0": "https://www.eclipse.org/legal/epl-v10.html",
"EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/",
"CDDL-1.0": "https://opensource.org/licenses/CDDL-1.0",
Ruby: "https://www.ruby-lang.org/en/about/license.txt",
"Python-2.0": "https://www.python.org/download/releases/2.0/license/",
"Public Domain": "https://creativecommons.org/publicdomain/zero/1.0/",
UNLICENSED: "",
};
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
// Try exact match first
if (licenseUrls[licenseType]) {
return licenseUrls[licenseType];
}
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
}
// Try case-insensitive match
const lowerType = licenseType.toLowerCase();
for (const [key, url] of Object.entries(licenseUrls)) {
if (key.toLowerCase() === lowerType) {
return url;
}
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes("AND") || licenseType.includes("OR")) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}
}
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes('AND') || licenseType.includes('OR')) {
// Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]];
}
}
// For non-standard licenses, return empty string (will use package link if available)
return '';
// For non-standard licenses, return empty string (will use package link if available)
return "";
}
/**
* Check for potentially problematic licenses that may not be MIT/corporate compatible
*/
function checkLicenseCompatibility(licenseSummary, licenseArray) {
const warnings = [];
const warnings = [];
// Define problematic license patterns
const problematicLicenses = {
// Copyleft licenses
'GPL-2.0': 'Strong copyleft license - requires derivative works to be GPL',
'GPL-3.0': 'Strong copyleft license - requires derivative works to be GPL',
'LGPL-2.1': 'Weak copyleft license - may require source disclosure for modifications',
'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications',
'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use',
'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use',
// Define problematic license patterns
const problematicLicenses = {
// Copyleft licenses
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
"LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications",
"LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications",
"AGPL-3.0": "Network copyleft license - requires source disclosure for network use",
"AGPL-1.0": "Network copyleft license - requires source disclosure for network use",
// Other potentially problematic licenses
'WTFPL': 'Potentially problematic license - legal uncertainty',
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
'CC-BY-SA-3.0': 'ShareAlike license - requires derivative works to use same license',
'CC-BY-NC-4.0': 'Non-commercial license - prohibits commercial use',
'CC-BY-NC-3.0': 'Non-commercial license - prohibits commercial use',
'OSL-3.0': 'Copyleft license - requires derivative works to be OSL',
'EPL-1.0': 'Weak copyleft license - may require source disclosure',
'EPL-2.0': 'Weak copyleft license - may require source disclosure',
'CDDL-1.0': 'Weak copyleft license - may require source disclosure',
'CDDL-1.1': 'Weak copyleft license - may require source disclosure',
'CPL-1.0': 'Weak copyleft license - may require source disclosure',
'MPL-1.1': 'Weak copyleft license - may require source disclosure',
'EUPL-1.1': 'Copyleft license - requires derivative works to be EUPL',
'EUPL-1.2': 'Copyleft license - requires derivative works to be EUPL',
'UNLICENSED': 'No license specified - usage rights unclear',
'Unknown': 'License not detected - manual review required'
};
// Other potentially problematic licenses
WTFPL: "Potentially problematic license - legal uncertainty",
"CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license",
"CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license",
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
"EPL-1.0": "Weak copyleft license - may require source disclosure",
"EPL-2.0": "Weak copyleft license - may require source disclosure",
"CDDL-1.0": "Weak copyleft license - may require source disclosure",
"CDDL-1.1": "Weak copyleft license - may require source disclosure",
"CPL-1.0": "Weak copyleft license - may require source disclosure",
"MPL-1.1": "Weak copyleft license - may require source disclosure",
"EUPL-1.1": "Copyleft license - requires derivative works to be EUPL",
"EUPL-1.2": "Copyleft license - requires derivative works to be EUPL",
UNLICENSED: "No license specified - usage rights unclear",
Unknown: "License not detected - manual review required",
};
// Known good licenses (no warnings needed)
const goodLicenses = new Set([
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
]);
// Known good licenses (no warnings needed)
const goodLicenses = new Set([
"MIT",
"MIT*",
"Apache-2.0",
"Apache License 2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"BSD",
"ISC",
"CC0-1.0",
"Public Domain",
"Unlicense",
"0BSD",
"BlueOak-1.0.0",
"Zlib",
"Artistic-2.0",
"Python-2.0",
"Ruby",
"MPL-2.0",
"CC-BY-4.0",
"SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
]);
// Helper function to normalize license names for comparison
function normalizeLicense(license) {
return license
.replace(/-or-later$/, '') // Remove -or-later suffix
.replace(/\+$/, '') // Remove + suffix
.trim();
// Helper function to normalize license names for comparison
function normalizeLicense(license) {
return license
.replace(/-or-later$/, "") // Remove -or-later suffix
.replace(/\+$/, "") // Remove + suffix
.trim();
}
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
}
// Check each license type
Object.entries(licenseSummary).forEach(([license, count]) => {
// Skip known good licenses
if (goodLicenses.has(license)) {
return;
}
// Check if this license only affects our own packages
const affectedPackages = licenseArray.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
});
const isOnlyOurPackages = affectedPackages.every(dep =>
dep.name === 'frontend' ||
dep.name.toLowerCase().includes('stirling-pdf') ||
dep.name.toLowerCase().includes('stirling_pdf') ||
dep.name.toLowerCase().includes('stirlingpdf')
);
if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) {
return; // Skip warnings for our own Stirling-PDF packages
}
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (license.includes('AND') || license.includes('OR')) {
// For OR licenses, check if there's at least one acceptable license option
if (license.includes('OR')) {
// Extract license components from OR expression
const orComponents = license
.replace(/[()]/g, '') // Remove parentheses
.split(' OR ')
.map(component => component.trim());
// Check if any component is in the goodLicenses set (with normalization)
const hasGoodLicense = orComponents.some(component => {
const normalized = normalizeLicense(component);
return goodLicenses.has(component) || goodLicenses.has(normalized);
});
if (hasGoodLicense) {
return; // Skip warning - can use the good license option
}
}
// For AND licenses or OR licenses with no good options, check for problematic components
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
license.includes(problematic)
);
if (hasProblematicComponent) {
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
const licenseType = license.includes('AND') ? 'AND' : 'OR';
const reason = licenseType === 'AND'
? 'Compound license with AND requirement - all components must be compatible'
: 'Compound license with potentially problematic components and no good fallback options';
warnings.push({
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
licenseType: license,
licenseUrl: '',
reason: reason,
packageCount: count,
affectedDependencies: affectedPackages
});
}
return;
}
// Check for exact matches with problematic licenses
if (problematicLicenses[license]) {
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : '');
const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses';
warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
licenseType: license,
licenseUrl: licenseUrl,
reason: problematicLicenses[license],
packageCount: count,
affectedDependencies: affectedPackages
});
} else {
// Unknown license type - flag for manual review
const affectedPackages = licenseArray
.filter(dep => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
return depLicense === license;
})
.map(dep => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
}));
warnings.push({
message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`,
licenseType: license,
licenseUrl: '',
reason: 'Unknown license type',
packageCount: count,
affectedDependencies: affectedPackages
});
}
// Check if this license only affects our own packages
const affectedPackages = licenseArray.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
});
return warnings;
const isOnlyOurPackages = affectedPackages.every(
(dep) =>
dep.name === "frontend" ||
dep.name.toLowerCase().includes("stirling-pdf") ||
dep.name.toLowerCase().includes("stirling_pdf") ||
dep.name.toLowerCase().includes("stirlingpdf"),
);
if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) {
return; // Skip warnings for our own Stirling-PDF packages
}
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (license.includes("AND") || license.includes("OR")) {
// For OR licenses, check if there's at least one acceptable license option
if (license.includes("OR")) {
// Extract license components from OR expression
const orComponents = license
.replace(/[()]/g, "") // Remove parentheses
.split(" OR ")
.map((component) => component.trim());
// Check if any component is in the goodLicenses set (with normalization)
const hasGoodLicense = orComponents.some((component) => {
const normalized = normalizeLicense(component);
return goodLicenses.has(component) || goodLicenses.has(normalized);
});
if (hasGoodLicense) {
return; // Skip warning - can use the good license option
}
}
// For AND licenses or OR licenses with no good options, check for problematic components
const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic));
if (hasProblematicComponent) {
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
}));
const licenseType = license.includes("AND") ? "AND" : "OR";
const reason =
licenseType === "AND"
? "Compound license with AND requirement - all components must be compatible"
: "Compound license with potentially problematic components and no good fallback options";
warnings.push({
message: `📋 This PR contains ${count} package${count > 1 ? "s" : ""} with compound license "${license}" - manual review recommended`,
licenseType: license,
licenseUrl: "",
reason: reason,
packageCount: count,
affectedDependencies: affectedPackages,
});
}
return;
}
// Check for exact matches with problematic licenses
if (problematicLicenses[license]) {
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
}));
const packageList =
affectedPackages
.map((pkg) => pkg.name)
.slice(0, 5)
.join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : "");
const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses";
warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
licenseType: license,
licenseUrl: licenseUrl,
reason: problematicLicenses[license],
packageCount: count,
affectedDependencies: affectedPackages,
});
} else {
// Unknown license type - flag for manual review
const affectedPackages = licenseArray
.filter((dep) => {
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
return depLicense === license;
})
.map((dep) => ({
name: dep.name,
version: dep.version,
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
}));
warnings.push({
message: `❓ This PR contains ${count} package${count > 1 ? "s" : ""} with unknown license type "${license}" - manual review required`,
licenseType: license,
licenseUrl: "",
reason: "Unknown license type",
packageCount: count,
affectedDependencies: affectedPackages,
});
}
});
return warnings;
}
+25 -26
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,66 +40,65 @@ async function generatePDF() {
let browser;
try {
// Launch Puppeteer
console.log('🌐 Launching browser...');
console.log("🌐 Launching browser...");
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
// Set viewport to match A4 proportions
await page.setViewport({
width: 794, // A4 width in pixels at 96 DPI
width: 794, // A4 width in pixels at 96 DPI
height: 1123, // A4 height in pixels at 96 DPI
deviceScaleFactor: 2 // Higher quality rendering
deviceScaleFactor: 2, // Higher quality rendering
});
// Navigate to the template file
const fileUrl = `file://${TEMPLATE_PATH}`;
console.log('📖 Loading HTML template...');
console.log("📖 Loading HTML template...");
await page.goto(fileUrl, {
waitUntil: 'networkidle0' // Wait for all resources to load
waitUntil: "networkidle0", // Wait for all resources to load
});
// Generate PDF with A4 dimensions
console.log('📝 Generating PDF...');
console.log("📝 Generating PDF...");
await page.pdf({
path: OUTPUT_PATH,
format: 'A4',
format: "A4",
printBackground: true,
margin: {
top: 0,
right: 0,
bottom: 0,
left: 0
left: 0,
},
preferCSSPageSize: true
preferCSSPageSize: true,
});
console.log('\n✅ PDF generated successfully!');
console.log("\n✅ PDF generated successfully!");
console.log(`📦 Output: ${OUTPUT_PATH}`);
// Get file size
const stats = statSync(OUTPUT_PATH);
const fileSizeInKB = (stats.size / 1024).toFixed(2);
console.log(`📊 File size: ${fileSizeInKB} KB`);
} catch (error) {
console.error('\n❌ Error generating PDF:', error.message);
console.error("\n❌ Error generating PDF:", error.message);
process.exit(1);
} finally {
if (browser) {
await browser.close();
console.log('🔒 Browser closed.');
console.log("🔒 Browser closed.");
}
}
console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n');
console.log("\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n");
}
// Run the generator
generatePDF().catch(error => {
console.error('Fatal error:', error);
generatePDF().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+3 -2
View File
@@ -20,8 +20,9 @@
--color-white: #ffffff;
/* Font Stack */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
--font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
}
* {
+210 -200
View File
@@ -1,234 +1,244 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stirling PDF - Sample Document</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="">
</div>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stirling PDF - Sample Document</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
</div>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
<div class="stat-badge">
<span class="stat-number">10M+</span>
<span class="stat-label">Downloads</span>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
</div>
</div>
<div class="hero-features">
<div class="feature-pill">Open Source</div>
<div class="feature-pill">Privacy First</div>
<div class="feature-pill">Self-Hosted</div>
</div>
</div>
</div>
<!-- Page 2: What is Stirling PDF -->
<div class="page page-2">
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool.
It enables you to carry out various operations on PDF files, including splitting,
merging, converting, rearranging, adding images, rotating, compressing, and more.
</p>
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
<div class="stat-badge">
<span class="stat-number">10M+</span>
<span class="stat-label">Downloads</span>
</div>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
</svg>
</div>
<h3>Workflow Automation</h3>
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h3>Multi-Language Support</h3>
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<h3>Privacy First</h3>
<p>Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Open Source</h3>
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
<div class="hero-features">
<div class="feature-pill">Open Source</div>
<div class="feature-pill">Privacy First</div>
<div class="feature-pill">Self-Hosted</div>
</div>
</div>
</div>
</div>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<!-- Page 2: What is Stirling PDF -->
<div class="page page-2">
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more.
</p>
<div class="features-grid">
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
</div>
<h3>Page Operations</h3>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
</div>
<ul class="feature-list">
<li>Merge & split PDFs</li>
<li>Rearrange pages</li>
<li>Rotate & crop</li>
<li>Extract pages</li>
<li>Multi-page layout</li>
</ul>
</div>
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
/>
</svg>
</div>
<h3>Workflow Automation</h3>
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h3>Security & Signing</h3>
<h3>Multi-Language Support</h3>
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
</div>
<ul class="feature-list">
<li>Password protection</li>
<li>Digital signatures</li>
<li>Watermarks</li>
<li>Permission controls</li>
<li>Redaction tools</li>
</ul>
</div>
<div class="feature-card" data-category="formatting">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z" />
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<h3>File Conversions</h3>
<h3>Privacy First</h3>
<p>
Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.
</p>
</div>
<ul class="feature-list">
<li>PDF to/from images</li>
<li>Office documents</li>
<li>HTML to PDF</li>
<li>Markdown to PDF</li>
<li>PDF to Word/Excel</li>
</ul>
</div>
<div class="feature-card" data-category="automation">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Automation</h3>
<h3>Open Source</h3>
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
</div>
<ul class="feature-list">
<li>Multi-step workflows</li>
<li>Chain PDF operations</li>
<li>Save recurring tasks</li>
<li>Batch file processing</li>
<li>API integration</li>
</ul>
</div>
</div>
<div class="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8" />
</svg>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
</div>
<h3>Plus Many More</h3>
</div>
<div class="additional-features-grid">
<ul>
<li>OCR text recognition</li>
<li>Compress PDFs</li>
<li>Add images & stamps</li>
<li>Detect blank pages</li>
<li>Extract images</li>
<li>Edit metadata</li>
</ul>
<ul>
<li>Flatten forms</li>
<li>PDF/A conversion</li>
<li>Add page numbers</li>
<li>Remove pages</li>
<li>Repair PDFs</li>
<li>And 40+ more tools</li>
</ul>
</div>
</div>
</div>
</div>
</body>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<div class="features-grid">
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<h3>Page Operations</h3>
</div>
<ul class="feature-list">
<li>Merge & split PDFs</li>
<li>Rearrange pages</li>
<li>Rotate & crop</li>
<li>Extract pages</li>
<li>Multi-page layout</li>
</ul>
</div>
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3>Security & Signing</h3>
</div>
<ul class="feature-list">
<li>Password protection</li>
<li>Digital signatures</li>
<li>Watermarks</li>
<li>Permission controls</li>
<li>Redaction tools</li>
</ul>
</div>
<div class="feature-card" data-category="formatting">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z"
/>
</svg>
</div>
<h3>File Conversions</h3>
</div>
<ul class="feature-list">
<li>PDF to/from images</li>
<li>Office documents</li>
<li>HTML to PDF</li>
<li>Markdown to PDF</li>
<li>PDF to Word/Excel</li>
</ul>
</div>
<div class="feature-card" data-category="automation">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2"
/>
</svg>
</div>
<h3>Automation</h3>
</div>
<ul class="feature-list">
<li>Multi-step workflows</li>
<li>Chain PDF operations</li>
<li>Save recurring tasks</li>
<li>Batch file processing</li>
<li>API integration</li>
</ul>
</div>
</div>
<div class="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8"
/>
</svg>
</div>
<h3>Plus Many More</h3>
</div>
<div class="additional-features-grid">
<ul>
<li>OCR text recognition</li>
<li>Compress PDFs</li>
<li>Add images & stamps</li>
<li>Detect blank pages</li>
<li>Extract images</li>
<li>Edit metadata</li>
</ul>
<ul>
<li>Flatten forms</li>
<li>PDF/A conversion</li>
<li>Add page numbers</li>
<li>Remove pages</li>
<li>Repair PDFs</li>
<li>And 40+ more tools</li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>
+20 -21
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,29 +59,28 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
}
let failed = false;
failed = ensureEnvFile('.env', 'config/.env.example') || failed;
failed = ensureEnvFile(".env", "config/.env.example") || failed;
if (isDesktop) {
failed = ensureEnvFile('.env.desktop', 'config/.env.desktop.example') || failed;
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
}
if (isSaas) {
failed = ensureEnvFile('.env.saas', 'config/.env.saas.example') || failed;
failed = ensureEnvFile(".env.saas", "config/.env.saas.example") || failed;
}
// Warn about any VITE_ vars set in the environment that aren't listed in any example file.
const allExampleKeys = new Set([
...getExampleKeys('config/.env.example'),
...getExampleKeys('config/.env.desktop.example'),
...getExampleKeys('config/.env.saas.example'),
...getExampleKeys("config/.env.example"),
...getExampleKeys("config/.env.desktop.example"),
...getExampleKeys("config/.env.saas.example"),
]);
const unknownViteVars = Object.keys(process.env)
.filter(k => k.startsWith('VITE_') && !allExampleKeys.has(k));
const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k));
if (unknownViteVars.length > 0) {
console.warn(
'setup-env: the following VITE_ vars are set but not listed in any example file:\n' +
unknownViteVars.map(k => ` ${k}`).join('\n') +
'\n Add them to the appropriate config/.env.*.example file if they are required.'
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
unknownViteVars.map((k) => ` ${k}`).join("\n") +
"\n Add them to the appropriate config/.env.*.example file if they are required.",
);
}
+1 -3
View File
@@ -2,9 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
+77 -93
View File
@@ -1,98 +1,82 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.9.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop"
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.9.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop"
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
}
]
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": ["deb", "rpm", "dmg", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": ["libs/*.jar", "runtime/jre/**/*"],
"fileAssociations": [
{
"ext": ["pdf"],
"name": "PDF Document",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false,
"additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature"
}
]
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"wix": {
"fragmentPaths": ["windows/wix/provisioning.wxs"],
"componentGroupRefs": ["ProvisioningComponentGroup"]
}
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": [
"deb",
"rpm",
"dmg",
"msi"
],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": [
"pdf"
],
"name": "PDF Document",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com",
"wix": {
"fragmentPaths": [
"windows/wix/provisioning.wxs"
],
"componentGroupRefs": [
"ProvisioningComponentGroup"
]
}
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null,
"infoPlist": "Info.plist"
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": [
"stirlingpdf"
]
}
}
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null,
"infoPlist": "Info.plist"
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": ["stirlingpdf"]
}
}
}
}
+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"
}
]
}
+1 -3
View File
@@ -21,9 +21,7 @@ import "@app/utils/fileIdSafety";
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
</PreferencesProvider>
);
}
+5 -7
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,11 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
</div>
<NavigationWarningModal />
</>
+44 -44
View File
@@ -8,7 +8,12 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext";
import {
AppConfigProvider,
AppConfigProviderProps,
AppConfigRetryOptions,
useAppConfig,
} from "@app/contexts/AppConfigContext";
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
@@ -20,8 +25,8 @@ import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
@@ -41,14 +46,14 @@ function BrandingAssetManager() {
const { favicon, logo192, manifestHref } = useLogoAssets();
useEffect(() => {
if (typeof document === 'undefined') {
if (typeof document === "undefined") {
return;
}
const setLinkHref = (selector: string, href: string) => {
const link = document.querySelector<HTMLLinkElement>(selector);
if (link && link.getAttribute('href') !== href) {
link.setAttribute('href', href);
if (link && link.getAttribute("href") !== href) {
link.setAttribute("href", href);
}
};
@@ -62,7 +67,7 @@ function BrandingAssetManager() {
}
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, 'children' | 'retryOptions'>;
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, "children" | "retryOptions">;
export interface AppProvidersProps {
children: ReactNode;
@@ -98,49 +103,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
+88 -74
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,47 +32,59 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { fileIds: activeFileIds } = useAllFiles();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
const extension = fileName.split('.').pop()?.toLowerCase();
return selectedTool.supportedFormats.includes(extension || '');
}, [selectedTool?.supportedFormats]);
const isFileSupported = useCallback(
(fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
const extension = fileName.split(".").pop()?.toLowerCase();
return selectedTool.supportedFormats.includes(extension || "");
},
[selectedTool?.supportedFormats],
);
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
try {
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
} catch (error) {
console.error('Failed to process selected files:', error);
}
}, [onRecentFileSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) {
const handleRecentFilesSelected = useCallback(
async (files: StirlingFileStub[]) => {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFileUpload(files);
await refreshRecentFiles();
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
} catch (error) {
console.error('Failed to process dropped files:', error);
console.error("Failed to process selected files:", error);
}
}
}, [onFileUpload, refreshRecentFiles]);
},
[onRecentFileSelect],
);
const handleRemoveFileByIndex = useCallback(async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
}, [handleRemoveFile, recentFiles]);
const handleNewFileUpload = useCallback(
async (files: File[]) => {
if (files.length > 0) {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFileUpload(files);
await refreshRecentFiles();
} catch (error) {
console.error("Failed to process dropped files:", error);
}
}
},
[onFileUpload, refreshRecentFiles],
);
const handleRemoveFileByIndex = useCallback(
async (index: number) => {
await handleRemoveFile(index, recentFiles, setRecentFiles);
},
[handleRemoveFile, recentFiles],
);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
@@ -89,7 +101,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
return () => {
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
console.log("FileManager unmounting - FileContext handles blob URL cleanup");
};
}, []);
@@ -97,7 +109,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Use useMemo to only track Google Drive config changes, not all config updates
const googleDriveBackendConfig = useMemo(
() => extractGoogleDriveBackendConfig(config),
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId]
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId],
);
useEffect(() => {
@@ -105,29 +117,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
src: "https://apis.google.com/js/api.js",
id: "gapi-script",
async: true,
defer: true,
}),
loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
src: "https://accounts.google.com/gsi/client",
id: "gis-script",
async: true,
defer: true,
}),
]).catch((error) => {
console.warn('Failed to preload Google Drive scripts:', error);
console.warn("Failed to preload Google Drive scripts:", error);
});
}
}, [googleDriveBackendConfig]);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
const modalMaxWidth = isMobile ? '100%' : '1200px';
const modalMaxHeight = '1200px';
const modalMinWidth = isMobile ? '320px' : '800px';
const modalHeight = "80vh";
const modalWidth = isMobile ? "100%" : "80vw";
const modalMaxWidth = isMobile ? "100%" : "1200px";
const modalMaxHeight = "1200px";
const modalMinWidth = isMobile ? "320px" : "800px";
return (
<Modal
@@ -141,23 +153,25 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: 'relative',
margin: isMobile ? '1rem' : '2rem'
position: "relative",
margin: isMobile ? "1rem" : "2rem",
},
body: { padding: 0 },
header: { display: 'none' }
header: { display: "none" },
}}
>
<div style={{
position: 'relative',
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: '0 auto',
overflow: 'hidden'
}}>
<div
style={{
position: "relative",
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: "0 auto",
overflow: "hidden",
}}
>
<Dropzone
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
@@ -165,14 +179,14 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
multiple={true}
activateOnClick={false}
style={{
height: '100%',
width: '100%',
border: 'none',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-file-manager)'
height: "100%",
width: "100%",
border: "none",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--bg-file-manager)",
}}
styles={{
inner: { pointerEvents: 'all' }
inner: { pointerEvents: "all" },
}}
>
<FileManagerProvider
@@ -14,12 +14,7 @@ interface StorageStatsCardProps {
onReloadFiles: () => void;
}
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
}) => {
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
const { t } = useTranslation();
if (!storageStats) return null;
@@ -59,12 +54,7 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
Reload Files
</Button>
</Group>
@@ -73,4 +63,4 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
);
};
export default StorageStatsCard;
export default StorageStatsCard;
@@ -1,4 +1,4 @@
import React, { createContext, useContext, ReactNode } from 'react';
import React, { createContext, useContext, ReactNode } from "react";
interface PDFAnnotationContextValue {
// Drawing mode management
@@ -58,7 +58,7 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
setSignatureConfig,
}) => {
const contextValue: PDFAnnotationContextValue = {
activateDrawMode,
@@ -72,20 +72,16 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
setSignatureConfig,
};
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
};
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext);
if (context === undefined) {
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider");
}
return context;
};
};
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
import { useSignature } from '@app/contexts/SignatureContext';
import React, { useEffect, useState } from "react";
import { Stack, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { usePDFAnnotation } from "@app/components/annotation/providers/PDFAnnotationProvider";
import { useSignature } from "@app/contexts/SignatureContext";
export interface AnnotationToolConfig {
enableDrawing?: boolean;
@@ -25,17 +25,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
config,
children,
onSignatureDataChange,
disabled = false
disabled = false,
}) => {
const { t } = useTranslation();
const {
activateSignaturePlacementMode,
undo,
redo
} = usePDFAnnotation();
const { activateSignaturePlacementMode, undo, redo } = usePDFAnnotation();
const { historyApiRef } = useSignature();
const [selectedColor, setSelectedColor] = useState('#000000');
const [selectedColor, setSelectedColor] = useState("#000000");
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
@@ -94,14 +90,12 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
signatureData,
onSignatureDataChange: handleSignatureDataChange,
onColorSwatchClick: () => setIsColorPickerOpen(true),
disabled
disabled,
})}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
</Alert>
{/* Color Picker Modal */}
@@ -1,15 +1,15 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
import { useState, useCallback, useEffect } from 'react';
import ColorizeIcon from '@mui/icons-material/Colorize';
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core";
import { useState, useCallback, useEffect } from "react";
import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
// the button is hidden in the UI if the API is not supported.
const supportsEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window;
const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
interface EyeDropper {
open(): Promise<{ sRGBHex: string }>;
}
declare const EyeDropper: { new(): EyeDropper };
declare const EyeDropper: { new (): EyeDropper };
interface ColorControlProps {
value: string;
@@ -24,7 +24,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
// Only propagate to the parent (which triggers expensive annotation updates)
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
const [localColor, setLocalColor] = useState(value);
useEffect(() => { setLocalColor(value); }, [value]);
useEffect(() => {
setLocalColor(value);
}, [value]);
const handleEyeDropper = useCallback(async () => {
if (!supportsEyeDropper) return;
@@ -50,13 +52,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -73,8 +75,16 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
onChange={setLocalColor}
onChangeEnd={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
"#000000",
"#ffffff",
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ffa500",
"transparent",
]}
swatchesPerRow={5}
size="sm"
@@ -82,7 +92,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper} style={{ color: 'var(--text-primary)' }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleEyeDropper}
style={{ color: "var(--text-primary)" }}
>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ColorPickerProps {
isOpen: boolean;
@@ -26,48 +26,42 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
opacityLabel,
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity');
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity");
return (
<Modal
opened={isOpen}
onClose={onClose}
title={resolvedTitle}
size="sm"
centered
>
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
swatchesPerRow={6}
size="lg"
fullWidth
/>
{showOpacity && onOpacityChange && opacity !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>{resolvedOpacityLabel}</Text>
<Text size="sm" fw={500}>
{resolvedOpacityLabel}
</Text>
<Slider
min={10}
max={100}
value={opacity}
onChange={onOpacityChange}
marks={[
{ value: 25, label: '25%' },
{ value: 50, label: '50%' },
{ value: 75, label: '75%' },
{ value: 100, label: '100%' },
{ value: 25, label: "25%" },
{ value: 50, label: "50%" },
{ value: 75, label: "75%" },
{ value: 100, label: "100%" },
]}
/>
</Stack>
)}
<Group justify="flex-end">
<Button onClick={onClose}>
{t('common.done', 'Done')}
</Button>
<Button onClick={onClose}>{t("common.done", "Done")}</Button>
</Group>
</Stack>
</Modal>
@@ -80,18 +74,6 @@ interface ColorSwatchButtonProps {
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: 'pointer' }}
onClick={onClick}
/>
);
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
};
@@ -1,10 +1,10 @@
import React, { useEffect, useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React, { useEffect, useRef, useState } from "react";
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
import SignaturePad from "signature_pad";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface DrawingCanvasProps {
selectedColor: string;
@@ -68,7 +68,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (savedSignatureData) {
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
@@ -92,13 +92,16 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
}, [autoOpen]);
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext('2d');
if (!ctx) return canvas.toDataURL('image/png');
const ctx = canvas.getContext("2d");
if (!ctx) return canvas.toDataURL("image/png");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
let minX = canvas.width,
minY = canvas.height,
maxX = 0,
maxY = 0;
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
@@ -117,21 +120,21 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
const trimHeight = maxY - minY + 1;
// Create trimmed canvas
const trimmedCanvas = document.createElement('canvas');
const trimmedCanvas = document.createElement("canvas");
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext('2d');
const trimmedCtx = trimmedCanvas.getContext("2d");
if (trimmedCtx) {
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
}
return trimmedCanvas.toDataURL('image/png');
return trimmedCanvas.toDataURL("image/png");
};
const renderPreview = (dataUrl: string) => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
@@ -153,7 +156,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
const untrimmedPng = canvas.toDataURL('image/png');
const untrimmedPng = canvas.toDataURL("image/png");
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
onSignatureDataChange(trimmedPng);
renderPreview(trimmedPng);
@@ -176,7 +179,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
const ctx = previewCanvasRef.current.getContext("2d");
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
@@ -209,7 +212,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
useEffect(() => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!initialSignatureData) {
@@ -227,42 +230,45 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<Paper withBorder p="md">
<Stack gap="sm">
<PrivateContent>
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: "1px solid #ccc",
borderRadius: "4px",
cursor: disabled ? "default" : "pointer",
backgroundColor: "#ffffff",
width: "100%",
}}
onClick={disabled ? undefined : openModal}
/>
</PrivateContent>
<Text size="sm" c="dimmed" ta="center">
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
{t("sign.canvas.clickToOpen", "Click to open the drawing canvas")}
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
<Modal
opened={modalOpen}
onClose={closeModal}
title={t("sign.canvas.modalTitle", "Draw your signature")}
size="auto"
centered
>
<Stack gap="md">
<Group gap="lg" align="flex-end" wrap="wrap">
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.colorLabel', 'Colour')}
{t("sign.canvas.colorLabel", "Colour")}
</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.penSizeLabel', 'Pen size')}
{t("sign.canvas.penSizeLabel", "Pen size")}
</Text>
<PenSizeSelector
value={penSize}
@@ -272,9 +278,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
placeholder={t("sign.canvas.penSizePlaceholder", "Size")}
size="compact-sm"
style={{ width: '80px' }}
style={{ width: "80px" }}
/>
</Stack>
</Group>
@@ -286,26 +292,24 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '50rem',
height: '25rem',
cursor: 'crosshair',
border: "1px solid #ccc",
borderRadius: "4px",
display: "block",
touchAction: "none",
backgroundColor: "white",
width: "100%",
maxWidth: "50rem",
height: "25rem",
cursor: "crosshair",
}}
/>
</PrivateContent>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Button variant="subtle" color="red" onClick={clear}>
{t('sign.canvas.clear', 'Clear canvas')}
</Button>
<Button onClick={closeModal}>
{t('common.done', 'Done')}
{t("sign.canvas.clear", "Clear canvas")}
</Button>
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
</div>
</Stack>
</Modal>
@@ -1,7 +1,7 @@
import React from 'react';
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import React from "react";
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LocalIcon } from "@app/components/shared/LocalIcon";
interface DrawingControlsProps {
onUndo?: () => void;
@@ -35,30 +35,30 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
return (
<Group gap="xs" wrap="nowrap" align="center">
{onUndo && (
<Tooltip label={t('sign.undo', 'Undo')}>
<Tooltip label={t("sign.undo", "Undo")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.undo', 'Undo')}
aria-label={t("sign.undo", "Undo")}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? 'gray' : 'blue'}
color={undoDisabled ? "gray" : "blue"}
>
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
</ActionIcon>
</Tooltip>
)}
{onRedo && (
<Tooltip label={t('sign.redo', 'Redo')}>
<Tooltip label={t("sign.redo", "Redo")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.redo', 'Redo')}
aria-label={t("sign.redo", "Redo")}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? 'gray' : 'blue'}
color={redoDisabled ? "gray" : "blue"}
>
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
</ActionIcon>
</Tooltip>
)}
@@ -67,13 +67,7 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
ml="auto"
>
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
{placeButtonText}
</Button>
)}
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { FileInput, Text, Stack, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { removeWhiteBackground } from '@app/utils/imageTransparency';
import { alert } from '@app/components/toast';
import React, { useState } from "react";
import { FileInput, Text, Stack, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { removeWhiteBackground } from "@app/utils/imageTransparency";
import { alert } from "@app/components/toast";
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
@@ -22,7 +22,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
placeholder,
hint,
allowBackgroundRemoval = false,
onProcessedImageData
onProcessedImageData,
}) => {
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false);
@@ -36,15 +36,18 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
try {
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
autoDetectCorner: true,
tolerance: 15
tolerance: 15,
});
onProcessedImageData?.(transparentImageDataUrl);
} catch (error) {
console.error('Error removing background:', error);
console.error("Error removing background:", error);
alert({
title: t('sign.image.backgroundRemovalFailedTitle', 'Background removal failed'),
body: t('sign.image.backgroundRemovalFailedMessage', 'Could not remove the background from the image. Using original image instead.'),
alertType: 'error'
title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"),
body: t(
"sign.image.backgroundRemovalFailedMessage",
"Could not remove the background from the image. Using original image instead.",
),
alertType: "error",
});
onProcessedImageData?.(null);
} finally {
@@ -52,7 +55,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
}
} else {
// When background removal is disabled, return the original image data
if (typeof imageSource === 'string') {
if (typeof imageSource === "string") {
onProcessedImageData?.(imageSource);
} else {
// Convert File to data URL if needed
@@ -69,8 +72,8 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
if (file && !disabled) {
try {
// Validate that it's actually an image file or SVG
if (!file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.svg')) {
console.error('Selected file is not an image or SVG');
if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) {
console.error("Selected file is not an image or SVG");
return;
}
@@ -78,10 +81,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
onImageChange(file);
let dataUrlToProcess: string;
// Check if file is SVG
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg');
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (isSvg) {
// For SVG, convert to PNG so it can be embedded in PDF
dataUrlToProcess = await convertSvgToPng(file);
@@ -98,7 +101,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
setOriginalImageData(dataUrlToProcess);
await processImage(dataUrlToProcess, removeBackground);
} catch (error) {
console.error('Error processing image file:', error);
console.error("Error processing image file:", error);
}
} else if (!file) {
// Clear image data when no file is selected
@@ -116,33 +119,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
reader.onload = async (e) => {
try {
const svgText = e.target?.result as string;
// Parse SVG to get dimensions
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, 'image/svg+xml');
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
const svgElement = svgDoc.documentElement;
// Get SVG dimensions
let width = 800; // Default width
let width = 800; // Default width
let height = 600; // Default height
if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) {
width = parseFloat(svgElement.getAttribute('width') || '800');
height = parseFloat(svgElement.getAttribute('height') || '600');
} else if (svgElement.hasAttribute('viewBox')) {
const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/);
if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) {
width = parseFloat(svgElement.getAttribute("width") || "800");
height = parseFloat(svgElement.getAttribute("height") || "600");
} else if (svgElement.hasAttribute("viewBox")) {
const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+|,/);
if (viewBox && viewBox.length === 4) {
width = parseFloat(viewBox[2]);
height = parseFloat(viewBox[3]);
}
}
// Ensure reasonable dimensions
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
width = 800;
height = 600;
}
// Scale large SVGs down
const maxDimension = 2048;
if (width > maxDimension || height > maxDimension) {
@@ -150,68 +153,73 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
width *= scale;
height *= scale;
}
console.log('Converting SVG to PNG:', { width, height });
console.log("Converting SVG to PNG:", { width, height });
// Create an image element to render SVG
const img = new Image();
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
img.onload = () => {
try {
// Use computed dimensions or image natural dimensions
const finalWidth = img.naturalWidth || img.width || width;
const finalHeight = img.naturalHeight || img.height || height;
console.log('Image loaded:', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, finalWidth, finalHeight });
console.log("Image loaded:", {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
finalWidth,
finalHeight,
});
// Create canvas to convert to PNG
const canvas = document.createElement('canvas');
const canvas = document.createElement("canvas");
canvas.width = finalWidth;
canvas.height = finalHeight;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error('Failed to get canvas context'));
reject(new Error("Failed to get canvas context"));
return;
}
// Fill with white background (optional, for transparency support)
ctx.fillStyle = 'white';
ctx.fillStyle = "white";
ctx.fillRect(0, 0, finalWidth, finalHeight);
// Draw SVG
ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
URL.revokeObjectURL(url);
// Convert canvas to PNG data URL
const pngDataUrl = canvas.toDataURL('image/png');
console.log('SVG converted to PNG successfully');
const pngDataUrl = canvas.toDataURL("image/png");
console.log("SVG converted to PNG successfully");
resolve(pngDataUrl);
} catch (error) {
URL.revokeObjectURL(url);
console.error('Error during canvas rendering:', error);
console.error("Error during canvas rendering:", error);
reject(error);
}
};
img.onerror = (error) => {
URL.revokeObjectURL(url);
console.error('Failed to load SVG image:', error);
reject(new Error('Failed to load SVG image'));
console.error("Failed to load SVG image:", error);
reject(new Error("Failed to load SVG image"));
};
img.src = url;
} catch (error) {
console.error('Error parsing SVG:', error);
console.error("Error parsing SVG:", error);
reject(error);
}
};
reader.onerror = () => {
console.error('Error reading file:', reader.error);
console.error("Error reading file:", reader.error);
reject(reader.error);
};
reader.readAsText(file);
@@ -231,7 +239,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
<PrivateContent>
<FileInput
label={label}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
accept="image/*,.svg"
onChange={handleImageChange}
disabled={disabled || isProcessing}
@@ -239,7 +247,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
</PrivateContent>
{allowBackgroundRemoval && (
<Checkbox
label={t('sign.image.removeBackground', 'Remove white background (make transparent)')}
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
checked={removeBackground}
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
disabled={disabled || !currentFile || isProcessing}
@@ -252,7 +260,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
)}
{isProcessing && (
<Text size="sm" c="dimmed">
{t('sign.image.processing', 'Processing image...')}
{t("sign.image.processing", "Processing image...")}
</Text>
)}
</Stack>
@@ -1,7 +1,7 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import OpacityIcon from '@mui/icons-material/Opacity';
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import OpacityIcon from "@mui/icons-material/Opacity";
interface OpacityControlProps {
value: number; // 0-100
@@ -16,7 +16,7 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.opacity', 'Opacity')}>
<Tooltip label={t("annotation.opacity", "Opacity")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -26,13 +26,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -44,15 +44,9 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.opacity', 'Opacity')}
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
</Stack>
</Popover.Dropdown>
</Popover>
@@ -1,15 +1,15 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch } from '@app/components/viewer/viewerTypes';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
import type { PdfAnnotationObject } from "@embedpdf/models";
import type { AnnotationPatch } from "@app/components/viewer/viewerTypes";
import TuneIcon from "@mui/icons-material/Tune";
import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
export type PropertiesAnnotationType = 'text' | 'note' | 'shape';
export type PropertiesAnnotationType = "text" | "note" | "shape";
interface PropertiesPopoverProps {
annotationType: PropertiesAnnotationType;
@@ -18,12 +18,7 @@ interface PropertiesPopoverProps {
disabled?: boolean;
}
export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
@@ -41,17 +36,17 @@ export function PropertiesPopover({
const fontSize = obj?.fontSize ?? 14;
const textAlign = obj?.textAlign;
const currentAlign =
typeof textAlign === 'number'
typeof textAlign === "number"
? textAlign === 1
? 'center'
? "center"
: textAlign === 2
? 'right'
: 'left'
: textAlign === 'center'
? 'center'
: textAlign === 'right'
? 'right'
: 'left';
? "right"
: "left"
: textAlign === "center"
? "center"
: textAlign === "right"
? "right"
: "left";
// For shapes
const opacity = Math.round((obj?.opacity ?? 1) * 100);
@@ -63,7 +58,7 @@ export function PropertiesPopover({
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.fontSize', 'Font size')}
{t("annotation.fontSize", "Font size")}
</Text>
<Slider
value={fontSize}
@@ -77,7 +72,7 @@ export function PropertiesPopover({
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
@@ -91,25 +86,25 @@ export function PropertiesPopover({
{/* Text Alignment */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.textAlignment', 'Text Alignment')}
{t("annotation.textAlignment", "Text Alignment")}
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
variant={currentAlign === "left" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
variant={currentAlign === "center" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
variant={currentAlign === "right" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
@@ -125,7 +120,7 @@ export function PropertiesPopover({
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={opacity}
@@ -148,7 +143,7 @@ export function PropertiesPopover({
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t('annotation.strokeWidth', 'Stroke')}
{t("annotation.strokeWidth", "Stroke")}
</Text>
<Slider
value={strokeWidth}
@@ -166,7 +161,7 @@ export function PropertiesPopover({
</div>
<Button
size="xs"
variant={!borderVisible ? 'filled' : 'light'}
variant={!borderVisible ? "filled" : "light"}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
@@ -176,9 +171,7 @@ export function PropertiesPopover({
});
}}
>
{borderVisible
? t('annotation.borderOn', 'Border: On')
: t('annotation.borderOff', 'Border: Off')}
{borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")}
</Button>
</Group>
</div>
@@ -188,7 +181,7 @@ export function PropertiesPopover({
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.properties', 'Properties')}>
<Tooltip label={t("annotation.properties", "Properties")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -198,13 +191,13 @@ export function PropertiesPopover({
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -214,8 +207,8 @@ export function PropertiesPopover({
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
{annotationType === 'shape' && renderShapeControls()}
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
{annotationType === "shape" && renderShapeControls()}
</Popover.Dropdown>
</Popover>
);
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import React, { useState, useEffect } from "react";
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
interface TextInputWithFontProps {
text: string;
@@ -12,8 +12,8 @@ interface TextInputWithFontProps {
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
textAlign?: 'left' | 'center' | 'right';
onTextAlignChange?: (align: 'left' | 'center' | 'right') => void;
textAlign?: "left" | "center" | "right";
onTextAlignChange?: (align: "left" | "center" | "right") => void;
disabled?: boolean;
label: string;
placeholder: string;
@@ -31,9 +31,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
textColor = "#000000",
onTextColorChange,
textAlign = 'left',
textAlign = "left",
onTextAlignChange,
disabled = false,
label,
@@ -42,7 +42,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
fontSizeLabel,
fontSizePlaceholder,
colorLabel,
onAnyChange
onAnyChange,
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
@@ -61,14 +61,37 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
}, [textColor]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
{ value: 'Courier', label: 'Courier' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Georgia', label: 'Georgia' },
{ value: "Helvetica", label: "Helvetica" },
{ value: "Times-Roman", label: "Times" },
{ value: "Courier", label: "Courier" },
{ value: "Arial", label: "Arial" },
{ value: "Georgia", label: "Georgia" },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
const fontSizeOptions = [
"8",
"12",
"16",
"20",
"24",
"28",
"32",
"36",
"40",
"48",
"56",
"64",
"72",
"80",
"96",
"112",
"128",
"144",
"160",
"176",
"192",
"200",
];
// Validate hex color
const isValidHexColor = (color: string): boolean => {
@@ -94,7 +117,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || 'Helvetica');
onFontFamilyChange(value || "Helvetica");
onAnyChange?.();
}}
data={fontOptions}
@@ -187,7 +210,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
setColorInput(textColor);
}
}}
style={{ width: '100%' }}
style={{ width: "100%" }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
@@ -195,9 +218,9 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
border: "1px solid #ccc",
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
cursor: disabled ? "default" : "pointer",
}}
/>
}
@@ -224,14 +247,14 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
<SegmentedControl
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as 'left' | 'center' | 'right');
onTextAlignChange(value as "left" | "center" | "right");
onAnyChange?.();
}}
disabled={disabled}
data={[
{ label: t('textAlign.left', 'Left'), value: 'left' },
{ label: t('textAlign.center', 'Center'), value: 'center' },
{ label: t('textAlign.right', 'Right'), value: 'right' },
{ label: t("textAlign.left", "Left"), value: "left" },
{ label: t("textAlign.center", "Center"), value: "center" },
{ label: t("textAlign.right", "Right"), value: "right" },
]}
/>
)}
@@ -1,7 +1,7 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import LineWeightIcon from '@mui/icons-material/LineWeight';
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import LineWeightIcon from "@mui/icons-material/LineWeight";
interface WidthControlProps {
value: number;
@@ -18,7 +18,7 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.width', 'Width')}>
<Tooltip label={t("annotation.width", "Width")}>
<ActionIcon
variant="subtle"
color="gray"
@@ -28,13 +28,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
@@ -46,15 +46,9 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.width', 'Width')}
{t("annotation.width", "Width")}
</Text>
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
</Stack>
</Popover.Dropdown>
</Popover>
@@ -1,33 +1,26 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas';
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
interface DrawingToolProps {
onDrawingChange?: (data: string | null) => void;
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({
onDrawingChange,
disabled = false
}) => {
const [selectedColor] = useState('#000000');
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
const [selectedColor] = useState("#000000");
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [penSizeInput, setPenSizeInput] = useState("2");
const toolConfig = {
enableDrawing: true,
showPlaceButton: true,
placeButtonText: "Place Drawing"
placeButtonText: "Place Drawing",
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
@@ -42,4 +35,4 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({
</Stack>
</BaseAnnotationTool>
);
};
};
@@ -1,17 +1,14 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool';
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
interface ImageToolProps {
onImageChange?: (data: string | null) => void;
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false
}) => {
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
@@ -23,7 +20,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
reject(new Error("Failed to read file"));
}
};
reader.onerror = () => reject(reader.error);
@@ -33,7 +30,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error('Error reading file:', error);
console.error("Error reading file:", error);
}
} else if (!file) {
setImageData(null);
@@ -44,15 +41,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({
const toolConfig = {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image"
placeButtonText: "Place Image",
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
@@ -64,4 +57,4 @@ export const ImageTool: React.FC<ImageToolProps> = ({
</Stack>
</BaseAnnotationTool>
);
};
};
@@ -1,14 +1,14 @@
import React, { useRef, useState } from 'react';
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
import React, { useRef, useState } from "react";
import { Button, Group, useMantineColorScheme } from "@mantine/core";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
@@ -16,11 +16,7 @@ interface AddFileCardProps {
multiple?: boolean;
}
const AddFileCard = ({
onFileSelect,
accept,
multiple = true
}: AddFileCardProps) => {
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
@@ -38,7 +34,7 @@ const AddFileCard = ({
e.stopPropagation();
const files = await openFilesFromDisk({
multiple,
onFallbackOpen: () => fileInputRef.current?.click()
onFallbackOpen: () => fileInputRef.current?.click(),
});
if (files.length > 0) {
onFileSelect(files);
@@ -56,7 +52,7 @@ const AddFileCard = ({
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = '';
event.target.value = "";
};
return (
@@ -67,17 +63,17 @@ const AddFileCard = ({
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
style={{ display: "none" }}
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t('fileEditor.addFiles', 'Add files')}
aria-label={t("fileEditor.addFiles", "Add files")}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCardClick();
}
@@ -86,11 +82,9 @@ const AddFileCard = ({
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
</div>
<div className={styles.headerIndex}>
{t('fileEditor.addFiles', 'Add Files')}
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
</div>
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
<div className={styles.kebab} />
</div>
@@ -99,84 +93,81 @@ const AddFileCard = ({
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
style={{ height: "2.2rem", width: "auto" }}
/>
</Group>
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '100%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.6rem",
width: "100%",
marginTop: "0.8rem",
marginBottom: "0.8rem",
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "2rem",
height: "38px",
paddingLeft: isUploadHover ? 0 : "1rem",
paddingRight: isUploadHover ? 0 : "1rem",
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
minWidth: isUploadHover ? "58px" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "1rem",
height: "38px",
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{terminology.uploadFromComputer}
</span>
)}
<LocalIcon
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
>
{terminology.dropFilesHere}
</span>
@@ -6,7 +6,10 @@
background: var(--file-card-bg);
border-radius: 0.0625rem;
cursor: pointer;
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
transition:
box-shadow 0.18s ease,
outline-color 0.18s ease,
transform 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: visible;
@@ -45,8 +48,8 @@
}
.headerResting {
background: #3B4B6E; /* dark blue for unselected in light mode */
color: #FFFFFF;
background: #3b4b6e; /* dark blue for unselected in light mode */
color: #ffffff;
border-bottom: 1px solid var(--border-default);
}
@@ -66,7 +69,7 @@
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #FFFFFF;
color: #ffffff;
border-bottom: 1px solid var(--unsupported-bar-border);
}
@@ -103,7 +106,7 @@
}
.headerIconButton {
color: #FFFFFF !important;
color: #ffffff !important;
}
/* Menu dropdown */
@@ -226,14 +229,13 @@
}
.pinned {
color: #FFC107 !important;
color: #ffc107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
background: #6B7280;
background: #6b7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
@@ -264,7 +266,8 @@
/* Animations */
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@@ -288,15 +291,15 @@
DARK MODE OVERRIDES
========================= */
:global([data-mantine-color-scheme="dark"]) .card {
outline-color: #3A4047; /* deselected stroke */
outline-color: #3a4047; /* deselected stroke */
}
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
outline-color: #4B525A; /* selected stroke (subtle grey) */
outline-color: #4b525a; /* selected stroke (subtle grey) */
}
:global([data-mantine-color-scheme="dark"]) .headerResting {
background: #1F2329; /* requested default unselected color */
background: #1f2329; /* requested default unselected color */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border); /* #3A4047 */
}
@@ -308,16 +311,16 @@
}
:global([data-mantine-color-scheme="dark"]) .title {
color: #D0D6DC; /* title text */
color: #d0d6dc; /* title text */
}
:global([data-mantine-color-scheme="dark"]) .meta {
color: #6B7280; /* subtitle text */
color: #6b7280; /* subtitle text */
}
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
outline-color: #3b4b6e;
}
/* =========================
@@ -1,22 +1,19 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '@app/contexts/FileContext';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { zipFileService } from '@app/services/zipFileService';
import { detectFileExtension } from '@app/utils/fileUtils';
import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail';
import AddFileCard from '@app/components/fileEditor/AddFileCard';
import FilePickerModal from '@app/components/shared/FilePickerModal';
import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadFile } from '@app/services/downloadService';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { zipFileService } from "@app/services/zipFileService";
import { detectFileExtension } from "@app/utils/fileUtils";
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
import AddFileCard from "@app/components/fileEditor/AddFileCard";
import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
onOpenPageEditor?: () => void;
@@ -25,16 +22,15 @@ interface FileEditorProps {
supportedExtensions?: string[];
}
const FileEditor = ({
toolMode = false,
supportedExtensions = ["pdf"]
}: FileEditorProps) => {
const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => {
// Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
const isFileSupported = useCallback(
(fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
},
[supportedExtensions],
);
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
@@ -62,11 +58,11 @@ const FileEditor = ({
const [_error, _setError] = useState<string | null>(null);
// Toast helpers
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => {
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
}, []);
const showError = useCallback((message: string) => {
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
alert({ alertType: "error", title: "Error", body: message, expandable: true });
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
@@ -76,7 +72,7 @@ const FileEditor = ({
// Compute effective max allowed files based on the active tool and mode
const maxAllowed = useMemo<number>(() => {
const rawMax = selectedTool?.maxFiles;
return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
// Enable selection mode automatically in tool mode
@@ -104,8 +100,8 @@ const FileEditor = ({
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to clear file errors on select all:', error);
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on select all:", error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]);
@@ -115,8 +111,8 @@ const FileEditor = ({
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to clear file errors on deselect:', error);
if (process.env.NODE_ENV === "development") {
console.warn("Failed to clear file errors on deselect:", error);
}
}
}, [setSelectedFiles, clearAllFileErrors]);
@@ -137,69 +133,75 @@ const FileEditor = ({
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
_setError(null);
const handleFileUpload = useCallback(
async (uploadedFiles: File[]) => {
_setError(null);
try {
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
try {
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to process files";
showError(errorMessage);
console.error("File processing error:", err);
}
},
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
const toggleFile = useCallback(
(fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter((id) => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
showError(errorMessage);
console.error('File processing error:', err);
}
}, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: FileId[];
if (isSelected) {
// Remove file from selection
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles;
const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax;
if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file
newSelection = [contextFileId];
} else {
// If at capacity, drop the oldest selected and append the new one
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else {
newSelection = [...currentSelectedIds, contextFileId];
}
}
}
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]);
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
},
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
@@ -208,154 +210,174 @@ const FileEditor = ({
}
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map(r => r.id);
const handleReorderFiles = useCallback(
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map((r) => r.id);
// Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
const targetIndex = currentIds.findIndex(id => id === targetFileId);
// Find indices
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
const targetIndex = currentIds.findIndex((id) => id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) {
console.warn('Could not find source or target file for reordering');
return;
}
// Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1
? selectedFileIds.filter(id => currentIds.includes(id))
: [sourceFileId];
// Create new order
const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
.sort((a, b) => b - a); // Sort descending
sourceIndices.forEach(index => {
newOrder.splice(index, 1);
});
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex(id => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
if (sourceIndex === -1 || targetIndex === -1) {
console.warn("Could not find source or target file for reordering");
return;
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Handle multi-file selection reordering
const filesToMove =
selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId];
// Update file order
reorderFiles(newOrder);
// Create new order
const newOrder = [...currentIds];
// Update status
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending
sourceIndices.forEach((index) => {
newOrder.splice(index, 1);
});
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex((id) => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Update file order
reorderFiles(newOrder);
// Update status
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
},
[activeStirlingFileStubs, reorderFiles, _setStatus],
);
// File operations using context
const handleCloseFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
const handleCloseFile = useCallback(
(fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleDownloadFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
if (record && file) {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath
});
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log('[FileEditor] Marking file as clean:', fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false
});
} else {
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
// Remove from context selections
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}
}, [activeStirlingFileStubs, selectors, fileActions]);
},
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds],
);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await fileActions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
const handleDownloadFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log("[FileEditor] handleDownloadFile called:", {
fileId,
hasRecord: !!record,
hasFile: !!file,
localFilePath: record?.localFilePath,
isDirty: record?.isDirty,
});
if (record && file) {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath,
});
console.log("[FileEditor] Download complete, checking dirty state:", {
localFilePath: record.localFilePath,
isDirty: record.isDirty,
savedPath: result.savedPath,
});
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log("[FileEditor] Marking file as clean:", fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false,
});
} else {
console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty });
}
}
},
[activeStirlingFileStubs, selectors, fileActions],
);
const handleUnzipFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await fileActions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: "success",
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500,
});
} else {
alert({
alertType: "error",
title: `Failed to extract files from ${file.name}`,
body: result.errors.join("\n"),
expandable: true,
durationMs: 3500,
});
}
} catch (error) {
console.error("Failed to unzip file:", error);
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
alertType: "error",
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500,
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
},
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
);
const handleViewFile = useCallback((fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex(r => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench('viewer');
}
}, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]);
const handleViewFile = useCallback(
(fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench("viewer");
}
},
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
@@ -365,91 +387,85 @@ const FileEditor = ({
// The files are already in FileContext, just need to add them to active files
showStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
showError('Failed to load some files from storage');
console.error("Error loading files from storage:", err);
showError("Failed to load some files from storage");
}
}, []);
return (
<Dropzone
onDrop={handleFileUpload}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
border: 'none',
border: "none",
borderRadius: 0,
backgroundColor: 'transparent'
backgroundColor: "transparent",
}}
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" style={{ overflow: 'auto' }}>
<Box pos="relative" style={{ overflow: "auto" }}>
<LoadingOverlay visible={state.ui.isProcessing} />
<Box p="md">
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📁
</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">
Upload PDF files, ZIP archives, or load from storage to get started
</Text>
</Stack>
</Center>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
rowGap: "1.5rem",
padding: "1rem",
pointerEvents: "auto",
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
key={record.id}
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
);
})}
</div>
)}
</Box>
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
rowGap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
key={record.id}
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
);
})}
</div>
)}
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
/>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
/>
</Box>
</Dropzone>
);
@@ -1,13 +1,11 @@
import React from 'react';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React from "react";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface FileEditorFileNameProps {
file: StirlingFileStub;
}
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
);
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
export default FileEditorFileName;
@@ -1,38 +1,36 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from '@mantine/core';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { alert } from '@app/components/toast';
import { useTranslation } from 'react-i18next';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import LinkIcon from '@mui/icons-material/Link';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '@app/types/fileContext';
import { zipFileService } from '@app/services/zipFileService';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileContext } from '@app/contexts/FileContext';
import { useFileState } from '@app/contexts/file/fileHooks';
import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { downloadFile } from '@app/services/downloadService';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
import ShareFileModal from '@app/components/shared/ShareFileModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { truncateCenter } from '@app/utils/textUtils';
import React, { useState, useCallback, useRef, useMemo } from "react";
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import CloseIcon from "@mui/icons-material/Close";
import VisibilityIcon from "@mui/icons-material/Visibility";
import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file";
import { formatFileSize } from "@app/utils/fileUtils";
import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
import { downloadFile } from "@app/services/downloadService";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { truncateCenter } from "@app/utils/textUtils";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
@@ -69,14 +67,7 @@ const FileEditorThumbnail = ({
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadOutlinedIcon = icons.download;
const {
pinFile,
unpinFile,
isFilePinned,
activeFiles,
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
@@ -93,7 +84,7 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find(f => f.fileId === file.id);
return activeFiles.find((f) => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
@@ -114,17 +105,17 @@ const FileEditorThumbnail = ({
}, [file.size]);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toUpperCase();
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
return (m?.[1] || "").toUpperCase();
}, [file.name]);
const extLower = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toLowerCase();
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
return (m?.[1] || "").toLowerCase();
}, [file.name]);
const isCBZ = extLower === 'cbz';
const isCBR = extLower === 'cbr';
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
@@ -137,71 +128,68 @@ const FileEditorThumbnail = ({
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
const pageLabel = useMemo(
() =>
pageCount > 0
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
: '',
[pageCount]
);
const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]);
const dateLabel = useMemo(() => {
const d = new Date(file.lastModified);
if (Number.isNaN(d.getTime())) return '';
if (Number.isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
year: 'numeric',
month: "short",
day: "2-digit",
year: "numeric",
}).format(d);
}, [file.lastModified]);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: "file",
fileId: file.id,
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
},
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectedFiles, onReorderFiles]);
return () => {
dragCleanup();
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
);
// Handle close with confirmation
const handleCloseWithConfirmation = useCallback(() => {
@@ -210,7 +198,7 @@ const FileEditorThumbnail = ({
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
@@ -221,12 +209,12 @@ const FileEditorThumbnail = ({
const result = await downloadFile({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath
localPath: file.localFilePath,
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
localFilePath: file.localFilePath ?? result.savedPath,
isDirty: false
isDirty: false,
});
} else if (result.cancelled) {
setShowCloseModal(false);
@@ -234,14 +222,14 @@ const FileEditorThumbnail = ({
}
} catch (error) {
console.error(`Failed to save ${file.name}:`, error);
alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true });
setShowCloseModal(false);
return;
}
}
// Then close
onCloseFile(file.id);
alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
@@ -250,95 +238,110 @@ const FileEditorThumbnail = ({
}, []);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
id: 'view',
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
label: t('openInViewer', 'Open in Viewer'),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
const hoverActions = useMemo<HoverAction[]>(
() => [
{
id: "view",
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
label: t("openInViewer", "Open in Viewer"),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
},
},
},
{
id: 'download',
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
{
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
},
},
},
...(canUpload || canShare
? [
...(canUpload ? [{
id: 'upload',
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t('fileManager.updateOnServer', 'Update on Server')
: t('fileManager.uploadToServer', 'Upload to Server'),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
}] : []),
...(canShare ? [{
id: 'share',
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t('fileManager.share', 'Share'),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
}] : []),
]
: []),
{
id: 'unzip',
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
label: t('fileManager.unzip', 'Unzip'),
onClick: (e) => {
e.stopPropagation();
if (onUnzipFile) {
onUnzipFile(file.id);
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
...(canUpload || canShare
? [
...(canUpload
? [
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
]
: []),
{
id: "unzip",
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
label: t("fileManager.unzip", "Unzip"),
onClick: (e) => {
e.stopPropagation();
if (onUnzipFile) {
onUnzipFile(file.id);
alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
},
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
},
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
},
{
id: 'close',
icon: <CloseIcon style={{ fontSize: 20 }} />,
label: t('close', 'Close'),
onClick: (e) => {
e.stopPropagation();
handleCloseWithConfirmation();
{
id: "close",
icon: <CloseIcon style={{ fontSize: 20 }} />,
label: t("close", "Close"),
onClick: (e) => {
e.stopPropagation();
handleCloseWithConfirmation();
},
color: "red",
},
color: 'red',
}
], [
t,
file.id,
file.name,
isZipFile,
isCBZ,
isCBR,
terminology,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded
]);
],
[
t,
file.id,
file.name,
isZipFile,
isCBZ,
isCBR,
terminology,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded,
],
);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
try {
fileActions.clearFileError(file.id);
} catch (_e) {
void _e;
}
}
if (isSharedFile && !sharedEditNoticeShownRef.current) {
sharedEditNoticeShownRef.current = true;
@@ -359,7 +362,6 @@ const FileEditorThumbnail = ({
return isSelected ? styles.headerSelected : styles.headerResting;
};
return (
<div
ref={fileElementRef}
@@ -369,7 +371,7 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{opacity: isDragging ? 0.9 : 1}}
style={{ opacity: isDragging ? 0.9 : 1 }}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
@@ -379,15 +381,12 @@ const FileEditorThumbnail = ({
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
<div className={styles.errorPill}>
<span>{t('error._value', 'Error')}</span>
<span>{t("error._value", "Error")}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
@@ -397,9 +396,7 @@ const FileEditorThumbnail = ({
/>
) : (
<div className={styles.unsupportedPill}>
<span>
{t('unsupported', 'Unsupported')}
</span>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
@@ -412,9 +409,9 @@ const FileEditorThumbnail = ({
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
<ActionIcon
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
@@ -427,9 +424,17 @@ const FileEditorThumbnail = ({
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
<Tooltip
label={
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
}
>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
aria-label={
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
data-tour="file-card-pin"
@@ -438,10 +443,10 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
@@ -454,35 +459,30 @@ const FileEditorThumbnail = ({
{/* Title + meta line */}
<div
style={{
padding: '0.5rem',
textAlign: 'center',
background: 'var(--file-card-bg)',
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
style={{
padding: "0.5rem",
textAlign: "center",
background: "var(--file-card-bg)",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
>
<Text size="lg" fw={700} className={styles.title} title={file.name}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</Text>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || 'FILE'}${prettySize}`}
>
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"}${prettySize}`}>
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ''}
{pageLabel ? ` - ${pageLabel}` : ''}
{extUpper ? ` - ${extUpper} file` : ""}
{pageLabel ? ` - ${pageLabel}` : ""}
</Text>
</div>
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
@@ -495,27 +495,29 @@ const FileEditorThumbnail = ({
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
img.style.display = "none";
img.parentElement?.setAttribute("data-thumb-missing", "true");
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
/>
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
}}
/>
</PrivateContent>
) : file.type?.startsWith('application/pdf') ? (
<Stack align="center" justify="center" gap="xs" style={{ height: '100%' }}>
) : file.type?.startsWith("application/pdf") ? (
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
<Loader size="sm" />
<Text size="xs" c="dimmed">Loading thumbnail...</Text>
<Text size="xs" c="dimmed">
Loading thumbnail...
</Text>
</Stack>
) : null}
</div>
@@ -527,74 +529,72 @@ const FileEditorThumbnail = ({
{/* Tool chain display at bottom */}
{file.toolHistory && (
<div style={{
position: 'absolute',
bottom: '4px',
left: '4px',
right: '4px',
padding: '4px 6px',
textAlign: 'center',
fontWeight: 600,
overflow: 'hidden',
whiteSpace: 'nowrap'
}}>
<div
style={{
position: "absolute",
bottom: "4px",
left: "4px",
right: "4px",
padding: "4px 6px",
textAlign: "center",
fontWeight: 600,
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth={'100%'}
color='var(--mantine-color-gray-7)'
maxWidth={"100%"}
color="var(--mantine-color-gray-7)"
/>
</div>
)}
</div>
{/* Hover Menu */}
<HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
{/* Close Confirmation Modal */}
<Modal
opened={showCloseModal}
onClose={handleCancelClose}
title={t('confirmClose', 'Confirm Close')}
title={t("confirmClose", "Confirm Close")}
centered
size="auto"
>
<Stack gap="md">
{file.isDirty && file.localFilePath ? (
<>
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseDiscard', 'Discard changes and close')}
{t("confirmCloseDiscard", "Discard changes and close")}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
{t('confirmCloseSave', 'Save and close')}
{t("confirmCloseSave", "Save and close")}
</Button>
</Group>
</>
) : (
<>
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
{t("confirmCloseConfirm", "Close File")}
</Button>
</Group>
</>
@@ -604,39 +604,27 @@ const FileEditorThumbnail = ({
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')}
title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")}
centered
size="auto"
>
<Stack gap="md">
<Text size="sm">
{t(
'fileManager.sharedEditNoticeBody',
'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.'
"fileManager.sharedEditNoticeBody",
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button onClick={() => setShowSharedEditNotice(false)}>
{t('fileManager.sharedEditNoticeConfirm', 'Got it')}
{t("fileManager.sharedEditNoticeConfirm", "Got it")}
</Button>
</Group>
</Stack>
</Modal>
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
)}
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
</div>
);
};
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
import LocalIcon from "@app/components/shared/LocalIcon";
interface FileEditorRightRailButtonsParams {
totalItems: number;
@@ -20,41 +20,44 @@ export function useFileEditorRightRailButtons({
}: FileEditorRightRailButtonsParams) {
const { t, i18n } = useTranslation();
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
{
id: 'file-select-all',
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.selectAll', 'Select All'),
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
section: 'top' as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: 'file-deselect-all',
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.deselectAll', 'Deselect All'),
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
section: 'top' as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: 'file-close-selected',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
section: 'top' as const,
order: 30,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
},
], [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
const buttons = useMemo<RightRailButtonWithAction[]>(
() => [
{
id: "file-select-all",
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.selectAll", "Select All"),
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
section: "top" as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: "file-deselect-all",
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.deselectAll", "Deselect All"),
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
section: "top" as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: "file-close-selected",
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
section: "top" as const,
order: 30,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
},
],
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
);
useRightRailButtons(buttons);
}
@@ -1,12 +1,12 @@
import React from 'react';
import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '@app/utils/fileUtils';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import React from "react";
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { useTranslation } from "react-i18next";
import { getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface CompactFileDetailsProps {
currentFile: StirlingFileStub | null;
@@ -29,47 +29,56 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
isAnimating,
onPrevious,
onNext,
onOpenFiles
onOpenFiles,
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1;
const showOwner = Boolean(
currentFile &&
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink),
);
const ownerLabel = currentFile
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
: '';
const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : "";
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Stack gap="xs" style={{ height: "100%" }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<Box style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
{/* Small preview */}
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box
style={{
width: "7.5rem",
height: "9.375rem",
flexShrink: 0,
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{currentFile && thumbnail ? (
<PrivateContent>
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
borderRadius: "0.25rem",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
}}
/>
</PrivateContent>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
<Center
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
</Center>
) : null}
</Box>
@@ -77,10 +86,10 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : 'No file selected'}</PrivateContent>
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{currentFile ? getFileSize(currentFile) : ""}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
{currentFile && ` • v${currentFile.versionNumber || 1}`}
</Text>
@@ -92,33 +101,23 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Compact tool chain for mobile */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed">
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join('')}
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join("")}
</Text>
)}
{currentFile && showOwner && (
<Text size="xs" c="dimmed">
{t('fileManager.owner', 'Owner')}: {ownerLabel}
{t("fileManager.owner", "Owner")}: {ownerLabel}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '0.25rem' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
>
<Box style={{ display: "flex", gap: "0.25rem" }}>
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
@@ -132,14 +131,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
disabled={!hasSelection}
fullWidth
style={{
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
@@ -1,79 +1,85 @@
import React from 'react';
import { Grid } from '@mantine/core';
import FileSourceButtons from '@app/components/fileManager/FileSourceButtons';
import FileDetails from '@app/components/fileManager/FileDetails';
import SearchInput from '@app/components/fileManager/SearchInput';
import FileListArea from '@app/components/fileManager/FileListArea';
import FileActions from '@app/components/fileManager/FileActions';
import HiddenFileInput from '@app/components/fileManager/HiddenFileInput';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import React from "react";
import { Grid } from "@mantine/core";
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
import FileDetails from "@app/components/fileManager/FileDetails";
import SearchInput from "@app/components/fileManager/SearchInput";
import FileListArea from "@app/components/fileManager/FileListArea";
import FileActions from "@app/components/fileManager/FileActions";
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const DesktopLayout: React.FC = () => {
const {
activeSource,
recentFiles,
modalHeight,
} = useFileManagerContext();
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
return (
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
{/* Column 1: File Sources */}
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
height: '100%',
}} data-tour="file-sources">
<Grid.Col
span="content"
p="lg"
style={{
minWidth: "13.625rem",
width: "13.625rem",
flexShrink: 0,
height: "100%",
}}
data-tour="file-sources"
>
<FileSourceButtons />
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
flex: '1 1 0px'
}}>
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
border: '1px solid var(--mantine-color-gray-2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
{activeSource === 'recent' && (
<Grid.Col
span="auto"
style={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: "1 1 0px",
}}
>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "var(--bg-file-list)",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
overflow: "hidden",
}}
>
{activeSource === "recent" && (
<>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<div
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<SearchInput />
</div>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<div
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<FileActions />
</div>
</>
)}
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<FileListArea
scrollAreaHeight={activeSource === 'recent' && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: '100%'}
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"}
scrollAreaStyle={{
height: activeSource === 'recent' && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: '100%',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
/>
</div>
@@ -81,14 +87,18 @@ const DesktopLayout: React.FC = () => {
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
height: '100%',
maxWidth: '18rem'
}}>
<div style={{ height: '100%', overflow: 'hidden' }}>
<Grid.Col
p="xl"
span="content"
style={{
minWidth: "25rem",
width: "25rem",
flexShrink: 0,
height: "100%",
maxWidth: "18rem",
}}
>
<div style={{ height: "100%", overflow: "hidden" }}>
<FileDetails />
</div>
</Grid.Col>
@@ -1,7 +1,7 @@
import React from 'react';
import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next';
import React from "react";
import { Stack, Text, useMantineTheme, alpha } from "@mantine/core";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useTranslation } from "react-i18next";
interface DragOverlayProps {
isVisible: boolean;
@@ -16,29 +16,29 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
return (
<div
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: alpha(theme.colors.blue[6], 0.1),
border: `0.125rem dashed ${theme.colors.blue[6]}`,
borderRadius: '1.875rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: "1.875rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
pointerEvents: 'none'
pointerEvents: "none",
}}
>
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
<Text size="xl" fw={500} c="blue.6">
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
{t("fileManager.dropFilesHere", "Drop files here to upload")}
</Text>
</Stack>
</div>
);
};
export default DragOverlay;
export default DragOverlay;
@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import React, { useState } from "react";
import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core";
import HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
const EmptyFilesState: React.FC = () => {
const { t } = useTranslation();
@@ -24,92 +24,90 @@ const EmptyFilesState: React.FC = () => {
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
{/* Container */}
<div
style={{
backgroundColor: 'transparent',
padding: '3rem 2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
minWidth: '20rem',
maxWidth: '28rem',
width: '100%'
backgroundColor: "transparent",
padding: "3rem 2rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1.5rem",
minWidth: "20rem",
maxWidth: "28rem",
width: "100%",
}}
>
{/* No Recent Files Message */}
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
<Text c="dimmed" ta="center" size="lg">
{t('fileManager.noRecentFiles', 'No recent files')}
{t("fileManager.noRecentFiles", "No recent files")}
</Text>
</Stack>
{/* Stirling PDF Logo */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? wordmark.white : wordmark.grey}
src={colorScheme === "dark" ? wordmark.white : wordmark.grey}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
style={{ height: "2.2rem", width: "auto" }}
/>
</Group>
{/* Upload Button */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
marginTop: '0.5rem',
marginBottom: '0.5rem'
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--bg-file-manager)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: isUploadHover ? '2rem' : '1rem',
height: '38px',
width: isUploadHover ? '100%' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
backgroundColor: "var(--bg-file-manager)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: isUploadHover ? "2rem" : "1rem",
height: "38px",
width: isUploadHover ? "100%" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease, border-radius .5s ease",
}}
onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon={icons.uploadIconName} width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{terminology.uploadFromComputer}
</span>
)}
<LocalIcon
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center' }}
>
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
{terminology.dropFilesHere}
</span>
</div>
@@ -30,9 +30,8 @@ const FileActions: React.FC = () => {
onDownloadSelected,
refreshRecentFiles,
storageFilter,
onStorageFilterChange
} =
useFileManagerContext();
onStorageFilterChange,
} = useFileManagerContext();
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
@@ -42,11 +41,11 @@ const FileActions: React.FC = () => {
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") },
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") },
]
: [
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") }
{ value: "local", label: t("fileManager.filterLocal", "Local") },
];
useEffect(() => {
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
@@ -56,10 +55,8 @@ const FileActions: React.FC = () => {
const hasSelection = selectedFileIds.length > 0;
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
const hasDownloadAccess = selectedFiles.every((file) => {
const role = (file.remoteOwnedByCurrentUser !== false
? 'editor'
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
return role === 'editor' || role === 'commenter' || role === 'viewer';
const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
return role === "editor" || role === "commenter" || role === "viewer";
});
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
@@ -80,7 +77,6 @@ const FileActions: React.FC = () => {
}
};
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
@@ -120,9 +116,7 @@ const FileActions: React.FC = () => {
<SegmentedControl
size="xs"
value={storageFilter}
onChange={(value) =>
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
}
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")}
data={storageFilterOptions}
/>
)}
@@ -1,19 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import FilePreview from '@app/components/shared/FilePreview';
import FileInfoCard from '@app/components/fileManager/FileInfoCard';
import CompactFileDetails from '@app/components/fileManager/CompactFileDetails';
import React, { useEffect, useState } from "react";
import { Stack, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import FilePreview from "@app/components/shared/FilePreview";
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
interface FileDetailsProps {
compact?: boolean;
}
const FileDetails: React.FC<FileDetailsProps> = ({
compact = false
}) => {
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
@@ -35,7 +33,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
if (isAnimating) return;
setIsAnimating(true);
setTimeout(() => {
setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1));
setIsAnimating(false);
}, 150);
};
@@ -44,7 +42,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
if (isAnimating) return;
setIsAnimating(true);
setTimeout(() => {
setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0));
setIsAnimating(false);
}, 150);
};
@@ -75,7 +73,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
{/* Section 1: Thumbnail Preview */}
<Box style={{ width: '100%', height: 'min(35vh, 280px)', textAlign: 'center', flexShrink: 0 }}>
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
<FilePreview
file={currentFile}
thumbnail={getCurrentThumbnail()}
@@ -89,10 +87,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
</Box>
{/* Section 2: File Details */}
<FileInfoCard
currentFile={currentFile}
modalHeight={modalHeight}
/>
<FileInfoCard currentFile={currentFile} modalHeight={modalHeight} />
<Button
size="md"
@@ -100,14 +95,13 @@ const FileDetails: React.FC<FileDetailsProps> = ({
disabled={!hasSelection}
fullWidth
style={{
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
color: "white",
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);

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