Compare commits

..
Author SHA1 Message Date
EthanHealy01 fc9960b6e6 fast path added 2026-06-02 01:20:35 +01:00
EthanHealy01 f8ce30ff37 Add AGPL-3.0 license text and fix formatting
- Add licenses/AGPL-3.0.txt (full license text for scripts/pymupdf_convert.py)
- Update root LICENSE to reference it
- Fix Spotless formatting in PyMuPdfConverter and ExternalAppDepConfig
2026-06-02 00:58:39 +01:00
EthanHealy01 f0a7f9af78 Collapse pymupdf-worker package into a single script
The pymupdf-worker/ directory existed only to register a pip entry point
for pymupdf-convert. Replace it with scripts/pymupdf_convert.py — a
standalone script installed directly onto PATH in the Dockerfile.

- Add scripts/pymupdf_convert.py (AGPL-3.0, same logic, shebang added)
- Update docker/base/Dockerfile to copy + install the script directly
  instead of pip-installing the package; remove now-redundant symlink
- Delete pymupdf-worker/ and its taskfile
- Update root LICENSE to reference the script file instead of directory
2026-06-02 00:48:55 +01:00
EthanHealy01 6309ca7234 Clean up pymupdf-convert integration per review feedback
- Remove AGPL/license comments from main codebase (Dockerfile, Java
  classes, javadocs) — legal isolation is structural, comments were noise
- PyMuPdfConverter: check CLI availability once at @PostConstruct instead
  of caching with a TTL; simpler and consistent with how LibreOffice/
  Tesseract availability is handled
2026-06-02 00:44:41 +01:00
EthanHealy01 f3f17d6381 fix: non-root user in Dockerfile, update stale HTTP references in worker 2026-06-01 19:45:35 +01:00
EthanHealy01 4487f23ff7 feat(pdf-to-markdown): add pymupdf-convert CLI for faster PDF→Markdown conversion
Introduces a new  directory containing a small standalone
CLI tool () that converts PDFs to Markdown using PyMuPDF
and pymupdf4llm. When installed, it replaces the slower pdftohtml-based
converter with a significantly faster path.
The tool is invoked as an OS subprocess, following the same pattern already
used for LibreOffice and Tesseract. A new  service handles
discovery (PATH probe, cached 30 s), temp file I/O, and process execution via
the existing  infrastructure. Auto-detected at startup via
 — no configuration flag required. Falls back
transparently to the bundled pdftohtml converter if not found.
 is baked into the Docker base image via the existing
Python venv build stage and symlinked onto PATH, so all Docker users get
the faster path automatically with no manual steps.
AGPL notice:  is licensed under AGPL-3.0-or-later because
it depends on PyMuPDF (AGPL-3.0 / Artifex commercial). It is a SEPARATE
program from the rest of Stirling PDF and shares no source code with the
MIT-licensed core or the proprietary engine. The subprocess boundary
ensures the AGPL copyleft attaches to this tool alone and does not extend
to Stirling PDF. The LICENSE file and pymupdf-worker/LICENSE document this
separation explicitly. Do not replace the subprocess invocation with an
in-process import of PyMuPDF.
2026-06-01 19:42:49 +01:00
brios 2c0ebc28a7 perf(api): optimize static asset caching, enable ETag support, and expand response compression mime types. (#6273) 2026-06-01 16:37:56 +01:00
Ludy d1486c7762 ci(github-actions): replace deprecated app-id input with client-id (#6485)
# Description of Changes

This change updates the GitHub App token generation step in the custom
`setup-bot` GitHub Action to use the new `client-id` input instead of
the deprecated `app-id` input when invoking
`actions/create-github-app-token`.

### What was changed

- Replaced the deprecated `app-id` parameter with `client-id` in
`.github/actions/setup-bot/action.yml`.
- Continued sourcing the value from the existing `inputs.app-id` action
input to avoid broader interface changes.

### Why the change was made

- `actions/create-github-app-token` has deprecated the `app-id` input
and now expects `client-id`.
- Updating the workflow removes the deprecation warning and ensures
compatibility with current and future versions of the action.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] 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)
- [ ] I have performed a self-review of my own code
- [ ] 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)
- [ ] 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)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-05-31 08:07:30 +01:00
Ludy 2ccff6f73f fix(update-service): correct GitHub branch reference for version retrieval (#6333) 2026-05-30 22:44:42 +01:00
Ludy 78da227eba fix: Use frontend/editor for locales paths (#6483) 2026-05-30 22:00:17 +01:00
Anthony Stirling 30e782e29c Disable Save-to-server when storage off, fix QR port 0 (#6473) 2026-05-29 19:37:42 +01:00
Anthony Stirling 2b0905887b Add desktop multi-window support (#6463) 2026-05-29 19:35:54 +01:00
ConnorYoh 28b81828b5 PAYG: PricingPolicyService + admin REST + 30s read cache (#6469)
## What this is

PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data
model from #6460 — answers "what pricing policy applies to this team
right now?" with a fast cache and an admin write surface.

## Scope

| Piece | Where |
|---|---|
| `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s
Caffeine cache + admin write paths |
`app/saas/.../payg/policy/PricingPolicyService.java` |
| `PolicyChangedEvent` — published after admin writes for in-process
cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java`
|
| Admin REST — list / get / create / set-default / set team override /
get effective |
`app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` +
DTOs |
| `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for
set-default | repository update |
| `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going
stale (carried over from the #6460 review concern) | new test |
| V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit,
per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` |

## Lookup precedence

1. `PaygTeamExtensions.pricingPolicyId` set → return that policy
2. Else return the `pricing_policy` row with `is_default = TRUE`
3. Override row points at a deleted policy → log warn, fall back to
default (safety net for racing deletes)
4. No default → `IllegalStateException` (V12 seed guarantees one exists)

## Cache behaviour

- 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`.
- **Single correctness model: the TTL.** Cross-instance propagation is
at-most-30-seconds. The writer instance sees its own change immediately
via the `PolicyChangedEvent` after-commit publish. Other instances pick
it up on the next TTL expiry.
- Admin reads use `getEffectivePolicyUncached` so admins always see
their own write straight back.

**Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a
Postgres `LISTEN policy_changed` runner so cross-instance propagation
was instant. Dropped — admin policy changes are events-per-week and the
30s TTL is already the correctness floor; the listener was ~250 lines of
nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect
loop, lock-protected connection lifecycle) for a use case that isn't on
the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with
three concrete triggers that would justify reintroducing it (aggressive
cap enforcement, Redis landing for other reasons, real-time admin UI).

## Writes — transactional, fire `PolicyChangedEvent` after commit

- `create(draft)` — rejects pre-set `policy_id` or `is_default=true`
(promotion must go through `setDefault` so the partial unique idx is
freed first).
- `setDefault(id)` — atomically clears the existing default via
`clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if
the row is already default.
- `setTeamOverride(teamId, policyId | null)` — validates the policy
exists before save; `null` clears the override.

`publishOnCommit` uses `TransactionSynchronizationManager.afterCommit`
so listeners never see pre-commit state. Outside a transaction (test
paths) falls through to immediate publish.

## Admin REST surface — `/api/v1/admin/payg/...`

All endpoints `@PreAuthorize("hasRole('ADMIN')")`:

- `GET  /policies` — list all
- `GET  /policies/{id}` — read one
- `POST /policies` — create new (non-default)
- `POST /policies/{id}/set-default` — atomic promote
- `PUT /teams/{teamId}/policy-override` — set or clear per-team override
- `GET  /teams/{teamId}/effective-policy` — cache-bypassing live read

Validation errors → 400, unknown rows → 404.

## Counterpart Supabase PR


[`Stirling-PDF-SaaS#298`](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/298)
— seeds the same V1 default policy on the Supabase side via
`20260528000002_payg_seed_default_policy.sql`.

## Tests

- 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss,
invalidation on event, mutation paths publishing event, error cases.
- 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path
+ error mapping, DTO defensive-copy invariant.
- 2 × `SaasJpaConfigScanTest` — reflection-based guard that
`payg.repository` is in `@EnableJpaRepositories` and `payg` is in
`@EntityScan`. Without this, new sub-packages can silently fail to wire
at runtime — same class of bug that the #6460 review caught.

Full `:saas:test` BUILD SUCCESSFUL.

## Design doc

`notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the
schema half landed in #6460). §9 carries the 30s-TTL trade-off note.
2026-05-29 16:27:01 +00:00
James Brunton 2c01f41142 Update indexeddb to v9 to unify SaaS and OSS users (#6474)
# Description of Changes
The production SaaS is currently on v8 of IndexedDB due to various
schema changes for Smart Folders, which haven't made their way into OSS.
OSS is currently on v4 of IndexedDB, so if we release an OSS build to
the SaaS deployment, existing users will not be able to use it because
the DB version is 'too old'.

This PR updates the IDB version number to v9 so both OSS and SaaS users
will be able to upgrade to it. Theoretically both types of user should
be able to keep their IDB files without issue. SaaS previously actively
wiped the user's files in an old version (v6/v7) and users who haven't
used it since then will have their DBs wiped, but that'd happen anyway
if they use current SaaS so I don't think that matters.
2026-05-29 15:23:40 +00:00
James Brunton 4d5eeb103f Fix username display issues (#6471)
# Description of Changes
Main fixes:
- Fix the display of the username in the bottom left
- Now displays as "User" when not logged in on self-hosted (desktop) and
"Guest" on SaaS when logged in anonymously
- Now updates properly when the user logs in/out in SaaS, desktop and
self-hosted
- Fix incremental build issues in the desktop app that have been here
since the start (I hope at least - I think the issue is that the JLink
is built read-only and then on subsequent builds you get OS errors when
trying to override the JLink with the new version. There's no real need
for it to be read-only that I know of, so we might as well just make it
R/W and ship like that)
2026-05-29 14:35:47 +00:00
87 changed files with 3929 additions and 694 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ runs:
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ inputs.app-id }}
client-id: ${{ inputs.app-id }}
private-key: ${{ inputs.private-key }}
- name: Configure Git
run: |
+2 -1
View File
@@ -184,7 +184,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = Path.cwd() / "frontend" / "public" / "locales"
base_dir = Path.cwd() / "frontend" / "editor" / "public" / "locales"
for file_path in file_arr:
file_path = Path(file_path)
@@ -372,6 +372,7 @@ if __name__ == "__main__":
os.path.join(
os.getcwd(),
"frontend",
"editor",
"public",
"locales",
"*",
+22 -1
View File
@@ -133,8 +133,29 @@ tasks:
--no-header-files
--no-man-pages
--output runtime/jre
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
# staged copies are read-only too. On any subsequent incremental
# build the copier tries to overwrite them and fails with a bare
# `Permission denied (os error 13)` (Rust's io::Error Display drops
# the path, so the failure is opaque). Make the source writable here
# so the staged destinations are writable and can be overwritten.
#
# Trade-off: this task runs for both `task desktop:dev` and
# `task desktop:build`, so production bundles also ship mode-644
# JRE files instead of 444. Functionally harmless on POSIX (the
# `other` bit is `r--` either way, and on macOS code signing is the
# real integrity check) and on Windows the DOS read-only attribute
# isn't load-bearing for the bundled JDK. If we ever need strict
# 444 in production, split the chmod into a dev-only step and have
# `desktop:build` run `jlink:clean` first to force a fresh build.
- cmd: chmod -R u+w runtime/jre
platforms: [linux, darwin]
- cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }"
platforms: [windows]
status:
- test -d editor/src-tauri/runtime/jre
- test -f runtime/jre/release
jlink:clean:
desc: "Remove JLink runtime and bundled JARs"
+4
View File
@@ -10,6 +10,10 @@ if that directory exists, is licensed under the license defined in "app/propriet
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
* All content that resides under the "engine/" directory of this repository,
if that directory exists, is licensed under the license defined in "engine/LICENSE".
* "scripts/pymupdf_convert.py", if that file exists, is licensed under the GNU Affero
General Public License v3.0 (or later) as declared in its file header. It is a separate
program invoked as an OS subprocess; its license does not extend to other content in
this repository.
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
* All content that resides under the "frontend/src/desktop/" directory of this repository,
@@ -1350,6 +1350,10 @@ public class ApplicationProperties {
public int getFfmpegSessionLimit() {
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
}
public int getPyMuPdfConvertSessionLimit() {
return 2;
}
}
@Data
@@ -1427,6 +1431,10 @@ public class ApplicationProperties {
public long getFfmpegTimeoutMinutes() {
return ffmpegTimeoutMinutes > 0 ? ffmpegTimeoutMinutes : 30;
}
public long getPyMuPdfConvertTimeoutMinutes() {
return 10;
}
}
}
}
@@ -0,0 +1,91 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.ProcessExecutor;
/**
* Converts PDFs to Markdown by invoking the {@code pymupdf-convert} CLI tool as a separate
* subprocess.
*/
@Slf4j
@Service
public class PyMuPdfConverter {
private boolean available;
@PostConstruct
void init() {
available = probe();
if (available) {
log.info("pymupdf-convert found — PyMuPDF Markdown conversion enabled.");
} else {
log.info("pymupdf-convert not found — PyMuPDF Markdown conversion disabled.");
}
}
public boolean isAvailable() {
return available;
}
private boolean probe() {
boolean isWindows =
System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows");
List<String> cmd =
isWindows
? List.of("where", "pymupdf-convert")
: List.of("which", "pymupdf-convert");
try {
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
boolean done = p.waitFor(5, TimeUnit.SECONDS);
return done && p.exitValue() == 0;
} catch (Exception e) {
log.debug("pymupdf-convert availability check failed: {}", e.getMessage());
return false;
}
}
/**
* Convert a PDF to Markdown by invoking {@code pymupdf-convert} as a subprocess.
*
* @throws IOException on process failure or if the tool is not installed
*/
public String convertToMarkdown(byte[] pdfBytes, String filename) throws IOException {
String safeName =
(filename == null || filename.isBlank())
? "document.pdf"
: filename.replace("\"", "");
Path tempDir = Files.createTempDirectory("stirling-pymupdf-");
Path inputPdf = tempDir.resolve(safeName);
Path outputMd = tempDir.resolve("output.md");
try {
Files.write(inputPdf, pdfBytes);
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYMUPDF_CONVERT)
.runCommandWithOutputHandling(
List.of(
"pymupdf-convert",
inputPdf.toAbsolutePath().toString(),
outputMd.toAbsolutePath().toString()));
return Files.readString(outputMd, StandardCharsets.UTF_8);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("PyMuPDF conversion interrupted", e);
} finally {
Files.deleteIfExists(inputPdf);
Files.deleteIfExists(outputMd);
Files.deleteIfExists(tempDir);
}
}
}
@@ -4,6 +4,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -30,6 +31,7 @@ import io.github.pixee.security.Filenames;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.PyMuPdfConverter;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@Slf4j
@@ -159,6 +161,57 @@ public class PDFToFile {
finalOut, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* PDF-&gt;Markdown with optional PyMuPDF acceleration.
*
* <p>When {@code pymupdf-convert} is installed and on PATH, conversion is delegated to it as a
* subprocess. On any failure — or when the tool is absent — this transparently falls back to
* the bundled {@code pdftohtml}-based converter.
*/
public ResponseEntity<Resource> processPdfToMarkdown(
MultipartFile inputFile, PyMuPdfConverter pyMuPdfConverter)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
if (pyMuPdfConverter != null && pyMuPdfConverter.isAvailable()) {
try {
String originalName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
String baseName = originalName;
if (originalName != null && originalName.contains(".")) {
baseName = originalName.substring(0, originalName.lastIndexOf('.'));
}
String markdown =
pyMuPdfConverter.convertToMarkdown(inputFile.getBytes(), originalName);
return buildMarkdownZipResponse(markdown, baseName);
} catch (IOException e) {
log.warn(
"PyMuPDF conversion failed; falling back to pdftohtml converter: {}",
e.getMessage());
}
}
return processPdfToMarkdown(inputFile);
}
private ResponseEntity<Resource> buildMarkdownZipResponse(String markdown, String pdfBaseName)
throws IOException {
String fileName = pdfBaseName + "ToMarkdown.zip";
TempFile finalOut = tempFileManager.createManagedTempFile(".zip");
try (OutputStream fos = Files.newOutputStream(finalOut.getPath());
ZipOutputStream zipOutputStream = new ZipOutputStream(fos)) {
ZipEntry mdEntry = new ZipEntry(pdfBaseName + ".md");
zipOutputStream.putNextEntry(mdEntry);
zipOutputStream.write(markdown.getBytes(StandardCharsets.UTF_8));
zipOutputStream.closeEntry();
} catch (Exception e) {
finalOut.close();
throw e;
}
return WebResponseUtils.fileToWebResponse(
finalOut, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Updates image references in markdown to point to the images/ folder. Matches patterns like
* ![alt](filename.png) and converts to ![alt](images/filename.png)
@@ -115,6 +115,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getSessionLimit()
.getFfmpegSessionLimit();
case PYMUPDF_CONVERT ->
applicationProperties
.getProcessExecutor()
.getSessionLimit()
.getPyMuPdfConvertSessionLimit();
};
long timeoutMinutes =
@@ -180,6 +185,11 @@ public class ProcessExecutor {
.getProcessExecutor()
.getTimeoutMinutes()
.getFfmpegTimeoutMinutes();
case PYMUPDF_CONVERT ->
applicationProperties
.getProcessExecutor()
.getTimeoutMinutes()
.getPyMuPdfConvertTimeoutMinutes();
};
return new ProcessExecutor(
processType, semaphoreLimit, liveUpdates, timeoutMinutes);
@@ -550,7 +560,8 @@ public class ProcessExecutor {
GHOSTSCRIPT,
OCR_MY_PDF,
CFF_CONVERTER,
FFMPEG
FFMPEG,
PYMUPDF_CONVERT
}
@Setter
@@ -21,6 +21,13 @@ public class EndpointInterceptor implements HandlerInterceptor {
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String requestURI = request.getRequestURI();
// Prevent API responses from being stored by browsers or intermediary caches by default
String servletPath = request.getServletPath();
if (servletPath != null && servletPath.startsWith("/api/")) {
response.setHeader("Cache-Control", "private, no-store");
}
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
if (!isEnabled) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
@@ -101,6 +101,9 @@ public class ExternalAppDepConfig {
// Python / OpenCV special handling
checkPythonAndOpenCV();
// PyMuPDF optional acceleration
checkPyMuPdf();
dependenciesChecked = true;
} finally {
endpointConfiguration.logDisabledEndpointsSummary();
@@ -236,6 +239,15 @@ public class ExternalAppDepConfig {
}
}
private void checkPyMuPdf() {
if (isCommandAvailable("pymupdf-convert")) {
log.warn("pymupdf-convert detected — PDF->Markdown will use PyMuPDF acceleration.");
} else {
log.info(
"pymupdf-convert not found — PDF->Markdown will use the bundled pdftohtml converter.");
}
}
private void disablePythonAndOpenCV(String reason) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
@@ -1,5 +1,8 @@
package stirling.software.SPDF.config;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
@@ -24,6 +27,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
private static final CacheControl NO_CACHE = CacheControl.noCache();
private static final CacheControl IMMUTABLE_ONE_YEAR =
CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable();
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
@@ -31,37 +38,95 @@ public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
// Check customFiles/static first, then fall back to classpath
String staticPath =
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath();
// 1. Service worker and PWA metadata (never store)
// Browsers revalidate SW bytes anyway; no-store is the safest for atomic updates.
registry.addResourceHandler(
"/sw.js", "/manifest.json", "/site.webmanifest", "/browserconfig.xml")
.addResourceLocations(staticPath, "classpath:/static/")
.setCacheControl(CacheControl.noStore())
.resourceChain(true);
// 2. Vite fingerprinted assets (immutable)
// These already have content hashes in filenames (e.g. index-ChAS4tCC.js)
registry.addResourceHandler("/assets/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
.addResourceLocations(staticPath + "assets/", "classpath:/static/assets/")
.setCacheControl(IMMUTABLE_ONE_YEAR)
.resourceChain(true);
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
// Note: index.html is handled by ReactRoutingController for dynamic processing
registry.addResourceHandler("/index.html")
// 3. Media and fonts (immutable)
registry.addResourceHandler("/images/**", "/fonts/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
staticPath + "images/",
"classpath:/static/images/",
staticPath + "fonts/",
"classpath:/static/fonts/")
.setCacheControl(IMMUTABLE_ONE_YEAR)
.resourceChain(true);
// Handle all other static resources (js, css, images, fonts, etc.)
// Check customFiles/static first for user overrides
// 4. Branding and stable non-fingerprinted assets (1 day + SWR)
// Use stale-while-revalidate to improve perceived performance.
registry.addResourceHandler(
"/favicon.*",
"/apple-touch-icon.png",
"/android-chrome-*.png",
"/mstile-*.png",
"/safari-pinned-tab.svg",
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
"/pdfium/**",
"/locales/**",
"/css/**",
"/js/**",
"/vendor/**",
"/samples/**",
"/og_images/**",
"/Login/**",
"/manifest-classic.json")
.addResourceLocations(
staticPath,
"classpath:/static/",
staticPath + "pdfjs/",
"classpath:/static/pdfjs/",
staticPath + "pdfjs-legacy/",
"classpath:/static/pdfjs-legacy/",
staticPath + "pdfium/",
"classpath:/static/pdfium/",
staticPath + "locales/",
"classpath:/static/locales/",
staticPath + "css/",
"classpath:/static/css/",
staticPath + "js/",
"classpath:/static/js/",
staticPath + "vendor/",
"classpath:/static/vendor/",
staticPath + "samples/",
"classpath:/static/samples/",
staticPath + "og_images/",
"classpath:/static/og_images/",
staticPath + "Login/",
"classpath:/static/Login/")
.setCacheControl(
CacheControl.maxAge(Duration.ofDays(1))
.cachePublic()
.staleWhileRevalidate(Duration.ofDays(7)))
.resourceChain(true);
// 5. Catch-all (SPA fallback)
// Must check with server to ensure index.html is always fresh.
registry.addResourceHandler("/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
.addResourceLocations(staticPath, "classpath:/static/")
.setCacheControl(NO_CACHE)
.resourceChain(true);
}
@Override
@@ -115,9 +180,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
applicationProperties.getSystem().getCorsAllowedOrigins());
// Combine user-configured origins with Tauri origins
java.util.List<String> allOrigins =
new java.util.ArrayList<>(
applicationProperties.getSystem().getCorsAllowedOrigins());
List<String> allOrigins =
new ArrayList<>(applicationProperties.getSystem().getCorsAllowedOrigins());
// Always include Tauri origins for desktop app compatibility
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
@@ -158,7 +222,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
} else {
// Default to allowing all origins when nothing is configured
logger.debug(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
"No CORS allowed origins configured in settings.yml"
+ " (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
@@ -119,11 +119,30 @@ public class ConfigController {
String localIp = GeneralUtils.getLocalNetworkIp();
if (localIp != null) {
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
}
return "";
}
/**
* The port the embedded server is actually listening on. With {@code server.port=0} (an
* ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
* stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
* once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
* real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
*/
// visible for testing
String resolveEffectiveServerPort(AppConfig appConfig) {
String configured = appConfig.getServerPort();
if (configured == null || "0".equals(configured.trim())) {
String actual = applicationContext.getEnvironment().getProperty("local.server.port");
if (actual != null && !actual.isBlank()) {
return actual;
}
}
return configured;
}
private static boolean isLoopbackHost(String host) {
return "localhost".equalsIgnoreCase(host)
|| "127.0.0.1".equals(host)
@@ -161,7 +180,7 @@ public class ConfigController {
// Note: Frontend expects "baseUrl" field name for compatibility
configData.put("baseUrl", appConfig.getBackendUrl());
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
configData.put("serverPort", resolveEffectiveServerPort(appConfig));
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
@@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -134,13 +135,22 @@ public class ReactRoutingController {
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
try {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(processIndexHtml());
} catch (Exception ex) {
log.error("Failed to serve index.html, returning fallback", ex);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
return ResponseEntity.ok()
.cacheControl(CacheControl.noCache().mustRevalidate())
.contentType(MediaType.TEXT_HTML)
.body(buildFallbackHtml());
}
}
@@ -15,6 +15,7 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.PyMuPdfConverter;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@@ -23,6 +24,7 @@ import stirling.software.common.util.TempFileManager;
public class ConvertPDFToMarkdown {
private final TempFileManager tempFileManager;
private final PyMuPdfConverter pyMuPdfConverter;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -37,6 +39,6 @@ public class ConvertPDFToMarkdown {
throws Exception {
MultipartFile inputFile = file.getFileInput();
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
return pdfToFile.processPdfToMarkdown(inputFile);
return pdfToFile.processPdfToMarkdown(inputFile, pyMuPdfConverter);
}
}
@@ -33,7 +33,7 @@ spring.security.filter.dispatcher-types=REQUEST,ERROR
# Response compression
server.compression.enabled=true
server.compression.min-response-size=1024
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript,image/svg+xml,application/x-font-ttf,font/opentype,application/vnd.ms-fontobject,font/woff,font/woff2,application/font-woff,application/font-woff2
spring.web.error.path=/error
spring.web.error.whitelabel.enabled=false
@@ -244,4 +244,52 @@ class ConfigControllerTest {
assertNotNull(result);
assertFalse(result.contains("localhost"));
}
@Test
void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() {
System sys = mock(System.class);
when(applicationProperties.getSystem()).thenReturn(sys);
when(sys.getFrontendUrl()).thenReturn(null);
// Loopback host forces the detected-LAN-IP branch, which is where an
// ephemeral server.port=0 would otherwise leak through as ":0".
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getServerName()).thenReturn("localhost");
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
when(appConfig.getServerPort()).thenReturn("0");
org.springframework.core.env.Environment environment =
mock(org.springframework.core.env.Environment.class);
when(applicationContext.getEnvironment()).thenReturn(environment);
when(environment.getProperty("local.server.port")).thenReturn("54321");
String result = configController.resolveFrontendUrl(req, appConfig);
assertNotNull(result);
assertTrue(result.endsWith(":54321"));
assertFalse(result.contains(":0"));
}
@Test
void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() {
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getServerPort()).thenReturn("0");
org.springframework.core.env.Environment environment =
mock(org.springframework.core.env.Environment.class);
when(applicationContext.getEnvironment()).thenReturn(environment);
when(environment.getProperty("local.server.port")).thenReturn("54321");
assertEquals("54321", configController.resolveEffectiveServerPort(appConfig));
}
@Test
void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() {
AppConfig appConfig = mock(AppConfig.class);
when(appConfig.getServerPort()).thenReturn("8080");
// Non-zero configured port is authoritative; the runtime env is never consulted.
assertEquals("8080", configController.resolveEffectiveServerPort(appConfig));
}
}
@@ -2,6 +2,7 @@ package stirling.software.SPDF.model.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -23,12 +24,13 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.service.PyMuPdfConverter;
import stirling.software.common.util.PDFToFile;
class ConvertPDFToMarkdownTest {
private MockMvc mockMvc() {
return MockMvcBuilders.standaloneSetup(new ConvertPDFToMarkdown(null))
return MockMvcBuilders.standaloneSetup(new ConvertPDFToMarkdown(null, null))
.setControllerAdvice(new GlobalErrorHandler())
.build();
}
@@ -52,7 +54,9 @@ class ConvertPDFToMarkdownTest {
Mockito.mockConstruction(
PDFToFile.class,
(mock, ctx) -> {
when(mock.processPdfToMarkdown(any(MultipartFile.class)))
when(mock.processPdfToMarkdown(
any(MultipartFile.class),
nullable(PyMuPdfConverter.class)))
.thenAnswer(
inv ->
ResponseEntity.ok()
@@ -83,7 +87,8 @@ class ConvertPDFToMarkdownTest {
// And that the uploaded file was passed to processPdfToMarkdown()
PDFToFile created = construction.constructed().get(0);
ArgumentCaptor<MultipartFile> captor = ArgumentCaptor.forClass(MultipartFile.class);
verify(created, times(1)).processPdfToMarkdown(captor.capture());
verify(created, times(1))
.processPdfToMarkdown(captor.capture(), nullable(PyMuPdfConverter.class));
MultipartFile passed = captor.getValue();
// Minimal plausibility checks
@@ -98,7 +103,9 @@ class ConvertPDFToMarkdownTest {
Mockito.mockConstruction(
PDFToFile.class,
(mock, ctx) -> {
when(mock.processPdfToMarkdown(any(MultipartFile.class)))
when(mock.processPdfToMarkdown(
any(MultipartFile.class),
nullable(PyMuPdfConverter.class)))
.thenThrow(new RuntimeException("boom"));
})) {
@@ -32,7 +32,10 @@ public enum AiPdfContentType {
// Heavy content
COMPLIANCE("compliance"),
IMAGES("images");
IMAGES("images"),
// PyMuPDF worker — pre-rendered Markdown
PYMUPDF_MARKDOWN("pymupdf_markdown");
private final String value;
@@ -31,6 +31,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.FileStorage;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.PyMuPdfConverter;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFile;
@@ -74,6 +75,7 @@ public class AiWorkflowService {
private final TempFileManager tempFileManager;
private final FileIdStrategy fileIdStrategy;
private final AiEngineEndpointResolver endpointResolver;
private final PyMuPdfConverter pyMuPdfConverter;
@FunctionalInterface
public interface ProgressListener {
@@ -137,6 +139,9 @@ public class AiWorkflowService {
? new ArrayList<>()
: new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
boolean workerAvailable = pyMuPdfConverter.isAvailable();
initialRequest.setPymupdfWorkerAvailable(workerAvailable);
log.info("[pymupdf-convert] available={}", workerAvailable);
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
@@ -183,6 +188,13 @@ public class AiWorkflowService {
cannotContinue("AI engine requested content extraction more than once."));
}
// Fast path: when the engine identifies a pdf-to-markdown task and pymupdf-convert is
// available, skip feeding content back to the engine and convert directly.
if ("pdf_to_markdown".equals(response.getResumeWith())
&& request.isPymupdfWorkerAvailable()) {
return runPyMuPdfConversion(filesById, listener);
}
List<AiWorkflowFileRequest> requestedFiles = response.getFiles();
// Validate requested file ids before loading anything
@@ -365,6 +377,31 @@ public class AiWorkflowService {
return new WorkflowState.Terminal(response);
}
private WorkflowState runPyMuPdfConversion(
Map<String, MultipartFile> filesById, ProgressListener listener) throws IOException {
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.PROCESSING));
List<Resource> outputs = new ArrayList<>();
for (MultipartFile file : filesById.values()) {
String baseName =
file.getOriginalFilename() != null
? file.getOriginalFilename().replaceFirst("\\.[^.]+$", "")
: "document";
String markdown =
pyMuPdfConverter.convertToMarkdown(file.getBytes(), file.getOriginalFilename());
String safeFilename = Filenames.toSimpleFileName(baseName + ".md");
byte[] bytes = markdown.getBytes(java.nio.charset.StandardCharsets.UTF_8);
outputs.add(
new org.springframework.core.io.ByteArrayResource(bytes) {
@Override
public String getFilename() {
return safeFilename;
}
});
}
return new WorkflowState.Terminal(
buildCompletedResponse("Converted PDF to Markdown.", outputs, List.of(), null));
}
private WorkflowState onGenerateFile(AiWorkflowResponse response, ProgressListener listener)
throws IOException {
String content = response.getGeneratedContent();
@@ -745,5 +782,6 @@ public class AiWorkflowService {
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
private List<String> enabledEndpoints = new ArrayList<>();
private boolean pymupdfWorkerAvailable;
}
}
@@ -30,6 +30,7 @@ import stirling.software.SPDF.pdf.parser.PdfModels.RawLine;
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
import stirling.software.SPDF.pdf.parser.PdfModels.TextFragment;
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
import stirling.software.common.service.PyMuPdfConverter;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
@@ -44,6 +45,7 @@ public class PdfContentExtractor {
private final TabulaTableParser tabulaTableParser;
private final PdfIngester pdfIngester;
private final PyMuPdfConverter pyMuPdfConverter;
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
@@ -190,6 +192,8 @@ public class PdfContentExtractor {
extractText(lf, fileReq, remainingPages, remainingCharacters));
case PAGE_LAYOUT ->
Optional.<PdfContentResult>ofNullable(extractPageLayout(lf, remainingPages));
case PYMUPDF_MARKDOWN ->
Optional.<PdfContentResult>ofNullable(extractPyMuPdfMarkdown(lf));
default -> {
log.warn(
"Content type {} not yet implemented, skipping for {}",
@@ -255,6 +259,11 @@ public class PdfContentExtractor {
artifact.setFiles(results.stream().map(PageLayoutFileResult.class::cast).toList());
yield artifact;
}
case PYMUPDF_MARKDOWN -> {
PyMuPdfMarkdownArtifact artifact = new PyMuPdfMarkdownArtifact();
artifact.setFiles(results.stream().map(PyMuPdfMarkdownResult.class::cast).toList());
yield artifact;
}
case TOOL_REPORT ->
throw new IllegalArgumentException(
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
@@ -370,7 +379,8 @@ public class PdfContentExtractor {
enum ArtifactKind {
EXTRACTED_TEXT("extracted_text"),
PAGE_LAYOUT("page_layout"),
TOOL_REPORT("tool_report");
TOOL_REPORT("tool_report"),
PYMUPDF_MARKDOWN("pymupdf_markdown");
private final String value;
@@ -469,4 +479,46 @@ public class PdfContentExtractor {
private final ArtifactKind kind = ArtifactKind.PAGE_LAYOUT;
private List<PageLayoutFileResult> files = new ArrayList<>();
}
private PyMuPdfMarkdownResult extractPyMuPdfMarkdown(LoadedFile lf) {
try {
log.info("[pymupdf-convert] converting file={}", lf.fileName());
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
lf.document().save(baos);
String markdown = pyMuPdfConverter.convertToMarkdown(baos.toByteArray(), lf.fileName());
log.info(
"[pymupdf-convert] success file={} markdown-chars={}",
lf.fileName(),
markdown.length());
PyMuPdfMarkdownResult result = new PyMuPdfMarkdownResult();
result.setFileName(lf.fileName());
result.setMarkdown(markdown);
return result;
} catch (Exception e) {
log.warn(
"[pymupdf-convert] failed for file={}, falling back to page layout: {}",
lf.fileName(),
e.getMessage());
return null;
}
}
/** PyMuPDF worker pre-rendered Markdown for one file. */
@Data
static final class PyMuPdfMarkdownResult implements PdfContentResult {
private String fileName;
private String markdown;
@Override
public ArtifactKind getArtifactKind() {
return ArtifactKind.PYMUPDF_MARKDOWN;
}
}
/** Artifact carrying PyMuPDF-rendered Markdown for all input files. */
@Data
static final class PyMuPdfMarkdownArtifact implements WorkflowArtifact {
private final ArtifactKind kind = ArtifactKind.PYMUPDF_MARKDOWN;
private List<PyMuPdfMarkdownResult> files = new ArrayList<>();
}
}
@@ -49,6 +49,7 @@ import stirling.software.common.service.FileStorage;
import stirling.software.common.service.FileStorage.StoredFile;
import stirling.software.common.service.InternalApiClient;
import stirling.software.common.service.InternalApiTimeoutException;
import stirling.software.common.service.PyMuPdfConverter;
import stirling.software.common.service.ToolMetadataService;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.TempFileRegistry;
@@ -85,6 +86,7 @@ class AiWorkflowServiceTest {
@Mock private ToolMetadataService toolMetadataService;
@Mock private FileIdStrategy fileIdStrategy;
@Mock private AiEngineEndpointResolver endpointResolver;
@Mock private PyMuPdfConverter pyMuPdfConverter;
@TempDir Path tempDir;
@@ -117,7 +119,8 @@ class AiWorkflowServiceTest {
toolMetadataService,
tempFileManager,
fileIdStrategy,
endpointResolver);
endpointResolver,
pyMuPdfConverter);
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
}
@@ -0,0 +1,28 @@
package stirling.software.saas.payg.policy;
import org.springframework.context.ApplicationEvent;
/**
* Fires after a successful admin write to a {@code pricing_policy*} or {@code
* payg_team_extensions.pricing_policy_id} row. {@link PricingPolicyService} listens and invalidates
* its in-process cache so the writer instance reflects the change immediately. Other instances pick
* up the change on the next 30-second TTL expiry.
*
* <p>{@code payload} is informational only ({@code "create:42"}, {@code "setDefault:7"}, etc.) —
* the invalidation strategy is "blow the whole cache" regardless of what changed.
*/
public class PolicyChangedEvent extends ApplicationEvent {
private static final long serialVersionUID = 1L;
private final String payload;
public PolicyChangedEvent(Object source, String payload) {
super(source);
this.payload = payload;
}
public String getPayload() {
return payload;
}
}
@@ -0,0 +1,255 @@
package stirling.software.saas.payg.policy;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.PricingPolicyRepository;
/**
* Read-side facade over {@link PricingPolicyRepository}. The hot-path question is "what pricing
* policy applies to this team right now?" — answered by either the team's per-team override (via
* {@link PaygTeamExtensions#getPricingPolicyId()}) or the row with {@code is_default = TRUE}.
*
* <p>Reads are cached per-{@code teamId} for {@value #CACHE_TTL_SECONDS} seconds. The TTL is the
* correctness floor: a policy change is visible on every instance within that window without any
* coordination. Admin writes additionally fire a {@link PolicyChangedEvent} after commit so the
* instance handling the write sees its own change immediately; other instances pick it up on the
* next TTL expiry.
*
* <p><b>Writes are transactional and publish a {@link PolicyChangedEvent} after commit.</b> The
* after-commit timing matters: publishing inside the tx would clear caches on instances that
* haven't yet seen the row change, racing them into re-reading stale state. After-commit (via
* {@link TransactionSynchronizationManager}) guarantees the new state is visible before any
* listener fires.
*
* <p><b>Cache value is a JPA entity.</b> Callers must not mutate the returned policy — treat as
* read-only. We accept this rather than wrapping in a DTO to keep the PR small; if mutation becomes
* a footgun, swap the cache value type for an immutable snapshot.
*/
@Service
@Slf4j
public class PricingPolicyService {
static final int CACHE_TTL_SECONDS = 30;
private static final int CACHE_MAX_SIZE = 10_000;
private final PricingPolicyRepository policyRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final ApplicationEventPublisher eventPublisher;
/**
* Cache keyed by {@code teamId}. Null teamId not supported (caller's bug). Value is the
* effective policy — either the team's override or the default row.
*/
private final Cache<Long, PricingPolicy> byTeamCache;
public PricingPolicyService(
PricingPolicyRepository policyRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
ApplicationEventPublisher eventPublisher) {
this.policyRepository = Objects.requireNonNull(policyRepository, "policyRepository");
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher");
this.byTeamCache =
Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
.recordStats()
.build();
}
/**
* Resolves the effective policy for {@code teamId}: per-team override if set, else the row with
* {@code is_default = TRUE}. Throws {@link IllegalStateException} if no default exists — the
* seed migration is expected to put one there.
*
* <p>{@link Transactional}({@code readOnly = true}) so the eager-loaded {@code stepLimits} and
* {@code stripePriceIds} collections initialize inside the same session.
*/
@Transactional(readOnly = true)
public PricingPolicy getEffectivePolicy(Long teamId) {
Objects.requireNonNull(teamId, "teamId");
return byTeamCache.get(teamId, this::loadEffectivePolicy);
}
/** Bypasses the cache. Useful for admin endpoints that want a fresh read after a mutation. */
@Transactional(readOnly = true)
public PricingPolicy getEffectivePolicyUncached(Long teamId) {
Objects.requireNonNull(teamId, "teamId");
return loadEffectivePolicy(teamId);
}
/** Lists every policy (admin read). Not cached — admin pages should always see fresh state. */
@Transactional(readOnly = true)
public List<PricingPolicy> listAll() {
return policyRepository.findAll();
}
@Transactional(readOnly = true)
public Optional<PricingPolicy> findByVersion(String version) {
return policyRepository.findByVersion(version);
}
@Transactional(readOnly = true)
public Optional<PricingPolicy> findById(Long policyId) {
return policyRepository.findById(policyId);
}
/** Creates a new policy row. Publishes {@link PolicyChangedEvent} after commit. */
@Transactional
public PricingPolicy create(PricingPolicy draft) {
Objects.requireNonNull(draft, "draft");
if (draft.getId() != null) {
throw new IllegalArgumentException(
"Create draft must not carry a policy_id; use update() to modify an existing"
+ " row.");
}
if (Boolean.TRUE.equals(draft.getIsDefault())) {
// Promotion to default must go through setDefault() so the existing default is
// atomically cleared first; otherwise the partial unique index rejects the insert.
throw new IllegalArgumentException(
"Create with is_default=true is not allowed; create the row then call"
+ " setDefault(id).");
}
PricingPolicy saved = policyRepository.save(draft);
publishOnCommit("create:" + saved.getId());
return saved;
}
/**
* Promotes {@code newDefaultId} to be the default policy, atomically clearing the existing
* default first. Idempotent — calling with a row already flagged default is a silent no-op (no
* event fired; no state actually changed).
*/
@Transactional
public PricingPolicy setDefault(Long newDefaultId) {
Objects.requireNonNull(newDefaultId, "newDefaultId");
PricingPolicy target =
policyRepository
.findById(newDefaultId)
.orElseThrow(
() ->
new IllegalArgumentException(
"No pricing_policy with id " + newDefaultId));
if (Boolean.TRUE.equals(target.getIsDefault())) {
return target;
}
policyRepository.clearDefaultFlag();
target.setIsDefault(true);
PricingPolicy saved = policyRepository.save(target);
publishOnCommit("setDefault:" + saved.getId());
return saved;
}
/**
* Sets {@code teamId}'s per-team policy override. {@code policyId = null} clears the override
* (team falls back to default). Validates the policy exists.
*/
@Transactional
public void setTeamOverride(Long teamId, Long policyId) {
Objects.requireNonNull(teamId, "teamId");
if (policyId != null && !policyRepository.existsById(policyId)) {
throw new IllegalArgumentException("No pricing_policy with id " + policyId);
}
PaygTeamExtensions extensions =
teamExtensionsRepository
.findById(teamId)
.orElseThrow(
() ->
new IllegalStateException(
"No payg_team_extensions row for team "
+ teamId
+ " — should have been created on first"
+ " PAYG access."));
extensions.setPricingPolicyId(policyId);
teamExtensionsRepository.save(extensions);
publishOnCommit("teamOverride:" + teamId);
}
/**
* Invalidates the cache. Called on every {@link PolicyChangedEvent} regardless of which row
* changed — cache hit rate is already team-scoped so the cost of a clear is bounded by how many
* active teams there are.
*/
@EventListener
public void onPolicyChanged(PolicyChangedEvent event) {
long evicted = byTeamCache.estimatedSize();
byTeamCache.invalidateAll();
log.debug(
"PricingPolicyService cache invalidated (payload='{}', approx {} entries dropped)",
event.getPayload(),
evicted);
}
/** Visible for tests. */
long cacheSize() {
return byTeamCache.estimatedSize();
}
/**
* Schedules a {@link PolicyChangedEvent} to fire after the current transaction commits, or
* fires immediately if no transaction is active (e.g. test paths calling write methods without
* a tx). Inside-transaction firing would have listeners clearing caches before the row change
* is visible to other connections — racing them into re-reading stale state.
*/
private void publishOnCommit(String payload) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
eventPublisher.publishEvent(
new PolicyChangedEvent(PricingPolicyService.this, payload));
}
});
} else {
eventPublisher.publishEvent(new PolicyChangedEvent(this, payload));
}
}
private PricingPolicy loadEffectivePolicy(Long teamId) {
Optional<Long> overrideId =
teamExtensionsRepository
.findById(teamId)
.map(PaygTeamExtensions::getPricingPolicyId);
if (overrideId.isPresent()) {
Long id = overrideId.get();
Optional<PricingPolicy> override = policyRepository.findById(id);
if (override.isPresent()) {
return override.get();
}
// Override points at a missing policy — log and fall through to default rather than
// failing hard. The admin path that sets the override should validate up front; this
// is a safety net for racing deletes.
log.warn(
"Team {} has pricing_policy_id={} set as override but that row is missing;"
+ " falling back to default.",
teamId,
id);
}
return policyRepository
.findFirstByIsDefaultTrue()
.orElseThrow(
() ->
new IllegalStateException(
"No default pricing_policy row found — the V11 seed"
+ " migration must run before"
+ " PricingPolicyService is reachable."));
}
}
@@ -0,0 +1,82 @@
package stirling.software.saas.payg.policy.admin;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.policy.PricingPolicy;
/**
* Request/response DTOs for the pricing-policy admin endpoints. Records rather than the JPA entity
* directly so the admin API surface is decoupled from internal columns (e.g. {@code @Version}
* optimistic-lock fields, audit timestamps).
*/
final class PolicyDtos {
private PolicyDtos() {}
/** Outbound representation of a {@link PricingPolicy}. */
record PolicyResponse(
Long policyId,
String version,
LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
Integer docPagesPerUnit,
Long docBytesPerUnit,
Integer minChargeUnits,
Integer fileUnitCap,
Map<JobSource, Integer> stepLimits,
Set<String> stripePriceIds,
Boolean isDefault,
String notes,
String createdBy,
LocalDateTime createdAt) {
static PolicyResponse from(PricingPolicy p) {
return new PolicyResponse(
p.getId(),
p.getVersion(),
p.getEffectiveFrom(),
p.getEffectiveTo(),
p.getDocPagesPerUnit(),
p.getDocBytesPerUnit(),
p.getMinChargeUnits(),
p.getFileUnitCap(),
// Copy the outer collections so a caller's mutation can't leak back into the
// cached entity. Values (Integer, String) are immutable, so a shallow copy is
// sufficient here.
new HashMap<>(p.getStepLimits()),
new HashSet<>(p.getStripePriceIds()),
p.getIsDefault(),
p.getNotes(),
p.getCreatedBy(),
p.getCreatedAt());
}
}
/**
* Inbound payload for {@code POST /policies}. {@code stepLimits} and {@code stripePriceIds}
* default to empty collections if omitted. {@code effectiveFrom} defaults to {@code now()}.
*/
record CreatePolicyRequest(
String version,
LocalDateTime effectiveFrom,
LocalDateTime effectiveTo,
Integer docPagesPerUnit,
Long docBytesPerUnit,
Integer minChargeUnits,
Integer fileUnitCap,
Map<JobSource, Integer> stepLimits,
Set<String> stripePriceIds,
String notes,
String createdBy) {}
/**
* Inbound payload for {@code PUT /teams/{teamId}/policy-override}. {@code policyId = null}
* clears the override (team falls back to default).
*/
record TeamOverrideRequest(Long policyId) {}
}
@@ -0,0 +1,170 @@
package stirling.software.saas.payg.policy.admin;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
/**
* Admin-only CRUD for {@link PricingPolicy} rows + per-team override + default-promotion. Every
* mutation routes through {@link PricingPolicyService} so the cache invalidation event is published
* exactly once per mutation, after commit. Reads return live data (no cache) so admins always see
* their own write.
*
* <p>Path namespace {@code /api/v1/admin/payg/...} matches the design's other admin endpoints
* (cap-setting, cohort migration). Every endpoint requires {@code ROLE_ADMIN}.
*/
@Hidden
@RestController
@RequestMapping("/api/v1/admin/payg")
@Profile("saas")
@Tag(name = "PAYG Admin — Pricing Policy", description = "Admin CRUD for pricing policies")
@RequiredArgsConstructor
@Slf4j
public class PricingPolicyAdminController {
private final PricingPolicyService policyService;
@GetMapping("/policies")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "List all pricing policies (admin)")
public ResponseEntity<List<PolicyResponse>> listPolicies() {
return ResponseEntity.ok(
policyService.listAll().stream().map(PolicyResponse::from).toList());
}
@GetMapping("/policies/{policyId}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get a single pricing policy by id (admin)")
public ResponseEntity<PolicyResponse> getPolicy(@PathVariable Long policyId) {
return policyService
.findById(policyId)
.map(p -> ResponseEntity.ok(PolicyResponse.from(p)))
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping("/policies")
@PreAuthorize("hasRole('ADMIN')")
@Operation(
summary = "Create a new pricing policy (admin)",
description =
"Creates a non-default policy. To promote to default, call set-default after"
+ " creation.")
public ResponseEntity<?> createPolicy(@RequestBody CreatePolicyRequest req) {
try {
PricingPolicy draft = mapCreateRequest(req);
PricingPolicy saved = policyService.create(draft);
return ResponseEntity.status(HttpStatus.CREATED).body(PolicyResponse.from(saved));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(error(e.getMessage()));
}
}
@PostMapping("/policies/{policyId}/set-default")
@PreAuthorize("hasRole('ADMIN')")
@Operation(
summary = "Promote a policy to default (admin)",
description =
"Atomically clears the existing default flag and sets this row's flag."
+ " Teams without an override use the default.")
public ResponseEntity<?> setDefault(@PathVariable Long policyId) {
try {
PricingPolicy promoted = policyService.setDefault(policyId);
return ResponseEntity.ok(PolicyResponse.from(promoted));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error(e.getMessage()));
}
}
@PutMapping("/teams/{teamId}/policy-override")
@PreAuthorize("hasRole('ADMIN')")
@Operation(
summary = "Set or clear a team's per-team pricing-policy override (admin)",
description =
"Payload {policyId: <id>} sets the override; {policyId: null} clears it"
+ " (team falls back to default).")
public ResponseEntity<?> setTeamOverride(
@PathVariable Long teamId, @RequestBody TeamOverrideRequest req) {
try {
policyService.setTeamOverride(teamId, req == null ? null : req.policyId());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException | IllegalStateException e) {
HttpStatus status =
e instanceof IllegalStateException
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
return ResponseEntity.status(status).body(error(e.getMessage()));
}
}
@GetMapping("/teams/{teamId}/effective-policy")
@PreAuthorize("hasRole('ADMIN')")
@Operation(
summary = "Read the effective policy for a team (admin)",
description =
"Returns the override if set, else the default. Bypasses the read cache so"
+ " admins always see the latest state.")
public ResponseEntity<PolicyResponse> getEffectivePolicy(@PathVariable Long teamId) {
return ResponseEntity.ok(
PolicyResponse.from(policyService.getEffectivePolicyUncached(teamId)));
}
private static PricingPolicy mapCreateRequest(CreatePolicyRequest req) {
if (req == null) {
throw new IllegalArgumentException("Request body required.");
}
if (req.version() == null || req.version().isBlank()) {
throw new IllegalArgumentException("version is required.");
}
if (req.docPagesPerUnit() == null || req.docBytesPerUnit() == null) {
throw new IllegalArgumentException("docPagesPerUnit and docBytesPerUnit are required.");
}
PricingPolicy p = new PricingPolicy();
p.setVersion(req.version());
p.setEffectiveFrom(req.effectiveFrom() != null ? req.effectiveFrom() : LocalDateTime.now());
p.setEffectiveTo(req.effectiveTo());
p.setDocPagesPerUnit(req.docPagesPerUnit());
p.setDocBytesPerUnit(req.docBytesPerUnit());
p.setMinChargeUnits(req.minChargeUnits() != null ? req.minChargeUnits() : 1);
p.setFileUnitCap(req.fileUnitCap() != null ? req.fileUnitCap() : 1000);
p.setStepLimits(
req.stepLimits() != null ? new HashMap<>(req.stepLimits()) : new HashMap<>());
p.setStripePriceIds(
req.stripePriceIds() != null
? new HashSet<>(req.stripePriceIds())
: new HashSet<>());
p.setIsDefault(false);
p.setNotes(req.notes());
p.setCreatedBy(req.createdBy());
return p;
}
private static java.util.Map<String, String> error(String message) {
return java.util.Map.of("error", message == null ? "unknown" : message);
}
}
@@ -3,6 +3,8 @@ package stirling.software.saas.payg.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import stirling.software.saas.payg.policy.PricingPolicy;
@@ -13,4 +15,15 @@ public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Lo
Optional<PricingPolicy> findByVersion(String version);
Optional<PricingPolicy> findFirstByIsDefaultTrue();
/**
* Atomically clears the {@code is_default} flag on whichever row currently carries it. Used by
* {@code setDefault(newId)} to free the slot before flipping the new row's flag — the {@code
* uq_pricing_policy_default} partial unique index would otherwise reject the second row.
*
* <p>Returns the count of rows updated (0 if no default existed yet, 1 normally).
*/
@Modifying
@Query("UPDATE PricingPolicy p SET p.isDefault = false WHERE p.isDefault = true")
int clearDefaultFlag();
}
@@ -0,0 +1,37 @@
-- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists.
-- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once
-- Stripe Prices are wired in production.
--
-- This migration is separated from V11 because V11 has already shipped to main — adding rows to
-- it would change its Flyway checksum and break existing deployments.
INSERT INTO pricing_policy (
version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, is_default, notes, created_by
)
SELECT
'v1-initial', CURRENT_TIMESTAMP, 25, 5242880,
1, 1000, TRUE,
'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.',
'system'
WHERE NOT EXISTS (
SELECT 1 FROM pricing_policy WHERE is_default = TRUE
);
-- Step limits for the default policy across every JobSource. References the row inserted above
-- via the partial unique index on is_default=TRUE.
INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, src.job_source, src.step_limit
FROM pricing_policy p
CROSS JOIN (
VALUES
('WEB', 10),
('API', 10),
('PIPELINE', 20), -- automations get a longer chain
('DESKTOP_APP', 10)
) AS src(job_source, step_limit)
WHERE p.is_default = TRUE
AND NOT EXISTS (
SELECT 1 FROM pricing_policy_step_limit s
WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source
);
@@ -0,0 +1,268 @@
package stirling.software.saas.payg.policy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.context.ApplicationEventPublisher;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.PricingPolicyRepository;
/**
* Unit tests for {@link PricingPolicyService}: lookup precedence (override → default), cache
* hit/miss, invalidation on {@link PolicyChangedEvent}, mutation paths publishing the event.
*/
class PricingPolicyServiceTest {
private PricingPolicyRepository policyRepo;
private PaygTeamExtensionsRepository extensionsRepo;
private ApplicationEventPublisher events;
private PricingPolicyService service;
private PricingPolicy defaultPolicy;
private PricingPolicy overridePolicy;
@BeforeEach
void setUp() {
policyRepo = Mockito.mock(PricingPolicyRepository.class);
extensionsRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
events = Mockito.mock(ApplicationEventPublisher.class);
service = new PricingPolicyService(policyRepo, extensionsRepo, events);
defaultPolicy = policy(1L, "v1-default", true);
overridePolicy = policy(2L, "v1-enterprise", false);
when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.of(defaultPolicy));
when(policyRepo.findById(1L)).thenReturn(Optional.of(defaultPolicy));
when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
when(policyRepo.existsById(2L)).thenReturn(true);
}
@Test
void noOverride_returnsDefault() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
PricingPolicy result = service.getEffectivePolicy(42L);
assertThat(result).isEqualTo(defaultPolicy);
}
@Test
void overrideSet_returnsOverride() {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(42L);
ext.setPricingPolicyId(2L);
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
PricingPolicy result = service.getEffectivePolicy(42L);
assertThat(result).isEqualTo(overridePolicy);
}
@Test
void overridePointsAtMissingPolicy_fallsBackToDefault() {
// Race condition: team's override row references a policy that has since been deleted.
// Service should log + fall back rather than throw, so the team still gets billed
// correctly under the default.
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(42L);
ext.setPricingPolicyId(999L);
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
when(policyRepo.findById(999L)).thenReturn(Optional.empty());
PricingPolicy result = service.getEffectivePolicy(42L);
assertThat(result).isEqualTo(defaultPolicy);
}
@Test
void noDefaultExists_throws() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getEffectivePolicy(42L))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No default pricing_policy row");
}
@Test
void secondCallHitsCache_noRepoLookup() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
service.getEffectivePolicy(42L);
service.getEffectivePolicy(42L);
service.getEffectivePolicy(42L);
// Three calls, one DB lookup — the cache holds the result.
verify(policyRepo, times(1)).findFirstByIsDefaultTrue();
verify(extensionsRepo, times(1)).findById(42L);
}
@Test
void uncachedRead_alwaysHitsRepo() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
service.getEffectivePolicyUncached(42L);
service.getEffectivePolicyUncached(42L);
verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
}
@Test
void policyChangedEvent_invalidatesCache() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
service.getEffectivePolicy(42L);
assertThat(service.cacheSize()).isEqualTo(1);
service.onPolicyChanged(new PolicyChangedEvent(this, "test"));
assertThat(service.cacheSize()).isZero();
// Next call repopulates from DB.
service.getEffectivePolicy(42L);
verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
}
@Test
void create_rejectsDraftWithId() {
PricingPolicy draft = policy(99L, "v2", false);
assertThatThrownBy(() -> service.create(draft))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must not carry a policy_id");
verify(events, never()).publishEvent(any());
}
@Test
void create_rejectsDefaultFlagPreSet() {
PricingPolicy draft = policy(null, "v2", true);
assertThatThrownBy(() -> service.create(draft))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("setDefault");
verify(events, never()).publishEvent(any());
}
@Test
void create_savesAndPublishesEvent() {
PricingPolicy draft = policy(null, "v2-fresh", false);
PricingPolicy saved = policy(3L, "v2-fresh", false);
when(policyRepo.save(draft)).thenReturn(saved);
PricingPolicy result = service.create(draft);
assertThat(result).isEqualTo(saved);
ArgumentCaptor<PolicyChangedEvent> evt = ArgumentCaptor.forClass(PolicyChangedEvent.class);
verify(events).publishEvent(evt.capture());
assertThat(evt.getValue().getPayload()).contains("create:3");
}
@Test
void setDefault_promotesAndClearsExisting() {
// newDefaultId = 2, current default is 1
PricingPolicy promoted = policy(2L, "v1-enterprise", true);
when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
when(policyRepo.save(any(PricingPolicy.class))).thenReturn(promoted);
PricingPolicy result = service.setDefault(2L);
verify(policyRepo).clearDefaultFlag();
assertThat(result.getIsDefault()).isTrue();
verify(events, atLeastOnce()).publishEvent(any(PolicyChangedEvent.class));
}
@Test
void setDefault_alreadyDefault_isNoop() {
// Calling setDefault on the row that's already default → return it, don't re-flag, but
// still don't fire an event (no state change). Keeps callers idempotent without spamming
// listeners.
PricingPolicy result = service.setDefault(1L);
assertThat(result).isEqualTo(defaultPolicy);
verify(policyRepo, never()).clearDefaultFlag();
verify(policyRepo, never()).save(any(PricingPolicy.class));
verify(events, never()).publishEvent(any());
}
@Test
void setDefault_unknownId_throws() {
when(policyRepo.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.setDefault(999L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("No pricing_policy with id 999");
verify(events, never()).publishEvent(any());
}
@Test
void setTeamOverride_setsAndPublishes() {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(42L);
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
service.setTeamOverride(42L, 2L);
assertThat(ext.getPricingPolicyId()).isEqualTo(2L);
verify(extensionsRepo).save(ext);
verify(events).publishEvent(any(PolicyChangedEvent.class));
}
@Test
void setTeamOverride_clearsWithNullPolicyId() {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(42L);
ext.setPricingPolicyId(2L);
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
service.setTeamOverride(42L, null);
assertThat(ext.getPricingPolicyId()).isNull();
}
@Test
void setTeamOverride_unknownPolicyId_throwsBeforeSave() {
when(policyRepo.existsById(999L)).thenReturn(false);
assertThatThrownBy(() -> service.setTeamOverride(42L, 999L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("No pricing_policy with id 999");
verify(extensionsRepo, never()).save(any(PaygTeamExtensions.class));
verify(events, never()).publishEvent(any());
}
@Test
void setTeamOverride_missingExtensionsRow_throws() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.setTeamOverride(42L, 2L))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("payg_team_extensions row");
}
private static PricingPolicy policy(Long id, String version, boolean isDefault) {
PricingPolicy p = new PricingPolicy();
p.setId(id);
p.setVersion(version);
p.setEffectiveFrom(LocalDateTime.now());
p.setDocPagesPerUnit(25);
p.setDocBytesPerUnit(5L * 1024 * 1024);
p.setMinChargeUnits(1);
p.setFileUnitCap(1000);
p.setIsDefault(isDefault);
return p;
}
}
@@ -0,0 +1,230 @@
package stirling.software.saas.payg.policy.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
/**
* Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
* CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
* error mapping (4xx for validation, 404 for missing rows).
*/
@ExtendWith(MockitoExtension.class)
class PricingPolicyAdminControllerTest {
@Mock private PricingPolicyService service;
private PricingPolicyAdminController controller;
@BeforeEach
void setUp() {
controller = new PricingPolicyAdminController(service);
}
@Test
void listPolicies_returnsAll() {
when(service.listAll())
.thenReturn(List.of(policy(1L, "v1", true), policy(2L, "v2", false)));
ResponseEntity<List<PolicyResponse>> resp = controller.listPolicies();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).hasSize(2);
assertThat(resp.getBody().get(0).version()).isEqualTo("v1");
}
@Test
void getPolicy_returnsOk() {
when(service.findById(1L)).thenReturn(Optional.of(policy(1L, "v1", true)));
ResponseEntity<PolicyResponse> resp = controller.getPolicy(1L);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().policyId()).isEqualTo(1L);
}
@Test
void getPolicy_missingReturns404() {
when(service.findById(999L)).thenReturn(Optional.empty());
ResponseEntity<PolicyResponse> resp = controller.getPolicy(999L);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void createPolicy_happyPath() {
CreatePolicyRequest req =
new CreatePolicyRequest(
"v2",
LocalDateTime.now(),
null,
25,
5L * 1024 * 1024,
1,
1000,
null,
null,
"notes",
"admin@example.com");
PricingPolicy saved = policy(99L, "v2", false);
ArgumentCaptor<PricingPolicy> draft = ArgumentCaptor.forClass(PricingPolicy.class);
when(service.create(draft.capture())).thenReturn(saved);
ResponseEntity<?> resp = controller.createPolicy(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(((PolicyResponse) resp.getBody()).policyId()).isEqualTo(99L);
assertThat(draft.getValue().getVersion()).isEqualTo("v2");
// Controller must never let isDefault=true through to the service — setDefault is the
// only path for promotion.
assertThat(draft.getValue().getIsDefault()).isFalse();
}
@Test
void createPolicy_missingVersion_returns400() {
CreatePolicyRequest req =
new CreatePolicyRequest(
null, null, null, 25, 5L * 1024 * 1024, 1, 1000, null, null, null, null);
ResponseEntity<?> resp = controller.createPolicy(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verifyNoInteractions(service);
}
@Test
void createPolicy_missingDocFields_returns400() {
CreatePolicyRequest req =
new CreatePolicyRequest(
"v2", null, null, null, null, 1, 1000, null, null, null, null);
ResponseEntity<?> resp = controller.createPolicy(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verifyNoInteractions(service);
}
@Test
void setDefault_returnsOk() {
when(service.setDefault(2L)).thenReturn(policy(2L, "v2-promoted", true));
ResponseEntity<?> resp = controller.setDefault(2L);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(((PolicyResponse) resp.getBody()).isDefault()).isTrue();
}
@Test
void setDefault_unknownId_returns404() {
when(service.setDefault(999L))
.thenThrow(new IllegalArgumentException("No pricing_policy with id 999"));
ResponseEntity<?> resp = controller.setDefault(999L);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void setTeamOverride_noContent() {
TeamOverrideRequest req = new TeamOverrideRequest(2L);
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(service).setTeamOverride(42L, 2L);
}
@Test
void setTeamOverride_nullBody_clearsOverride() {
// Curl with no body, or {} → req == null is handled as "clear".
ResponseEntity<?> resp = controller.setTeamOverride(42L, null);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(service).setTeamOverride(42L, null);
}
@Test
void setTeamOverride_unknownPolicy_returns400() {
TeamOverrideRequest req = new TeamOverrideRequest(999L);
org.mockito.Mockito.doThrow(new IllegalArgumentException("No pricing_policy with id 999"))
.when(service)
.setTeamOverride(42L, 999L);
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void setTeamOverride_missingTeamExtensions_returns404() {
TeamOverrideRequest req = new TeamOverrideRequest(2L);
org.mockito.Mockito.doThrow(new IllegalStateException("No payg_team_extensions row"))
.when(service)
.setTeamOverride(42L, 2L);
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getEffectivePolicy_bypassesCache() {
when(service.getEffectivePolicyUncached(42L)).thenReturn(policy(1L, "v1", true));
ResponseEntity<PolicyResponse> resp = controller.getEffectivePolicy(42L);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().version()).isEqualTo("v1");
verify(service).getEffectivePolicyUncached(42L);
}
@Test
void policyResponse_collectionsAreDefensiveCopies() {
PricingPolicy p = policy(1L, "v1", true);
p.setStepLimits(new java.util.HashMap<>(Map.of()));
p.setStripePriceIds(new java.util.HashSet<>());
PolicyResponse resp = PolicyResponse.from(p);
// Mutating the source after building the response should not affect the response.
p.getStepLimits().put(stirling.software.saas.payg.model.JobSource.WEB, 99);
p.getStripePriceIds().add("price_xyz");
assertThat(resp.stepLimits()).isEmpty();
assertThat(resp.stripePriceIds()).isEmpty();
}
private static PricingPolicy policy(Long id, String version, boolean isDefault) {
PricingPolicy p = new PricingPolicy();
p.setId(id);
p.setVersion(version);
p.setEffectiveFrom(LocalDateTime.now());
p.setDocPagesPerUnit(25);
p.setDocBytesPerUnit(5L * 1024 * 1024);
p.setMinChargeUnits(1);
p.setFileUnitCap(1000);
p.setIsDefault(isDefault);
return p;
}
}
+11 -4
View File
@@ -348,12 +348,17 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
python3 python3-venv ca-certificates binutils && \
rm -rf /var/lib/apt/lists/*
COPY scripts/pymupdf_convert.py /tmp/pymupdf_convert.py
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
python3 -m venv /opt/venv --system-site-packages && \
/opt/venv/bin/pip install --no-cache-dir --prefer-binary --only-binary=:all: \
weasyprint pdf2image opencv-python-headless ocrmypdf \
cryptography \
"unoserver==${UNOSERVER_VERSION}" && \
"unoserver==${UNOSERVER_VERSION}" \
pymupdf pymupdf4llm && \
install -m 0755 /tmp/pymupdf_convert.py /usr/local/bin/pymupdf-convert && \
rm /tmp/pymupdf_convert.py && \
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \
find /opt/venv \( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true && \
rm -rf /opt/venv/lib/python*/site-packages/pip \
@@ -608,6 +613,8 @@ RUN ldconfig /usr/local/lib && \
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)" && \
PYTHONDONTWRITEBYTECODE=1 \
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')" && \
PYTHONDONTWRITEBYTECODE=1 \
/opt/venv/bin/python -c "import pymupdf4llm; print('pymupdf4llm OK')" && \
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# Non-root user
@@ -643,9 +650,9 @@ RUN set -eux; \
ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert; \
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
ln -sf /opt/venv/bin/ocrmypdf /usr/local/bin/ocrmypdf; \
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
ln -sf /opt/venv/bin/ocrmypdf /usr/local/bin/ocrmypdf; \
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
fc-cache -f
# Metadata labels - base image
@@ -47,6 +47,7 @@ class OrchestratorRequest(ApiModel):
enabled_endpoints: Annotated[list[ToolEndpoint], BeforeValidator(drop_unknown_tool_endpoints)] = Field(
default_factory=list
)
pymupdf_worker_available: bool = False
class UnsupportedCapabilityResponse(ApiModel):
@@ -90,6 +90,7 @@ nothingToUndo = "Nothing to undo"
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
noValidFiles = "No valid files to process"
oops = "Oops!"
openInNewWindow = "Open in new window"
openInViewer = "Open in Viewer"
operationCancelled = "Operation cancelled"
page = "Page"
@@ -1505,6 +1506,32 @@ user = "User"
usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address."
webOnlyUser = "Web Only User"
[agents]
auto_redaction_description = "Redact PII automatically"
auto_redaction_name = "Auto Redaction"
back_to_tools = "Back to tools"
coming_soon = "Coming soon"
compliance_description = "Audit documents for compliance"
compliance_name = "Compliance Check"
data_extraction_description = "Extract tables & structured data"
data_extraction_name = "Data Extraction"
doc_summary_description = "Summarise long documents"
doc_summary_name = "Summariser"
form_filler_description = "Fill PDF forms intelligently"
form_filler_name = "Form Filler"
fullscreen_title = "Stirling Agents"
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
pdf_to_markdown_name = "PDF to Markdown"
section_title = "Agents"
show_less = "Show less"
start_chat = "Start chatting"
stirling_description = "Your general-purpose PDF assistant"
stirling_full_name = "Stirling General Agent"
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
stirling_name = "Stirling"
stirling_tooltip = "Stirling agent"
view_all = "View all agents"
[analytics]
disable = "Disable analytics"
enable = "Enable analytics"
@@ -1764,6 +1791,10 @@ insufficientPermissions = "You do not have permission to perform this action."
pleaseLoginAgain = "Please login again."
sessionExpired = "Session Expired"
[auth.displayName]
guest = "Guest"
user = "User"
[auto-rename]
description = "Automatically finds the title from your PDF content and uses it as the filename."
header = "Auto Rename PDF"
@@ -2684,58 +2715,15 @@ title = "Change Permissions"
[changePermissions.tooltip.warning]
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
[agents]
section_title = "Agents"
fullscreen_title = "Stirling Agents"
stirling_name = "Stirling"
stirling_full_name = "Stirling General Agent"
stirling_tooltip = "Stirling agent"
stirling_description = "Your general-purpose PDF assistant"
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
back_to_tools = "Back to tools"
coming_soon = "Coming soon"
view_all = "View all agents"
show_less = "Show less"
start_chat = "Start chatting"
data_extraction_name = "Data Extraction"
data_extraction_description = "Extract tables & structured data"
doc_summary_name = "Summariser"
doc_summary_description = "Summarise long documents"
auto_redaction_name = "Auto Redaction"
auto_redaction_description = "Redact PII automatically"
compliance_name = "Compliance Check"
compliance_description = "Audit documents for compliance"
form_filler_name = "Form Filler"
form_filler_description = "Fill PDF forms intelligently"
pdf_to_markdown_name = "PDF to Markdown"
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
[chat.header]
settings = "Agent settings"
agentMenu = "Stirling agent options"
clearChat = "Clear chat"
settings = "Agent settings"
[chat.input]
attach = "Attach files"
placeholder = "What do you want to do?"
send = "Send message"
attach = "Attach files"
[chat.quickActions]
heading = "Get started"
openFromComputer = "Open from computer"
browseYourFiles = "Browse your files"
rotateOne = "Rotate this document"
rotateMany = "Rotate these documents"
compressOne = "Compress this document"
compressMany = "Compress these documents"
mergeMany = "Merge these {{count}} documents into 1"
splitOne = "Split this document"
convertOne = "Convert this document to PDF"
convertMany = "Convert these documents to PDF"
fileSummary_one = "1 file in workbench ({{types}})"
fileSummary_other = "{{count}} files in workbench ({{types}})"
moreFiles = "+{{count}} more"
removeFile = "Remove {{name}}"
[chat.progress]
analyzing = "Analysing your request..."
@@ -2752,14 +2740,31 @@ whole_doc_read_done = "Finished reading the document..."
whole_doc_read_started = "Reading the document..."
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
[chat.quickActions]
browseYourFiles = "Browse your files"
compressMany = "Compress these documents"
compressOne = "Compress this document"
convertMany = "Convert these documents to PDF"
convertOne = "Convert this document to PDF"
fileSummary_one = "1 file in workbench ({{types}})"
fileSummary_other = "{{count}} files in workbench ({{types}})"
heading = "Get started"
mergeMany = "Merge these {{count}} documents into 1"
moreFiles = "+{{count}} more"
openFromComputer = "Open from computer"
removeFile = "Remove {{name}}"
rotateMany = "Rotate these documents"
rotateOne = "Rotate this document"
splitOne = "Split this document"
[chat.responses]
cannot_continue = "Something went wrong and I can't continue."
cannot_do = "I'm unable to do that."
done = "Done."
need_clarification = "Could you clarify your request?"
cannot_do = "I'm unable to do that."
not_found = "I couldn't find the requested information."
unsupported_capability = "Unsupported capability: {{capability}}"
cannot_continue = "Something went wrong and I can't continue."
processing = "Processing ({{outcome}})..."
unsupported_capability = "Unsupported capability: {{capability}}"
[chat.toolsUsed]
summary = "Ran {{count}} tools"
@@ -3910,6 +3915,7 @@ renameFolder = "Rename folder"
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
save = "Save"
saveToServer = "Save to server"
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
search = "Search"
searchPlaceholder = "Search this folder & subfolders"
selectAll = "Select all"
@@ -7954,6 +7960,7 @@ bulkTitle = "Upload checked files"
description = "This uploads the current file to server storage for your own access."
errorTitle = "Upload failed"
failure = "Upload failed. Please check your login and storage settings."
featureDisabled = "Saving to the server isn't enabled on this server."
fileCount = "{{count}} files"
fileLabel = "File"
hint = "Public links and access modes are controlled by your server settings."
@@ -8127,18 +8134,18 @@ viewerMode = "Switch to the file editor to add multiple files."
[toolPanel]
allTools = "All tools"
alpha = "Alpha"
backToAllTools = "Back to all tools"
backToDefault = "Back"
backToTools = "Back to tools"
collapse = "Collapse panel"
comingSoon = "Coming soon:"
expand = "Expand panel"
goBack = "Go back"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
search = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
backToDefault = "Back"
backToAllTools = "Back to all tools"
goBack = "Go back"
[toolPanel.fullscreen]
comingSoon = "Coming soon:"
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"windows": ["main", "main-*"],
"permissions": [
"core:default",
"core:window:allow-destroy",
@@ -5,9 +5,18 @@ pub mod auth;
pub mod default_app;
pub mod platform;
pub mod print;
pub mod window;
pub use backend::{cleanup_backend, get_backend_port, start_backend};
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
pub use window::{
forward_files_to_window,
open_files_in_new_window,
open_in_new_window,
pop_window_file_ids,
target_window_label,
MAIN_WINDOW_LABEL,
};
pub use connection::{
get_connection_config,
is_first_launch,
@@ -0,0 +1,216 @@
use crate::commands::files::add_opened_file;
use crate::utils::add_log;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
// The primary window created from tauri.conf.json.
pub const MAIN_WINDOW_LABEL: &str = "main";
static NEXT_WINDOW_ID: AtomicU32 = AtomicU32::new(2);
// Per-window queues of stored-file IDs waiting to be opened. Unlike disk paths
// (which use the global OPENED_FILES queue), these reference files already in
// the shared IndexedDB store, so a "new window" opened from the My Files page
// loads the same file by reference. Keyed by the new window's label.
static PENDING_FILE_IDS: Mutex<Option<HashMap<String, Vec<String>>>> = Mutex::new(None);
fn next_window_label() -> String {
let id = NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst);
format!("main-{}", id)
}
fn queue_file_ids(label: &str, ids: Vec<String>) {
let mut guard = PENDING_FILE_IDS.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
map.entry(label.to_string()).or_default().extend(ids);
}
// Shared window builder: every Stirling window must use identical WebView2
// browser args so they can share one user-data folder (see the note below),
// so all spawn paths funnel through here.
fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow, String> {
let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
.title("Stirling-PDF")
.inner_size(1280.0, 800.0)
// Below this width the file manager collapses to its mobile layout,
// so keep new windows above the breakpoint.
.min_inner_size(1030.0, 600.0)
.resizable(true);
// WebView2 (Windows only) requires every webview sharing a user-data folder
// to use identical additional_browser_args. wry's behaviour
// (webview2/mod.rs:294): when the user provides args it uses them as-is and
// does NOT prepend its own default `--disable-features=msWebOOUI,...`. So the
// main window's actual args are EXACTLY what tauri.conf.json declares -
// nothing more. We mirror that string byte-for-byte so windows share one data
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
#[cfg(target_os = "windows")]
let builder =
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
builder.build().map_err(|e| e.to_string())
}
// Run `work` on the main thread and await its result. WebView2 on Windows
// refuses to create a webview off the main thread (HRESULT 0x8007139F), but
// Tauri command handlers run on a worker thread - so any window creation has to
// hop over first. Centralised here so every command does it the same way.
async fn run_on_main_thread_result<F, R>(app: &AppHandle, work: F) -> Result<R, String>
where
F: FnOnce() -> R + Send + 'static,
R: Send + 'static,
{
let (tx, rx) = tokio::sync::oneshot::channel();
app.run_on_main_thread(move || {
let _ = tx.send(work());
})
.map_err(|e| e.to_string())?;
rx.await.map_err(|e| e.to_string())
}
// Spawn a new webview window in the same Tauri process.
// The backend stays single; only the frontend is duplicated.
// If `paths` is non-empty, they're enqueued under the new window's label,
// so the React app pops them on mount just like a fresh launch with a file.
fn spawn_new_window(app: &AppHandle, paths: Vec<String>) -> Result<String, String> {
let label = next_window_label();
for path in &paths {
add_opened_file(path.clone());
}
match build_window(app, &label, "/") {
Ok(window) => {
add_log(format!(
"🪟 Spawned new window '{}' with {} initial file(s)",
label,
paths.len()
));
// The new window pops the shared queue on mount, so the files are
// already waiting for it. We target the emit at this window only
// (not a broadcast) so already-open windows don't race to pop them.
if !paths.is_empty() {
let _ = window.emit_to(label.as_str(), "files-changed", ());
}
Ok(label)
}
Err(err) => {
add_log(format!(
"❌ Failed to spawn new window '{}': {}",
label, err
));
Err(err)
}
}
}
#[tauri::command]
pub async fn open_in_new_window(app: AppHandle, paths: Vec<String>) -> Result<String, String> {
let valid_paths: Vec<String> = paths
.into_iter()
.filter(|p| {
let exists = std::path::Path::new(p).exists();
if !exists {
add_log(format!(
"⚠️ Ignoring non-existent path for new window: {}",
p
));
}
exists
})
.collect();
let app_clone = app.clone();
run_on_main_thread_result(&app, move || spawn_new_window(&app_clone, valid_paths)).await?
}
// Open already-stored files (by IndexedDB id) in a fresh window. Used by the
// "Open in new window" action on the My Files page. The ids are queued under
// the new window's label; the new window pops them on mount and loads them from
// the shared store into its workspace.
#[tauri::command]
pub async fn open_files_in_new_window(
app: AppHandle,
file_ids: Vec<String>,
) -> Result<String, String> {
let label = next_window_label();
let app_clone = app.clone();
run_on_main_thread_result(&app, move || {
build_window(&app_clone, &label, "/").map(|window| {
let count = file_ids.len();
// Queue the ids only after the window is created, so a failed build
// doesn't leave orphaned ids under a label no window will consume.
queue_file_ids(&label, file_ids);
add_log(format!(
"🪟 Spawned new window '{}' for {} stored file(s)",
label, count
));
// The new window also pops on mount; this emit is a nudge in case it
// mounted before the ids were queued.
let _ = window.emit_to(label.as_str(), "window-files-ready", ());
label.clone()
})
})
.await?
}
// Pop (return and clear) the stored-file ids queued for the calling window.
#[tauri::command]
pub async fn pop_window_file_ids(window: WebviewWindow) -> Result<Vec<String>, String> {
let label = window.label().to_string();
let ids = {
let mut guard = PENDING_FILE_IDS.lock().unwrap();
guard
.as_mut()
.and_then(|map| map.remove(&label))
.unwrap_or_default()
};
if !ids.is_empty() {
add_log(format!(
"📂 Returning {} stored file id(s) for window '{}'",
ids.len(),
label
));
}
Ok(ids)
}
// Pick the best existing window to receive an opened file: the focused one,
// else the main window, else any open window. Returns None only if there are
// no windows at all. Used so file-opens (file association, "open with") land in
// the window the user is actually looking at, and still work if the original
// "main" window has been closed.
pub fn target_window_label(app: &AppHandle) -> Option<String> {
let windows = app.webview_windows();
if let Some((label, _)) = windows
.iter()
.find(|(_, w)| w.is_focused().unwrap_or(false))
{
return Some(label.clone());
}
if windows.contains_key(MAIN_WINDOW_LABEL) {
return Some(MAIN_WINDOW_LABEL.to_string());
}
windows.keys().next().cloned()
}
// Add files to the shared queue and notify a specific window to consume them.
// Used by drag-drop, the macOS open event, and the second-instance callback
// (when --new-window is NOT set). The emit is targeted at `label` so only that
// window pops the queue - other windows ignore it and keep their own files.
pub fn forward_files_to_window(app: &AppHandle, label: &str, paths: Vec<String>) {
for path in &paths {
add_opened_file(path.clone());
}
if let Some(window) = app.get_webview_window(label) {
let _ = app.emit_to(label, "files-changed", ());
let _ = window.set_focus();
let _ = window.unminimize();
} else {
// Target window is gone; let any window pick the files up.
let _ = app.emit("files-changed", ());
}
}
+66 -60
View File
@@ -11,12 +11,16 @@ use commands::{
clear_opened_files,
clear_refresh_token,
clear_user_info,
forward_files_to_window,
is_default_pdf_handler,
get_auth_token,
get_backend_port,
get_connection_config,
get_opened_files,
open_files_in_new_window,
open_in_new_window,
pop_opened_files,
pop_window_file_ids,
get_refresh_token,
get_user_info,
is_first_launch,
@@ -31,6 +35,8 @@ use commands::{
print_pdf_file_native,
start_backend,
start_oauth_login,
target_window_label,
MAIN_WINDOW_LABEL,
};
use commands::connection::apply_provisioning_if_present;
use state::connection_state::AppConnectionState;
@@ -47,6 +53,16 @@ fn dispatch_deep_link(app: &AppHandle, url: &str) {
}
}
// Extract existing file paths from CLI args (skips the executable name).
fn parse_launch_files(args: &[String]) -> Vec<String> {
args
.iter()
.skip(1)
.filter(|arg| std::path::Path::new(arg).exists())
.cloned()
.collect()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -66,38 +82,33 @@ pub fn run() {
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
// Runs in the existing instance when a second launch is attempted
// (e.g. "open with" / double-click while the app is running).
add_log(format!("📂 Second instance detected with args: {:?}", args));
// Scan args for PDF files (skip first arg which is the executable)
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
let files = parse_launch_files(&args);
// Route to the window the user is in (focused -> main -> any) so opens
// consolidate into one window instead of spawning a new one.
let label = target_window_label(app).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
if !files.is_empty() {
add_log(format!("📂 Forwarding {} file(s) to existing window '{}'", files.len(), label));
forward_files_to_window(app, &label, files);
} else if let Some(window) = app.get_webview_window(&label) {
// No files: just bring the app to the front.
let _ = window.set_focus();
let _ = window.unminimize();
}
// Emit a generic notification that files were added (frontend will re-read storage)
let _ = app.emit("files-changed", ());
}))
.setup(|app| {
add_log("🚀 Tauri app setup started".to_string());
// Process command line arguments on first launch
// Files passed on the command line at first launch load into the main
// window once the frontend mounts.
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Initial file from command line: {}", arg));
add_opened_file(arg.clone());
}
for path in parse_launch_files(&args) {
add_log(format!("📂 Initial file from command line: {}", path));
add_opened_file(path);
}
{
@@ -147,6 +158,9 @@ pub fn run() {
get_opened_files,
pop_opened_files,
clear_opened_files,
open_in_new_window,
open_files_in_new_window,
pop_window_file_ids,
get_tauri_logs,
get_connection_config,
set_connection_mode,
@@ -183,26 +197,19 @@ pub fn run() {
// Don't cleanup here - let JavaScript handler prevent close if needed
// Backend cleanup happens in ExitRequested when window actually closes
}
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => {
use tauri::DragDropEvent;
match drag_drop_event {
DragDropEvent::Drop { paths, .. } => {
add_log(format!("📂 Files dropped: {:?}", paths));
let mut added_files = false;
if let DragDropEvent::Drop { paths, .. } = drag_drop_event {
add_log(format!("📂 Files dropped on window '{}': {:?}", label, paths));
let file_paths: Vec<String> = paths
.iter()
.filter_map(|p| p.to_str().map(|s| s.to_string()))
.collect();
for path in paths {
if let Some(path_str) = path.to_str() {
add_log(format!("📂 Processing dropped file: {}", path_str));
add_opened_file(path_str.to_string());
added_files = true;
}
}
if added_files {
let _ = app_handle.emit("files-changed", ());
}
// Route to the window the file was actually dropped on.
if !file_paths.is_empty() {
forward_files_to_window(app_handle, &label, file_paths);
}
_ => {}
}
}
#[cfg(target_os = "macos")]
@@ -210,30 +217,29 @@ pub fn run() {
use urlencoding::decode;
add_log(format!("📂 Tauri file opened event: {:?}", urls));
let mut added_files = false;
for url in urls {
let url_str = url.as_str();
if url_str.starts_with("file://") {
let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str);
let file_paths: Vec<String> = urls
.iter()
.filter_map(|url| {
let url_str = url.as_str();
if !url_str.starts_with("file://") {
return None;
}
let encoded = url_str.strip_prefix("file://").unwrap_or(url_str);
// Decode URL-encoded characters (%20 -> space, etc.)
let file_path = match decode(encoded_path) {
Ok(decoded) => decoded.into_owned(),
match decode(encoded) {
Ok(decoded) => Some(decoded.into_owned()),
Err(e) => {
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e));
encoded_path.to_string() // Fallback to encoded path
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded, e));
Some(encoded.to_string())
}
};
}
})
.collect();
add_log(format!("📂 Processing opened file: {}", file_path));
add_opened_file(file_path);
added_files = true;
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
if added_files {
let _ = app_handle.emit("files-changed", ());
if !file_paths.is_empty() {
// Route to the window the user is in (focused -> main -> any).
let label = target_window_label(app_handle).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
forward_files_to_window(app_handle, &label, file_paths);
}
}
_ => {
@@ -1,6 +1,19 @@
export interface AuthContextType {
session: null;
user: { id?: string; email?: string; [key: string]: unknown } | null;
/**
* Human-readable name to show in the UI for the current session.
* - A real identity (username/email/full_name) when the user is signed in.
* - A layer-specific placeholder (e.g. "Guest" in SaaS, "User" in
* proprietary) for anonymous sessions.
* - null only when there is no user object at all (signed-out, or core
* OSS with no auth context) - consumers can fall back to whatever
* makes sense in their build.
*
* Each layer derives this from its own native user shape - consumers
* should treat the resulting string as opaque display text.
*/
displayName: string | null;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
@@ -15,6 +28,7 @@ export function useAuth(): AuthContextType {
return {
session: null,
user: null,
displayName: null,
loading: false,
error: null,
signOut: async () => {},
@@ -39,6 +39,8 @@ interface FileDetailsPanelProps {
onRemove: (fileIds: FileId[]) => void;
/** Save to server; only shown when at least one selected file is local-only. */
onSaveToServer?: (files: StirlingFileStub[]) => void;
/** When set, Save to server renders disabled with this tooltip (storage off). */
saveToServerDisabledReason?: string | null;
}
export function FileDetailsPanel({
@@ -51,6 +53,7 @@ export function FileDetailsPanel({
onMove,
onRemove,
onSaveToServer,
saveToServerDisabledReason,
}: FileDetailsPanelProps) {
const { t } = useTranslation();
const { sharingEnabled } = useSharingEnabled();
@@ -319,15 +322,34 @@ export function FileDetailsPanel({
>
{t("filesPage.moveTo", "Move to…")}
</Button>
{/* Save to server; shown when any selected file is local-only. */}
{/* Save to server; shown when any selected file is local-only. When
storage is off it stays visible but disabled with a tooltip (same
treatment as Manage sharing above). */}
{onSaveToServer && localOnlyFiles.length > 0 && (
<Button
leftSection={<CloudUploadIcon fontSize="small" />}
variant="default"
onClick={() => onSaveToServer(localOnlyFiles)}
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
multiline
w={260}
>
{t("filesPage.saveToServer", "Save to server")}
</Button>
<Button
leftSection={<CloudUploadIcon fontSize="small" />}
variant="default"
disabled={Boolean(saveToServerDisabledReason)}
onClick={() => onSaveToServer(localOnlyFiles)}
styles={{
root: {
// Keep tooltip hoverable while button is disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
},
}}
>
{t("filesPage.saveToServer", "Save to server")}
</Button>
</Tooltip>
)}
<Button
leftSection={<DeleteIcon fontSize="small" />}
@@ -32,6 +32,7 @@ import { findFolderIcon } from "@app/components/filesPage/folderIcons";
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
export type FilesPageViewMode = "grid" | "list";
@@ -77,6 +78,8 @@ interface FileGridProps {
onPromptMoveFiles: (fileIds: FileId[]) => void;
/** Per-file Save to server; hidden when file already has remoteStorageId. */
onSaveToServer?: (file: StirlingFileStub) => void;
/** When set, the Save to server item renders disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
/** When supplied the list-view column headers become sortable. */
sortMode?: FilesPageSortMode;
onChangeSortMode?: (mode: FilesPageSortMode) => void;
@@ -333,6 +336,7 @@ function GridView({
onRemoveFiles,
onPromptMoveFiles,
onSaveToServer,
saveToServerDisabledReason,
}: FileGridProps) {
return (
<div className="files-page-grid" role="list">
@@ -385,6 +389,7 @@ function GridView({
onSaveToServer={
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
);
}
@@ -583,6 +588,8 @@ interface FileCardProps {
onMove: () => void;
/** Kebab Save to server; only fires when file is local-only. */
onSaveToServer?: () => void;
/** When set, the kebab Save to server is disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
}
function FileCard({
@@ -598,6 +605,7 @@ function FileCard({
onRemove,
onMove,
onSaveToServer,
saveToServerDisabledReason,
}: FileCardProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement>(null);
@@ -758,6 +766,7 @@ function FileCard({
>
{t("filesPage.quickView", "Quick view")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
@@ -767,17 +776,33 @@ function FileCard({
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* Per-file Save to server; hidden when already on server. */}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{onSaveToServer && file.remoteStorageId == null && (
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
<Menu.Divider />
<Menu.Item
@@ -811,6 +836,7 @@ function ListView({
onRenameFolder,
onDeleteFolder,
onSaveToServer,
saveToServerDisabledReason,
onChangeFolderAppearance,
onRemoveFiles,
onPromptMoveFiles,
@@ -944,6 +970,7 @@ function ListView({
onSaveToServer={
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
);
}
@@ -1146,6 +1173,8 @@ interface FileRowProps {
onMove: () => void;
/** Kebab Save to server; only fires when file is local-only. */
onSaveToServer?: () => void;
/** When set, the kebab Save to server is disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
}
function FileRow({
@@ -1161,6 +1190,7 @@ function FileRow({
onRemove,
onMove,
onSaveToServer,
saveToServerDisabledReason,
}: FileRowProps) {
const { t } = useTranslation();
const kebabRef = useRef<HTMLButtonElement>(null);
@@ -1324,6 +1354,7 @@ function FileRow({
>
{t("filesPage.quickView", "Quick view")}
</Menu.Item>
<OpenInNewWindowMenuItem file={file} />
<Menu.Item
leftSection={<DriveFileMoveIcon fontSize="small" />}
onClick={(e) => {
@@ -1333,17 +1364,33 @@ function FileRow({
>
{t("filesPage.moveTo", "Move to…")}
</Menu.Item>
{/* Per-file Save to server; hidden when already on server. */}
{/* Per-file Save to server; shown for local-only files. When
storage is off it stays visible but disabled with a tooltip. */}
{onSaveToServer && file.remoteStorageId == null && (
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
<Tooltip
label={saveToServerDisabledReason}
disabled={!saveToServerDisabledReason}
withinPortal
position="left"
multiline
w={240}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
<Menu.Item
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={(e) => {
e.stopPropagation();
onSaveToServer();
}}
style={
saveToServerDisabledReason
? { pointerEvents: "auto" }
: undefined
}
>
{t("filesPage.saveToServer", "Save to server")}
</Menu.Item>
</Tooltip>
)}
<Menu.Divider />
<Menu.Item
@@ -106,6 +106,17 @@ export default function FileManagerView() {
const isMobile = useIsMobile();
const isMobileUploadAvailable =
Boolean(appConfig?.enableMobileScanner) && !isMobile;
// Server storage gate; mirrors ConfigController's storageEnabled
// (enableLogin && storage.isEnabled). When off, Save-to-server stays
// visible but disabled with an explanatory tooltip (discoverability beats
// hiding - mirrors the New folder / Manage sharing gates in this view).
const uploadEnabled = appConfig?.storageEnabled === true;
const saveToServerDisabledReason: string | null = uploadEnabled
? null
: t(
"filesPage.saveToServerDisabledHint",
"Saving to the server isn't enabled on this server. Ask your admin to enable it.",
);
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { actions: navActions } = useNavigationActions();
const { requestNavigation } = useNavigationGuard();
@@ -1185,19 +1196,35 @@ export default function FileManagerView() {
</Button>
</Tooltip>
)}
{/* Save to server; hidden when no local-only file selected. */}
{/* Save to server; shown whenever local-only files are
selected. When storage is off it stays visible but
disabled, tooltip pointing at the admin. */}
{localOnlySelectedStubs.length > 0 && (
<Tooltip
label={t("filesPage.saveToServer", "Save to server")}
label={
saveToServerDisabledReason ??
t("filesPage.saveToServer", "Save to server")
}
withinPortal
multiline={Boolean(saveToServerDisabledReason)}
w={saveToServerDisabledReason ? 240 : undefined}
>
<Button
size="sm"
variant="default"
leftSection={<CloudUploadIcon fontSize="small" />}
disabled={Boolean(saveToServerDisabledReason)}
onClick={() =>
setSaveToServerTarget(localOnlySelectedStubs)
}
styles={{
root: {
// Keep the tooltip hoverable while disabled.
pointerEvents: saveToServerDisabledReason
? "auto"
: undefined,
},
}}
aria-label={t(
"filesPage.saveToServer",
"Save to server",
@@ -1450,6 +1477,7 @@ export default function FileManagerView() {
onRemoveFiles={handleRemoveFiles}
onPromptMoveFiles={promptMoveFiles}
onSaveToServer={(file) => setSaveToServerTarget([file])}
saveToServerDisabledReason={saveToServerDisabledReason}
// Center-of-grid CTAs when the empty state shows - same
// handlers the corner header buttons use so behaviour
// (disabled tooltips, native file picker, dialog) is
@@ -1495,6 +1523,7 @@ export default function FileManagerView() {
onMove={promptMoveFiles}
onRemove={handleRemoveFiles}
onSaveToServer={(files) => setSaveToServerTarget(files)}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
)}
</div>
@@ -1521,6 +1550,7 @@ export default function FileManagerView() {
onMove={promptMoveFiles}
onRemove={handleRemoveFiles}
onSaveToServer={(files) => setSaveToServerTarget(files)}
saveToServerDisabledReason={saveToServerDisabledReason}
/>
)}
</Drawer>
@@ -0,0 +1,35 @@
import { Menu } from "@mantine/core";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useTranslation } from "react-i18next";
import { StirlingFileStub } from "@app/types/fileContext";
import { useOpenInNewWindow } from "@app/extensions/openInNewWindow";
interface OpenInNewWindowMenuItemProps {
file: StirlingFileStub;
}
/**
* Kebab menu item that opens a stored file in a separate window. Desktop-only:
* the underlying extension is a no-op on web, so this renders nothing there
* (and for any file that can't be opened in a new window).
*/
export function OpenInNewWindowMenuItem({
file,
}: OpenInNewWindowMenuItemProps) {
const { t } = useTranslation();
const { canOpenInNewWindow, openInNewWindow } = useOpenInNewWindow();
if (!canOpenInNewWindow(file)) return null;
return (
<Menu.Item
leftSection={<OpenInNewIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(file);
}}
>
{t("openInNewWindow", "Open in new window")}
</Menu.Item>
);
}
@@ -22,11 +22,9 @@ export default function AppConfigModalLazy({
if (opened) setShouldMount(true);
}, [opened]);
if (!shouldMount) return null;
return (
<Suspense fallback={null}>
<AppConfigModal opened={opened} onClose={onClose} />
{shouldMount && <AppConfigModal opened={opened} onClose={onClose} />}
</Suspense>
);
}
@@ -89,11 +89,21 @@ const BulkUploadToServerModal: React.FC<BulkUploadToServerModalProps> = ({
onClose();
} catch (error) {
console.error("Failed to upload files to server:", error);
// A 403 means the server has storage turned off (or login disabled,
// which gates storage). Say so plainly instead of the generic
// "check your settings" message, which reads as a user mistake.
const status = (error as { response?: { status?: number } })?.response
?.status;
setErrorMessage(
t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
status === 403
? t(
"storageUpload.featureDisabled",
"Saving to the server isn't enabled on this server.",
)
: t(
"storageUpload.failure",
"Upload failed. Please check your login and storage settings.",
),
);
} finally {
setIsUploading(false);
@@ -18,6 +18,7 @@ import {
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAuth } from "@app/auth/UseSession";
import {
useIndexedDB,
useIndexedDBRevision,
@@ -107,19 +108,39 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const { activeFileId, setActiveFileId } = useViewer();
const { addFiles } = useFileHandler();
const indexedDB = useIndexedDB();
const [displayName, setDisplayName] = useState<string>("Guest");
// Each auth layer derives its own displayName from its native user shape.
// Fall back to the proprietary REST endpoint only when the auth
// context yields nothing - then to "User" as a generic last resort.
const { displayName: authDisplayName } = useAuth();
const [accountUsername, setAccountUsername] = useState<string | null>(null);
const displayName =
authDisplayName ?? accountUsername ?? t("auth.displayName.user", "User");
useEffect(() => {
if (!config?.enableLogin) return;
if (!config?.enableLogin) {
setAccountUsername(null);
return;
}
if (authDisplayName) {
// The auth context has a name; don't bother hitting the REST
// endpoint, but clear any stale cached value from a prior call.
setAccountUsername(null);
return;
}
accountService
.getAccountData()
.then((data) => {
if (data?.username) setDisplayName(data.username);
// Always reflect the latest result - including clearing it on
// sign-out, when the endpoint returns no username (or 401s into
// the catch branch below). Without this, signing out would leave
// the old username on screen.
setAccountUsername(data?.username ?? null);
})
.catch(() => {
/* not logged in or security disabled */
setAccountUsername(null);
});
}, [config?.enableLogin]);
}, [config?.enableLogin, authDisplayName]);
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
@@ -31,7 +31,11 @@ const toneStyles: Record<
};
interface InfoBannerProps {
icon: string;
/**
* Either a LocalIcon name (string) for the standard sized icon slot, or a
* pre-rendered ReactNode (e.g. a logo image) which is dropped in as-is.
*/
icon?: string | ReactNode;
title?: ReactNode;
message: ReactNode;
buttonText?: string;
@@ -48,6 +52,8 @@ interface InfoBannerProps {
iconColor?: string;
buttonColor?: string;
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
/** Override the button label colour (for dark/custom theme variants). */
buttonTextColor?: string;
minHeight?: number | string;
closeIconColor?: string;
compact?: boolean;
@@ -74,6 +80,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
iconColor,
buttonColor,
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
compact = false,
@@ -120,12 +127,21 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
{icon != null &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width={iconSize}
height={iconSize}
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={compact ? 1 : 2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
@@ -161,6 +177,11 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
height={compact ? "0.75rem" : "0.9rem"}
/>
}
styles={
buttonTextColor
? { label: { color: buttonTextColor } }
: undefined
}
>
{buttonText}
</Button>
@@ -0,0 +1,20 @@
import { StirlingFileStub } from "@app/types/fileContext";
export interface OpenInNewWindowApi {
/** Whether this file can be opened in a separate window. */
canOpenInNewWindow: (file: StirlingFileStub) => boolean;
/** Open the file in a separate window. */
openInNewWindow: (file: StirlingFileStub) => void;
}
/**
* Core (web) build: multiple windows aren't a thing in the browser, so this is
* a no-op. The desktop build overrides this file via path resolution to spawn
* a real Tauri window.
*/
export function useOpenInNewWindow(): OpenInNewWindowApi {
return {
canOpenInNewWindow: () => false,
openInNewWindow: () => {},
};
}
@@ -15,10 +15,17 @@ import {
* back its pre-v3 snapshot and silently erased every v3 field on every
* row (isLeaf, versionNumber, originalFileId, parentFileId, toolHistory).
*
* A future v5 migration written as a third separate cursor walk would
* A future v10 migration written as a third separate cursor walk would
* re-introduce the exact same failure mode for anyone jumping multiple
* versions. This test pins the behaviour by seeding a v2 DB and asserting
* every v3+v4 field is present and correctly set after the upgrade.
*
* Also covers the SaaS-lineage reconciliation: SaaS shipped its own
* versions of this database up to v8 (v5 added folder_* stores, v8 was
* the terminal SaaS schema). When the unified codebase first opens a
* SaaS browser's database it has to drop those orphan stores, backfill
* folderId on every file row, and (for v6/v7 specifically) force-delete
* the database because its data is known-corrupt.
*/
const DB_NAME = DATABASE_CONFIGS.FILES.name;
@@ -110,6 +117,90 @@ function seedV3Database(): Promise<void> {
});
}
/**
* Seed a SaaS-shaped database at the given version. Mirrors the SaaS
* terminal schema: `files` store plus the three folder_* / smart_folders
* stores that we now treat as orphans. SaaS file rows are v3-shaped
* (they got the file history fields via the SaaS migrateFileHistoryFields
* path) but have never had a folderId.
*/
function seedSaasDatabase(version: number, fileIds: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, version);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files", { keyPath: "id" });
}
if (!db.objectStoreNames.contains("folder_members")) {
db.createObjectStore("folder_members", { keyPath: "folderId" });
}
if (!db.objectStoreNames.contains("folder_run_states")) {
db.createObjectStore("folder_run_states", { keyPath: "folderId" });
}
if (!db.objectStoreNames.contains("smart_folders")) {
db.createObjectStore("smart_folders", { keyPath: "folderId" });
}
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(
["files", "folder_members", "smart_folders"],
"readwrite",
);
const filesStore = tx.objectStore("files");
for (const id of fileIds) {
filesStore.add({
id,
name: `${id}.pdf`,
type: "application/pdf",
size: 1024,
lastModified: 5000,
data: new Blob([id], { type: "application/pdf" }),
// v3 fields - SaaS records always have these by v3+
isLeaf: true,
versionNumber: 1,
originalFileId: id,
parentFileId: undefined,
toolHistory: [],
// intentionally no folderId field - SaaS lineage never had it
});
}
// Drop a SaaS-only row in folder_members and smart_folders so we
// can verify the orphan stores were actually dropped (not just
// empty).
tx.objectStore("folder_members").add({
folderId: "saas-folder-1",
fileIds: [...fileIds],
});
tx.objectStore("smart_folders").add({
folderId: "saas-folder-1",
files: {},
lastUpdated: 6000,
});
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error ?? new Error("SaaS seed tx failed"));
};
req.onerror = () => reject(req.error ?? new Error("SaaS seed open failed"));
});
}
function getObjectStoreNames(): Promise<string[]> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME);
req.onsuccess = () => {
const db = req.result;
const names = Array.from(db.objectStoreNames);
db.close();
resolve(names);
};
req.onerror = () => reject(req.error);
});
}
function readAllFiles(): Promise<unknown[]> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME);
@@ -193,4 +284,112 @@ describe("IndexedDB migration (FILES store)", () => {
);
indexedDBManager.closeDatabase(DB_NAME);
});
test("SaaS v8 -> latest backfills folderId, preserves files, drops orphan stores", async () => {
await seedSaasDatabase(8, ["saas-file-a", "saas-file-b"]);
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
expect(rows).toHaveLength(2);
for (const row of rows) {
// SaaS file rows already had v3 fields - migration should leave them alone.
expect(row.isLeaf).toBe(true);
expect(row.versionNumber).toBe(1);
expect(row.originalFileId).toBe(row.id);
expect(row.toolHistory).toEqual([]);
// Critical: SaaS lineage never had folderId, the new schema requires it.
expect(row.folderId).toBeNull();
}
// Orphan SaaS-only stores should be gone; the v9 schema's `folders`
// store should exist; the `files` store survives.
const stores = await getObjectStoreNames();
expect(stores).toContain("files");
expect(stores).toContain("folders");
expect(stores).not.toContain("folder_members");
expect(stores).not.toContain("folder_run_states");
expect(stores).not.toContain("smart_folders");
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
TARGET_VERSION,
);
});
test("SaaS v5 (pre-orphan-stores edge case) backfills folderId", async () => {
// v5 predates folder_members / folder_run_states / smart_folders in
// SaaS lineage, so seed it with only the files store. SaaS v5 file
// rows still lack folderId; this verifies the field-presence check
// doesn't depend on the orphan stores existing.
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 5);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files", { keyPath: "id" });
}
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction("files", "readwrite");
tx.objectStore("files").add({
id: "saas-v5-file",
name: "saas-v5.pdf",
type: "application/pdf",
size: 256,
lastModified: 7000,
data: new Blob(["v5"], { type: "application/pdf" }),
isLeaf: true,
versionNumber: 1,
originalFileId: "saas-v5-file",
parentFileId: undefined,
toolHistory: [],
});
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error ?? new Error("v5 seed tx failed"));
};
req.onerror = () => reject(req.error ?? new Error("v5 seed open failed"));
});
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
expect(rows).toHaveLength(1);
expect(rows[0]!.folderId).toBeNull();
});
test("SaaS v6 database is force-deleted (data lost, schema reset to v9)", async () => {
await seedSaasDatabase(6, ["v6-corrupt-file"]);
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
// Wipe path - files are gone, but the DB is now a clean v9 install.
const rows = await readAllFiles();
expect(rows).toHaveLength(0);
const stores = await getObjectStoreNames();
expect(stores).toContain("files");
expect(stores).toContain("folders");
expect(stores).not.toContain("folder_members");
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
TARGET_VERSION,
);
});
test("SaaS v7 database is force-deleted (data lost, schema reset to v9)", async () => {
await seedSaasDatabase(7, ["v7-corrupt-file"]);
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const rows = await readAllFiles();
expect(rows).toHaveLength(0);
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
TARGET_VERSION,
);
});
});
@@ -47,6 +47,24 @@ class IndexedDBManager {
return existingPromise;
}
// SaaS lineage shipped a v6 and a v7 of stirling-pdf-files whose
// upgrade paths corrupted records (separate cursor walks racing in
// one versionchange transaction). The SaaS build wipes those
// databases on open to get users unstuck; we carry the wipe forward
// here so any SaaS browser that hadn't reopened the app since then
// gets a clean v9 install instead of trying to migrate corrupt data.
// Affected users have already lost their files - this is just the
// recovery path they were already on.
if (config.name === "stirling-pdf-files") {
const existingVersion = await this.getDatabaseVersion(config.name);
if (existingVersion === 6 || existingVersion === 7) {
console.warn(
`Deleting corrupt SaaS v${existingVersion} ${config.name} database. Files will be lost but the app will work.`,
);
await this.deleteDatabase(config.name);
}
}
const initPromise = this.performDatabaseInit(config);
this.initPromises.set(config.name, initPromise);
@@ -150,6 +168,25 @@ class IndexedDBManager {
this.migrateFilesStore(store, oldVersion);
}
});
// Drop stores that the SaaS lineage created in v6 but that this
// codebase doesn't use. We use a different folder model now
// (a `folders` store plus a `folderId` foreign key on each
// file row), so folder_members / folder_run_states /
// smart_folders are dead weight. The deleteObjectStore calls
// must happen inside this versionchange transaction.
if (config.name === "stirling-pdf-files") {
for (const orphan of [
"folder_members",
"folder_run_states",
"smart_folders",
]) {
if (db.objectStoreNames.contains(orphan)) {
db.deleteObjectStore(orphan);
console.info(`Dropped orphan SaaS store: ${orphan}`);
}
}
}
};
});
}
@@ -173,7 +210,7 @@ class IndexedDBManager {
* `if (oldVersion < N) { ... }` sections below.
*/
private migrateFilesStore(store: IDBObjectStore, oldVersion: number): void {
if (oldVersion >= 4) return; // nothing to migrate at the current schema
if (oldVersion >= 9) return; // nothing to migrate at the current schema
const cursor = store.openCursor();
let migrated = 0;
@@ -213,9 +250,12 @@ class IndexedDBManager {
}
}
// v4: folderId. Required to exist on every row so the folderId
// index doesn't drop the record out of bounded-key cursor scans.
if (oldVersion < 4 && record.folderId === undefined) {
// folderId. OSS lineage added this in v4. SaaS lineage never had
// it (its v5 and v8 file rows both lack the field), so we gate on
// field presence rather than oldVersion. Required on every row
// so the folderId index doesn't drop the record out of
// bounded-key cursor scans.
if (record.folderId === undefined) {
record.folderId = null;
needsUpdate = true;
}
@@ -238,7 +278,7 @@ class IndexedDBManager {
cursor.onerror = (event) => {
// Same reasoning as the per-record catch above: abort the upgrade so the
// schema doesn't get marked as v4 with rows still on the v3 shape.
// schema doesn't get marked as v9 with rows still on the older shape.
const err = (event.target as IDBRequest).error;
console.error("Files-store migration cursor failed:", err);
try {
@@ -324,7 +364,7 @@ class IndexedDBManager {
export const DATABASE_CONFIGS = {
FILES: {
name: "stirling-pdf-files",
version: 4,
version: 9,
stores: [
{
name: "files",
@@ -185,7 +185,7 @@ export class UpdateService {
*/
async getCurrentVersionFromGitHub(): Promise<string> {
const url =
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle";
"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/V2-master/build.gradle";
try {
const response = await fetch(url);
@@ -1,4 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
@@ -1,4 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
@@ -1,4 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
@@ -1,7 +1,8 @@
import { test as base, expect } from "@app/tests/helpers/test-base";
import { test as base, expect } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
skipOnboarding,
type MockAppApiOptions,
} from "@app/tests/helpers/api-stubs";
@@ -56,8 +57,7 @@ export const test = base.extend<StubFixtures>({
seedJwt: [false, { option: true }],
page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => {
// `page` comes from test-base, which has already seeded cookie consent
// and attached the console-error recorder before any navigation runs.
await seedCookieConsent(page);
if (seedJwt) {
// Logged-in users hit the orchestrator path that surfaces the
// analytics opt-in / MFA prompts — use the stronger bypass-all flag
@@ -1,216 +1,14 @@
import { test as base, expect, type Page } from "@playwright/test";
import { test as base, expect } from "@playwright/test";
/**
* Console message types that should fail the test if they appear.
* `console.error()` -> type "error", `console.warn()` -> type "warning".
*/
const FAILING_CONSOLE_TYPES = new Set(["error", "warning"]);
/**
* Patterns ignored globally on every page. Keep this list small and
* well-justified — each entry suppresses a genuine console warning for
* every test, which means we lose detection of regressions in that
* surface. Only add things that:
*
* - fire on first page render of *every* test (so per-test
* suppression would just be ceremony), AND
* - are environmental noise (third-party CDN, dev-server quirk,
* known init-order quirk) rather than something a test could
* reasonably assert.
*
* Anything that fires only on specific flows belongs in an inline
* `expectConsoleError` / `suppressConsoleErrors` at the call site.
*/
const GLOBAL_IGNORE_PATTERNS: RegExp[] = [
// Stripe.js logs an HTTP warning when loaded over localhost. Third-party,
// expected in dev, no production impact.
/You may test your Stripe\.js integration over HTTP/,
// i18next's HTTP backend fails to load namespace files under Vite dev's
// `@fs/` URLs; the app falls back to embedded English copy and tests
// still pass functional assertions.
/i18next::backendConnector: loading namespace/,
// scarfTracking.firePixel() is invoked from a router effect on the first
// route render, before the useScarfTracking hook has called
// setScarfConfig(). Harmless (the pixel is a no-op on first call) but
// worth a follow-up to reorder init. See utils/scarfTracking.ts.
/\[scarfTracking\] firePixel\(\) called before setScarfConfig/,
// ── Vite dev-server flakiness under parallel-worker load ────────────────
// The next block suppresses the entire cascade that follows when Vite's
// dev server briefly stops accepting connections (because several workers
// hit it simultaneously). In CI we serve a pre-built dist via
// `vite preview`, where none of this happens; locally the cascade is just
// environmental noise. None of these patterns mask production-only bugs.
// 1) Browser-level network failure for an unreachable URL.
/Failed to load resource: net::ERR_/,
// 2) Vite's lazy chunk loader sees the network failure and throws.
/Failed to fetch dynamically imported module/,
// 3) PDF.js / pdfium WASM streaming fetch trips on the same outage.
/WebAssembly compilation aborted: Network error/,
/wasm streaming compile failed/,
/failed to asynchronously prepare wasm/,
/falling back to ArrayBuffer instantiation/,
// 4) React-dom logs its own wrapper line when the lazy chunk error reaches
// a Suspense / ErrorBoundary. Suppress only this exact wrapper — real
// React errors that aren't chunk-load failures still surface elsewhere.
/The above error occurred in one of your React components/,
// 5) Our ErrorBoundary's componentDidCatch dumps ~15 supplementary
// diagnostic lines. They are useful in prod but in tests they are
// pure noise on top of whatever already failed. Match by source URL.
/\(https?:\/\/[^)]*\/src\/core\/components\/shared\/ErrorBoundary\.tsx:/,
];
/**
* Per-page collector for console errors / warnings / uncaught page errors.
*
* The fixture installs one of these on every page. Messages that aren't
* absorbed by an active `expectConsoleError` / `suppressConsoleErrors`
* scope are reported in fixture teardown and fail the test.
*/
class ConsoleErrorRecorder {
private readonly failed: string[] = [];
private readonly scopes: Array<{ pattern: RegExp; matched: boolean }> = [];
record(text: string): void {
for (const pattern of GLOBAL_IGNORE_PATTERNS) {
if (pattern.test(text)) return; // documented global noise
}
for (const scope of this.scopes) {
if (scope.pattern.test(text)) {
scope.matched = true;
return; // absorbed by an active scope, not a failure
}
}
this.failed.push(text);
}
async withScope<T>(
pattern: RegExp,
fn: () => Promise<T>,
requireMatch: boolean,
): Promise<T> {
const scope = { pattern, matched: false };
this.scopes.push(scope);
try {
const result = await fn();
if (requireMatch && !scope.matched) {
throw new Error(
`expectConsoleError: no console error/warning matched ${pattern} ` +
`during the scoped action`,
);
}
return result;
} finally {
const idx = this.scopes.indexOf(scope);
if (idx >= 0) this.scopes.splice(idx, 1);
}
}
failures(): string[] {
return this.failed;
}
}
const recordersByPage = new WeakMap<Page, ConsoleErrorRecorder>();
function attachConsoleErrorRecorder(page: Page): ConsoleErrorRecorder {
const recorder = new ConsoleErrorRecorder();
recordersByPage.set(page, recorder);
page.on("console", (msg) => {
const type = msg.type();
if (!FAILING_CONSOLE_TYPES.has(type)) return;
const { url, lineNumber, columnNumber } = msg.location();
const where = url ? ` (${url}:${lineNumber}:${columnNumber})` : "";
recorder.record(`[console.${type}] ${msg.text()}${where}`);
});
page.on("pageerror", (err) => {
recorder.record(`[pageerror] ${err.message}`);
});
return recorder;
}
function getRecorder(page: Page, caller: string): ConsoleErrorRecorder {
const recorder = recordersByPage.get(page);
if (!recorder) {
throw new Error(
`${caller} requires the \`test\` exported from ` +
`\`@app/tests/helpers/test-base\` (or stub-test-base). ` +
`Are you importing \`test\` directly from "@playwright/test"?`,
);
}
return recorder;
}
/**
* Run `fn` and *require* at least one console error / warning / page error
* that matches `pattern` to occur during it. Matching messages are
* absorbed (they don't fail the test); if none match, this throws.
*
* Use when a test deliberately exercises an error path and the error
* surfaces in the console:
*
* await expectConsoleError(page, /Validation failed/, async () => {
* await page.getByRole("button", { name: "Submit" }).click();
* await expect(page.getByRole("alert")).toBeVisible();
* });
*
* The scope only covers messages emitted while `fn` is awaiting, so
* remember to `await` any UI assertion that the error has surfaced
* *inside* the callback rather than after it returns.
*/
export async function expectConsoleError<T>(
page: Page,
pattern: RegExp,
fn: () => Promise<T>,
): Promise<T> {
return getRecorder(page, "expectConsoleError").withScope(pattern, fn, true);
}
/**
* Run `fn` and silently absorb any console errors / warnings / page
* errors matching `pattern`, without asserting that one occurred. Use
* sparingly — `expectConsoleError` is preferred because it also verifies
* the error path actually fires.
*
* await suppressConsoleErrors(page, /MUI Grid v1 deprecated/, async () => {
* await page.getByRole("button", { name: "Open settings" }).click();
* });
*/
export async function suppressConsoleErrors<T>(
page: Page,
pattern: RegExp,
fn: () => Promise<T>,
): Promise<T> {
return getRecorder(page, "suppressConsoleErrors").withScope(
pattern,
fn,
false,
);
}
/**
* Custom test fixture shared across all Playwright suites. Two things
* happen for every test that uses this base (directly or transitively
* via `stub-test-base.ts`):
*
* 1. The cookie-consent cookie is seeded before any navigation so the
* `#cc-main` banner never renders and never intercepts clicks.
* 2. Console errors/warnings and uncaught page errors are captured.
* If any unhandled message appears during the test, the fixture
* throws during teardown and the test fails. Tests that legitimately
* produce errors should wrap the offending step in
* `expectConsoleError(page, /pattern/, async () => { ... })`.
* Custom test fixture that auto-dismisses the cookie consent banner
* before every test. The banner (#cc-main) overlays the page and
* intercepts pointer events, causing click timeouts across all tests.
*
* Usage: import { test, expect } from '@app/tests/helpers/test-base';
*/
export const test = base.extend({
page: async ({ page }, use) => {
const recorder = attachConsoleErrorRecorder(page);
// Set the cookie consent cookie before any navigation so the banner
// never appears. The cookieconsent library (orestbida/cookieconsent)
// reads this cookie on init and skips the banner if consent exists.
@@ -231,16 +29,6 @@ export const test = base.extend({
]);
await use(page);
const failures = recorder.failures();
if (failures.length > 0) {
throw new Error(
`Test produced ${failures.length} unhandled console error(s)/warning(s):\n` +
failures.map((m) => ` ${m}`).join("\n") +
`\n\nIf any of these are expected, wrap the action in ` +
`expectConsoleError(page, /pattern/, async () => { ... }).`,
);
}
},
});
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
// ---------------------------------------------------------------------------
@@ -18,8 +18,7 @@
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
@@ -5,8 +5,7 @@
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
@@ -17,8 +17,7 @@
* unlock-all-wrong-password - all transitively covered or low-value.
*/
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import fs from "fs";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
@@ -284,6 +284,52 @@ test.describe("Files page", () => {
});
});
test.describe("Save to server gating (storage disabled)", () => {
test.beforeEach(async ({ page }) => {
// storageEnabled:false -> Save-to-server stays visible for local-only
// files but is disabled (with an explanatory tooltip), not hidden, so
// users discover the feature and know to ask their admin.
await stubStorageApis(page, { storageEnabled: false });
await seedFiles(page, [
{ id: "local-a", name: "local-a.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
test("bulk Save to server is disabled (not hidden) when storage off", async ({
page,
}) => {
await gotoFilesPage(page);
await page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "local-a.pdf" })
.click();
const saveButtons = page.getByRole("button", {
name: /^Save to server$/i,
});
// Present (toolbar + details panel) and every instance disabled.
const count = await saveButtons.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i += 1) {
await expect(saveButtons.nth(i)).toBeVisible();
await expect(saveButtons.nth(i)).toBeDisabled();
}
});
test("per-file kebab Save to server is disabled (not hidden) when storage off", async ({
page,
}) => {
await gotoFilesPage(page);
const localCard = page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "local-a.pdf" });
await localCard.getByRole("button", { name: /File actions/i }).click();
const item = page.getByRole("menuitem", { name: /^Save to server$/i });
await expect(item).toBeVisible();
await expect(item).toBeDisabled();
});
});
test.describe("Upload behaviour", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
/**
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -6,8 +6,7 @@
* STIRLING_FLAVOR=saas ./gradlew :stirling-pdf:bootRun --args="--server.port=18083 --spring.profiles.include=dev"
* STIRLING_SAAS_URL=http://localhost:18083 npx playwright test --project=stubbed saas-backend-smoke
*/
import { test, expect } from "@app/tests/helpers/test-base";
import { request } from "@playwright/test";
import { test, expect, request } from "@playwright/test";
const SAAS_URL = process.env.STIRLING_SAAS_URL ?? "http://localhost:18083";
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -7,9 +7,11 @@ import {
} from "@app/services/connectionModeService";
import { authService, UserInfo } from "@app/services/authService";
import { OPEN_SIGN_IN_EVENT } from "@app/constants/signInEvents";
import { useAuth } from "@app/auth/UseSession";
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const { signOut } = useAuth();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
@@ -43,7 +45,22 @@ export const ConnectionSettings: React.FC = () => {
if (config?.mode === "selfhosted" && config?.server_config?.url) {
localStorage.setItem("server_url", config.server_config.url);
}
await authService.logout();
// Use the proprietary signOut (which also fans out the SIGNED_OUT event
// to the AuthProvider so the React tree sees the unauthenticated state)
// and treat authService.logout() as a fallback if it errors. The previous
// implementation only called authService.logout() directly, which cleared
// the Tauri-stored token+user_info but left the proprietary AuthProvider's
// session state stale - so the FileSidebar badge kept showing the prior
// user's name until the next session check happened to fire.
try {
await signOut();
} catch (signOutError) {
console.warn(
"[ConnectionSettings] signOut() failed, falling back to authService.logout()",
signOutError,
);
await authService.logout();
}
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
@@ -1,14 +1,20 @@
import { useSaveShortcut } from "@app/hooks/useSaveShortcut";
import { useExitWarning } from "@app/hooks/useExitWarning";
import { useNewWindowShortcut } from "@app/hooks/useNewWindowShortcut";
import { useOpenWindowFiles } from "@app/hooks/useOpenWindowFiles";
/**
* Desktop-only component that sets up keyboard shortcuts and exit warnings
* - Ctrl/Cmd+S to save selected files
* - Ctrl/Cmd+N to open an empty new window
* - Loads files queued for this window ("Open in new window" from My Files)
* - Warning on app exit if unsaved files
* Renders nothing, just sets up the listeners
*/
export function SaveShortcutListener() {
useSaveShortcut();
useNewWindowShortcut();
useOpenWindowFiles();
useExitWarning();
return null;
}
@@ -0,0 +1,24 @@
import { StirlingFileStub } from "@app/types/fileContext";
import { fileOpenService } from "@app/services/fileOpenService";
import { useMultiWindowSupported } from "@app/hooks/useMultiWindowSupported";
import type { OpenInNewWindowApi } from "@core/extensions/openInNewWindow";
/**
* Desktop build: open a stored file in a new Tauri window. The new window loads
* the file by id from the shared IndexedDB store (see useOpenWindowFiles), which
* only works where windows share one persistent web store - so this is gated on
* useMultiWindowSupported (disabled on Linux).
*/
export function useOpenInNewWindow(): OpenInNewWindowApi {
const supported = useMultiWindowSupported();
return {
canOpenInNewWindow: (file: StirlingFileStub) =>
supported && Boolean(file.id),
openInNewWindow: (file: StirlingFileStub) => {
if (file.id) {
fileOpenService.openFilesInNewWindow([file.id]);
}
},
};
}
@@ -15,21 +15,110 @@ export async function isDesktopSaaSAuthMode(): Promise<boolean> {
}
}
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
/**
* In SaaS mode the apiClient points at the SaaS gateway, which doesn't
* expose `/api/v1/auth/logout` (Supabase manages session lifecycle); POSTing
* there returns 500 and floods the error toasts even though local cleanup
* succeeds. Self-hosted mode IS a Spring backend so the endpoint exists.
*/
export async function shouldCallBackendLogout(): Promise<boolean> {
try {
const userInfo = await authService.getUserInfo();
if (!userInfo) {
return null;
}
return {
username: userInfo.username,
email: userInfo.email,
};
const mode = await connectionModeService.getCurrentMode();
return mode !== "saas";
} catch {
// If we can't read the mode, err on the side of trying the POST -
// a 500 is noisy but the catch branch still completes the local
// sign-out, so we'd rather attempt the backend call than skip it
// for a deployment that actually does have the endpoint.
return true;
}
}
/**
* Supabase JWT payload claims we care about. Desktop knows it issues
* Supabase-shaped tokens, so it can read them with proper types here -
* proprietary's auth client never needs to learn about user_metadata.
*/
interface SupabaseJwtClaims {
email?: string;
user_metadata?: {
full_name?: string;
name?: string;
};
is_anonymous?: boolean;
}
/**
* Decode the payload section of a JWT for display purposes only.
*
* SECURITY: this does NOT verify the signature. The returned claims are
* untrusted - never use them for authorisation decisions. The Supabase
* server validates the signature on every API call; this decoder exists
* solely to render the user's name/email in the UI before that
* server-validated state lands.
*/
function decodeSupabaseJwt(token: string): SupabaseJwtClaims | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const base64 = parts[1]
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
return JSON.parse(atob(base64)) as SupabaseJwtClaims;
} catch {
return null;
}
}
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
// Preferred source: the Tauri-cached user_info written at login time.
let cachedUser: { username: string; email: string | undefined } | null = null;
try {
const userInfo = await authService.getUserInfo();
if (userInfo) {
cachedUser = {
username: userInfo.username,
email: userInfo.email,
};
}
} catch {
/* fall through to JWT decode */
}
// Fallback: decode the JWT itself. The cache can lag (the
// jwt-available event fires before save_user_info in OAuth login) or be
// missing entirely (older tokens minted before user_info caching was
// wired up). The token always carries enough to identify the account.
let jwtClaims: SupabaseJwtClaims | null = null;
const token =
typeof window !== "undefined"
? window.localStorage.getItem("stirling_jwt")
: null;
if (token) {
jwtClaims = decodeSupabaseJwt(token);
}
if (!cachedUser && !jwtClaims) {
return null;
}
const email = cachedUser?.email || jwtClaims?.email;
const metadata = jwtClaims?.user_metadata;
const username =
cachedUser?.username ||
metadata?.full_name ||
metadata?.name ||
email ||
"";
return {
username,
email,
is_anonymous: jwtClaims?.is_anonymous === true,
};
}
export async function refreshPlatformSession(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { getDesktopOs, DesktopOs } from "@app/services/platformService";
/**
* Whether multiple windows are supported on the current OS.
*
* Multi-window relies on every window sharing one persistent web store, so a
* new window sees the same login / files / settings:
* - Windows: shared WebView2 user-data dir ✅
* - macOS: shared WKWebsiteDataStore.default() ✅
* - Linux: WebKitGTK gives each window its own store and Tauri exposes no way
* to share it, so a new window would start blank. Multi-window is disabled
* there.
*
* Uses an allowlist of known-good platforms, so anything unresolved (null) or
* unknown (detection failed) stays disabled rather than risking a blank window.
*/
export function useMultiWindowSupported(): boolean {
const [os, setOs] = useState<DesktopOs | null>(null);
useEffect(() => {
getDesktopOs()
.then(setOs)
.catch(() => setOs(DesktopOs.Unknown));
}, []);
return os === DesktopOs.Windows || os === DesktopOs.Mac;
}
@@ -0,0 +1,38 @@
import { useEffect } from "react";
import { fileOpenService } from "@app/services/fileOpenService";
import { useMultiWindowSupported } from "@app/hooks/useMultiWindowSupported";
/**
* Desktop-only keyboard shortcut: Ctrl+N (Cmd+N on macOS) opens an empty new
* window. The new window runs in the same Tauri process and shares the bundled
* backend. Disabled on platforms where multi-window isn't supported (Linux) so
* the new window doesn't start blank - see useMultiWindowSupported.
*
* Safe to bind plain Ctrl/Cmd+N here: the listener is only registered inside the
* Tauri desktop app (useMultiWindowSupported is false on web), so the browser's
* native Ctrl+N is never intercepted in the web build.
*/
export function useNewWindowShortcut() {
const supported = useMultiWindowSupported();
useEffect(() => {
if (!supported) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.repeat) return;
const modifier = event.ctrlKey || event.metaKey;
if (
modifier &&
!event.shiftKey &&
!event.altKey &&
event.key.toLowerCase() === "n"
) {
event.preventDefault();
fileOpenService.openInNewWindow([]);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [supported]);
}
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
import { fileOpenService } from "@app/services/fileOpenService";
import { fileStorage } from "@app/services/fileStorage";
import { materializeServerStubs } from "@app/services/fileSyncService";
import { useFileActions } from "@app/contexts/file/fileHooks";
import { StirlingFileStub } from "@app/types/fileContext";
import { FileId } from "@app/types/file";
/**
* Desktop-only: when a window is spawned via "Open in new window" from the My
* Files page, the file ids are queued in the Rust backend under this window's
* label. On mount we pop them and load the matching stored files from the
* shared IndexedDB store into this window's workspace.
*
* Mirrors the files-page "add to workspace" path (FileManagerView): stored
* stubs may be server-only (no local bytes), so they go through
* materializeServerStubs before being added.
*/
export function useOpenWindowFiles() {
const { actions } = useFileActions();
const consumedRef = useRef(false);
useEffect(() => {
if (consumedRef.current) return;
const loadPendingFiles = async () => {
const fileIds = await fileOpenService.popWindowFileIds();
if (fileIds.length === 0) return;
consumedRef.current = true;
const stubs = (
await Promise.all(
fileIds.map((id) => fileStorage.getStirlingFileStub(id as FileId)),
)
).filter((s): s is StirlingFileStub => Boolean(s));
if (stubs.length === 0) {
console.warn(
"[Desktop] open-in-new-window: no stored files found for ids",
fileIds,
);
return;
}
// Download + ingest any server-only stubs first; local stubs pass through.
const materialized = await materializeServerStubs(stubs, {
addFiles: actions.addFilesWithOptions,
updateStub: actions.updateStirlingFileStub,
});
if (materialized.length > 0) {
await actions.addStirlingFileStubs(materialized, { selectFiles: true });
console.log(
`[Desktop] Opened ${materialized.length} stored file(s) in new window`,
);
}
};
loadPendingFiles().catch((error) => {
console.error("[Desktop] Failed to load files for new window:", error);
});
}, [actions]);
}
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { fileOpenService } from "@app/services/fileOpenService";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
export function useOpenedFile() {
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
@@ -45,16 +45,22 @@ export function useOpenedFile() {
// Read files on mount
readFilesFromStorage();
// Listen for files-changed events (when new files are added to storage)
// Listen for files-changed events scoped to THIS window only.
// Rust emits via window.emit(...) / app.emit_to(label, ...) so each
// Tauri window sees only its own queue updates.
let unlisten: (() => void) | undefined;
listen("files-changed", async () => {
console.log("📂 files-changed event received, re-reading storage...");
await readFilesFromStorage();
}).then((unlistenFn) => {
unlisten = unlistenFn;
});
const currentWindow = getCurrentWebviewWindow();
currentWindow
.listen("files-changed", async () => {
console.log(
`📂 files-changed event received on window '${currentWindow.label}', re-reading storage...`,
);
await readFilesFromStorage();
})
.then((unlistenFn) => {
unlisten = unlistenFn;
});
// Cleanup function
return () => {
if (unlisten) unlisten();
};
@@ -145,6 +145,7 @@ export class ConnectionModeService {
console.log("Switching to SaaS mode");
const previousMode = this.currentConfig?.mode ?? null;
const serverConfig: ServerConfig = { url: saasServerUrl };
await invoke("set_connection_mode", {
@@ -166,6 +167,26 @@ export class ConnectionModeService {
this.notifyListeners();
// Re-dispatch `jwt-available` so the proprietary AuthProvider re-runs
// getSession() now that the mode is "saas". During OAuth login,
// authService.saveTokenEverywhere fires the initial `jwt-available`
// BEFORE switchToSaaS runs - at that point getSession sees mode="local"
// and takes the standard Spring path, which fails for Supabase JWTs.
// Without re-firing here the AuthProvider's session state stays null
// until a manual reload.
//
// Only fire when the mode actually changed AND there's a token to
// validate - otherwise a stay-in-mode call (e.g. updating the SaaS
// server URL while already signed in) would cause every `jwt-available`
// consumer to refetch unnecessarily.
if (
previousMode !== "saas" &&
typeof window !== "undefined" &&
localStorage.getItem("stirling_jwt")
) {
window.dispatchEvent(new CustomEvent("jwt-available"));
}
console.log("Switched to SaaS mode successfully");
}
@@ -208,6 +229,8 @@ export class ConnectionModeService {
console.log("Switching to self-hosted mode:", serverConfig);
const previousMode = this.currentConfig?.mode ?? null;
await invoke("set_connection_mode", {
mode: "selfhosted",
serverConfig,
@@ -231,6 +254,17 @@ export class ConnectionModeService {
this.notifyListeners();
// See the comment in switchToSaaS: re-fire `jwt-available` so the
// AuthProvider re-validates its session in the new mode. The same race
// applies to the self-hosted OAuth flow. Only on an actual mode change.
if (
previousMode !== "selfhosted" &&
typeof window !== "undefined" &&
localStorage.getItem("stirling_jwt")
) {
window.dispatchEvent(new CustomEvent("jwt-available"));
}
console.log("Switched to self-hosted mode successfully");
}
@@ -7,6 +7,11 @@ export interface FileOpenService {
): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
clearOpenedFiles(): Promise<void>;
onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function
openInNewWindow(paths?: string[]): Promise<void>;
/** Open already-stored files (by IndexedDB id) in a new window. */
openFilesInNewWindow(fileIds: string[]): Promise<void>;
/** Pop the stored-file ids queued for the current window (consumed on mount). */
popWindowFileIds(): Promise<string[]>;
}
class TauriFileOpenService implements FileOpenService {
@@ -54,6 +59,35 @@ class TauriFileOpenService implements FileOpenService {
}
}
async openInNewWindow(paths: string[] = []): Promise<void> {
try {
const label = await invoke<string>("open_in_new_window", { paths });
console.log(`🪟 Spawned new window: ${label}`);
} catch (error) {
console.error("❌ Failed to open in new window:", error);
}
}
async openFilesInNewWindow(fileIds: string[]): Promise<void> {
try {
const label = await invoke<string>("open_files_in_new_window", {
fileIds,
});
console.log(`🪟 Spawned new window ${label} for stored files:`, fileIds);
} catch (error) {
console.error("❌ Failed to open files in new window:", error);
}
}
async popWindowFileIds(): Promise<string[]> {
try {
return await invoke<string[]>("pop_window_file_ids");
} catch (error) {
console.error("❌ Failed to pop window file ids:", error);
return [];
}
}
onFileOpened(callback: (filePath: string) => void): () => void {
let cleanup: (() => void) | null = null;
let isCleanedUp = false;
@@ -140,6 +174,18 @@ class WebFileOpenService implements FileOpenService {
// No-op cleanup for web mode
};
}
async openInNewWindow(_paths: string[] = []): Promise<void> {
// Multi-window isn't a thing in browser mode.
}
async openFilesInNewWindow(_fileIds: string[]): Promise<void> {
// Multi-window isn't a thing in browser mode.
}
async popWindowFileIds(): Promise<string[]> {
return [];
}
}
// Export the appropriate service based on environment
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import type { TFunction } from "i18next";
import type { User } from "@app/auth/springAuthClient";
import { deriveDisplayName } from "@app/auth/UseSession";
// Stub t() that returns the fallback string. The real i18next instance
// looks up "auth.displayName.user" -> "User" but we don't need that
// machinery here.
const t: TFunction = ((_key: string, fallback?: string) =>
fallback ?? "") as TFunction;
function makeUser(overrides: Partial<User> = {}): User {
return {
id: "user-1",
email: "alice@example.com",
username: "alice",
role: "USER",
...overrides,
};
}
describe("proprietary deriveDisplayName", () => {
it("returns null when there is no user object", () => {
expect(deriveDisplayName(null, t)).toBeNull();
expect(deriveDisplayName(undefined, t)).toBeNull();
});
it("returns the username when present", () => {
expect(deriveDisplayName(makeUser({ username: "alice" }), t)).toBe("alice");
});
it("falls back to email when username is empty", () => {
expect(
deriveDisplayName(
makeUser({ username: "", email: "bob@example.com" }),
t,
),
).toBe("bob@example.com");
});
it("returns null when both username and email are empty", () => {
expect(
deriveDisplayName(makeUser({ username: "", email: "" }), t),
).toBeNull();
});
it("returns the localised 'User' placeholder for anonymous users", () => {
expect(
deriveDisplayName(
makeUser({ is_anonymous: true, username: "anon-uuid" }),
t,
),
).toBe("User");
});
it("treats anonymous flag as authoritative - even a populated username is overridden", () => {
// The Spring backend may assign a generated username to anonymous users;
// we still want the localised placeholder shown to the UI.
expect(
deriveDisplayName(
makeUser({ is_anonymous: true, username: "anonymous-12345" }),
t,
),
).toBe("User");
});
});
@@ -6,6 +6,8 @@ import {
ReactNode,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { springAuth } from "@app/auth/springAuthClient";
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
import type {
@@ -22,15 +24,42 @@ import type {
interface AuthContextType {
session: Session | null;
user: User | null;
/**
* Human-readable name to show in the UI for the current session.
* - A real identity (username/email) when the user is signed in.
* - The localised "User" placeholder for anonymous sessions
* (proprietary's chosen label - see deriveDisplayName).
* - null only when there is no user object at all (signed-out), so
* consumers can fall back to whatever makes sense.
*/
displayName: string | null;
loading: boolean;
error: AuthError | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
/**
* Derive a display name from the Spring user. Anonymous users get the
* localised "User" placeholder (proprietary's chosen label for unsigned-in
* sessions); returns null only when there is no user object at all so
* consumers can pick their own fallback.
*
* Exported for unit testing.
*/
export function deriveDisplayName(
user: User | null | undefined,
t: TFunction,
): string | null {
if (!user) return null;
if (user.is_anonymous) return t("auth.displayName.user", "User");
return user.username || user.email || null;
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
displayName: null,
loading: true,
error: null,
signOut: async () => {},
@@ -100,15 +129,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { error } = await springAuth.signOut();
// Always clear the in-memory session: springAuth.signOut() removes the
// local token and platform user_info even when the backend POST fails,
// so the user is effectively signed out either way. Leaving session
// populated on error would mean the UI keeps the old user's badge until
// a manual reload (the SIGNED_OUT notifyListeners call also covers this
// path now, but clearing here is defence in depth).
setSession(null);
if (error) {
console.error("[Auth] Sign out error:", error);
setError(error);
} else {
console.debug("[Auth] Signed out successfully");
setSession(null);
}
} catch (err) {
console.error("[Auth] Unexpected error during sign out:", err);
setSession(null);
setError(err as AuthError);
}
}, []);
@@ -248,9 +285,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
const { t } = useTranslation();
const user = session?.user ?? null;
const value: AuthContextType = {
session,
user: session?.user ?? null,
user,
displayName: deriveDisplayName(user, t),
loading,
error,
signOut,
@@ -18,6 +18,7 @@ import {
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
shouldCallBackendLogout,
} from "@app/extensions/platformSessionBridge";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
@@ -352,9 +353,13 @@ class SpringAuthClient {
platformUser?.email ||
platformUser?.username ||
"desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
email: platformUser?.email ?? "",
// Username may be empty when the platform layer can't identify
// the user - downstream displayName derivation handles that
// case and falls back to a generic placeholder.
username: platformUser?.username ?? "",
role: "USER",
is_anonymous: platformUser?.is_anonymous,
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
@@ -575,15 +580,24 @@ class SpringAuthClient {
"1",
);
}
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
// Only call the backend logout endpoint when the platform tells us
// the current backend implements it. In desktop SaaS mode the
// apiClient points at the SaaS gateway, which doesn't expose
// `/api/v1/auth/logout` (Supabase manages session lifecycle); POSTing
// there returns 500 and pollutes error toasts even though the local
// cleanup below succeeds.
if (await shouldCallBackendLogout()) {
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
}
}
// Clean up local storage
@@ -641,6 +655,12 @@ class SpringAuthClient {
cleanupError,
);
}
// The user is logged out *locally* even if the backend call failed
// (token + platform user_info are gone). The previous version skipped
// this notification on error - the AuthProvider then never cleared
// its session state, leaving the UI claiming the user was still signed
// in until a full reload.
this.notifyListeners("SIGNED_OUT", null);
return {
error: { message: getErrorMessage(error, "Logout failed") },
};
@@ -1,6 +1,16 @@
/**
* Resolved identity for the current session, as understood by the platform
* layer (desktop) that owns the underlying token format. The proprietary
* auth client treats these fields as opaque - it does NOT inspect the JWT
* directly. Each platform decides how to populate this from whatever
* token/user storage it owns (e.g. desktop reads the Tauri user_info store
* plus the Supabase JWT claims; web has no platform layer).
*/
export interface PlatformSessionUser {
username: string;
email?: string;
/** True for anonymous/guest sessions (e.g. Supabase anonymous sign-in). */
is_anonymous?: boolean;
}
/**
@@ -10,6 +20,14 @@ export async function isDesktopSaaSAuthMode(): Promise<boolean> {
return false;
}
/**
* Whether the currently-authoritative backend exposes `/api/v1/auth/logout`
* and should be hit during sign-out.
*/
export async function shouldCallBackendLogout(): Promise<boolean> {
return true;
}
/**
* Proprietary/web default: no platform user store.
*/
@@ -109,6 +109,7 @@ describe("Login", () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
displayName: null,
loading: false,
error: null,
signOut: vi.fn(),
@@ -155,6 +156,7 @@ describe("Login", () => {
vi.mocked(useAuth).mockReturnValue({
session: mockSession,
user: mockSession.user,
displayName: mockSession.user.username,
loading: false,
error: null,
signOut: vi.fn(),
@@ -178,6 +180,7 @@ describe("Login", () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
displayName: null,
loading: true,
error: null,
signOut: vi.fn(),
@@ -0,0 +1,118 @@
import { describe, it, expect } from "vitest";
import type { TFunction } from "i18next";
import { deriveDisplayName, type User } from "@app/auth/UseSession";
// Stub t() that returns the fallback string passed to it.
const t: TFunction = ((_key: string, fallback?: string) =>
fallback ?? "") as TFunction;
// Minimal Supabase-shaped User. The real type has many more fields but
// none of them matter for displayName derivation.
function makeUser(overrides: Partial<User> = {}): User {
return {
id: "00000000-0000-0000-0000-000000000001",
aud: "authenticated",
email: "alice@example.com",
app_metadata: {},
user_metadata: {},
created_at: "2026-01-01T00:00:00Z",
...overrides,
} as User;
}
describe("saas deriveDisplayName", () => {
it("returns null when there is no user object", () => {
expect(deriveDisplayName(null, t)).toBeNull();
expect(deriveDisplayName(undefined, t)).toBeNull();
});
it("prefers the bridged username over metadata and email", () => {
expect(
deriveDisplayName(
makeUser({
username: "alice",
user_metadata: { full_name: "Alice Wonderland" },
email: "alice@example.com",
}),
t,
),
).toBe("alice");
});
it("falls back to user_metadata.full_name when username is missing", () => {
expect(
deriveDisplayName(
makeUser({
username: undefined,
user_metadata: { full_name: "Alice Wonderland" },
}),
t,
),
).toBe("Alice Wonderland");
});
it("falls back to user_metadata.name when full_name is missing", () => {
expect(
deriveDisplayName(
makeUser({
username: undefined,
user_metadata: { name: "Alice" },
}),
t,
),
).toBe("Alice");
});
it("falls back to email when no name fields are populated", () => {
expect(
deriveDisplayName(
makeUser({
username: undefined,
user_metadata: {},
email: "alice@example.com",
}),
t,
),
).toBe("alice@example.com");
});
it("returns null when nothing identifies the user", () => {
expect(
deriveDisplayName(
makeUser({
username: undefined,
user_metadata: {},
email: undefined,
}),
t,
),
).toBeNull();
});
it("returns the localised 'Guest' placeholder for anonymous users", () => {
expect(
deriveDisplayName(
makeUser({
is_anonymous: true,
email: "anon@local",
user_metadata: { full_name: "Whatever" },
}),
t,
),
).toBe("Guest");
});
it("treats anonymous flag as authoritative - populated identity fields are ignored", () => {
// Anonymous Supabase sessions can carry a synthetic email; the UI
// should still see the placeholder, not the synthetic address.
expect(
deriveDisplayName(
makeUser({
is_anonymous: true,
username: "anon-uuid",
}),
t,
),
).toBe("Guest");
});
});
+39 -1
View File
@@ -6,6 +6,8 @@ import {
ReactNode,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { supabase } from "@app/auth/supabase";
import type {
Session,
@@ -31,6 +33,29 @@ import {
// Extend Supabase User to include optional username for compatibility
export type User = SupabaseUser & { username?: string };
/**
* Derive a display name from the Supabase user. Prefers the OAuth-provided
* full_name / name, then the email. Anonymous users get the localised
* "Guest" placeholder (SaaS's chosen label for guest sessions); returns
* null only when there is no user object at all so consumers can pick
* their own fallback.
*
* Exported for unit testing.
*/
export function deriveDisplayName(
user: User | null | undefined,
t: TFunction,
): string | null {
if (!user) return null;
if (user.is_anonymous) return t("auth.displayName.guest", "Guest");
const metadata = user.user_metadata as
| { full_name?: string; name?: string }
| undefined;
return (
user.username || metadata?.full_name || metadata?.name || user.email || null
);
}
export interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
@@ -43,6 +68,15 @@ export interface TrialStatus {
interface AuthContextType {
session: Session | null;
user: User | null;
/**
* Human-readable name to show in the UI for the current session.
* - A real identity (full_name / name / email) when the user is signed in.
* - The localised "Guest" placeholder for anonymous (Supabase
* `is_anonymous`) sessions - SaaS's chosen label, see deriveDisplayName.
* - null only when there is no user object at all (signed-out), so
* consumers can fall back to whatever makes sense.
*/
displayName: string | null;
loading: boolean;
error: AuthError | null;
creditBalance: number | null;
@@ -66,6 +100,7 @@ interface AuthContextType {
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
displayName: null,
loading: true,
error: null,
creditBalance: null,
@@ -660,9 +695,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
const { t } = useTranslation();
const user = session?.user ?? null;
const value: AuthContextType = {
session,
user: session?.user ?? null,
user,
displayName: deriveDisplayName(user, t),
loading,
error,
creditBalance,
@@ -1,199 +0,0 @@
import React, { ReactNode } from "react";
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
type InfoBannerTone = "info" | "warning";
const toneStyles: Record<
InfoBannerTone,
{
background: string;
border: string;
text: string;
icon: string;
buttonColor: string;
}
> = {
info: {
background: "var(--mantine-color-blue-0)",
border: "var(--mantine-color-blue-2)",
text: "var(--mantine-color-blue-9)",
icon: "var(--mantine-color-blue-6)",
buttonColor: "blue",
},
warning: {
background: "var(--mantine-color-orange-0)",
border: "var(--mantine-color-orange-3)",
text: "var(--mantine-color-orange-9)",
icon: "var(--mantine-color-orange-7)",
buttonColor: "orange",
},
};
interface InfoBannerProps {
icon?: string | ReactNode; // SaaS supports ReactNode (e.g., logo images)
title?: ReactNode;
message: ReactNode;
buttonText?: string;
buttonIcon?: string;
onButtonClick?: () => void;
onDismiss?: () => void;
dismissible?: boolean;
loading?: boolean;
show?: boolean;
tone?: InfoBannerTone;
background?: string;
borderColor?: string;
textColor?: string;
iconColor?: string;
buttonColor?: string;
buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle";
buttonTextColor?: string; // SaaS-specific for dark theme buttons
minHeight?: number | string;
closeIconColor?: string; // SaaS-specific for dark theme
compact?: boolean;
}
/**
* SaaS-specific info banner with enhanced theming support
* Supports ReactNode icons (e.g., logo images) and custom button text colors
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
title,
message,
buttonText,
buttonIcon = "check-circle-rounded",
onButtonClick,
onDismiss,
dismissible = true,
loading = false,
show = true,
tone = "info",
background,
borderColor,
textColor,
iconColor,
buttonColor,
buttonVariant = "light",
buttonTextColor,
minHeight = 56,
closeIconColor,
compact = false,
}) => {
if (!show) {
return null;
}
const toneStyle = toneStyles[tone] ?? toneStyles.info;
const handleDismiss = () => {
onDismiss?.();
};
return (
<Paper
p={compact ? "xs" : "sm"}
radius={0}
style={{
background: background ?? toneStyle.background,
borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
}}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
justify="space-between"
style={{ width: "100%" }}
>
<Group
gap="sm"
align="center"
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}
>
{icon &&
(typeof icon === "string" ? (
<LocalIcon
icon={icon}
width="1.2rem"
height="1.2rem"
style={{ color: iconColor ?? toneStyle.icon, flexShrink: 0 }}
/>
) : (
<div
style={{ flexShrink: 0, display: "flex", alignItems: "center" }}
>
{icon}
</div>
))}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
>
{title}
</Text>
)}
<Text
fw={title ? 400 : 500}
size="sm"
style={{ color: textColor ?? toneStyle.text }}
lineClamp={2}
>
{message}
</Text>
</Stack>
</Group>
<Group gap="xs" align="center" wrap="nowrap">
{buttonText && onButtonClick && (
<Button
variant={buttonVariant}
color={buttonColor ?? toneStyle.buttonColor}
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={
<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />
}
styles={
buttonTextColor
? {
label: {
color: buttonTextColor,
},
}
: buttonVariant !== "white" && buttonVariant !== "filled"
? {
label: {
color: textColor ?? toneStyle.text,
},
}
: undefined
}
>
{buttonText}
</Button>
)}
{dismissible && (
<ActionIcon
variant="subtle"
color={closeIconColor ? undefined : "gray"}
size="sm"
onClick={handleDismiss}
aria-label="Dismiss"
style={closeIconColor ? { color: closeIconColor } : undefined}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
)}
</Group>
</Group>
</Paper>
);
};
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+1 -1
View File
@@ -345,7 +345,7 @@ def main() -> None:
# Project layout assumptions
cwd = os.getcwd()
locales_dir = os.path.join(cwd, "frontend", "public", "locales")
locales_dir = os.path.join(cwd, "frontend", "editor", "public", "locales")
reference_file = os.path.join(locales_dir, "en-GB", "translation.toml")
scripts_directory = os.path.join(cwd, "scripts")
translation_state_file = os.path.join(scripts_directory, "ignore_translation.toml")
+52
View File
@@ -0,0 +1,52 @@
#!/opt/venv/bin/python3
# Stirling PDF PyMuPDF Convert CLI
# Copyright (C) 2025 Stirling PDF Inc.
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <https://www.gnu.org/licenses/>.
"""CLI entry point: convert a PDF file to Markdown.
Usage::
pymupdf-convert <input.pdf> <output.md>
"""
from __future__ import annotations
import sys
from pathlib import Path
import pymupdf
import pymupdf4llm
def main() -> None:
if len(sys.argv) != 3:
print("Usage: pymupdf-convert <input.pdf> <output.md>", file=sys.stderr)
sys.exit(1)
input_path = Path(sys.argv[1])
output_path = Path(sys.argv[2])
if not input_path.exists():
print(f"Input file not found: {input_path}", file=sys.stderr)
sys.exit(1)
with pymupdf.open(str(input_path)) as doc:
markdown = pymupdf4llm.to_markdown(doc, show_progress=False)
output_path.write_text(markdown, encoding="utf-8")
if __name__ == "__main__":
main()
+4 -2
View File
@@ -22,7 +22,9 @@ import tomli_w
class TranslationMerger:
def __init__(
self,
locales_dir: str = os.path.join(os.getcwd(), "frontend", "public", "locales"),
locales_dir: str = os.path.join(
os.getcwd(), "frontend", "editor", "public", "locales"
),
ignore_file: str = os.path.join(
os.getcwd(), "scripts", "ignore_translation.toml"
),
@@ -371,7 +373,7 @@ def main():
)
parser.add_argument(
"--locales-dir",
default=os.path.join(os.getcwd(), "frontend", "public", "locales"),
default=os.path.join(os.getcwd(), "frontend", "editor", "public", "locales"),
help="Path to locales directory",
)
parser.add_argument(