mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9960b6e6 | ||
|
|
f8ce30ff37 | ||
|
|
f0a7f9af78 | ||
|
|
6309ca7234 | ||
|
|
f3f17d6381 | ||
|
|
4487f23ff7 | ||
|
|
2c0ebc28a7 | ||
|
|
d1486c7762 | ||
|
|
2ccff6f73f | ||
|
|
78da227eba | ||
|
|
30e782e29c | ||
|
|
2b0905887b | ||
|
|
28b81828b5 | ||
|
|
2c01f41142 | ||
|
|
4d5eeb103f |
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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->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
|
||||
*  and converts to 
|
||||
|
||||
@@ -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")
|
||||
|
||||
+21
-2
@@ -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));
|
||||
|
||||
+13
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+48
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -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"));
|
||||
})) {
|
||||
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+53
-1
@@ -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<>();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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) {}
|
||||
}
|
||||
+170
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
);
|
||||
+268
@@ -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;
|
||||
}
|
||||
}
|
||||
+230
@@ -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
@@ -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", ());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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/>.
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user