mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e21e466b | ||
|
|
62fe11af48 | ||
|
|
62967c9065 | ||
|
|
61b67ecdaf | ||
|
|
4164bc15c6 | ||
|
|
163041af03 | ||
|
|
f51ea06cbc | ||
|
|
929e8f6269 | ||
|
|
e5f8183320 | ||
|
|
3582e82d99 | ||
|
|
5ce9259ddf | ||
|
|
93c646f704 | ||
|
|
822bbaf7c3 | ||
|
|
9fb7e531b1 | ||
|
|
b21187803d | ||
|
|
b4b9a149fb | ||
|
|
592dc1a1a2 | ||
|
|
b8ee6e589d | ||
|
|
f3d4ead177 | ||
|
|
d0793ea8bb | ||
|
|
bdccc90e80 | ||
|
|
59ed4f5fd1 | ||
|
|
7bf18cc4c7 | ||
|
|
af5f54274d | ||
|
|
78acd9a14b | ||
|
|
cfaf777f2b | ||
|
|
4e901f7524 | ||
|
|
0c0a96aa83 | ||
|
|
69f642f87d | ||
|
|
d6168a5c55 |
@@ -16,6 +16,11 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
rebase-strategy: "auto"
|
||||
groups:
|
||||
simple-java-mail:
|
||||
patterns:
|
||||
- "org.simplejavamail:simple-java-mail"
|
||||
- "org.simplejavamail:outlook-module"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
|
||||
@@ -2,7 +2,8 @@ name: Playwright E2E (stubbed)
|
||||
|
||||
# Reusable workflow called from build.yml. Backend-free Playwright suite —
|
||||
# fast, no Spring Boot required. Runs against the `stubbed` project which
|
||||
# mocks API responses in the browser.
|
||||
# mocks API responses in the browser. Fans out one job per browser
|
||||
# (chromium/firefox/webkit) so all three run in parallel on their own runner.
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -11,7 +12,19 @@ permissions:
|
||||
|
||||
jobs:
|
||||
playwright-e2e:
|
||||
name: playwright-e2e (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# One browser breaking must not mask a failure in another - report all.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- browser: chromium
|
||||
project: stubbed
|
||||
- browser: firefox
|
||||
project: stubbed-firefox
|
||||
- browser: webkit
|
||||
project: stubbed-webkit
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
@@ -27,16 +40,16 @@ jobs:
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
- name: Install Playwright (chromium only)
|
||||
run: task e2e:install -- chromium
|
||||
- name: Install Playwright (${{ matrix.browser }})
|
||||
run: task e2e:install -- ${{ matrix.browser }}
|
||||
- name: Build frontend (production bundle for vite preview)
|
||||
env:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
- name: Run stubbed E2E tests (${{ matrix.browser }})
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
@@ -50,6 +63,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-stubbed-${{ github.run_id }}
|
||||
name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }}
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -59,9 +59,10 @@ jobs:
|
||||
# the story itself — a shared component, a theme token — still surfaces within
|
||||
# a day.
|
||||
a11y-all-stories:
|
||||
name: a11y (every story)
|
||||
name: a11y (every story, light + dark)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Two full sweeps (one per theme), each ~30 minutes of browser time.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
@@ -81,7 +82,7 @@ jobs:
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: a11y gate (every story)
|
||||
- name: a11y gate (every story, light + dark)
|
||||
run: task frontend:storybook:a11y
|
||||
|
||||
- name: Upload scan reports
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api
|
||||
|
||||
# False positive: generic-api-key matches the Java type name "X509Certificate"
|
||||
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
|
||||
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
|
||||
app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.java:generic-api-key:224
|
||||
|
||||
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
|
||||
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
|
||||
|
||||
@@ -15,6 +15,15 @@ tasks:
|
||||
cmds:
|
||||
- npx playwright test --project=stubbed {{.CLI_ARGS}}
|
||||
|
||||
stubbed-project:
|
||||
desc: "Run the stubbed E2E suite for a single Playwright project"
|
||||
dir: frontend/editor
|
||||
deps: [ ':frontend:prepare' ]
|
||||
vars:
|
||||
PROJECT: '{{.PROJECT | default "stubbed"}}'
|
||||
cmds:
|
||||
- npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}}
|
||||
|
||||
live:
|
||||
desc: "Run live E2E tests"
|
||||
summary: |
|
||||
|
||||
@@ -211,11 +211,13 @@ tasks:
|
||||
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
|
||||
|
||||
storybook:a11y:
|
||||
desc: "a11y regression gate over every story: fail only on NEW axe violations"
|
||||
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
storybook:a11y:changed:
|
||||
desc: "a11y gate over the stories this branch affects (default base origin/main)"
|
||||
@@ -244,13 +246,17 @@ tasks:
|
||||
fi
|
||||
node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
|
||||
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
|
||||
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
storybook:a11y:record:
|
||||
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
|
||||
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
|
||||
deps: [prepare, storybook:browser]
|
||||
cmds:
|
||||
- node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
|
||||
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
|
||||
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json
|
||||
|
||||
# ============================================================
|
||||
# Code quality
|
||||
|
||||
@@ -21,8 +21,8 @@ dependencies {
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
api 'org.simplejavamail:simple-java-mail:9.2.0'
|
||||
api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support
|
||||
api 'jakarta.mail:jakarta.mail-api:2.1.5'
|
||||
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
|
||||
|
||||
|
||||
+14
-10
@@ -1,5 +1,6 @@
|
||||
package stirling.software.common.config.swagger;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.model.tool.ToolIOCase;
|
||||
import stirling.software.common.model.tool.ToolIOWhen;
|
||||
import stirling.software.common.service.ToolIOParameterDefaults;
|
||||
|
||||
/**
|
||||
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
|
||||
@@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer
|
||||
if (declaration == null) {
|
||||
return operation;
|
||||
}
|
||||
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
|
||||
operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod()));
|
||||
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
|
||||
return operation;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toExtension(ToolIO declaration) {
|
||||
private static Map<String, Object> toExtension(ToolIO declaration, Method handler) {
|
||||
Map<String, Object> extension = new LinkedHashMap<>();
|
||||
extension.put("accepts", names(declaration.accepts()));
|
||||
extension.put("produces", declaration.produces().name());
|
||||
extension.put("arity", declaration.arity().name());
|
||||
if (declaration.cases().length > 0) {
|
||||
extension.put("cases", cases(declaration));
|
||||
extension.put("cases", cases(declaration, handler));
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> cases(ToolIO declaration) {
|
||||
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
|
||||
private static List<Map<String, Object>> cases(ToolIO declaration, Method handler) {
|
||||
return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList();
|
||||
}
|
||||
|
||||
private static Map<String, Object> toCase(ToolIOCase rule) {
|
||||
private static Map<String, Object> toCase(ToolIOCase rule, Method handler) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put(
|
||||
"when",
|
||||
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
|
||||
entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList());
|
||||
entry.put("produces", rule.produces().name());
|
||||
entry.put("arity", rule.arity().name());
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static Map<String, Object> toCondition(ToolIOWhen condition) {
|
||||
private static Map<String, Object> toCondition(ToolIOWhen condition, Method handler) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("param", condition.param());
|
||||
entry.put("matches", List.of(condition.matches()));
|
||||
// The default the endpoint uses when this parameter is absent, so a step that never sends
|
||||
// it still resolves. Omitted when the parameter is required with none.
|
||||
ToolIOParameterDefaults.resolve(handler, condition.param())
|
||||
.ifPresent(value -> entry.put("default", value));
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,18 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
|
||||
public record ToolIOSpec(
|
||||
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
|
||||
|
||||
public record When(String param, List<String> matches) {
|
||||
/**
|
||||
* @param paramDefault the value used when the parameter is absent, or null when it has no
|
||||
* default - an absent parameter then leaves the case unresolved rather than defaulted.
|
||||
*/
|
||||
public record When(String param, List<String> matches, String paramDefault) {
|
||||
|
||||
boolean holdsFor(Object value) {
|
||||
String normalised = normalise(value);
|
||||
@@ -19,6 +24,14 @@ public record ToolIOSpec(
|
||||
}
|
||||
}
|
||||
|
||||
/** Supplies the default value a request parameter takes when a caller omits it. */
|
||||
@FunctionalInterface
|
||||
public interface ParameterDefaults {
|
||||
Optional<String> defaultFor(String param);
|
||||
|
||||
ParameterDefaults NONE = param -> Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Both sides of a condition are normalised at comparison, not at construction: the declaration
|
||||
* reaches the frontend and the engine as published data, and normalising only one side there
|
||||
@@ -44,25 +57,33 @@ public record ToolIOSpec(
|
||||
}
|
||||
|
||||
public static ToolIOSpec from(ToolIO annotation) {
|
||||
return from(annotation, ParameterDefaults.NONE);
|
||||
}
|
||||
|
||||
public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) {
|
||||
return new ToolIOSpec(
|
||||
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
|
||||
annotation.produces(),
|
||||
annotation.arity(),
|
||||
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
|
||||
Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList());
|
||||
}
|
||||
|
||||
private static Case toCase(ToolIOCase rule) {
|
||||
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
|
||||
private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) {
|
||||
List<When> when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList();
|
||||
return new Case(when, rule.produces(), rule.arity());
|
||||
}
|
||||
|
||||
private static When toWhen(ToolIOWhen condition) {
|
||||
return new When(condition.param(), List.of(condition.matches()));
|
||||
private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) {
|
||||
return new When(
|
||||
condition.param(),
|
||||
List.of(condition.matches()),
|
||||
defaults.defaultFor(condition.param()).orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
|
||||
* declared output comes back uncertain: a value we never saw might have picked another branch.
|
||||
* First matching {@link Case} wins. A parameter the caller omitted resolves to its declared
|
||||
* default; only a parameter with no default leaves the output uncertain, since an unseen value
|
||||
* might then have picked another branch.
|
||||
*
|
||||
* @param parameters the step's configured parameters, or null when not known
|
||||
*/
|
||||
@@ -71,12 +92,17 @@ public record ToolIOSpec(
|
||||
for (Case rule : cases) {
|
||||
boolean allHold = true;
|
||||
for (When condition : rule.when()) {
|
||||
if (parameters == null || !parameters.containsKey(condition.param())) {
|
||||
Object value;
|
||||
if (parameters != null && parameters.containsKey(condition.param())) {
|
||||
value = parameters.get(condition.param());
|
||||
} else if (condition.paramDefault() != null) {
|
||||
value = condition.paramDefault();
|
||||
} else {
|
||||
sawUnknownParam = true;
|
||||
allHold = false;
|
||||
continue;
|
||||
}
|
||||
allHold &= condition.holdsFor(parameters.get(condition.param()));
|
||||
allHold &= condition.holdsFor(value);
|
||||
}
|
||||
if (allHold) {
|
||||
return new Output(rule.produces(), rule.arity(), true);
|
||||
|
||||
+1
-1
@@ -17,7 +17,6 @@ import java.util.concurrent.Semaphore;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.apache.pdfbox.io.MemoryUsageSetting;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
@@ -31,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.DeletingRandomAccessFile;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.Optional;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* The value a request parameter takes when the caller omits it, read from the request model so a
|
||||
* {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches
|
||||
* on. The default is read from the field so it cannot drift.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ToolIOParameterDefaults {
|
||||
|
||||
// Swagger's sentinel for an unset @Schema string member; not a real default value.
|
||||
private static final String SCHEMA_UNSET = "##default";
|
||||
|
||||
private ToolIOParameterDefaults() {}
|
||||
|
||||
/**
|
||||
* The default {@code param} resolves to when absent, or empty when the parameter is required
|
||||
* with no declared default - in which case an unset value leaves the output genuinely unknown
|
||||
* rather than defaulted, and the chain reports it as uncertain.
|
||||
*
|
||||
* <p>Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a
|
||||
* primitive's language default, or an initializer), then the empty string for an optional field
|
||||
* left null, and finally empty for a required field with none of the above.
|
||||
*/
|
||||
public static Optional<String> resolve(Method handler, String param) {
|
||||
for (Parameter parameter : handler.getParameters()) {
|
||||
Field field = findField(parameter.getType(), param);
|
||||
if (field != null) {
|
||||
return fromField(parameter.getType(), field);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static Optional<String> fromField(Class<?> owner, Field field) {
|
||||
Schema schema = field.getAnnotation(Schema.class);
|
||||
if (schema != null
|
||||
&& !schema.defaultValue().isEmpty()
|
||||
&& !SCHEMA_UNSET.equals(schema.defaultValue())) {
|
||||
return Optional.of(schema.defaultValue());
|
||||
}
|
||||
Object value = readField(owner, field);
|
||||
if (value != null) {
|
||||
return Optional.of(String.valueOf(value));
|
||||
}
|
||||
return isRequired(field, schema) ? Optional.empty() : Optional.of("");
|
||||
}
|
||||
|
||||
private static Object readField(Class<?> owner, Field field) {
|
||||
try {
|
||||
Object instance = owner.getDeclaredConstructor().newInstance();
|
||||
field.setAccessible(true);
|
||||
return field.get(instance);
|
||||
} catch (ReflectiveOperationException | RuntimeException e) {
|
||||
// A request model we cannot instantiate leaves the default unknown, which the check
|
||||
// treats conservatively as uncertain. Never break startup over it.
|
||||
log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isRequired(Field field, Schema schema) {
|
||||
if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
|
||||
return true;
|
||||
}
|
||||
return field.isAnnotationPresent(NotNull.class)
|
||||
|| field.isAnnotationPresent(NotBlank.class);
|
||||
}
|
||||
|
||||
private static Field findField(Class<?> type, String name) {
|
||||
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
|
||||
try {
|
||||
return c.getDeclaredField(name);
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
// Try the superclass; request models extend a shared file-input base.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
|
||||
if (annotation == null) {
|
||||
return;
|
||||
}
|
||||
ToolIOSpec spec = ToolIOSpec.from(annotation);
|
||||
Method method = handler.getMethod();
|
||||
ToolIOSpec spec =
|
||||
ToolIOSpec.from(
|
||||
annotation, param -> ToolIOParameterDefaults.resolve(method, param));
|
||||
for (String pattern : extractPatterns(info)) {
|
||||
target.put(pattern, spec);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.apache.pdfbox.examples.util;
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -194,7 +194,7 @@ public class EmlParser {
|
||||
}
|
||||
attachment.setFilename(filename);
|
||||
|
||||
String contentId = embedded ? stripCid(resourceName) : null;
|
||||
String contentId = embedded ? stripCid(resource.getContentId()) : null;
|
||||
attachment.setContentId(contentId);
|
||||
|
||||
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
|
||||
|
||||
+6
-1
@@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest {
|
||||
for (JsonNode match : condition.get("matches")) {
|
||||
matches.add(match.asString());
|
||||
}
|
||||
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
|
||||
JsonNode paramDefault = condition.get("default");
|
||||
when.add(
|
||||
new ToolIOSpec.When(
|
||||
condition.get("param").asString(),
|
||||
matches,
|
||||
paramDefault == null ? null : paramDefault.asString()));
|
||||
}
|
||||
cases.add(
|
||||
new ToolIOSpec.Case(
|
||||
|
||||
+20
@@ -61,6 +61,7 @@ public class PdfOverlayController {
|
||||
int overlayPos = request.getOverlayPosition();
|
||||
|
||||
MultipartFile[] overlayFiles = request.getOverlayFiles();
|
||||
validateOverlayFiles(overlayFiles);
|
||||
File[] overlayPdfFiles = new File[overlayFiles.length];
|
||||
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
|
||||
|
||||
@@ -120,10 +121,29 @@ public class PdfOverlayController {
|
||||
}
|
||||
}
|
||||
|
||||
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
|
||||
// caller omits them, which would otherwise surface as a 500 instead of a 400.
|
||||
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
|
||||
if (overlayFiles == null || overlayFiles.length == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.overlayFilesRequired", "At least one overlay file is required");
|
||||
}
|
||||
for (MultipartFile overlayFile : overlayFiles) {
|
||||
if (overlayFile == null || overlayFile.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.overlayFileEmpty", "Overlay files must not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Integer, String> prepareOverlayGuide(
|
||||
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
|
||||
throws IOException {
|
||||
Map<Integer, String> overlayGuide = new HashMap<>();
|
||||
if (mode == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
|
||||
}
|
||||
switch (mode) {
|
||||
case "SequentialOverlay":
|
||||
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
|
||||
|
||||
+1
-1
@@ -13,7 +13,6 @@ import java.util.Calendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.apache.pdfbox.examples.signature.CreateSignatureBase;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
@@ -76,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.SPDF.pdf.signature.CreateSignatureBase;
|
||||
import stirling.software.SPDF.service.HardwareKeyStoreService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
package stirling.software.SPDF.pdf.signature;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
package stirling.software.SPDF.pdf.signature;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
package stirling.software.SPDF.pdf.signature;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
package stirling.software.SPDF.pdf.signature;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.apache.pdfbox.examples.util;
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
+32
-1
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import stirling.software.common.model.tool.ToolFormat;
|
||||
import stirling.software.common.model.tool.ToolIO;
|
||||
import stirling.software.common.model.tool.ToolIOSpec;
|
||||
import stirling.software.common.service.ToolIOParameterDefaults;
|
||||
|
||||
/**
|
||||
* Every document-transforming endpoint must declare its I/O, or it becomes a hole in the
|
||||
@@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest {
|
||||
assertEquals(Set.of("ps", "pcl", "xps"), declared);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAbsentParameterResolvesToItsRequestModelDefault() {
|
||||
// A pipeline step often omits a parameter a case branches on. The default is read from the
|
||||
// request model, so the output resolves anyway instead of coming back uncertain.
|
||||
|
||||
// Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire.
|
||||
assertEquals(
|
||||
ToolFormat.PDF,
|
||||
spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format());
|
||||
assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain());
|
||||
|
||||
// Change Permissions posts to add-password with no password fields; both default to blank,
|
||||
// so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF.
|
||||
assertEquals(
|
||||
ToolFormat.PDF,
|
||||
spec("/api/v1/security/add-password").resolveOutput(Map.of()).format());
|
||||
assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() {
|
||||
// pdf/text branches on outputFormat, which is required with no default. Absent, its output
|
||||
// is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT.
|
||||
assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyRemovePasswordAcceptsAnEncryptedDocument() {
|
||||
assertTrue(
|
||||
@@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest {
|
||||
}
|
||||
required.add(full);
|
||||
if (declaration != null) {
|
||||
declared.put(full, ToolIOSpec.from(declaration));
|
||||
declared.put(
|
||||
full,
|
||||
ToolIOSpec.from(
|
||||
declaration,
|
||||
param -> ToolIOParameterDefaults.resolve(method, param)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
package stirling.software.SPDF.pdf.signature;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.apache.pdfbox.examples.signature;
|
||||
package stirling.software.SPDF.pdf.signature;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.apache.pdfbox.examples.util;
|
||||
package stirling.software.SPDF.utils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -65,6 +65,8 @@ class ToolIOWhen(ApiModel):
|
||||
|
||||
param: str
|
||||
matches: list[str]
|
||||
# The value the endpoint uses when this parameter is absent; None when it has none.
|
||||
default: str | None = None
|
||||
|
||||
|
||||
class ToolIOCase(ApiModel):
|
||||
@@ -378,7 +380,10 @@ def collect_tool_io(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
|
||||
|
||||
def _render_when(condition: dict[str, Any]) -> str:
|
||||
return f"ToolIOWhen(param={json.dumps(condition['param'])}, matches={json.dumps(condition['matches'])})"
|
||||
parts = [f"param={json.dumps(condition['param'])}", f"matches={json.dumps(condition['matches'])}"]
|
||||
if "default" in condition:
|
||||
parts.append(f"default={json.dumps(condition['default'])}")
|
||||
return f"ToolIOWhen({', '.join(parts)})"
|
||||
|
||||
|
||||
def _render_case(case: dict[str, Any]) -> str:
|
||||
|
||||
@@ -58,6 +58,8 @@ class ToolIOWhen(ApiModel):
|
||||
|
||||
param: str
|
||||
matches: list[str]
|
||||
# The value the endpoint uses when this parameter is absent; None when it has none.
|
||||
default: str | None = None
|
||||
|
||||
|
||||
class ToolIOCase(ApiModel):
|
||||
@@ -101,7 +103,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SIMO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"])],
|
||||
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"], default="multiple")],
|
||||
produces=ToolFormat.IMAGE,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
@@ -130,15 +132,19 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["ps"])],
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["ps"], default="eps")],
|
||||
produces=ToolFormat.POSTSCRIPT,
|
||||
arity=ToolArity.SISO,
|
||||
),
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["pcl"])], produces=ToolFormat.PCL, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["pcl"], default="eps")],
|
||||
produces=ToolFormat.PCL,
|
||||
arity=ToolArity.SISO,
|
||||
),
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["xps"])], produces=ToolFormat.XPS, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="outputFormat", matches=["xps"], default="eps")],
|
||||
produces=ToolFormat.XPS,
|
||||
arity=ToolArity.SISO,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -151,7 +157,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.MIMO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"])],
|
||||
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"], default="false")],
|
||||
produces=ToolFormat.PDF,
|
||||
arity=ToolArity.MISO,
|
||||
)
|
||||
@@ -202,7 +208,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="dryRun", matches=["true"])], produces=ToolFormat.JSON, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="dryRun", matches=["true"], default="false")],
|
||||
produces=ToolFormat.JSON,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -223,7 +231,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="sidecar", matches=["true"])], produces=ToolFormat.ZIP, arity=ToolArity.SISO
|
||||
when=[ToolIOWhen(param="sidecar", matches=["true"], default="false")],
|
||||
produces=ToolFormat.ZIP,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -242,7 +252,10 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
|
||||
arity=ToolArity.SISO,
|
||||
cases=[
|
||||
ToolIOCase(
|
||||
when=[ToolIOWhen(param="password", matches=[""]), ToolIOWhen(param="ownerPassword", matches=[""])],
|
||||
when=[
|
||||
ToolIOWhen(param="password", matches=[""], default=""),
|
||||
ToolIOWhen(param="ownerPassword", matches=[""], default=""),
|
||||
],
|
||||
produces=ToolFormat.PDF,
|
||||
arity=ToolArity.SISO,
|
||||
)
|
||||
|
||||
@@ -89,11 +89,16 @@ def resolve_output(spec: ToolIOSpec, parameters: dict[str, object] | None) -> Re
|
||||
for rule in spec.cases:
|
||||
all_hold = True
|
||||
for condition in rule.when:
|
||||
if parameters is None or condition.param not in parameters:
|
||||
if parameters is not None and condition.param in parameters:
|
||||
raw: object = parameters[condition.param]
|
||||
elif condition.default is not None:
|
||||
# The caller omitted it, so it takes the endpoint's default.
|
||||
raw = condition.default
|
||||
else:
|
||||
saw_unknown_param = True
|
||||
all_hold = False
|
||||
continue
|
||||
normalised = _normalise(parameters[condition.param])
|
||||
normalised = _normalise(raw)
|
||||
all_hold = all_hold and any(_normalise(m) == normalised for m in condition.matches)
|
||||
if all_hold:
|
||||
return ResolvedOutput(format=rule.produces, arity=rule.arity, certain=True)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"editor/src/core/assets/Brand.stories.tsx :: Logos": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [
|
||||
"aria-progressbar-name",
|
||||
"color-contrast"
|
||||
@@ -886,9 +889,6 @@
|
||||
"editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [
|
||||
"button-name"
|
||||
],
|
||||
"editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
@@ -1377,8 +1377,17 @@
|
||||
"editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [
|
||||
"button-name"
|
||||
],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": ["color-contrast"],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": ["color-contrast"],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"],
|
||||
"editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"],
|
||||
"editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [
|
||||
@@ -1399,6 +1408,12 @@
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"],
|
||||
"editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Collapsible.stories.tsx :: Default": [
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Drawer.stories.tsx :: Playground": [
|
||||
"aria-allowed-role",
|
||||
"color-contrast"
|
||||
@@ -1565,11 +1580,16 @@
|
||||
"editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"],
|
||||
"editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [
|
||||
"color-contrast"
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: Playground": [
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: Playground": ["color-contrast"],
|
||||
"editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [
|
||||
"color-contrast"
|
||||
"color-contrast",
|
||||
"scrollable-region-focusable"
|
||||
],
|
||||
"editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"],
|
||||
"editor/src/portal/components/AppShell.stories.tsx :: Mobile": [
|
||||
@@ -1951,26 +1971,12 @@
|
||||
"editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [
|
||||
"aria-progressbar-name",
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [
|
||||
"aria-progressbar-name",
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/SecurityTab.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/infrastructure/StorageTab.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [
|
||||
"color-contrast"
|
||||
],
|
||||
@@ -2266,18 +2272,6 @@
|
||||
"color-contrast",
|
||||
"landmark-unique"
|
||||
],
|
||||
"editor/src/portal/views/Documents.stories.tsx :: Default": [
|
||||
"aria-prohibited-attr",
|
||||
"color-contrast",
|
||||
"empty-table-header",
|
||||
"nested-interactive"
|
||||
],
|
||||
"editor/src/portal/views/Documents.stories.tsx :: Empty": [
|
||||
"aria-prohibited-attr",
|
||||
"color-contrast",
|
||||
"empty-table-header",
|
||||
"nested-interactive"
|
||||
],
|
||||
"editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [
|
||||
"color-contrast",
|
||||
"landmark-no-duplicate-banner",
|
||||
@@ -2304,23 +2298,11 @@
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/views/Pipelines.stories.tsx :: Default": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
],
|
||||
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
],
|
||||
"editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"],
|
||||
"editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Default": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Empty": [
|
||||
"color-contrast",
|
||||
"empty-table-header"
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"],
|
||||
"editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"],
|
||||
"editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [
|
||||
"aria-hidden-focus"
|
||||
],
|
||||
@@ -2354,6 +2336,9 @@
|
||||
"aria-dialog-name",
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [
|
||||
"color-contrast"
|
||||
],
|
||||
"editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [
|
||||
"button-name",
|
||||
"color-contrast"
|
||||
|
||||
+20
-12
@@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
* the portal layer at editor/src/portal/). MDX docs pages live in
|
||||
* editor/src/portal/docs/.
|
||||
*/
|
||||
/**
|
||||
* Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and
|
||||
* `@proprietary/*`. Resolve them exactly the way the editor's own build does -
|
||||
* through vite-tsconfig-paths against the proprietary vite tsconfig - so the
|
||||
* shared Storybook can host editor components without duplicating the alias map
|
||||
* here. Built per pass: the main bundle and the worker bundle each need their own.
|
||||
*/
|
||||
const editorPathAliases = () =>
|
||||
tsconfigPaths({
|
||||
projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")],
|
||||
});
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../editor/src/portal/**/*.mdx",
|
||||
@@ -47,19 +59,15 @@ const config: StorybookConfig = {
|
||||
// than a relative path.
|
||||
"@public": resolve(__dirname, "../editor/public"),
|
||||
};
|
||||
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
|
||||
// @proprietary/*. Resolve them exactly the way the editor's own build does —
|
||||
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
|
||||
// shared Storybook can host editor components without duplicating the alias
|
||||
// map here.
|
||||
config.plugins = config.plugins ?? [];
|
||||
config.plugins.push(
|
||||
tsconfigPaths({
|
||||
projects: [
|
||||
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
config.plugins.push(editorPathAliases());
|
||||
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so
|
||||
// without this a worker importing @app/* fails to resolve while the same
|
||||
// import works everywhere else. Mirrors editor/vite.config.ts.
|
||||
config.worker = {
|
||||
...(config.worker ?? {}),
|
||||
plugins: () => [editorPathAliases()],
|
||||
};
|
||||
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
|
||||
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
|
||||
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
|
||||
|
||||
@@ -29,7 +29,16 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@core/tokens/tokens.css";
|
||||
import "@core/theme/index.css";
|
||||
// The editor's semantic token layer (--bg-surface, --onboarding-title, …).
|
||||
// The app reaches it through its style entry; without it here, components
|
||||
// styled on those variables render unthemed (e.g. transparent modal surfaces)
|
||||
// and axe measures contrast against colours the app never shows.
|
||||
import "@core/styles/theme.css";
|
||||
import "@core/tokens/base.css";
|
||||
// Portal element reset + typography. Scoped to .portal-scope in the app so it
|
||||
// can't leak into the editor; the decorator below adds that class around
|
||||
// portal stories only, mirroring how PortalApp mounts.
|
||||
import "@portal/theme/base.css";
|
||||
|
||||
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
|
||||
// glob, so the toolbar language switcher can flip between all languages with no
|
||||
@@ -201,6 +210,13 @@ const withProviders: Decorator = (Story, context) => {
|
||||
// anything that isn't "dark" as light — matching the addon's own
|
||||
// `selected || defaultTheme` fallback where defaultTheme is light.
|
||||
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
|
||||
// PortalApp mounts its views inside a .portal-scope wrapper, which is what
|
||||
// the portal's base.css keys its reset/typography on. Give portal stories
|
||||
// the same wrapper (and only them — the scoping exists precisely so portal
|
||||
// styles never apply to editor components).
|
||||
const isPortalStory = (context.parameters.fileName ?? "").includes(
|
||||
"/portal/",
|
||||
);
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -214,7 +230,13 @@ const withProviders: Decorator = (Story, context) => {
|
||||
<TierKey tier={tier}>
|
||||
<UIProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Story />
|
||||
{isPortalStory ? (
|
||||
<div className="portal-scope">
|
||||
<Story />
|
||||
</div>
|
||||
) : (
|
||||
<Story />
|
||||
)}
|
||||
</Suspense>
|
||||
</UIProvider>
|
||||
</TierKey>
|
||||
@@ -229,6 +251,12 @@ const withProviders: Decorator = (Story, context) => {
|
||||
|
||||
const preview: Preview = {
|
||||
loaders: [mswLoader],
|
||||
// The scan runs once per theme (SCAN_THEME=light|dark, forwarded by
|
||||
// .storybook/vitest.config.ts); pinning the global here themes every story in
|
||||
// the run. Unset — the Storybook UI — falls back to the toolbar default.
|
||||
initialGlobals: {
|
||||
theme: import.meta.env.VITE_SCAN_THEME === "dark" ? "dark" : "light",
|
||||
},
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
controls: {
|
||||
|
||||
@@ -14,6 +14,14 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
|
||||
* Run with: npx vitest run --config .storybook/vitest.config.ts
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Forwards the SCAN_THEME env var into the browser bundle, where preview.tsx
|
||||
// uses it to pin the theme global for the whole run. The Storybook dev/build
|
||||
// pipeline never sets it, so the toolbar default stays "light" there.
|
||||
define: {
|
||||
"import.meta.env.VITE_SCAN_THEME": JSON.stringify(
|
||||
process.env.SCAN_THEME ?? "",
|
||||
),
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Pre-scan every story + the preview so Vite discovers the story set's large
|
||||
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
|
||||
|
||||
@@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
/** Shared by every stubbed project so a spec sees one layout on all engines. */
|
||||
const STUBBED_VIEWPORT = { width: 1920, height: 1080 };
|
||||
|
||||
const chromiumViewport = {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
viewport: STUBBED_VIEWPORT,
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
@@ -55,7 +58,8 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Stubbed - no backend required, chromium-only for CI speed
|
||||
// Stubbed - no backend required. The chromium arm of the cross-browser
|
||||
// set below; CI fans all three out, one job per engine.
|
||||
{
|
||||
name: "stubbed",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
@@ -93,16 +97,17 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// Cross-browser coverage for the stubbed suite (opt-in locally)
|
||||
// Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`,
|
||||
// or a layout difference here reads as an engine outage.
|
||||
{
|
||||
name: "stubbed-firefox",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT },
|
||||
},
|
||||
{
|
||||
name: "stubbed-webkit",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT },
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -3883,6 +3883,8 @@ addFiles = "Add files"
|
||||
addingFiles = "Adding files…"
|
||||
collapse = "Collapse sidebar"
|
||||
customizeGroups = "Customize groups"
|
||||
dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it."
|
||||
dataLostTitle = "File data is unavailable"
|
||||
dropHint = "Open files to get started"
|
||||
dropToAdd = "Drop files to add"
|
||||
expand = "Expand sidebar"
|
||||
@@ -3903,6 +3905,8 @@ viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
dataLost = "Data lost"
|
||||
dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it."
|
||||
delete = "Delete"
|
||||
moreActions = "More actions"
|
||||
openInViewer = "Open in viewer"
|
||||
|
||||
@@ -258,6 +258,8 @@ export type ToolArity = ${union(vocabulary.arities as string[])};
|
||||
export interface ToolIOWhen {
|
||||
param: string;
|
||||
matches: string[];
|
||||
/** The value the endpoint uses when this parameter is absent; omitted when it has none. */
|
||||
default?: string;
|
||||
}
|
||||
|
||||
/** An output that applies when every condition in \`when\` holds. */
|
||||
|
||||
@@ -632,7 +632,9 @@ const CODE_EXEMPT_PATH = [
|
||||
/\/onboarding\//,
|
||||
/addStamp|addWatermark|\/tooltips\//,
|
||||
/UpgradeBanner|AdminPlanSection/,
|
||||
/\.test\.[jt]sx?$|\.stories\.[jt]sx?$|\/types\//,
|
||||
// Stories are checked like app code; colour-as-data lines opt out with
|
||||
// `theme-allow-color`.
|
||||
/\.test\.[jt]sx?$|\/types\//,
|
||||
];
|
||||
const CODE_HEX =
|
||||
/#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/g;
|
||||
|
||||
@@ -13,6 +13,10 @@ import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackTex
|
||||
import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
|
||||
import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
|
||||
|
||||
// Fixed swatch so the light-on-dark mark variant previews on a dark
|
||||
// surface in either theme.
|
||||
const DARK_SWATCH = "#1a1a1a"; // theme-allow-color fixed preview swatch
|
||||
|
||||
type Asset = { label: string; src: string; onDark?: boolean };
|
||||
type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] };
|
||||
|
||||
@@ -55,15 +59,13 @@ function Swatch({ label, src, onDark, h }: Asset & { h: number }) {
|
||||
padding: 16,
|
||||
minWidth: 140,
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(128,128,128,0.25)",
|
||||
background: onDark ? "#1a1a1a" : "#ffffff",
|
||||
border: "1px solid var(--c-border)",
|
||||
background: onDark ? DARK_SWATCH : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<img src={src} alt={label} style={{ height: h, maxWidth: 200 }} />
|
||||
</div>
|
||||
<figcaption
|
||||
style={{ fontSize: 12, color: "var(--c-text-subtle, #71717a)" }}
|
||||
>
|
||||
<figcaption style={{ fontSize: 12, color: "var(--c-text-subtle)" }}>
|
||||
{label}
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from "react";
|
||||
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
|
||||
|
||||
interface PDFAnnotationContextValue {
|
||||
// Drawing mode management
|
||||
@@ -22,8 +23,8 @@ interface PDFAnnotationContextValue {
|
||||
isPlacementMode: boolean;
|
||||
|
||||
// Signature configuration
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
signatureConfig: SignParameters | null;
|
||||
setSignatureConfig: (config: SignParameters | null) => void;
|
||||
}
|
||||
|
||||
const PDFAnnotationContext = createContext<
|
||||
@@ -43,8 +44,8 @@ interface PDFAnnotationProviderProps {
|
||||
storeImageData: (id: string, data: string) => void;
|
||||
getImageData: (id: string) => string | undefined;
|
||||
isPlacementMode: boolean;
|
||||
signatureConfig: any | null;
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
signatureConfig: SignParameters | null;
|
||||
setSignatureConfig: (config: SignParameters | null) => void;
|
||||
}
|
||||
|
||||
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
|
||||
@@ -14,9 +14,17 @@ export interface AnnotationToolConfig {
|
||||
placeButtonText?: string;
|
||||
}
|
||||
|
||||
interface InjectedAnnotationToolProps {
|
||||
selectedColor: string;
|
||||
signatureData: string | null;
|
||||
onSignatureDataChange: (data: string | null) => void;
|
||||
onColorSwatchClick: () => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface BaseAnnotationToolProps {
|
||||
config: AnnotationToolConfig;
|
||||
children: React.ReactNode;
|
||||
children: React.ReactElement<Partial<InjectedAnnotationToolProps>>;
|
||||
onSignatureDataChange?: (data: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -90,7 +98,7 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
/>
|
||||
|
||||
{/* Tool Content */}
|
||||
{React.cloneElement(children as React.ReactElement<any>, {
|
||||
{React.cloneElement(children, {
|
||||
selectedColor,
|
||||
signatureData,
|
||||
onSignatureDataChange: handleSignatureDataChange,
|
||||
|
||||
@@ -300,6 +300,10 @@ const FileEditorThumbnail = ({
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
|
||||
const policyEnforcing = policies.some((p) => p.enforcing);
|
||||
// The overlay swallows clicks, so a run that never settles would leave the card
|
||||
// unusable with no way out. Dismissible, like the viewer's; resets per run.
|
||||
const [enforcingDismissed, setEnforcingDismissed] = useState(false);
|
||||
if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false);
|
||||
// The policy currently enforcing, so the overlay's icon/spinner match that
|
||||
// policy's badge instead of a fixed blue.
|
||||
const enforcingPolicy = policies.find((p) => p.enforcing);
|
||||
@@ -548,8 +552,9 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* Policy enforcement overlay — shown while any policy is in-flight */}
|
||||
<PolicyEnforcingOverlay
|
||||
enforcing={policyEnforcing}
|
||||
enforcing={policyEnforcing && !enforcingDismissed}
|
||||
zIndex={2}
|
||||
onDismiss={() => setEnforcingDismissed(true)}
|
||||
accentVar={enforcingPolicy?.accentColor}
|
||||
categoryId={enforcingPolicy?.id}
|
||||
/>
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
import { FOLDER_COLOR_PALETTE, FolderRecord } from "@app/types/folder";
|
||||
|
||||
const folder: FolderRecord = {
|
||||
id: "folder-1" as FolderRecord["id"],
|
||||
name: "Contracts",
|
||||
parentFolderId: null,
|
||||
color: "#3b82f6",
|
||||
color: FOLDER_COLOR_PALETTE[0],
|
||||
icon: "star",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
|
||||
import { FOLDER_COLOR_PALETTE } from "@app/types/folder";
|
||||
|
||||
const meta = {
|
||||
title: "FilesPage/FolderThumbnail",
|
||||
@@ -10,14 +11,14 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
color: "#6366f1",
|
||||
color: FOLDER_COLOR_PALETTE[4],
|
||||
fileCount: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const RowSize: Story = {
|
||||
args: {
|
||||
color: "#22c55e",
|
||||
color: FOLDER_COLOR_PALETTE[1],
|
||||
fileCount: 3,
|
||||
size: "row",
|
||||
},
|
||||
@@ -25,7 +26,7 @@ export const RowSize: Story = {
|
||||
|
||||
export const WithIconGlyph: Story = {
|
||||
args: {
|
||||
color: "#f97316",
|
||||
color: FOLDER_COLOR_PALETTE[7],
|
||||
fileCount: 5,
|
||||
iconGlyph: "📄",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
|
||||
import { createFolderId, FolderRecord } from "@app/types/folder";
|
||||
import {
|
||||
createFolderId,
|
||||
FOLDER_COLOR_PALETTE,
|
||||
FolderRecord,
|
||||
} from "@app/types/folder";
|
||||
|
||||
const workId = createFolderId();
|
||||
const invoicesId = createFolderId();
|
||||
@@ -11,7 +15,7 @@ const folders: FolderRecord[] = [
|
||||
id: workId,
|
||||
name: "Work",
|
||||
parentFolderId: null,
|
||||
color: "#3b82f6",
|
||||
color: FOLDER_COLOR_PALETTE[0],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
@@ -19,7 +23,7 @@ const folders: FolderRecord[] = [
|
||||
id: invoicesId,
|
||||
name: "Invoices",
|
||||
parentFolderId: workId,
|
||||
color: "#10b981",
|
||||
color: FOLDER_COLOR_PALETTE[1],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
@@ -27,7 +31,7 @@ const folders: FolderRecord[] = [
|
||||
id: archivedId,
|
||||
name: "Archived",
|
||||
parentFolderId: null,
|
||||
color: "#f59e0b",
|
||||
color: FOLDER_COLOR_PALETTE[2],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
@@ -12,6 +12,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
@@ -34,6 +35,7 @@ const FileManagerView = lazy(
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// The consent banner used to be initialised by the footer; the legal links
|
||||
@@ -41,7 +43,7 @@ export default function Workbench() {
|
||||
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { files: activeFiles } = useAllFiles();
|
||||
const { files: activeFiles, fileIds } = useAllFiles();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
@@ -136,6 +138,20 @@ export default function Workbench() {
|
||||
}
|
||||
|
||||
if (activeFiles.length === 0) {
|
||||
// Files are open but their bytes are still loading (a cold PDF engine can
|
||||
// take seconds). Showing the drop zone here reads as "the click did nothing".
|
||||
if (fileIds.length > 0) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("fileManager.loadingFiles", "Loading files...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
// The shared preview only loads the portal tokens; the onboarding modal reads
|
||||
// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them
|
||||
// here or the modal surface renders transparent over the dark overlay.
|
||||
import "@app/styles/theme.css";
|
||||
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
// The shared preview only loads the portal tokens; the shell reads the editor
|
||||
// theme tokens (--bg-surface, --onboarding-title, …), so load them here or the
|
||||
// card renders transparent over the dark overlay.
|
||||
import "@app/styles/theme.css";
|
||||
import OnboardingSlideShell, {
|
||||
ShellHero,
|
||||
type ShellButton,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
// The shared preview only loads the portal tokens; the onboarding modal reads
|
||||
// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them
|
||||
// here or the modal surface renders transparent over the dark overlay.
|
||||
import "@app/styles/theme.css";
|
||||
import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide";
|
||||
import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
|
||||
import DragDropGrid, {
|
||||
type DragHandleProps,
|
||||
} from "@app/components/pageEditor/DragDropGrid";
|
||||
|
||||
interface MockGridItem {
|
||||
id: string;
|
||||
@@ -22,7 +24,7 @@ const renderItem = (
|
||||
clearBoxSelection: () => void,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
dragHandleProps?: any,
|
||||
dragHandleProps?: DragHandleProps,
|
||||
zoomLevel?: number,
|
||||
) => {
|
||||
const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {};
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
DraggableAttributes,
|
||||
useSensor,
|
||||
useSensors,
|
||||
PointerSensor,
|
||||
@@ -28,6 +29,10 @@ import {
|
||||
useDroppable,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
export type DragHandleProps = DraggableAttributes & {
|
||||
ref: React.RefCallback<HTMLElement>;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitAfter?: boolean;
|
||||
@@ -51,7 +56,7 @@ interface DragDropGridProps<T extends DragDropItem> {
|
||||
clearBoxSelection: () => void,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
dragHandleProps?: any,
|
||||
dragHandleProps?: DragHandleProps,
|
||||
zoomLevel?: number,
|
||||
) => React.ReactNode;
|
||||
getThumbnailData?: (
|
||||
@@ -232,7 +237,7 @@ interface DraggableItemProps<T extends DragDropItem> {
|
||||
clearBoxSelection: () => void,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
dragHandleProps?: any,
|
||||
dragHandleProps?: DragHandleProps,
|
||||
zoomLevel?: number,
|
||||
) => React.ReactNode;
|
||||
zoomLevel: number;
|
||||
@@ -253,7 +258,7 @@ const DraggableItemInner = <T extends DragDropItem>({
|
||||
zoomLevel,
|
||||
}: DraggableItemProps<T>) => {
|
||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||
const pageNumber = (item as any).pageNumber ?? index + 1;
|
||||
const pageNumber = item.pageNumber ?? index + 1;
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
|
||||
@@ -11,7 +11,9 @@ import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import "@app/components/pageEditor/PageEditor.module.css";
|
||||
import PageThumbnail from "@app/components/pageEditor/PageThumbnail";
|
||||
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
|
||||
import DragDropGrid, {
|
||||
type DragHandleProps,
|
||||
} from "@app/components/pageEditor/DragDropGrid";
|
||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||
@@ -33,6 +35,13 @@ export interface PageEditorProps {
|
||||
onFunctionsReady?: (functions: PageEditorFunctions) => void;
|
||||
}
|
||||
|
||||
interface PageEditorFileEntry {
|
||||
fileId: FileId;
|
||||
name: string;
|
||||
versionNumber: number | undefined;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use split contexts to prevent re-renders
|
||||
@@ -106,14 +115,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(",");
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
const fileObjectsRef = useRef(new Map<FileId, any>());
|
||||
const fileObjectsRef = useRef(new Map<FileId, PageEditorFileEntry>());
|
||||
const gridItemRefsRef = useRef<React.MutableRefObject<
|
||||
Map<string, HTMLDivElement>
|
||||
> | null>(null);
|
||||
|
||||
const pageEditorFiles = useMemo(() => {
|
||||
const cache = fileObjectsRef.current;
|
||||
const newFiles: any[] = [];
|
||||
const newFiles: PageEditorFileEntry[] = [];
|
||||
|
||||
fileOrder.forEach((fileId) => {
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
@@ -605,7 +614,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
clearBoxSelection: () => void,
|
||||
activeDragIds: string[],
|
||||
justMoved: boolean,
|
||||
dragHandleProps?: any,
|
||||
dragHandleProps?: DragHandleProps,
|
||||
zoomLevelParam?: number,
|
||||
) => {
|
||||
gridItemRefsRef.current = refs;
|
||||
|
||||
@@ -17,6 +17,7 @@ import AddIcon from "@mui/icons-material/Add";
|
||||
import { PDFPage, PDFDocument } from "@app/types/pageEditor";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
|
||||
import { type DragHandleProps } from "@app/components/pageEditor/DragDropGrid";
|
||||
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
@@ -38,7 +39,7 @@ interface PageThumbnailProps {
|
||||
activeDragIds: string[];
|
||||
justMoved?: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
dragHandleProps?: any;
|
||||
dragHandleProps?: DragHandleProps;
|
||||
onReorderPages: (
|
||||
sourcePageNumber: number,
|
||||
targetIndex: number,
|
||||
|
||||
@@ -31,5 +31,5 @@ export const AllVariants: Story = {
|
||||
|
||||
/** Explicit `color` overrides the variant's default accent. */
|
||||
export const CustomColor: Story = {
|
||||
args: { variant: "pdf", color: "#e64980" },
|
||||
args: { variant: "pdf", color: "#e64980" }, // theme-allow-color demoes the colour override
|
||||
};
|
||||
|
||||
@@ -201,13 +201,27 @@ export function FileSelectorPicker({
|
||||
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
|
||||
}, [isOpen]);
|
||||
|
||||
// Load saved files when the saved tab is active and the picker is open
|
||||
// Load saved files when the saved tab is active and the picker is open.
|
||||
// Cancellable: storage reads can be slow or reject, and outlive the popover.
|
||||
useEffect(() => {
|
||||
if (activeTab !== "saved" || !isOpen) return;
|
||||
let cancelled = false;
|
||||
setSavedLoading(true);
|
||||
loadRecentFiles()
|
||||
.then(setSavedStubs)
|
||||
.finally(() => setSavedLoading(false));
|
||||
.then((stubs) => {
|
||||
if (!cancelled) setSavedStubs(stubs);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setSavedStubs([]);
|
||||
console.warn("Failed to load saved files for the picker:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSavedLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setSavedLoading(false);
|
||||
};
|
||||
}, [activeTab, isOpen, loadRecentFiles]);
|
||||
|
||||
const workbenchIdSet = useMemo(
|
||||
@@ -541,7 +555,9 @@ export function FileSelectorPicker({
|
||||
</div>
|
||||
|
||||
<ScrollArea h={260} className={styles.list}>
|
||||
{savedLoading ? (
|
||||
{/* Workbench renders from memory, so a stuck storage read must not
|
||||
spin it too. */}
|
||||
{savedLoading && activeTab === "saved" ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,8 @@ import {
|
||||
deleteServerFile,
|
||||
type DeleteScope,
|
||||
} from "@app/services/serverStorageDelete";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
@@ -288,6 +289,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||
// Files whose stored bytes this session PROVED unreadable. Rows render a
|
||||
// "data lost" state instead of pretending the file can open; storage keeps
|
||||
// the record so a reload re-tests it.
|
||||
const [lostFileIds, setLostFileIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
onRecordUnreadable((fileId) =>
|
||||
setLostFileIds((prev) => new Set(prev).add(fileId as string)),
|
||||
),
|
||||
[],
|
||||
);
|
||||
const [stubsLoaded, setStubsLoaded] = useState(false);
|
||||
// Kebab "Save to cloud" target; drives BulkUploadToServerModal.
|
||||
const [saveToServerTarget, setSaveToServerTarget] = useState<
|
||||
@@ -306,32 +320,45 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const storageEnabled = config?.storageEnabled === true && !isAnonymous;
|
||||
|
||||
const refreshStubs = useCallback(async () => {
|
||||
// Leaf files from IDB - same source as the file selection modal.
|
||||
const stubs = await indexedDB.loadLeafMetadata();
|
||||
const idbIds = new Set(stubs.map((s) => s.id as string));
|
||||
// `stubsLoaded` gates the spinner, so the `finally` below must set it on
|
||||
// every path - callers never await this, so a rejection goes nowhere.
|
||||
let stubs: StirlingFileStub[] = [];
|
||||
try {
|
||||
// Leaf files from IDB - same source as the file selection modal.
|
||||
stubs = await indexedDB.loadLeafMetadata();
|
||||
} catch (error) {
|
||||
// Carry on with the in-memory workbench files: an unreadable library
|
||||
// should cost the user their history, not the file they're working on.
|
||||
console.error("Failed to read the file library from storage:", error);
|
||||
}
|
||||
|
||||
// Also include workbench files not yet flushed to IDB.
|
||||
const pendingStubs = state.files.ids
|
||||
.map((id) => state.files.byId[id])
|
||||
.filter(
|
||||
(stub): stub is NonNullable<typeof stub> =>
|
||||
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
|
||||
try {
|
||||
const idbIds = new Set(stubs.map((s) => s.id as string));
|
||||
|
||||
// Also include workbench files not yet flushed to IDB.
|
||||
const pendingStubs = state.files.ids
|
||||
.map((id) => state.files.byId[id])
|
||||
.filter(
|
||||
(stub): stub is NonNullable<typeof stub> =>
|
||||
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
|
||||
);
|
||||
|
||||
const allStubs = [...stubs, ...pendingStubs];
|
||||
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
|
||||
const superseded = new Set(
|
||||
allStubs.map((s) => s.parentFileId as string | undefined),
|
||||
);
|
||||
|
||||
const allStubs = [...stubs, ...pendingStubs];
|
||||
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
|
||||
const superseded = new Set(
|
||||
allStubs.map((s) => s.parentFileId as string | undefined),
|
||||
);
|
||||
const currentStubs = allStubs.filter(
|
||||
(s) => !superseded.has(s.id as string),
|
||||
);
|
||||
setAllFileStubs(
|
||||
currentStubs.sort(
|
||||
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
|
||||
),
|
||||
);
|
||||
setStubsLoaded(true);
|
||||
const currentStubs = allStubs.filter(
|
||||
(s) => !superseded.has(s.id as string),
|
||||
);
|
||||
setAllFileStubs(
|
||||
currentStubs.sort(
|
||||
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setStubsLoaded(true);
|
||||
}
|
||||
}, [indexedDB, state.files.ids, state.files.byId]);
|
||||
|
||||
// Refresh on mount, workbench changes, or external IndexedDB writes —
|
||||
@@ -370,7 +397,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
setDeleteTarget(stub);
|
||||
return;
|
||||
}
|
||||
await fileActions.removeFiles([fileId], true);
|
||||
// Its superseded versions go too - see orphanedAncestorIds.
|
||||
const orphans = await fileStorage.orphanedAncestorIds([fileId]);
|
||||
await fileActions.removeFiles([fileId, ...orphans], true);
|
||||
await refreshStubs();
|
||||
},
|
||||
[allFileStubs, fileActions, refreshStubs],
|
||||
@@ -388,7 +417,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
await deleteServerFile(stub.remoteStorageId);
|
||||
}
|
||||
if (scope === "device" || scope === "everywhere") {
|
||||
await fileActions.removeFiles([stub.id], true);
|
||||
const orphans = await fileStorage.orphanedAncestorIds([stub.id]);
|
||||
await fileActions.removeFiles([stub.id, ...orphans], true);
|
||||
} else if (scope === "cloud") {
|
||||
// Local copy kept - drop the dead remote pointer so the cloud badge
|
||||
// clears (the sidebar doesn't reconcile with the server itself).
|
||||
@@ -524,6 +554,22 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (!stub) return;
|
||||
|
||||
// Its bytes are gone; opening it can only fail. Say so instead of a
|
||||
// click that goes nowhere.
|
||||
if (stub.dataUnavailable || lostFileIds.has(fileId as string)) {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("fileSidebar.dataLostTitle", "File data is unavailable"),
|
||||
body: t(
|
||||
"fileSidebar.dataLostBody",
|
||||
"This browser lost this file's contents. Upload it again to keep working with it.",
|
||||
),
|
||||
expandable: false,
|
||||
durationMs: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In the Watched Folders view a click sends the file into the open folder
|
||||
// (mirrors how a click toggles a file into the active workbench elsewhere).
|
||||
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
|
||||
@@ -578,6 +624,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
},
|
||||
[
|
||||
allFileStubs,
|
||||
lostFileIds,
|
||||
t,
|
||||
state.files.ids,
|
||||
state.ui.selectedFileIds,
|
||||
fileActions,
|
||||
@@ -765,6 +813,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
? state.files.byId[workbenchFileId]?.thumbnailUrl
|
||||
: undefined) || stub.thumbnailUrl;
|
||||
const fileOrigin = getFileOrigin(stub);
|
||||
const dataUnavailable =
|
||||
stub.dataUnavailable === true || lostFileIds.has(stub.id as string);
|
||||
// Key by lineage (originalFileId) so a version swap updates the row in place instead of
|
||||
// remounting. But a 1-input→many-output op (split) yields sibling leaves that share one
|
||||
// originalFileId; those would collide on the key, so fall back to the unique leaf id when a
|
||||
@@ -787,6 +837,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
onClick={handleFileClick}
|
||||
onEyeClick={handleEyeClick}
|
||||
dataUnavailable={dataUnavailable}
|
||||
draggable={isWatchedFoldersActive}
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
|
||||
@@ -447,3 +447,13 @@
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* The stored bytes are gone - the row says so instead of pretending to open. */
|
||||
.file-sidebar-datalost-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
color: var(--c-danger);
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
|
||||
import CloudDoneIcon from "@mui/icons-material/CloudDone";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import type { FileId } from "@app/types/file";
|
||||
@@ -163,6 +164,9 @@ export interface FileItemProps {
|
||||
onVersionHistory?: (fileId: FileId) => void;
|
||||
/** Whether this file has more than one version (drives the menu item). */
|
||||
hasVersionHistory?: boolean;
|
||||
/** The stored bytes are gone (WebKit lost the blob's backing store). The row
|
||||
* says so instead of pretending the file can open. */
|
||||
dataUnavailable?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
@@ -177,6 +181,7 @@ export const FileItem = React.memo(function FileItem({
|
||||
isSelected,
|
||||
isActive,
|
||||
isViewedInViewer,
|
||||
dataUnavailable,
|
||||
thumbnailUrl,
|
||||
onClick,
|
||||
onEyeClick,
|
||||
@@ -294,6 +299,21 @@ export const FileItem = React.memo(function FileItem({
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{dataUnavailable && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"fileSidebar.fileItem.dataLostTooltip",
|
||||
"This browser lost this file's contents. Upload it again to keep working with it.",
|
||||
)}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span className="file-sidebar-datalost-badge" data-no-select>
|
||||
<ErrorOutlineIcon sx={{ fontSize: "0.85rem" }} />
|
||||
{t("fileSidebar.fileItem.dataLost", "Data lost")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isUploadedToCloud && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
|
||||
@@ -6,6 +6,9 @@ export function PolicyEnforcingOverlay(_props: {
|
||||
accentVar?: string;
|
||||
/** Category of the enforcing policy — picks its icon in the real overlay. */
|
||||
categoryId?: string;
|
||||
/** Shows a dismiss control in the real overlay, so a run that never settles
|
||||
* can't leave the surface underneath permanently unclickable. */
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ const FullscreenToolList = ({
|
||||
);
|
||||
const recommendedItems = useMemo(() => {
|
||||
if (!quickSection)
|
||||
return [] as Array<{ id: string; tool: ToolRegistryEntry }>;
|
||||
const items: Array<{ id: string; tool: ToolRegistryEntry }> = [];
|
||||
return [] as Array<{ id: ToolId; tool: ToolRegistryEntry }>;
|
||||
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
|
||||
quickSection.subcategories.forEach((sc) =>
|
||||
sc.tools.forEach((t) => items.push(t)),
|
||||
);
|
||||
@@ -217,13 +217,13 @@ const FullscreenToolList = ({
|
||||
</header>
|
||||
{showDescriptions ? (
|
||||
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
|
||||
{recommendedItems.map((item: any) =>
|
||||
{recommendedItems.map((item) =>
|
||||
renderToolItem(item.id, item.tool),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tool-panel__fullscreen-list">
|
||||
{recommendedItems.map((item: any) =>
|
||||
{recommendedItems.map((item) =>
|
||||
renderToolItem(item.id, item.tool),
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,13 +34,17 @@ export default function ToolSelector({
|
||||
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
|
||||
// Filter out excluded tools (like 'automate' itself), tools that don't support
|
||||
// automation, and tools with no operationConfig - the executor resolves a step
|
||||
// through operationConfig, so offering one without it fails only at run time.
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
return (
|
||||
Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]
|
||||
).filter(
|
||||
([key, tool]) =>
|
||||
!excludeTools.includes(key) && getToolSupportsAutomate(tool),
|
||||
!excludeTools.includes(key) &&
|
||||
getToolSupportsAutomate(tool) &&
|
||||
Boolean(tool.operationConfig),
|
||||
);
|
||||
}, [toolRegistry, excludeTools]);
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import FileUploadButton from "@app/components/shared/FileUploadButton";
|
||||
|
||||
interface CertificateFilesSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(
|
||||
key: K,
|
||||
value: CertSignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet
|
||||
|
||||
interface CertificateFormatSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(
|
||||
key: K,
|
||||
value: CertSignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
interface CertificateTypeSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(
|
||||
key: K,
|
||||
value: CertSignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+22
-11
@@ -22,7 +22,10 @@ import {
|
||||
|
||||
interface HardwareCertificateSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(
|
||||
key: K,
|
||||
value: CertSignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -176,16 +179,20 @@ const HardwareCertificateSettings = ({
|
||||
setError(null);
|
||||
listWindowsCertificates()
|
||||
.then(applyCerts)
|
||||
.catch((e: any) =>
|
||||
.catch((e) => {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
setError(
|
||||
e?.response?.data?.message ||
|
||||
e?.message ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t(
|
||||
"certSign.hardware.windowsLoadError",
|
||||
"Could not read the Windows certificate store",
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [applyCerts, t]);
|
||||
|
||||
@@ -221,16 +228,20 @@ const HardwareCertificateSettings = ({
|
||||
pin: parameters.password,
|
||||
})
|
||||
.then(applyCerts)
|
||||
.catch((e: any) =>
|
||||
.catch((e) => {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } };
|
||||
message?: string;
|
||||
};
|
||||
setError(
|
||||
e?.response?.data?.message ||
|
||||
e?.message ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t(
|
||||
"certSign.hardware.pkcs11LoadError",
|
||||
"Could not read certificates from the token. Check the PIN and driver.",
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [
|
||||
applyCerts,
|
||||
|
||||
@@ -5,7 +5,10 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet
|
||||
|
||||
interface SignatureAppearanceSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(
|
||||
key: K,
|
||||
value: CertSignParameters[K],
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -101,7 +104,12 @@ const SignatureAppearanceSettings = ({
|
||||
<NumberInput
|
||||
label={t("certSign.pageNumber", "Page Number")}
|
||||
value={parameters.pageNumber}
|
||||
onChange={(value) => onParameterChange("pageNumber", value || 1)}
|
||||
onChange={(value) =>
|
||||
onParameterChange(
|
||||
"pageNumber",
|
||||
typeof value === "number" ? value : 1,
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,10 @@ const SignatureSettingsInput = ({
|
||||
}: SignatureSettingsInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (key: keyof SignatureSettings, val: any) => {
|
||||
const handleChange = <K extends keyof SignatureSettings>(
|
||||
key: K,
|
||||
val: SignatureSettings[K],
|
||||
) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
@@ -104,7 +107,9 @@ const SignatureSettingsInput = ({
|
||||
<NumberInput
|
||||
label={t("certSign.pageNumber", "Page Number")}
|
||||
value={value.pageNumber || 1}
|
||||
onChange={(val) => handleChange("pageNumber", val || 1)}
|
||||
onChange={(val) =>
|
||||
handleChange("pageNumber", typeof val === "number" ? val : 1)
|
||||
}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
|
||||
@@ -52,6 +52,14 @@ import {
|
||||
const MAX_RENDER_WIDTH = 820;
|
||||
const MIN_BOX_SIZE = 18;
|
||||
|
||||
// Firefox-only fallback for document.caretRangeFromPoint (not in lib.dom.d.ts).
|
||||
const docWithCaret = document as Document & {
|
||||
caretPositionFromPoint?: (
|
||||
x: number,
|
||||
y: number,
|
||||
) => { offsetNode: Node; offset: number } | null;
|
||||
};
|
||||
|
||||
const normalizeFontFormat = (format?: string | null): string => {
|
||||
if (!format) {
|
||||
return "ttf";
|
||||
@@ -352,7 +360,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
new Map(),
|
||||
);
|
||||
const draggingImageRef = useRef<string | null>(null);
|
||||
const rndRefs = useRef<Map<string, any>>(new Map());
|
||||
const rndRefs = useRef<Map<string, Rnd>>(new Map());
|
||||
const pendingDragUpdateRef = useRef<number | null>(null);
|
||||
const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
@@ -1378,7 +1386,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
const cssTop = (pageHeight - bounds.top) * scale;
|
||||
|
||||
// Get current position from Rnd component
|
||||
const currentState = rndRef.state || {};
|
||||
const currentState = (rndRef.state as { x?: number; y?: number }) || {};
|
||||
const currentX = currentState.x ?? 0;
|
||||
const currentY = currentState.y ?? 0;
|
||||
|
||||
@@ -2851,11 +2859,13 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
(document as any).caretPositionFromPoint
|
||||
docWithCaret.caretPositionFromPoint
|
||||
) {
|
||||
const pos = (
|
||||
document as any
|
||||
).caretPositionFromPoint(clickX, clickY);
|
||||
const pos =
|
||||
docWithCaret.caretPositionFromPoint(
|
||||
clickX,
|
||||
clickY,
|
||||
);
|
||||
if (pos) {
|
||||
const range = document.createRange();
|
||||
range.setStart(
|
||||
|
||||
@@ -12,7 +12,7 @@ const mockSignatures: SavedSignature[] = [
|
||||
signerName: "Jordan Lee",
|
||||
fontFamily: "cursive",
|
||||
fontSize: 32,
|
||||
textColor: "#1a1a1a",
|
||||
textColor: "#1a1a1a", // theme-allow-color signature ink is user data
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
IndexedDBProvider,
|
||||
useIndexedDB,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { onRecordUnreadable } from "@app/services/fileStorage";
|
||||
import { useZipConfirmation } from "@app/hooks/useZipConfirmation";
|
||||
import ZipWarningModal from "@app/components/shared/ZipWarningModal";
|
||||
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
|
||||
@@ -186,6 +187,21 @@ function FileContextInner({
|
||||
setUnlockError(null);
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
// Storage proved a file's bytes unreadable (WebKit losing a blob's backing
|
||||
// store). Drop it: the viewer would otherwise spin on a document that can
|
||||
// never load. The record stays, so a reload re-tests it.
|
||||
useEffect(
|
||||
() =>
|
||||
onRecordUnreadable((fileId) => {
|
||||
if (!stateRef.current.files.byId[fileId]) return;
|
||||
console.error(
|
||||
`[FileContext] dropping ${fileId} from the workbench: its stored bytes are unreadable`,
|
||||
);
|
||||
lifecycleManager.removeFiles([fileId], stateRef);
|
||||
}),
|
||||
[lifecycleManager],
|
||||
);
|
||||
|
||||
const handleUnlockSkip = useCallback(() => {
|
||||
if (activeEncryptedFileId) {
|
||||
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
|
||||
|
||||
@@ -455,7 +455,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
.map((s) => s.id);
|
||||
if (localIds.length > 0) {
|
||||
await fileActions.removeFiles(localIds, true);
|
||||
// Take the superseded versions with it, or their bytes sit in storage
|
||||
// forever - invisible, because listings only show leaves.
|
||||
const orphans = await fileStorage.orphanedAncestorIds(localIds);
|
||||
await fileActions.removeFiles([...localIds, ...orphans], true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,3 +222,40 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)",
|
||||
expect(next.ui.selectedFileIds).toEqual(["b2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileContextReducer — REMOVE_FILES", () => {
|
||||
/** Deleting from the library dispatches this for files that were never in the
|
||||
* workbench; reallocating then re-renders every consumer for nothing. */
|
||||
it("is a true no-op when none of the ids are in the workbench", () => {
|
||||
const state = stateWith([stub("a")]);
|
||||
const next = fileContextReducer(state, {
|
||||
type: "REMOVE_FILES",
|
||||
payload: { fileIds: ["gone" as FileId] },
|
||||
});
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
|
||||
it("still removes the ids it does hold", () => {
|
||||
const state = stateWith([stub("a"), stub("b")]);
|
||||
const next = fileContextReducer(state, {
|
||||
type: "REMOVE_FILES",
|
||||
payload: { fileIds: ["a" as FileId, "gone" as FileId] },
|
||||
});
|
||||
expect(next.files.ids).toEqual(["b"]);
|
||||
expect(next.files.byId["a" as FileId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the files slice when only a selection is cleared", () => {
|
||||
const base = stateWith([stub("a")]);
|
||||
const state: FileContextState = {
|
||||
...base,
|
||||
ui: { ...base.ui, selectedFileIds: ["gone" as FileId] },
|
||||
};
|
||||
const next = fileContextReducer(state, {
|
||||
type: "REMOVE_FILES",
|
||||
payload: { fileIds: ["gone" as FileId] },
|
||||
});
|
||||
expect(next.files).toBe(state.files);
|
||||
expect(next.ui.selectedFileIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +183,20 @@ export function fileContextReducer(
|
||||
const remainingIds = state.files.ids.filter(
|
||||
(id) => !fileIds.includes(id),
|
||||
);
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(
|
||||
(id) => !fileIds.includes(id),
|
||||
);
|
||||
|
||||
// Deleting a library file that was never in the workbench removes nothing
|
||||
// here, and must not re-render every file and UI consumer.
|
||||
const removedFromWorkbench =
|
||||
remainingIds.length !== state.files.ids.length ||
|
||||
fileIds.some((id) => id in state.files.byId);
|
||||
const deselected =
|
||||
validSelectedFileIds.length !== state.ui.selectedFileIds.length;
|
||||
if (!removedFromWorkbench && !deselected) return state;
|
||||
|
||||
const newById = { ...state.files.byId };
|
||||
|
||||
// Remove files from state (resource cleanup handled by lifecycle manager)
|
||||
@@ -190,21 +204,14 @@ export function fileContextReducer(
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(
|
||||
(id) => !fileIds.includes(id),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: remainingIds,
|
||||
byId: newById,
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds,
|
||||
},
|
||||
files: removedFromWorkbench
|
||||
? { ids: remainingIds, byId: newById }
|
||||
: state.files,
|
||||
ui: deselected
|
||||
? { ...state.ui, selectedFileIds: validSelectedFileIds }
|
||||
: state.ui,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
clearBulkAddProgress,
|
||||
} from "@app/services/bulkAddProgress";
|
||||
const DEBUG = process.env.NODE_ENV === "development";
|
||||
/** How long a file may sit unhydrated before the console says so. Reporting only:
|
||||
* the read is never abandoned, because large files legitimately take time. */
|
||||
const STALLED_LOAD_MS = 8000;
|
||||
const HYDRATION_CONCURRENCY = 2;
|
||||
let activeHydrations = 0;
|
||||
const hydrationQueue: Array<() => Promise<void>> = [];
|
||||
@@ -447,10 +450,8 @@ export async function addFiles(
|
||||
if (file.type === "application/pdf") {
|
||||
try {
|
||||
if (await FileAnalyzer.isPDFUserPasswordProtected(file)) {
|
||||
fileStub.processedFile = (fileStub.processedFile || {
|
||||
pages: [],
|
||||
}) as any;
|
||||
fileStub.processedFile!.isEncrypted = true;
|
||||
fileStub.processedFile = fileStub.processedFile || { pages: [] };
|
||||
fileStub.processedFile.isEncrypted = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Never block upload on analysis failure — but log so it's debuggable
|
||||
@@ -699,7 +700,7 @@ export async function undoConsumeFiles(
|
||||
file: File,
|
||||
fileId: FileId,
|
||||
existingThumbnail?: string,
|
||||
) => Promise<any>;
|
||||
) => Promise<StirlingFileStub>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
bumpRevision?: () => void;
|
||||
} | null,
|
||||
@@ -856,61 +857,78 @@ export async function addStirlingFileStubs(
|
||||
// Load File object and hydrate metadata in background (non-blocking)
|
||||
const fileId = stub.id;
|
||||
|
||||
// Load File object from IndexedDB asynchronously
|
||||
scheduleMetadataHydration(async () => {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(fileId);
|
||||
// Regenerate page metadata + thumbnails. Queued, because parsing several
|
||||
// PDFs at once is what the concurrency limit exists to bound.
|
||||
const scheduleMetadataFor = (stirlingFile: StirlingFile): void => {
|
||||
scheduleMetadataHydration(async () => {
|
||||
const processedFileMetadata =
|
||||
await generateProcessedFileMetadata(stirlingFile);
|
||||
if (!processedFileMetadata) return;
|
||||
|
||||
const updates: Partial<StirlingFileStub> = {
|
||||
processedFile: processedFileMetadata,
|
||||
};
|
||||
|
||||
// Update thumbnail only if current stub doesn't have one
|
||||
const currentStub = stateRef.current.files.byId[fileId];
|
||||
if (
|
||||
!currentStub?.thumbnailUrl &&
|
||||
processedFileMetadata.thumbnailUrl
|
||||
) {
|
||||
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
|
||||
if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) {
|
||||
lifecycleManager.trackBlobUrl(processedFileMetadata.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef);
|
||||
});
|
||||
};
|
||||
|
||||
// Load and publish the File, ahead of any parsing. NOT queued: whether a
|
||||
// file opens at all must not wait on other files' parses.
|
||||
void (async () => {
|
||||
// A storage read that never settles renders as a file that silently won't
|
||||
// open. Name it in the console rather than leaving the user guessing.
|
||||
const stall = setTimeout(
|
||||
() =>
|
||||
console.error(
|
||||
`[Hydration] ${stub.name} (${fileId}) has been loading for ${STALLED_LOAD_MS / 1000}s - the IndexedDB read has not settled`,
|
||||
),
|
||||
STALLED_LOAD_MS,
|
||||
);
|
||||
const stirlingFile = await fileStorage
|
||||
.getStirlingFile(fileId)
|
||||
.finally(() => clearTimeout(stall));
|
||||
if (!stirlingFile) {
|
||||
// A row with no bytes renders empty and its clicks look dead, so take it
|
||||
// back out. Storage keeps the record; fileStorage has said why.
|
||||
console.error(
|
||||
`[Hydration] No readable data for ${stub.name} (${fileId}); removing it from the workbench`,
|
||||
);
|
||||
lifecycleManager.removeFiles([fileId], stateRef);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the loaded file in filesRef
|
||||
filesRef.current.set(fileId, stirlingFile);
|
||||
|
||||
// Check if processedFile data needs regeneration
|
||||
if (stirlingFile.type.startsWith("application/pdf")) {
|
||||
const needsProcessing =
|
||||
!stub.processedFile ||
|
||||
!stub.processedFile.pages ||
|
||||
stub.processedFile.pages.length === 0 ||
|
||||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
// Regenerate metadata
|
||||
const processedFileMetadata =
|
||||
await generateProcessedFileMetadata(stirlingFile);
|
||||
|
||||
if (processedFileMetadata) {
|
||||
const updates: Partial<StirlingFileStub> = {
|
||||
processedFile: processedFileMetadata,
|
||||
};
|
||||
|
||||
// Update thumbnail only if current stub doesn't have one
|
||||
const currentStub = stateRef.current.files.byId[fileId];
|
||||
if (
|
||||
!currentStub?.thumbnailUrl &&
|
||||
processedFileMetadata.thumbnailUrl
|
||||
) {
|
||||
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
|
||||
if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) {
|
||||
lifecycleManager.trackBlobUrl(
|
||||
processedFileMetadata.thumbnailUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleManager.updateStirlingFileStub(
|
||||
fileId,
|
||||
updates,
|
||||
stateRef,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors).
|
||||
// filesRef is a ref, so the selectors gating the workbench only see the
|
||||
// file once something dispatches. Parsing it can't be a precondition.
|
||||
lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef);
|
||||
});
|
||||
|
||||
const needsProcessing =
|
||||
!stub.processedFile ||
|
||||
!stub.processedFile.pages ||
|
||||
stub.processedFile.pages.length === 0 ||
|
||||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
|
||||
if (
|
||||
stirlingFile.type.startsWith("application/pdf") &&
|
||||
needsProcessing
|
||||
) {
|
||||
scheduleMetadataFor(stirlingFile);
|
||||
}
|
||||
})().catch((error) =>
|
||||
console.error(`[Hydration] Failed to load ${fileId}:`, error),
|
||||
);
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
|
||||
@@ -398,7 +398,7 @@ export function useFileContext() {
|
||||
addFiles: actions.addFiles,
|
||||
consumeFiles: actions.consumeFiles,
|
||||
undoConsumeFiles: actions.undoConsumeFiles,
|
||||
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
|
||||
recordOperation: (_fileId: FileId, _operation: unknown) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (
|
||||
_fileId: FileId,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
FileContextState,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* A clicked file is only visible once hydration DISPATCHES: the workbench reads
|
||||
* files out of a ref, so `activeFiles` stays empty until then. Parsing must not
|
||||
* gate that - a PDF engine that stalls used to leave the workbench on its empty
|
||||
* state with the row showing as open, and clicks doing nothing.
|
||||
*/
|
||||
|
||||
const getStirlingFile = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile },
|
||||
}));
|
||||
/** The stall under test: the page parse never settles. */
|
||||
vi.mock("@app/utils/thumbnailUtils", () => ({
|
||||
generateThumbnailPairWithMetadata: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
const stub = (id: string): StirlingFileStub =>
|
||||
({
|
||||
id: id as FileId,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: 10,
|
||||
lastModified: 0,
|
||||
}) as StirlingFileStub;
|
||||
|
||||
async function harness(ids: string[]) {
|
||||
vi.resetModules();
|
||||
getStirlingFile.mockImplementation(
|
||||
async (id: FileId) =>
|
||||
new File(["%PDF-1.7"], `${id}.pdf`, { type: "application/pdf" }),
|
||||
);
|
||||
const { addStirlingFileStubs } =
|
||||
await import("@app/contexts/file/fileActions");
|
||||
|
||||
const stubs = ids.map(stub);
|
||||
const state = {
|
||||
files: { ids: [], byId: {} },
|
||||
pinnedFiles: new Set(),
|
||||
ui: { selectedFileIds: [], selectedPageNumbers: [] },
|
||||
} as unknown as FileContextState;
|
||||
const stateRef = { current: state };
|
||||
const filesRef = { current: new Map<FileId, File>() };
|
||||
const published: FileId[] = [];
|
||||
const lifecycleManager = {
|
||||
updateStirlingFileStub: (fileId: FileId) => published.push(fileId),
|
||||
removeFiles: () => {},
|
||||
trackBlobUrl: () => {},
|
||||
};
|
||||
|
||||
await addStirlingFileStubs(
|
||||
stubs,
|
||||
{},
|
||||
stateRef,
|
||||
filesRef,
|
||||
() => {},
|
||||
lifecycleManager as never,
|
||||
);
|
||||
return { filesRef, published };
|
||||
}
|
||||
|
||||
describe("workbench hydration — a stalled parse can't hide the file", () => {
|
||||
test("publishes every file's bytes while their parses hang", async () => {
|
||||
// Three, because the parse queue only runs two at a time: the third proves
|
||||
// loading isn't queued behind parses that never finish.
|
||||
const { filesRef, published } = await harness(["a", "b", "c"]);
|
||||
|
||||
await vi.waitFor(() => expect(published).toHaveLength(3));
|
||||
expect([...filesRef.current.keys()]).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { FileId } from "@app/types/file";
|
||||
import {
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
StirlingFileStub,
|
||||
ProcessedFilePage,
|
||||
} from "@app/types/fileContext";
|
||||
@@ -39,7 +40,7 @@ export class FileLifecycleManager {
|
||||
*/
|
||||
cleanupFile = (
|
||||
fileId: FileId,
|
||||
stateRef?: React.MutableRefObject<any>,
|
||||
stateRef?: React.MutableRefObject<FileContextState>,
|
||||
): void => {
|
||||
// Use comprehensive cleanup (same as removeFiles)
|
||||
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||
@@ -77,7 +78,7 @@ export class FileLifecycleManager {
|
||||
scheduleCleanup = (
|
||||
fileId: FileId,
|
||||
delay: number = 30000,
|
||||
stateRef?: React.MutableRefObject<any>,
|
||||
stateRef?: React.MutableRefObject<FileContextState>,
|
||||
): void => {
|
||||
// Cancel existing timer
|
||||
const existingTimer = this.cleanupTimers.get(fileId);
|
||||
@@ -116,7 +117,7 @@ export class FileLifecycleManager {
|
||||
*/
|
||||
removeFiles = (
|
||||
fileIds: FileId[],
|
||||
stateRef?: React.MutableRefObject<any>,
|
||||
stateRef?: React.MutableRefObject<FileContextState>,
|
||||
): void => {
|
||||
fileIds.forEach((fileId) => {
|
||||
// Clean up all resources for this file
|
||||
@@ -132,7 +133,7 @@ export class FileLifecycleManager {
|
||||
*/
|
||||
private cleanupAllResourcesForFile = (
|
||||
fileId: FileId,
|
||||
stateRef?: React.MutableRefObject<any>,
|
||||
stateRef?: React.MutableRefObject<FileContextState>,
|
||||
): void => {
|
||||
// Remove from files ref
|
||||
this.filesRef.current.delete(fileId);
|
||||
@@ -188,7 +189,7 @@ export class FileLifecycleManager {
|
||||
updateStirlingFileStub = (
|
||||
fileId: FileId,
|
||||
updates: Partial<StirlingFileStub>,
|
||||
stateRef?: React.MutableRefObject<any>,
|
||||
stateRef?: React.MutableRefObject<FileContextState>,
|
||||
): void => {
|
||||
// Guard against updating removed files (race condition protection)
|
||||
if (!this.filesRef.current.has(fileId)) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Registry invariant: the Automate picker offers a tool whenever it doesn't opt out via
|
||||
* `supportsAutomate: false`, but automationExecutor resolves each step through the tool's
|
||||
* `operationConfig`. A tool that is offered without one is selectable in the builder and
|
||||
* only fails when the automation runs, with "Tool operation not supported: <toolId>".
|
||||
*
|
||||
* So a tool must either carry an operationConfig or declare supportsAutomate: false.
|
||||
*/
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { getToolSupportsAutomate } from "@app/data/toolsTaxonomy";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
i18n: { changeLanguage: vi.fn(), language: "en-US" },
|
||||
}),
|
||||
Trans: ({ children }: { children?: unknown }) => children,
|
||||
}));
|
||||
|
||||
describe("automatable tools", () => {
|
||||
test("every tool offered to Automate can be executed as a step", () => {
|
||||
const { result } = renderHook(() => useTranslatedToolCatalog());
|
||||
|
||||
const offeredWithoutConfig = Object.entries(result.current.regularTools)
|
||||
.filter(([, entry]) => entry && getToolSupportsAutomate(entry))
|
||||
.filter(([, entry]) => !entry.operationConfig)
|
||||
.map(([id]) => id);
|
||||
|
||||
expect(offeredWithoutConfig).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,8 @@ import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/u
|
||||
import { signOperationConfig } from "@app/hooks/tools/sign/useSignOperation";
|
||||
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
|
||||
import { removeAnnotationsOperationConfig } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||
import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation";
|
||||
import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
|
||||
import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation";
|
||||
import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation";
|
||||
import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation";
|
||||
@@ -526,6 +528,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
maxFiles: -1,
|
||||
endpoints: ["validate-signature"],
|
||||
synonyms: getSynonyms(t, "validateSignature"),
|
||||
// Reports on signatures rather than transforming the PDF, and its hook is
|
||||
// not on the operationConfig seam, so it cannot run as an automation step.
|
||||
supportsAutomate: false,
|
||||
automationSettings: null,
|
||||
},
|
||||
|
||||
@@ -755,6 +760,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["multi-page-layout"],
|
||||
operationConfig: asRegistryConfig(pageLayoutOperationConfig),
|
||||
automationSettings: lazySettings(
|
||||
() => import("@app/components/tools/pageLayout/PageLayoutSettings"),
|
||||
),
|
||||
@@ -967,7 +973,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-image-pdf"],
|
||||
operationConfig: undefined,
|
||||
operationConfig: asRegistryConfig(removeImageOperationConfig),
|
||||
synonyms: getSynonyms(t, "removeImage"),
|
||||
automationSettings: null,
|
||||
},
|
||||
@@ -1196,6 +1202,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["scanner-effect"],
|
||||
synonyms: getSynonyms(t, "scannerEffect"),
|
||||
// No frontend implementation yet (component is null), so it has no
|
||||
// operationConfig to execute as an automation step.
|
||||
supportsAutomate: false,
|
||||
automationSettings: null,
|
||||
},
|
||||
|
||||
|
||||
+7
@@ -80,6 +80,13 @@ export const changePermissionsOperationConfig = defineSingleFileTool({
|
||||
operationType: "changePermissions",
|
||||
endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool
|
||||
defaultParameters,
|
||||
// Both tools post to add-password. A permissions-only step carries none of the encryption
|
||||
// fields, so it is this tool and not Add Password; keyLength, always sent by Add Password,
|
||||
// is the reliable tell even when a password happens to be blank.
|
||||
claimsStoredStep: (apiParams) =>
|
||||
!("password" in apiParams) &&
|
||||
!("ownerPassword" in apiParams) &&
|
||||
!("keyLength" in apiParams),
|
||||
});
|
||||
|
||||
export const useChangePermissionsOperation = () => {
|
||||
|
||||
@@ -345,11 +345,15 @@ export const useConvertOperation = (parameters?: ConvertParameters) => {
|
||||
...convertOperationConfig,
|
||||
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
|
||||
getErrorMessage: (error) => {
|
||||
if (error.response?.data && typeof error.response.data === "string") {
|
||||
return error.response.data;
|
||||
const err = error as {
|
||||
response?: { data?: unknown };
|
||||
message?: string;
|
||||
};
|
||||
if (err.response?.data && typeof err.response.data === "string") {
|
||||
return err.response.data;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
if (err.message) {
|
||||
return err.message;
|
||||
}
|
||||
return t(
|
||||
"convert.errorConversion",
|
||||
|
||||
@@ -179,13 +179,15 @@ export const useOCROperation = () => {
|
||||
const ocrConfig: ToolOperationConfig<OCRParameters> = {
|
||||
...ocrOperationConfig,
|
||||
responseHandler,
|
||||
getErrorMessage: (error) =>
|
||||
error.message?.includes("OCR tools") &&
|
||||
error.message?.includes("not installed")
|
||||
getErrorMessage: (error) => {
|
||||
const message = (error as { message?: string }).message;
|
||||
return message?.includes("OCR tools") &&
|
||||
message?.includes("not installed")
|
||||
? "OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually."
|
||||
: createStandardErrorHandler(
|
||||
t("ocr.error.failed", "OCR operation failed"),
|
||||
)(error),
|
||||
)(error);
|
||||
},
|
||||
};
|
||||
|
||||
return useToolOperation(ocrConfig);
|
||||
|
||||
@@ -24,6 +24,8 @@ import { SPLIT_METHODS } from "@app/constants/splitConstants";
|
||||
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
|
||||
import { autoRotateOperationConfig } from "@app/hooks/tools/autoRotate/useAutoRotateOperation";
|
||||
import { defaultParameters as autoRotateDefaults } from "@app/hooks/tools/autoRotate/useAutoRotateParameters";
|
||||
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
|
||||
function entry(over: Partial<ToolRegistryEntry>): ToolRegistryEntry {
|
||||
return {
|
||||
@@ -239,6 +241,46 @@ describe("serialize/deserialize round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared-endpoint disambiguation", () => {
|
||||
const addPassword = entry({
|
||||
name: "Add Password",
|
||||
automationSettings: NoopSettings,
|
||||
operationConfig: asRegistryConfig(addPasswordOperationConfig),
|
||||
});
|
||||
const changePermissions = entry({
|
||||
name: "Change Permissions",
|
||||
automationSettings: NoopSettings,
|
||||
operationConfig: asRegistryConfig(changePermissionsOperationConfig),
|
||||
});
|
||||
const ADD_PASSWORD = "/api/v1/security/add-password";
|
||||
|
||||
// Permissions only, no encryption fields: this is Change Permissions.
|
||||
const permsOnly = {
|
||||
operation: ADD_PASSWORD,
|
||||
parameters: { preventPrinting: true },
|
||||
};
|
||||
// Carries keyLength (and a password): this is Add Password, even with a blank owner password.
|
||||
const withPassword = {
|
||||
operation: ADD_PASSWORD,
|
||||
parameters: { password: "s3cret", ownerPassword: "", keyLength: 256 },
|
||||
};
|
||||
|
||||
// Both share an endpoint, so the wrong one would win by registry order without a discriminator.
|
||||
for (const [label, registry] of [
|
||||
["add-password declared first", { addPassword, changePermissions }],
|
||||
["change-permissions declared first", { changePermissions, addPassword }],
|
||||
] as const) {
|
||||
test(`each stored step reloads as its own tool (${label})`, () => {
|
||||
expect(deserializeToolStep(permsOnly, registry).toolId).toBe(
|
||||
"changePermissions",
|
||||
);
|
||||
expect(deserializeToolStep(withPassword, registry).toolId).toBe(
|
||||
"addPassword",
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("stepRequiresUpload", () => {
|
||||
const step = (params: Record<string, unknown>): WorkingToolStep => ({
|
||||
toolId: "compress" as ToolId,
|
||||
|
||||
@@ -226,11 +226,13 @@ function findToolByEndpoint(
|
||||
step: ToolApiStep,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): [ToolId, ToolRegistryEntry] | undefined {
|
||||
const staticMatches: [ToolId, ToolRegistryEntry][] = [];
|
||||
let dynamic: [ToolId, ToolRegistryEntry] | undefined;
|
||||
for (const [id, entry] of Object.entries(registry)) {
|
||||
const endpoint = entry?.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "string") {
|
||||
if (endpoint === step.operation) return [id as ToolId, entry];
|
||||
if (endpoint === step.operation)
|
||||
staticMatches.push([id as ToolId, entry]);
|
||||
} else if (typeof endpoint === "function" && !dynamic) {
|
||||
const declared = entry?.operationConfig?.endpoints;
|
||||
const matched = declared
|
||||
@@ -239,9 +241,33 @@ function findToolByEndpoint(
|
||||
if (matched) dynamic = [id as ToolId, entry];
|
||||
}
|
||||
}
|
||||
if (staticMatches.length > 0) {
|
||||
return disambiguateStaticMatches(staticMatches, step.parameters);
|
||||
}
|
||||
return dynamic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most endpoints belong to one tool, so the single match is returned unchanged. When several
|
||||
* share an endpoint (Add Password and its permissions-only alias Change Permissions), prefer the
|
||||
* specialised tool that claims the stored parameters; otherwise fall back to the general owner
|
||||
* that declares no such claim.
|
||||
*/
|
||||
function disambiguateStaticMatches(
|
||||
matches: [ToolId, ToolRegistryEntry][],
|
||||
parameters: Record<string, unknown>,
|
||||
): [ToolId, ToolRegistryEntry] {
|
||||
if (matches.length === 1) return matches[0];
|
||||
const claimed = matches.find(([, entry]) =>
|
||||
entry.operationConfig?.claimsStoredStep?.(parameters),
|
||||
);
|
||||
if (claimed) return claimed;
|
||||
const general = matches.find(
|
||||
([, entry]) => !entry.operationConfig?.claimsStoredStep,
|
||||
);
|
||||
return general ?? matches[0];
|
||||
}
|
||||
|
||||
/** A stored step kept verbatim because its endpoint maps to no known tool. */
|
||||
function unmappedStep(step: ToolApiStep): UnknownToolStep {
|
||||
return {
|
||||
|
||||
@@ -74,7 +74,7 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
|
||||
responseHandler?: ResponseHandler;
|
||||
|
||||
/** Extract user-friendly error messages from API errors */
|
||||
getErrorMessage?: (error: any) => string;
|
||||
getErrorMessage?: (error: unknown) => string;
|
||||
|
||||
/** Default parameter values for automation */
|
||||
defaultParameters?: TParams;
|
||||
@@ -101,6 +101,13 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
|
||||
*/
|
||||
fromApiParams?(apiParams: ToolApiParams[TEndpoint]): Partial<TParams>;
|
||||
|
||||
/**
|
||||
* Whether a stored step belongs to this tool, used only to tell apart tools that share an endpoint.
|
||||
* Receives the raw stored request body. Absent means the tool is the general owner of its
|
||||
* endpoint and claims any step no specialised sibling claims.
|
||||
*/
|
||||
claimsStoredStep?(apiParams: Record<string, unknown>): boolean;
|
||||
|
||||
/**
|
||||
* For custom tools: if true, success implies all input files were successfully processed.
|
||||
* Use this for tools like Automate or Merge where Many-to-One relationships exist
|
||||
|
||||
@@ -218,7 +218,7 @@ export const useToolOperation = <TParams>(
|
||||
// Listen for global error file id events from HTTP interceptor during this run
|
||||
let externalErrorFileIds: string[] = [];
|
||||
const errorListener = (e: Event) => {
|
||||
const detail = (e as CustomEvent)?.detail as any;
|
||||
const detail = (e as CustomEvent<{ fileIds?: unknown }>)?.detail;
|
||||
if (detail?.fileIds) {
|
||||
externalErrorFileIds = Array.isArray(detail.fileIds)
|
||||
? detail.fileIds
|
||||
@@ -588,7 +588,7 @@ export const useToolOperation = <TParams>(
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
try {
|
||||
const handled = await handle422Error(error, (id) =>
|
||||
fileActions.markFileError(id as FileId),
|
||||
@@ -691,21 +691,22 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Show success message
|
||||
actions.setStatus(t("undoSuccess", "Operation undone successfully"));
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
let errorMessage = extractErrorMessage(error);
|
||||
|
||||
// Provide more specific error messages based on error type
|
||||
if (error.message?.includes("Mismatch between input files")) {
|
||||
const err = error as { message?: string; name?: string };
|
||||
if (err.message?.includes("Mismatch between input files")) {
|
||||
errorMessage = t(
|
||||
"undoDataMismatch",
|
||||
"Cannot undo: operation data is corrupted",
|
||||
);
|
||||
} else if (error.message?.includes("IndexedDB")) {
|
||||
} else if (err.message?.includes("IndexedDB")) {
|
||||
errorMessage = t(
|
||||
"undoStorageError",
|
||||
"Undo completed but some files could not be saved to storage",
|
||||
);
|
||||
} else if (error.name === "QuotaExceededError") {
|
||||
} else if (err.name === "QuotaExceededError") {
|
||||
errorMessage = t(
|
||||
"undoQuotaError",
|
||||
"Cannot undo: insufficient storage space",
|
||||
|
||||
@@ -389,6 +389,18 @@ export const useFileManager = () => {
|
||||
// Optimistic update — remove from UI immediately, delete IDB in background
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
onRemovedFromWorkbench?.(file.id);
|
||||
// Superseded versions go with it (see orphanedAncestorIds); best-effort,
|
||||
// because failing to tidy history must not fail the delete itself.
|
||||
void fileStorage
|
||||
.orphanedAncestorIds([file.id])
|
||||
.then((orphans) =>
|
||||
orphans.length > 0
|
||||
? fileStorage.deleteMultipleStirlingFiles(orphans)
|
||||
: undefined,
|
||||
)
|
||||
.catch((error) =>
|
||||
console.warn("Failed to remove superseded versions:", error),
|
||||
);
|
||||
indexedDB.deleteFile(file.id).catch((error) => {
|
||||
console.error("Failed to remove file from IndexedDB:", error);
|
||||
// Restore consistency — file is still in IDB so refresh brings it back
|
||||
|
||||
@@ -3,23 +3,26 @@ import "fake-indexeddb/auto";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
/**
|
||||
* Regression test for the WebKit nightly breakage introduced with the
|
||||
* large-file OOM fix (#7175): `storeStirlingFile` began putting the `File`
|
||||
* itself into IndexedDB (persisted by reference, so multi-GB uploads never
|
||||
* materialize in JS memory). WebKit refuses blob values whenever it can't write
|
||||
* the blob's backing file and rejects the request with `UnknownError: Error
|
||||
* preparing Blob/File data to be stored in object store`, so on WebKit every
|
||||
* upload silently failed to persist: files vanished on navigation, Compare
|
||||
* slots never filled, and the classification backfill had no bytes to read.
|
||||
* WebKit refuses blob values when it can't write the blob's backing file, so
|
||||
* every upload silently failed to persist after #7175. Retried as a copy now.
|
||||
*
|
||||
* The service now retries such a rejection with an ArrayBuffer copy and stops
|
||||
* offering blobs for the rest of the session.
|
||||
* It can also accept one and then lose the backing store. fake-indexeddb returns no
|
||||
* Blob, so that loss is injected at the read; real round-trips: the e2e spec.
|
||||
*/
|
||||
|
||||
const nativeAdd = IDBObjectStore.prototype.add;
|
||||
const alertMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@app/components/toast", () => ({
|
||||
alert: (options: unknown) => alertMock(options),
|
||||
}));
|
||||
|
||||
/** What each `add` attempt carried in `data` — the blob path or the copy path. */
|
||||
const nativeAdd = IDBObjectStore.prototype.add;
|
||||
const nativePut = IDBObjectStore.prototype.put;
|
||||
const nativeGet = IDBObjectStore.prototype.get;
|
||||
|
||||
/** What each `add` attempt carried in `data`: blob path or copy path. */
|
||||
let attempts: Array<"blob" | "copy"> = [];
|
||||
/** The same, for `put` - the rewrite path a lost backing store recovers through. */
|
||||
let putAttempts: Array<"blob" | "copy"> = [];
|
||||
|
||||
/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */
|
||||
class FailingRequest extends EventTarget {
|
||||
@@ -32,10 +35,7 @@ class FailingRequest extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record every add attempt, optionally failing the blob-valued ones the way an
|
||||
* engine without blob storage does.
|
||||
*/
|
||||
/** Record every add, optionally failing the blob-valued ones. */
|
||||
function instrumentAdd(options: { rejectBlobs: boolean }) {
|
||||
IDBObjectStore.prototype.add = function (
|
||||
this: IDBObjectStore,
|
||||
@@ -58,11 +58,72 @@ function instrumentAdd(options: { rejectBlobs: boolean }) {
|
||||
} as typeof IDBObjectStore.prototype.add;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh service per test: whether the engine accepts blobs is remembered for
|
||||
* the process lifetime by design, so tests must not inherit that decision from
|
||||
* each other.
|
||||
*/
|
||||
/** Record every put, so the copy-rewrite recovery can be observed. */
|
||||
function instrumentPut() {
|
||||
IDBObjectStore.prototype.put = function (
|
||||
this: IDBObjectStore,
|
||||
value: unknown,
|
||||
key?: IDBValidKey,
|
||||
) {
|
||||
putAttempts.push(
|
||||
(value as { data?: unknown } | null)?.data instanceof Blob
|
||||
? "blob"
|
||||
: "copy",
|
||||
);
|
||||
return key === undefined
|
||||
? nativePut.call(this, value)
|
||||
: nativePut.call(this, value, key);
|
||||
} as typeof IDBObjectStore.prototype.put;
|
||||
}
|
||||
|
||||
/** A stored blob whose backing store the engine has lost: it still reports a name,
|
||||
* type and size, and every read of its bytes fails the way WebKit's does. */
|
||||
function blobWithLostBackingStore(): Blob {
|
||||
const lost = () => {
|
||||
throw new DOMException(
|
||||
"The object can not be found here.",
|
||||
"NotFoundError",
|
||||
);
|
||||
};
|
||||
return Object.assign(
|
||||
new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }),
|
||||
{ slice: lost, arrayBuffer: lost, text: lost, stream: lost },
|
||||
);
|
||||
}
|
||||
|
||||
/** The next `deadReads` reads come back with a lost backing store, later ones
|
||||
* untouched - so a repaired record can still be read normally. */
|
||||
function loseBackingStoreOnRead(deadReads: number) {
|
||||
let remaining = deadReads;
|
||||
IDBObjectStore.prototype.get = function (
|
||||
this: IDBObjectStore,
|
||||
key: IDBValidKey | IDBKeyRange,
|
||||
) {
|
||||
const request = nativeGet.call(this, key as IDBValidKey);
|
||||
// One substitution per request, however often `result` is read.
|
||||
let injected = false;
|
||||
return new Proxy(request, {
|
||||
get(target, prop) {
|
||||
// Receiver must be the real request: IDBRequest's accessors are branded.
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (prop !== "result") {
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
if (!value || injected || remaining === 0) return value;
|
||||
injected = true;
|
||||
remaining--;
|
||||
return { ...(value as object), data: blobWithLostBackingStore() };
|
||||
},
|
||||
set(target, prop, value) {
|
||||
Reflect.set(target, prop, value, target);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} as typeof IDBObjectStore.prototype.get;
|
||||
}
|
||||
|
||||
/** A fresh service per test: the blob decision is remembered by design, so
|
||||
* tests must not inherit it from each other. */
|
||||
async function freshFileStorage() {
|
||||
vi.resetModules();
|
||||
const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] =
|
||||
@@ -86,10 +147,58 @@ async function freshFileStorage() {
|
||||
|
||||
beforeEach(() => {
|
||||
attempts = [];
|
||||
putAttempts = [];
|
||||
alertMock.mockClear();
|
||||
// The blob verdict is deliberately durable, so each test must start undecided.
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
IDBObjectStore.prototype.add = nativeAdd;
|
||||
IDBObjectStore.prototype.put = nativePut;
|
||||
IDBObjectStore.prototype.get = nativeGet;
|
||||
});
|
||||
|
||||
/** Abort the transaction the moment a write is issued over it. */
|
||||
function abortOnPut() {
|
||||
IDBObjectStore.prototype.put = function (this: IDBObjectStore) {
|
||||
const request = new FailingRequest(
|
||||
new DOMException("transaction aborted", "AbortError"),
|
||||
) as unknown as IDBRequest<IDBValidKey>;
|
||||
this.transaction.abort();
|
||||
return request;
|
||||
} as typeof IDBObjectStore.prototype.put;
|
||||
}
|
||||
|
||||
describe("read-modify-write — a refused rewrite must not hang or vanish", () => {
|
||||
/** The abort guard used to sit on the read promise, leaving the write with a
|
||||
* dead reject - and `.catch` can't rescue a promise that never settles. */
|
||||
test("settles instead of hanging when the write transaction aborts", async () => {
|
||||
expectConsole.error(/Failed to mark file as processed/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("aborts.pdf");
|
||||
|
||||
abortOnPut();
|
||||
|
||||
// Before the fix this never settled and the test timed out.
|
||||
await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
/** The copy-and-retry recovery can't be exercised here: it needs a record that
|
||||
* reads back as a Blob, which fake-indexeddb never returns. */
|
||||
test("a metadata rewrite still commits, and reports commit not put", async () => {
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("rewrite.pdf");
|
||||
|
||||
await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(true);
|
||||
// Missing record: `false`, not a throw and not a claim of success.
|
||||
await expect(
|
||||
fileStorage.markFileAsProcessed("nope" as never),
|
||||
).resolves.toBe(false);
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("rewrite.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeStirlingFile — blob-value fallback", () => {
|
||||
@@ -113,8 +222,7 @@ describe("storeStirlingFile — blob-value fallback", () => {
|
||||
const id = await store("webkit.pdf");
|
||||
|
||||
expect(attempts).toEqual(["blob", "copy"]);
|
||||
// Readable back is what every downstream consumer depends on: rehydration
|
||||
// after navigation, thumbnails, the classification backfill.
|
||||
// Readable back is what rehydration, thumbnails and backfill depend on.
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf");
|
||||
});
|
||||
|
||||
@@ -127,12 +235,32 @@ describe("storeStirlingFile — blob-value fallback", () => {
|
||||
attempts = [];
|
||||
const id = await store("second.pdf");
|
||||
|
||||
// Straight to the copy path — no repeated blob probe, and only the single
|
||||
// warning expected above.
|
||||
// Straight to the copy path, and only the one warning expected above.
|
||||
expect(attempts).toEqual(["copy"]);
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf");
|
||||
});
|
||||
|
||||
/** Committing is not evidence the bytes survived, and by the next reload the
|
||||
* source File is gone: without this the upload looks fine and the file is dead. */
|
||||
test("repairs a record whose stored blob loses its backing store", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
instrumentPut();
|
||||
loseBackingStoreOnRead(1); // only the store's own read-back is dead
|
||||
|
||||
const id = await store("dead-on-arrival.pdf");
|
||||
|
||||
// Accepted as a blob, then rewritten from the file still in hand.
|
||||
expect(attempts).toEqual(["blob"]);
|
||||
expect(putAttempts).toEqual(["copy"]);
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe(
|
||||
"dead-on-arrival.pdf",
|
||||
);
|
||||
// Self-healed, so nothing to tell the user about.
|
||||
expect(alertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not retry a failure a copy can't fix (quota)", async () => {
|
||||
const { store } = await freshFileStorage();
|
||||
IDBObjectStore.prototype.add = function (this: IDBObjectStore) {
|
||||
@@ -144,3 +272,246 @@ describe("storeStirlingFile — blob-value fallback", () => {
|
||||
expect(attempts).toEqual(["blob"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** An earlier session's record can't be repaired, and the user has to be told - but
|
||||
* the telling must never gate the open. Awaiting the probe stalled every file in
|
||||
* Safari, where the probe read of a lost backing store never settles. */
|
||||
describe("reads — a stored blob whose bytes are gone", () => {
|
||||
test("hands the file over and reports the loss out of band", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("lost.pdf");
|
||||
|
||||
loseBackingStoreOnRead(5); // every read from here on
|
||||
|
||||
// Not null, and not awaited on the probe: the caller is never blocked.
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("lost.pdf");
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// Told once, not once per reader: every consumer of the file hits this record.
|
||||
expect(alertMock).toHaveBeenCalledTimes(1);
|
||||
expect(alertMock.mock.calls[0][0]).toMatchObject({
|
||||
alertType: "warning",
|
||||
body: expect.stringContaining("lost.pdf"),
|
||||
});
|
||||
});
|
||||
|
||||
/** The loop this closes: a reload re-decides optimistically, writes blobs the
|
||||
* engine loses again, and the browser never settles on a shape that works. */
|
||||
test("remembers across reloads that this browser loses blob values", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const first = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await first.store("lost.pdf");
|
||||
loseBackingStoreOnRead(1);
|
||||
expect(await first.fileStorage.getStirlingFile(id)).not.toBeNull();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// A new page load: a fresh service, same browser profile.
|
||||
const next = await freshFileStorage();
|
||||
attempts = [];
|
||||
const later = await next.store("after-reload.pdf");
|
||||
|
||||
expect(attempts).toEqual(["copy"]);
|
||||
expect((await next.fileStorage.getStirlingFile(later))?.name).toBe(
|
||||
"after-reload.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
test("stops offering blob values for the rest of the session", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const first = await store("lost.pdf");
|
||||
|
||||
loseBackingStoreOnRead(1);
|
||||
expect(await fileStorage.getStirlingFile(first)).not.toBeNull();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// An engine that loses a blob it accepted can't be trusted with the next one,
|
||||
// so the read failure degrades writes too.
|
||||
attempts = [];
|
||||
const second = await store("later.pdf");
|
||||
expect(attempts).toEqual(["copy"]);
|
||||
expect((await fileStorage.getStirlingFile(second))?.name).toBe("later.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
/** Deleting a file used to leave its superseded versions in storage forever,
|
||||
* invisible (listings filter on isLeaf) and still holding their full bytes. */
|
||||
describe("orphanedAncestorIds", () => {
|
||||
const store = async (
|
||||
fileStorage: { storeStirlingFile: (f: never, s: never) => Promise<void> },
|
||||
id: string,
|
||||
parentFileId: string | undefined,
|
||||
isLeaf: boolean,
|
||||
) => {
|
||||
const { createStirlingFile, createNewStirlingFileStub } =
|
||||
await import("@app/types/fileContext");
|
||||
const file = new File(["%PDF-1.7"], `${id}.pdf`, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
const base = createNewStirlingFileStub(file);
|
||||
await fileStorage.storeStirlingFile(
|
||||
createStirlingFile(file, id as never) as never,
|
||||
{ ...base, id, isLeaf, parentFileId, originalFileId: "v1" } as never,
|
||||
);
|
||||
};
|
||||
|
||||
test("takes the superseded versions with the leaf", async () => {
|
||||
const { fileStorage } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
await store(fileStorage as never, "v1", undefined, false);
|
||||
await store(fileStorage as never, "v2", "v1", true);
|
||||
|
||||
expect(await fileStorage.orphanedAncestorIds(["v2" as never])).toEqual([
|
||||
"v1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves a split sibling's history alone", async () => {
|
||||
const { fileStorage } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
// Distinct ids: the fake database outlives the module reset between tests.
|
||||
await store(fileStorage as never, "split-root", undefined, false);
|
||||
await store(fileStorage as never, "split-a", "split-root", true);
|
||||
await store(fileStorage as never, "split-b", "split-root", true);
|
||||
|
||||
// `split-b` still descends from the root, so deleting `split-a` can't strip it.
|
||||
expect(await fileStorage.orphanedAncestorIds(["split-a" as never])).toEqual(
|
||||
[],
|
||||
);
|
||||
// Once both leaves go, the shared ancestor is genuinely unreachable.
|
||||
expect(
|
||||
await fileStorage.orphanedAncestorIds([
|
||||
"split-a" as never,
|
||||
"split-b" as never,
|
||||
]),
|
||||
).toEqual(["split-root"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Handing dead bytes over is only safe if whoever holds them is told to let go -
|
||||
* otherwise the viewer renders a document that never loads (an endless spinner). */
|
||||
describe("confirmed-unreadable records", () => {
|
||||
test("notifies listeners and refuses to hand the same file out twice", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
const { onRecordUnreadable } = await import("@app/services/fileStorage");
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("doomed.pdf");
|
||||
|
||||
const dropped: string[] = [];
|
||||
const unsubscribe = onRecordUnreadable((fileId) => dropped.push(fileId));
|
||||
|
||||
loseBackingStoreOnRead(5);
|
||||
// First read still hands the file over: the probe is out of band.
|
||||
expect(await fileStorage.getStirlingFile(id)).not.toBeNull();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// The holder is told, so the workbench can drop it instead of spinning.
|
||||
expect(dropped).toEqual([id]);
|
||||
// And a second consumer never gets the same dead bytes.
|
||||
expect(await fileStorage.getStirlingFile(id)).toBeNull();
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
/** A readable-blob substitute, for the rescue path: fake-indexeddb never returns
|
||||
* Blob values, so a healthy legacy blob record is injected the same way a dead
|
||||
* one is. */
|
||||
function substituteHealthyBlobOnRead(reads: number) {
|
||||
let remaining = reads;
|
||||
IDBObjectStore.prototype.get = function (
|
||||
this: IDBObjectStore,
|
||||
key: IDBValidKey | IDBKeyRange,
|
||||
) {
|
||||
const request = nativeGet.call(this, key as IDBValidKey);
|
||||
let injected = false;
|
||||
return new Proxy(request, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (prop !== "result") {
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
if (!value || injected || remaining === 0) return value;
|
||||
injected = true;
|
||||
remaining--;
|
||||
return {
|
||||
...(value as object),
|
||||
data: new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }),
|
||||
};
|
||||
},
|
||||
set(target, prop, value) {
|
||||
Reflect.set(target, prop, value, target);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} as typeof IDBObjectStore.prototype.get;
|
||||
}
|
||||
|
||||
/** The library must tell the truth per row: a record whose bytes are gone lists
|
||||
* as data-lost instead of a file that pretends to open. */
|
||||
describe("stub listings — data-lost auditing", () => {
|
||||
test("flags a dead record on the stub once the audit lands", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("husk.pdf");
|
||||
|
||||
loseBackingStoreOnRead(1);
|
||||
// First read schedules the out-of-band audit; unknown is not yet flagged.
|
||||
expect(
|
||||
(await fileStorage.getStirlingFileStub(id))?.dataUnavailable,
|
||||
).toBeUndefined();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
expect((await fileStorage.getStirlingFileStub(id))?.dataUnavailable).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("rescues a still-readable legacy blob to a copy on a no-blob browser", async () => {
|
||||
// The durable verdict says this browser loses blob values...
|
||||
localStorage.setItem("stirling.indexeddb.blobValuesUnsupported", "true");
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
instrumentPut();
|
||||
const id = await store("legacy.pdf");
|
||||
|
||||
// ...and a legacy record still holds a READABLE blob: save it while we can.
|
||||
substituteHealthyBlobOnRead(5);
|
||||
await fileStorage.getStirlingFileStub(id);
|
||||
await vi.waitFor(() => expect(putAttempts).toContain("copy"));
|
||||
// Rescued, not condemned: the stub stays openable.
|
||||
expect(
|
||||
(await fileStorage.getStirlingFileStub(id))?.dataUnavailable,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** One hung request inside the TTL bump's readwrite transaction wedged the whole
|
||||
* store: every later read and write queued behind it forever - the infinite
|
||||
* "Loading files..." after a Safari reload. Maintenance must not touch
|
||||
* blob-bodied records on a browser that can't rewrite them anyway. */
|
||||
describe("maintenanceMayRewrite", () => {
|
||||
test("keeps maintenance away from blob records on a no-blob browser", async () => {
|
||||
const { maintenanceMayRewrite } = await import("@app/services/fileStorage");
|
||||
const blobRecord = { data: new Blob(["x"]) };
|
||||
const copyRecord = { data: new ArrayBuffer(1) };
|
||||
|
||||
expect(maintenanceMayRewrite(blobRecord, false)).toBe(false);
|
||||
// Copies never hang and their rewrite is accepted - always safe.
|
||||
expect(maintenanceMayRewrite(copyRecord, false)).toBe(true);
|
||||
// On engines that genuinely support blobs (Chrome), nothing changes.
|
||||
expect(maintenanceMayRewrite(blobRecord, true)).toBe(true);
|
||||
expect(maintenanceMayRewrite(copyRecord, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
indexedDBManager,
|
||||
DATABASE_CONFIGS,
|
||||
} from "@app/services/indexedDBManager";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
/**
|
||||
* Storage record - single source of truth
|
||||
@@ -75,15 +76,135 @@ function isBlobValueRejection(error: unknown): boolean {
|
||||
return name === "UnknownError" || name === "DataCloneError";
|
||||
}
|
||||
|
||||
/** This engine loses Blob values, remembered per browser: session-scoped, each
|
||||
* reload re-decides optimistically and writes more files it will lose. */
|
||||
const BLOB_VALUES_UNSUPPORTED_KEY = "stirling.indexeddb.blobValuesUnsupported";
|
||||
|
||||
function readBlobValuesSupported(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(BLOB_VALUES_UNSUPPORTED_KEY) !== "true";
|
||||
} catch {
|
||||
// Storage unavailable (private mode): decide fresh each session.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function persistBlobValuesUnsupported(): void {
|
||||
try {
|
||||
localStorage.setItem(BLOB_VALUES_UNSUPPORTED_KEY, "true");
|
||||
} catch {
|
||||
// Storage unavailable: the session-scoped flag still degrades this session.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether maintenance writes (the thumbnail TTL bump) may re-read/re-write this
|
||||
* record. In WebKit, a `get` touching a blob-bodied record whose backing store is
|
||||
* damaged can HANG rather than error - and one pending request wedges the whole
|
||||
* object store: every later transaction, read or write, queues behind it forever.
|
||||
* That was the infinite "Loading files..." after a reload: the TTL bump's
|
||||
* transaction never completed, so nothing else on the store ever ran. On a
|
||||
* browser whose verdict is "blobs unsupported" the rewrite would be refused
|
||||
* anyway, so blob-bodied records are not worth the risk of touching at all.
|
||||
*/
|
||||
export function maintenanceMayRewrite(
|
||||
record: { data: ArrayBuffer | Blob },
|
||||
blobValuesSupported: boolean,
|
||||
): boolean {
|
||||
return !(record.data instanceof Blob) || blobValuesSupported;
|
||||
}
|
||||
|
||||
/** WebKit loses backing stores for blobs it accepted, and only a real read shows
|
||||
* it. One byte is enough: what fails is opening the store, not the length. */
|
||||
async function blobReadFailure(data: Blob): Promise<unknown> {
|
||||
try {
|
||||
await data.slice(0, 1).arrayBuffer();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error ?? new Error("Reading a stored blob's bytes failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Notified when a record's bytes are proven unreadable, so whoever is holding the
|
||||
* file can drop it instead of rendering a document that never arrives. */
|
||||
const unreadableListeners = new Set<(fileId: FileId) => void>();
|
||||
|
||||
export function onRecordUnreadable(
|
||||
listener: (fileId: FileId) => void,
|
||||
): () => void {
|
||||
unreadableListeners.add(listener);
|
||||
return () => unreadableListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The probe read itself can hang in WebKit, so anything that awaits it needs a
|
||||
* deadline. Distinct from a failure: nothing was proven either way. */
|
||||
const PROBE_UNANSWERED = { unanswered: true } as const;
|
||||
const PROBE_DEADLINE_MS = 3000;
|
||||
|
||||
function withProbeDeadline(
|
||||
probe: Promise<unknown>,
|
||||
): Promise<unknown | typeof PROBE_UNANSWERED> {
|
||||
return Promise.race([
|
||||
probe,
|
||||
new Promise<typeof PROBE_UNANSWERED>((resolve) =>
|
||||
setTimeout(() => resolve(PROBE_UNANSWERED), PROBE_DEADLINE_MS),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The File for a stored record. Re-wrapping a stored blob can cost WebKit the
|
||||
* backing handle, so hand it back untouched when its identity fields match.
|
||||
*/
|
||||
function fileFromRecord(record: StoredStirlingFileRecord): File {
|
||||
const { data } = record;
|
||||
if (
|
||||
data instanceof File &&
|
||||
data.name === record.name &&
|
||||
data.type === record.type &&
|
||||
data.lastModified === record.lastModified
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
return new File([data], record.name, {
|
||||
type: record.type,
|
||||
lastModified: record.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle on abort, for promises whose settle paths (a cursor tick, a request not
|
||||
* yet issued) never arrive. Call ONCE per transaction - there is one slot.
|
||||
*/
|
||||
function settleOnAbort(
|
||||
transaction: IDBTransaction,
|
||||
settle: (reason: Error) => void,
|
||||
): void {
|
||||
transaction.onabort = () =>
|
||||
settle(
|
||||
transaction.error ??
|
||||
new Error("IndexedDB transaction aborted before it completed"),
|
||||
);
|
||||
}
|
||||
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "files";
|
||||
/**
|
||||
* Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the
|
||||
* blob path avoids copying multi-GB files into JS memory, so we try it and
|
||||
* remember the answer, rather than pre-emptively degrading everywhere.
|
||||
*/
|
||||
private blobValuesSupported = true;
|
||||
/** Whether this engine takes Blob/File values, which avoid copying multi-GB
|
||||
* files into JS memory. Optimistic; a No outlives the session (see the key). */
|
||||
private blobValuesSupported = readBlobValuesSupported();
|
||||
/** Whether a stored blob's bytes have come back yet. Until they have, each
|
||||
* store proves it: accepting the write is no evidence the bytes survived. */
|
||||
private blobReadbackVerified = false;
|
||||
/** Ids whose TTL write failed. Without this the swallowed failure repeats a
|
||||
* whole-file rewrite on every listing. Session-scoped on purpose. */
|
||||
private readonly unwritableRecords = new Set<FileId>();
|
||||
/** Ids already reported as unreadable, so one dead record is surfaced once
|
||||
* rather than on every read of it. Session-scoped on purpose. */
|
||||
private readonly unreadableRecords = new Set<FileId>();
|
||||
/** Ids whose blob bytes this session has already audited (either way), so
|
||||
* listings don't re-probe every record on every refresh. */
|
||||
private readonly auditedRecords = new Set<FileId>();
|
||||
|
||||
/**
|
||||
* Get database connection using centralized manager
|
||||
@@ -101,7 +222,8 @@ class FileStorageService {
|
||||
|
||||
/** Fire-and-forget: bump thumbnailStoredAt (or clear expired thumbnail) for a set of ids. */
|
||||
private async bumpThumbnailTTL(ids: FileId[], clear = false): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const targets = ids.filter((id) => !this.unwritableRecords.has(id));
|
||||
if (targets.length === 0) return;
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
@@ -112,7 +234,7 @@ class FileStorageService {
|
||||
|
||||
// Issue all gets up front - each onsuccess creates a put before the
|
||||
// transaction can auto-commit, keeping it alive until all puts settle.
|
||||
ids.forEach((id) => {
|
||||
targets.forEach((id) => {
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
const record = req.result as StoredStirlingFileRecord | undefined;
|
||||
@@ -123,7 +245,30 @@ class FileStorageService {
|
||||
} else {
|
||||
record.thumbnailStoredAt = Date.now();
|
||||
}
|
||||
store.put(record);
|
||||
// One unwritable record must not take the batch with it: a rejected
|
||||
// put aborts the transaction the other queued gets are still using.
|
||||
try {
|
||||
const put = store.put(record);
|
||||
put.onerror = (event) => {
|
||||
// The write we just swallowed is the one that would have taken
|
||||
// this record out of the expiring set, so stop retrying it.
|
||||
this.unwritableRecords.add(id);
|
||||
this.noteBlobRefusal(put.error);
|
||||
console.warn(
|
||||
`[fileStorage] thumbnail TTL bump skipped for ${id}:`,
|
||||
put.error,
|
||||
);
|
||||
// Swallow it here so the failure doesn't abort the transaction.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
} catch (error) {
|
||||
this.unwritableRecords.add(id);
|
||||
console.warn(
|
||||
`[fileStorage] thumbnail TTL bump could not be issued for ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
@@ -186,18 +331,255 @@ class FileStorageService {
|
||||
} catch (error) {
|
||||
// Recoverable: re-add as a copy, and stop offering blobs this session.
|
||||
// Anything else is the caller's to report.
|
||||
if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) {
|
||||
if (!(record.data instanceof Blob) || !this.noteBlobRefusal(error)) {
|
||||
throw error;
|
||||
}
|
||||
this.blobValuesSupported = false;
|
||||
console.warn(
|
||||
"IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " +
|
||||
"Very large files may now exhaust renderer memory.",
|
||||
error,
|
||||
);
|
||||
record.data = await record.data.arrayBuffer();
|
||||
await this.addFileRecord(db, record);
|
||||
return;
|
||||
}
|
||||
|
||||
// Committed is not retrievable. Prove the round-trip while the source File is
|
||||
// still in hand; after a reload there is nothing left to repair from.
|
||||
if (record.data instanceof Blob && !this.blobReadbackVerified) {
|
||||
await this.verifyStoredBlobReadable(db, record, stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one stored blob back, rewriting the record from {@code source} if its
|
||||
* bytes don't come with it. Runs until one round-trip succeeds. */
|
||||
private async verifyStoredBlobReadable(
|
||||
db: IDBDatabase,
|
||||
record: StoredStirlingFileRecord,
|
||||
source: File,
|
||||
): Promise<void> {
|
||||
// A record we can't read back at all is the caller's problem, not the probe's.
|
||||
const stored = await this.readRecord(db, record.id).catch(() => undefined);
|
||||
if (!(stored?.data instanceof Blob)) return;
|
||||
|
||||
const failure = await withProbeDeadline(blobReadFailure(stored.data));
|
||||
if (!failure) {
|
||||
this.blobReadbackVerified = true;
|
||||
return;
|
||||
}
|
||||
if (failure === PROBE_UNANSWERED) {
|
||||
// Nothing proven, and an upload must never wait on a probe. Leave the record
|
||||
// as written; the read path reports it if the bytes really are gone.
|
||||
console.warn(
|
||||
`[fileStorage] readability probe for ${record.id} did not answer in ${PROBE_DEADLINE_MS}ms`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.noteBlobUnreadable(failure);
|
||||
try {
|
||||
record.data = await source.arrayBuffer();
|
||||
await this.putRecord(db, record);
|
||||
} catch (error) {
|
||||
// The record is unusable either way, and the read path reports that to the
|
||||
// user. Don't turn a write that already committed into a failure.
|
||||
console.warn(
|
||||
`[fileStorage] could not rewrite ${record.id} as an in-memory copy:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refused a Blob value? Stop offering blobs on this browser. Any write can flip
|
||||
* this: WebKit refuses per-operation, not per-engine. */
|
||||
private noteBlobRefusal(error: unknown): boolean {
|
||||
if (!isBlobValueRejection(error)) return false;
|
||||
this.disableBlobValues(
|
||||
"IndexedDB rejected a Blob value; falling back to in-memory copies on this browser. " +
|
||||
"Very large files may now exhaust renderer memory.",
|
||||
error,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A stored blob whose bytes won't read back means blob values can't be trusted
|
||||
* on this engine either, even though it accepted the write. */
|
||||
private noteBlobUnreadable(error: unknown): void {
|
||||
this.disableBlobValues(
|
||||
"IndexedDB accepted a Blob value but could not read its bytes back; " +
|
||||
"falling back to in-memory copies on this browser. " +
|
||||
"Very large files may now exhaust renderer memory.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
private disableBlobValues(message: string, error: unknown): void {
|
||||
if (!this.blobValuesSupported) return;
|
||||
this.blobValuesSupported = false;
|
||||
persistBlobValuesUnsupported();
|
||||
console.warn(message, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit a blob-backed record's bytes WITHOUT gating anything on the answer.
|
||||
* Awaiting this was a mistake: in Safari the probe read of a lost backing store
|
||||
* can stay pending forever, so it stalled every file open instead of the one
|
||||
* consumer that would have failed anyway.
|
||||
*
|
||||
* Two outcomes, both out of band:
|
||||
* - Bytes gone: mark + report, so the library shows "data lost" instead of a
|
||||
* file that pretends to open.
|
||||
* - Bytes readable on a browser whose verdict is "blobs unsupported": RESCUE the
|
||||
* record to an ArrayBuffer copy now, while the bytes still exist. Legacy blob
|
||||
* records on WebKit are one engine hiccup away from being lost for good.
|
||||
*/
|
||||
private reportIfUnreadable(record: StoredStirlingFileRecord): void {
|
||||
if (!(record.data instanceof Blob)) return;
|
||||
if (this.auditedRecords.has(record.id)) return;
|
||||
this.auditedRecords.add(record.id);
|
||||
void blobReadFailure(record.data).then((failure) => {
|
||||
if (!failure) {
|
||||
this.blobReadbackVerified = true;
|
||||
if (!this.blobValuesSupported) void this.rescueBlobRecord(record.id);
|
||||
return;
|
||||
}
|
||||
this.noteBlobUnreadable(failure);
|
||||
this.reportUnreadableRecord(record, failure);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one still-readable legacy blob record as an ArrayBuffer copy. Reads
|
||||
* the FULL bytes (the audit only proved the first one) and goes through
|
||||
* {@link updateRecord}'s read-modify-write so a concurrent metadata update
|
||||
* isn't clobbered by a stale snapshot.
|
||||
*/
|
||||
private async rescueBlobRecord(fileId: FileId): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const record = await this.readRecord(db, fileId);
|
||||
if (!(record?.data instanceof Blob)) return;
|
||||
const bytes = await withProbeDeadline(record.data.arrayBuffer());
|
||||
if (bytes === PROBE_UNANSWERED || !(bytes instanceof ArrayBuffer)) return;
|
||||
record.data = bytes;
|
||||
await this.putRecord(db, record);
|
||||
console.info(
|
||||
`[fileStorage] rescued "${record.name}" (${fileId}) to an in-memory copy before this browser could lose its blob`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Best-effort: a failed rescue leaves the record exactly as it was.
|
||||
console.warn(`[fileStorage] could not rescue ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** One console error and one toast per dead record: every consumer of the file
|
||||
* hits the same record, and the user needs the reason once, not per reader. */
|
||||
private reportUnreadableRecord(
|
||||
record: StoredStirlingFileRecord,
|
||||
failure: unknown,
|
||||
): void {
|
||||
if (this.unreadableRecords.has(record.id)) return;
|
||||
this.unreadableRecords.add(record.id);
|
||||
// Whoever is holding it needs to let go, or the viewer renders a document
|
||||
// whose bytes never arrive - a spinner with no terminal state.
|
||||
for (const listener of unreadableListeners) listener(record.id);
|
||||
console.error(
|
||||
`[fileStorage] stored data for "${record.name}" (${record.id}) cannot be read; ` +
|
||||
"the browser no longer has the blob's backing store",
|
||||
failure,
|
||||
);
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: "File data is unavailable",
|
||||
body:
|
||||
`"${record.name}" is saved in this browser but its contents can no longer be read. ` +
|
||||
"Upload the file again to keep working on it.",
|
||||
expandable: false,
|
||||
durationMs: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-modify-write one record in one transaction, resolving on COMMIT. Split
|
||||
* across two promises, the abort guard covers one and the other hangs. */
|
||||
private async updateRecord(
|
||||
fileId: FileId,
|
||||
mutate: (record: StoredStirlingFileRecord) => boolean | void,
|
||||
): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
try {
|
||||
return await this.readModifyWrite(db, fileId, mutate);
|
||||
} catch (error) {
|
||||
// The record we read back still carries its Blob body; retry as a copy.
|
||||
if (!this.noteBlobRefusal(error)) throw error;
|
||||
return await this.rewriteRecordAsCopy(db, fileId, mutate);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link updateRecord}'s happy path: one transaction, resolve on commit. */
|
||||
private readModifyWrite(
|
||||
db: IDBDatabase,
|
||||
fileId: FileId,
|
||||
mutate: (record: StoredStirlingFileRecord) => boolean | void,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
let written = false;
|
||||
settleOnAbort(transaction, reject);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve(written);
|
||||
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(fileId);
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as
|
||||
| StoredStirlingFileRecord
|
||||
| undefined;
|
||||
// Nothing to write: let the empty transaction commit and report false.
|
||||
if (!record || mutate(record) === false) return;
|
||||
written = true;
|
||||
store.put(record);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Recovery path: two transactions, because materializing the copy is async
|
||||
* and a transaction cannot survive an await. Last-write-wins either way. */
|
||||
private async rewriteRecordAsCopy(
|
||||
db: IDBDatabase,
|
||||
fileId: FileId,
|
||||
mutate: (record: StoredStirlingFileRecord) => boolean | void,
|
||||
): Promise<boolean> {
|
||||
const record = await this.readRecord(db, fileId);
|
||||
if (!record || mutate(record) === false) return false;
|
||||
if (record.data instanceof Blob) {
|
||||
record.data = await record.data.arrayBuffer();
|
||||
}
|
||||
await this.putRecord(db, record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One record by id, in its own transaction. */
|
||||
private readRecord(
|
||||
db: IDBDatabase,
|
||||
fileId: FileId,
|
||||
): Promise<StoredStirlingFileRecord | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const request = transaction.objectStore(this.storeName).get(fileId);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/** One `put`, resolving on commit. */
|
||||
private putRecord(
|
||||
db: IDBDatabase,
|
||||
record: StoredStirlingFileRecord,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
settleOnAbort(transaction, reject);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.objectStore(this.storeName).put(record);
|
||||
});
|
||||
}
|
||||
|
||||
/** Single `add` of a file record. Rejects with the underlying IDB error. */
|
||||
@@ -215,6 +597,7 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const request = store.add(record);
|
||||
@@ -227,37 +610,21 @@ class FileStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get StirlingFile with full data - for loading into workbench
|
||||
*/
|
||||
/** Get StirlingFile with full data - for loading into workbench. Null covers
|
||||
* both no such record and bytes gone; neither is a file callers can use. */
|
||||
async getStirlingFile(id: FileId): Promise<StirlingFile | null> {
|
||||
// Already proven unreadable this session: don't hand the same dead bytes to
|
||||
// another consumer that will spin on them. Session-scoped, so a reload retries.
|
||||
if (this.unreadableRecords.has(id)) return null;
|
||||
const db = await this.getDatabase();
|
||||
const record = await this.readRecord(db, id);
|
||||
if (!record) return null;
|
||||
// Reporting only, and NEVER awaited: WebKit can leave a read of a lost backing
|
||||
// store pending forever, and this is the path every file open goes through.
|
||||
this.reportIfUnreadable(record);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (!record) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create File from stored data
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const file = new File([blob], record.name, {
|
||||
type: record.type,
|
||||
lastModified: record.lastModified,
|
||||
});
|
||||
|
||||
// Convert to StirlingFile with preserved IDs
|
||||
const stirlingFile = createStirlingFile(file, record.fileId);
|
||||
resolve(stirlingFile);
|
||||
};
|
||||
});
|
||||
// Convert to StirlingFile with preserved IDs
|
||||
return createStirlingFile(fileFromRecord(record), record.fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,6 +645,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
@@ -295,9 +663,13 @@ class FileStorageService {
|
||||
// We still gate thumbnailUrl on freshness so stale thumbnails
|
||||
// don't leak through this read path.
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
// Out-of-band byte audit, so the library reflects lost data (and rescues
|
||||
// still-readable legacy blobs) instead of listing files that can't open.
|
||||
this.reportIfUnreadable(record);
|
||||
|
||||
const stub: StirlingFileStub = {
|
||||
id: record.id,
|
||||
dataUnavailable: this.unreadableRecords.has(record.id) || undefined,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
@@ -338,6 +710,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
@@ -352,12 +725,18 @@ class FileStorageService {
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
if (record && record.name && typeof record.size === "number") {
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
if (record.thumbnail) {
|
||||
if (
|
||||
record.thumbnail &&
|
||||
maintenanceMayRewrite(record, this.blobValuesSupported)
|
||||
) {
|
||||
if (fresh) tobump.push(record.id);
|
||||
else toexpire.push(record.id);
|
||||
}
|
||||
this.reportIfUnreadable(record);
|
||||
stubs.push({
|
||||
id: record.id,
|
||||
dataUnavailable:
|
||||
this.unreadableRecords.has(record.id) || undefined,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
@@ -425,6 +804,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const leafStubs: StirlingFileStub[] = [];
|
||||
@@ -444,12 +824,18 @@ class FileStorageService {
|
||||
record.isLeaf !== false
|
||||
) {
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
if (record.thumbnail) {
|
||||
if (
|
||||
record.thumbnail &&
|
||||
maintenanceMayRewrite(record, this.blobValuesSupported)
|
||||
) {
|
||||
if (fresh) tobump.push(record.id);
|
||||
else toexpire.push(record.id);
|
||||
}
|
||||
this.reportIfUnreadable(record);
|
||||
leafStubs.push({
|
||||
id: record.id,
|
||||
dataUnavailable:
|
||||
this.unreadableRecords.has(record.id) || undefined,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
@@ -579,6 +965,46 @@ class FileStorageService {
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Superseded versions that nothing else needs once {@code deleting} goes.
|
||||
*
|
||||
* Deleting a file removes one record; its older versions keep their full bytes
|
||||
* and are invisible (listings filter on isLeaf), so they accumulate forever.
|
||||
* Only for user-facing "delete this file" - deleting ONE version from the
|
||||
* history journey must leave the rest of the chain alone.
|
||||
*/
|
||||
async orphanedAncestorIds(deleting: FileId[]): Promise<FileId[]> {
|
||||
if (deleting.length === 0) return [];
|
||||
const stubs = await this.getAllStirlingFileStubs();
|
||||
const byId = new Map(stubs.map((s) => [s.id as string, s]));
|
||||
const doomed = new Set(deleting.map(String));
|
||||
|
||||
// Anything a surviving record descends from has to stay: split siblings
|
||||
// share a lineage, so one leaf's delete must not strip another's history.
|
||||
const keep = new Set<string>();
|
||||
for (const stub of stubs) {
|
||||
if (doomed.has(stub.id as string)) continue;
|
||||
let cursor = stub.parentFileId as string | undefined;
|
||||
while (cursor && !keep.has(cursor)) {
|
||||
keep.add(cursor);
|
||||
cursor = byId.get(cursor)?.parentFileId as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const orphans: FileId[] = [];
|
||||
for (const id of deleting) {
|
||||
let cursor = byId.get(String(id))?.parentFileId as string | undefined;
|
||||
while (cursor) {
|
||||
if (!keep.has(cursor) && !doomed.has(cursor) && byId.has(cursor)) {
|
||||
doomed.add(cursor);
|
||||
orphans.push(cursor as FileId);
|
||||
}
|
||||
cursor = byId.get(cursor)?.parentFileId as string | undefined;
|
||||
}
|
||||
}
|
||||
return orphans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete StirlingFile - single operation, no sync issues
|
||||
*/
|
||||
@@ -587,11 +1013,12 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
// On commit, not on the request: callers refresh their list from storage as
|
||||
// soon as this resolves, and an aborted delete would put the row back.
|
||||
settleOnAbort(transaction, reject);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.objectStore(this.storeName).delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -617,45 +1044,16 @@ class FileStorageService {
|
||||
* Update thumbnail for existing file
|
||||
*/
|
||||
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
try {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(id);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as StoredStirlingFileRecord;
|
||||
if (record) {
|
||||
record.thumbnail = thumbnail;
|
||||
record.thumbnailStoredAt = Date.now();
|
||||
const updateRequest = store.put(record);
|
||||
|
||||
updateRequest.onsuccess = () => {
|
||||
resolve(true);
|
||||
};
|
||||
updateRequest.onerror = () => {
|
||||
console.error("Failed to update thumbnail:", updateRequest.error);
|
||||
resolve(false);
|
||||
};
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
console.error(
|
||||
"Failed to get file for thumbnail update:",
|
||||
getRequest.error,
|
||||
);
|
||||
resolve(false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Transaction error during thumbnail update:", error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
// Reports failure as `false` rather than rejecting; callers just need an answer.
|
||||
try {
|
||||
return await this.updateRecord(id, (record) => {
|
||||
record.thumbnail = thumbnail;
|
||||
record.thumbnailStoredAt = Date.now();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update thumbnail:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,6 +1064,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
|
||||
@@ -720,24 +1119,16 @@ class FileStorageService {
|
||||
async createBlobUrl(id: FileId): Promise<string | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const record = await this.readRecord(db, id);
|
||||
if (!record) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (record) {
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
resolve(url);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
});
|
||||
// Stored blobs are handed straight to createObjectURL — re-wrapping
|
||||
// one can cost WebKit the backing handle. See fileFromRecord.
|
||||
const blob =
|
||||
record.data instanceof Blob
|
||||
? record.data
|
||||
: new Blob([record.data], { type: record.type });
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to create blob URL for ${id}:`, error);
|
||||
return null;
|
||||
@@ -750,32 +1141,9 @@ class FileStorageService {
|
||||
*/
|
||||
async markFileAsProcessed(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>(
|
||||
(resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
},
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return false; // File not found
|
||||
}
|
||||
|
||||
// Update the isLeaf flag to false
|
||||
record.isLeaf = false;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
return await this.updateRecord(fileId, (record) => {
|
||||
record.isLeaf = false;
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to mark file as processed:", error);
|
||||
return false;
|
||||
@@ -835,32 +1203,9 @@ class FileStorageService {
|
||||
*/
|
||||
async markFileAsLeaf(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>(
|
||||
(resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
},
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return false; // File not found
|
||||
}
|
||||
|
||||
// Update the isLeaf flag to true
|
||||
record.isLeaf = true;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
return await this.updateRecord(fileId, (record) => {
|
||||
record.isLeaf = true;
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to mark file as leaf:", error);
|
||||
return false;
|
||||
@@ -870,41 +1215,16 @@ class FileStorageService {
|
||||
/**
|
||||
* Update metadata fields for a stored file record.
|
||||
*
|
||||
* Resolves on transaction.oncomplete, NOT on the individual put's onsuccess,
|
||||
* so callers only receive `true` once the write actually commits. If the
|
||||
* transaction aborts after put() succeeded but before commit, we return false
|
||||
* - the previous behavior incorrectly claimed success in that window.
|
||||
* Returns `true` only once the write commits, never on the put's `onsuccess`.
|
||||
* {@link updateRecord} owns that guarantee for every write in this class.
|
||||
*/
|
||||
async updateFileMetadata(
|
||||
fileId: FileId,
|
||||
updates: Partial<StoredStirlingFileRecord>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
let recordFound = false;
|
||||
|
||||
const getRequest = store.get(fileId);
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as
|
||||
| StoredStirlingFileRecord
|
||||
| undefined;
|
||||
if (!record) {
|
||||
// Don't commit anything; caller wants false.
|
||||
return;
|
||||
}
|
||||
recordFound = true;
|
||||
const updatedRecord = { ...record, ...updates };
|
||||
store.put(updatedRecord);
|
||||
};
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
|
||||
transaction.oncomplete = () => resolve(recordFound);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("updateFileMetadata aborted"));
|
||||
return await this.updateRecord(fileId, (record) => {
|
||||
Object.assign(record, updates);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update file metadata:", error);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
import type { DatabaseConfig } from "@app/services/indexedDBManager";
|
||||
|
||||
/**
|
||||
* A blocked open fires `blocked` and then nothing at all - no success, no error -
|
||||
* until the other connection goes away. Unguarded, the open promise never settles
|
||||
* and every caller hangs SILENTLY: the file library spun forever with an empty
|
||||
* console, which is why this kept being reported as unreproducible.
|
||||
*/
|
||||
|
||||
const config = (name: string, version: number): DatabaseConfig => ({
|
||||
name,
|
||||
version,
|
||||
stores: [{ name: "things", keyPath: "id" }],
|
||||
});
|
||||
|
||||
/** A raw connection on an older version that never yields, i.e. the other tab. */
|
||||
function holdOlderVersion(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains("things")) {
|
||||
request.result.createObjectStore("things", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("openDatabase — blocked by another connection", () => {
|
||||
test("rejects with something actionable instead of hanging", async () => {
|
||||
expectConsole.warn(/blocked by another connection/);
|
||||
const { indexedDBManager } = await import("@app/services/indexedDBManager");
|
||||
const held = await holdOlderVersion("blocked-db");
|
||||
|
||||
vi.useFakeTimers();
|
||||
const open = indexedDBManager.openDatabase(config("blocked-db", 2));
|
||||
const settled = vi.fn();
|
||||
void open.then(settled, settled);
|
||||
|
||||
// Still pending before the grace period is up: a tab that yields quickly
|
||||
// must not be failed prematurely.
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await expect(open).rejects.toThrow(/blocked by another connection/);
|
||||
|
||||
held.close();
|
||||
});
|
||||
|
||||
test("dedupes concurrent callers onto one connection", async () => {
|
||||
const { indexedDBManager } = await import("@app/services/indexedDBManager");
|
||||
const spy = vi.spyOn(indexedDB, "open");
|
||||
|
||||
// Racing in the same tick is the case registration-after-await could not
|
||||
// dedupe, and only the first request would ever receive `blocked`.
|
||||
const [a, b, c] = await Promise.all([
|
||||
indexedDBManager.openDatabase(config("shared-db", 1)),
|
||||
indexedDBManager.openDatabase(config("shared-db", 1)),
|
||||
indexedDBManager.openDatabase(config("shared-db", 1)),
|
||||
]);
|
||||
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
expect(
|
||||
spy.mock.calls.filter(([name]) => name === "shared-db"),
|
||||
).toHaveLength(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,11 @@ export interface DatabaseConfig {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** How long to wait out another connection before failing an open with something
|
||||
* the user can act on. Rejecting does NOT cancel the request, so a connection
|
||||
* that arrives later is closed rather than held. */
|
||||
const BLOCKED_GRACE_MS = 5000;
|
||||
|
||||
class IndexedDBManager {
|
||||
private static instance: IndexedDBManager;
|
||||
private databases = new Map<string, IDBDatabase>();
|
||||
@@ -47,6 +52,26 @@ class IndexedDBManager {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
// Registered BEFORE anything async. A map written after a yield point can't
|
||||
// dedupe callers racing into it in the same tick, so every context that opened
|
||||
// this database during boot got its own connection - and per spec only the
|
||||
// FIRST request ever receives `blocked`, leaving the rest waiting on an event
|
||||
// that never comes.
|
||||
const initPromise = this.openWithRecovery(config);
|
||||
this.initPromises.set(config.name, initPromise);
|
||||
|
||||
try {
|
||||
const db = await initPromise;
|
||||
this.databases.set(config.name, db);
|
||||
return db;
|
||||
} catch (error) {
|
||||
this.initPromises.delete(config.name);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The v6/v7 wipe, kept off {@link openDatabase}'s synchronous registration path. */
|
||||
private async openWithRecovery(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||
// 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
|
||||
@@ -64,18 +89,7 @@ class IndexedDBManager {
|
||||
await this.deleteDatabase(config.name);
|
||||
}
|
||||
}
|
||||
|
||||
const initPromise = this.performDatabaseInit(config);
|
||||
this.initPromises.set(config.name, initPromise);
|
||||
|
||||
try {
|
||||
const db = await initPromise;
|
||||
this.databases.set(config.name, db);
|
||||
return db;
|
||||
} catch (error) {
|
||||
this.initPromises.delete(config.name);
|
||||
throw error;
|
||||
}
|
||||
return this.performDatabaseInit(config);
|
||||
}
|
||||
|
||||
private performDatabaseInit(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||
@@ -83,15 +97,60 @@ class IndexedDBManager {
|
||||
console.log(`Opening IndexedDB: ${config.name} v${config.version}`);
|
||||
const request = indexedDB.open(config.name, config.version);
|
||||
|
||||
// A blocked upgrade fires `blocked` and then NOTHING - no success, no error -
|
||||
// until the other connection goes away. Unguarded, the promise never settles
|
||||
// and every awaiting caller hangs with nothing in the console.
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
request.onblocked = () => {
|
||||
console.warn(
|
||||
`Opening ${config.name} is blocked by another connection (another tab on an older version?). ` +
|
||||
`Giving up in ${BLOCKED_GRACE_MS}ms if it doesn't yield.`,
|
||||
);
|
||||
blockedTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
new Error(
|
||||
`Opening ${config.name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms. ` +
|
||||
"Close other tabs of this app and reload.",
|
||||
),
|
||||
);
|
||||
}, BLOCKED_GRACE_MS);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
console.error(`Failed to open ${config.name}:`, request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
const db = request.result;
|
||||
// We already gave up waiting: close it rather than hold a handle nobody
|
||||
// awaits, or we become the next tab's blocker.
|
||||
if (settled) {
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
console.log(`Successfully opened ${config.name}`);
|
||||
|
||||
// Another tab wants a newer schema. Forget BEFORE closing: a cached but
|
||||
// closed handle is worse than none, because every transaction on it throws.
|
||||
db.onversionchange = () => {
|
||||
console.warn(
|
||||
`${config.name}: another tab requested a version change; closing this connection`,
|
||||
);
|
||||
this.databases.delete(config.name);
|
||||
this.initPromises.delete(config.name);
|
||||
db.close();
|
||||
};
|
||||
|
||||
// Set up close handler to clean up our references
|
||||
db.onclose = () => {
|
||||
console.log(`Database ${config.name} closed`);
|
||||
@@ -329,9 +388,36 @@ class IndexedDBManager {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const deleteRequest = indexedDB.deleteDatabase(name);
|
||||
// A delete blocks exactly like an upgrade, and this one is awaited on the
|
||||
// files open path - so an unguarded block hangs the whole storage layer.
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||
deleteRequest.onblocked = () => {
|
||||
console.warn(
|
||||
`Deleting ${name} is blocked by another connection; giving up in ${BLOCKED_GRACE_MS}ms.`,
|
||||
);
|
||||
blockedTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
new Error(
|
||||
`Deleting ${name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms.`,
|
||||
),
|
||||
);
|
||||
}, BLOCKED_GRACE_MS);
|
||||
};
|
||||
|
||||
deleteRequest.onerror = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(deleteRequest.error);
|
||||
};
|
||||
deleteRequest.onsuccess = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
console.log(`Deleted database: ${name}`);
|
||||
resolve();
|
||||
};
|
||||
@@ -343,17 +429,32 @@ class IndexedDBManager {
|
||||
*/
|
||||
async getDatabaseVersion(name: string): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
// This probe runs BEFORE the guarded open, and a versionless open can be
|
||||
// delayed indefinitely by another tab mid-versionchange. Unknown after the
|
||||
// grace period beats hanging every storage consumer: the real open that
|
||||
// follows has its own blocked guard and a message the user can act on.
|
||||
const giveUp = setTimeout(() => {
|
||||
console.warn(
|
||||
`Version probe for ${name} did not answer in ${BLOCKED_GRACE_MS}ms; proceeding without it.`,
|
||||
);
|
||||
resolve(null);
|
||||
}, BLOCKED_GRACE_MS);
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => {
|
||||
clearTimeout(giveUp);
|
||||
const db = request.result;
|
||||
const version = db.version;
|
||||
db.close();
|
||||
resolve(version);
|
||||
};
|
||||
request.onerror = () => resolve(null);
|
||||
request.onerror = () => {
|
||||
clearTimeout(giveUp);
|
||||
resolve(null);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
// Cancel the upgrade
|
||||
request.transaction?.abort();
|
||||
clearTimeout(giveUp);
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A WASM instantiate that fails must reject, not hang. `instantiateWasm` reports
|
||||
* success by callback, so a swallowed rejection leaves `init()` pending and takes
|
||||
* every thumbnail, page parse and form read with it - silently.
|
||||
*/
|
||||
|
||||
const init = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@embedpdf/pdfium", () => ({ init }));
|
||||
|
||||
const wasmModule = vi.hoisted(() => ({}) as WebAssembly.Module);
|
||||
vi.mock("@app/services/wasmPrecompiler", () => ({
|
||||
pdfiumWasmModulePromise: Promise.resolve(wasmModule),
|
||||
startEagerWasmCompilation: () => {},
|
||||
pdfiumWasmUrl: "http://localhost/pdfium.wasm",
|
||||
}));
|
||||
|
||||
/** emscripten's contract: it calls instantiateWasm and waits to be called back. */
|
||||
function emscriptenInit(
|
||||
instantiate: (imports: object, ok: () => void) => void,
|
||||
) {
|
||||
return new Promise(() => {
|
||||
instantiate({}, () => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadService() {
|
||||
vi.resetModules();
|
||||
return await import("@app/services/pdfiumService");
|
||||
}
|
||||
|
||||
describe("pdfium bootstrap", () => {
|
||||
test("rejects when instantiating the pre-compiled module fails", async () => {
|
||||
const failure = new Error("LinkError: import mismatch");
|
||||
vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(failure as never);
|
||||
init.mockImplementation((overrides: Record<string, never>) =>
|
||||
emscriptenInit(
|
||||
overrides.instantiateWasm as unknown as (
|
||||
imports: object,
|
||||
ok: () => void,
|
||||
) => void,
|
||||
),
|
||||
);
|
||||
|
||||
const { getPdfiumModule } = await loadService();
|
||||
|
||||
// Before the fix this never settled, so the test timed out.
|
||||
await expect(getPdfiumModule()).rejects.toThrow(/LinkError/);
|
||||
});
|
||||
|
||||
test("a failed load isn't cached, so the next call retries", async () => {
|
||||
const instantiate = vi
|
||||
.spyOn(WebAssembly, "instantiate")
|
||||
.mockRejectedValueOnce(new Error("transient") as never)
|
||||
.mockResolvedValue({} as never);
|
||||
const ready = { PDFiumExt_Init: () => {} };
|
||||
init.mockImplementation(
|
||||
(overrides: Record<string, never>) =>
|
||||
new Promise((resolve) => {
|
||||
const instantiateWasm = overrides.instantiateWasm as unknown as (
|
||||
imports: object,
|
||||
ok: () => void,
|
||||
) => void;
|
||||
instantiateWasm({}, () => resolve(ready));
|
||||
}),
|
||||
);
|
||||
|
||||
const { getPdfiumModule } = await loadService();
|
||||
|
||||
await expect(getPdfiumModule()).rejects.toThrow(/transient/);
|
||||
await expect(getPdfiumModule()).resolves.toBe(ready);
|
||||
expect(instantiate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -80,17 +80,24 @@ function wasmUrl(): string {
|
||||
* This is the low-level PDFium WASM interface with all C functions wrapped.
|
||||
* Prefer `withDocument()` for document-scoped work.
|
||||
*/
|
||||
export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
if (_module) return _module;
|
||||
if (!_initPromise) {
|
||||
// Ensure eager compilation has started if PDF service is requested before idle timeout
|
||||
startEagerWasmCompilation();
|
||||
/** Reuses the WASM pre-compiled at boot. Every failure must reach this promise:
|
||||
* `instantiateWasm` reports success by callback, so a rejection inside it leaves
|
||||
* `init()` pending forever - and with it every thumbnail, parse and form read. */
|
||||
async function initPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
// Ensure eager compilation has started if PDF service is requested before idle timeout
|
||||
startEagerWasmCompilation();
|
||||
|
||||
const overrides: PdfiumModuleOverrides = {
|
||||
locateFile: () => wasmUrl(),
|
||||
};
|
||||
const overrides: PdfiumModuleOverrides = { locateFile: () => wasmUrl() };
|
||||
const precompiled = await pdfiumWasmModulePromise;
|
||||
|
||||
// Eagerly reuse pre-compiled WASM module from app boot if available
|
||||
let reportFailure: (error: unknown) => void = () => {};
|
||||
const instantiateFailed = new Promise<never>((_, reject) => {
|
||||
reportFailure = reject;
|
||||
});
|
||||
|
||||
// No pre-compiled module: leave instantiateWasm alone so emscripten fetches the
|
||||
// WASM itself and rejects init() on failure, instead of a fallback that can't.
|
||||
if (precompiled) {
|
||||
overrides.instantiateWasm = (
|
||||
imports: WebAssembly.Imports,
|
||||
successCallback: (
|
||||
@@ -98,40 +105,34 @@ export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
module: WebAssembly.Module,
|
||||
) => void,
|
||||
) => {
|
||||
pdfiumWasmModulePromise
|
||||
.then((wasmModule) => {
|
||||
if (wasmModule) {
|
||||
return WebAssembly.instantiate(wasmModule, imports).then(
|
||||
(instance) => {
|
||||
successCallback(instance, wasmModule);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
throw new Error("No pre-compiled WASM module found");
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn(
|
||||
"Eager WebAssembly instantiation failed, falling back to streaming compilation:",
|
||||
err,
|
||||
);
|
||||
WebAssembly.instantiateStreaming(fetch(wasmUrl()), imports).then(
|
||||
(result) => {
|
||||
successCallback(result.instance, result.module);
|
||||
},
|
||||
);
|
||||
});
|
||||
WebAssembly.instantiate(precompiled, imports)
|
||||
.then((instance) => successCallback(instance, precompiled))
|
||||
.catch(reportFailure);
|
||||
};
|
||||
}
|
||||
|
||||
_initPromise = init(overrides as Partial<PdfiumModule>).then((m) => {
|
||||
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
|
||||
try {
|
||||
m.PDFiumExt_Init();
|
||||
} catch {
|
||||
/* already initialized */
|
||||
}
|
||||
_module = m;
|
||||
return m;
|
||||
const m = await Promise.race([
|
||||
init(overrides as Partial<PdfiumModule>),
|
||||
instantiateFailed,
|
||||
]);
|
||||
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
|
||||
try {
|
||||
m.PDFiumExt_Init();
|
||||
} catch {
|
||||
/* already initialized */
|
||||
}
|
||||
_module = m;
|
||||
return m;
|
||||
}
|
||||
|
||||
export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
if (_module) return _module;
|
||||
if (!_initPromise) {
|
||||
_initPromise = initPdfiumModule().catch((error: unknown) => {
|
||||
// Don't cache the failure: every PDF feature in the app goes through here,
|
||||
// so a transient WASM fetch would take them all down for the session.
|
||||
_initPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return _initPromise;
|
||||
|
||||
@@ -2,6 +2,10 @@ import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { installFailOnConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// jsdom is missing the same APIs WebKit is, so tests must agree with the
|
||||
// browser. Same module `src/index.tsx` installs.
|
||||
import "@app/utils/engineShims";
|
||||
|
||||
installFailOnConsole();
|
||||
|
||||
// Mock localStorage for tests
|
||||
|
||||
@@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock only essential services that are actually called by the tests
|
||||
vi.mock("../../services/fileStorage", () => ({
|
||||
// FileContext subscribes to this to drop files whose bytes are unreadable.
|
||||
onRecordUnreadable: () => () => {},
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
|
||||
@@ -66,6 +66,8 @@ const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock only essential services that are actually called by the tests
|
||||
vi.mock("../../services/fileStorage", () => ({
|
||||
// FileContext subscribes to this to drop files whose bytes are unreadable.
|
||||
onRecordUnreadable: () => () => {},
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
|
||||
@@ -131,6 +131,9 @@ export interface MockAppApiOptions {
|
||||
languages?: string[];
|
||||
/** Default locale. */
|
||||
defaultLocale?: string;
|
||||
/** Advertise server-side storage. Off by default - a spec that stubs or
|
||||
* blocks `/api/v1/storage/files` must set this or the route is never hit. */
|
||||
storageEnabled?: boolean;
|
||||
/** Merge overrides into the endpoint availability map. */
|
||||
endpointsAvailability?: Record<string, { enabled: boolean }>;
|
||||
/** Backend probe status. Set to `"DOWN"` to exercise offline-mode UI. */
|
||||
@@ -156,6 +159,7 @@ export async function mockAppApis(
|
||||
},
|
||||
languages = ["en-US"],
|
||||
defaultLocale = "en-US",
|
||||
storageEnabled = false,
|
||||
endpointsAvailability = {},
|
||||
backendStatus = "UP",
|
||||
} = opts;
|
||||
@@ -173,6 +177,7 @@ export async function mockAppApis(
|
||||
isAdmin,
|
||||
languages,
|
||||
defaultLocale,
|
||||
storageEnabled,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -230,4 +230,7 @@ test.describe("Compare tool slot selection", () => {
|
||||
page.locator('[data-testid="compare-slot-comparison"]'),
|
||||
).toHaveAttribute("data-slot-state", "empty");
|
||||
});
|
||||
|
||||
// These specs stop at slot state. Actually running a comparison lives in
|
||||
// `engine-capabilities.spec.ts`, which is cross-browser in PR CI.
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Runs on all three engines in PR CI, asserting on evidence that can only exist
|
||||
* if the engine did the work. Keep small - it is paid for three times per PR. */
|
||||
|
||||
import path from "path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { dismissTourTooltip, uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
|
||||
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
|
||||
const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf");
|
||||
const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf");
|
||||
|
||||
/** A missing global or prototype method always surfaces as one of these.
|
||||
* Matching the shape keeps benign engine noise out (console-clean.spec.ts). */
|
||||
const MISSING_API_ERROR =
|
||||
/is not a function|is not a constructor|undefined is not an object|has no method/i;
|
||||
|
||||
/** Collect the "this engine lacks an API we used" errors seen on the page. */
|
||||
function recordMissingApiErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error: Error) => {
|
||||
const text = String(error);
|
||||
if (MISSING_API_ERROR.test(text)) errors.push(text);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function fillCompareSlot(
|
||||
page: Page,
|
||||
role: "base" | "comparison",
|
||||
filePath: string,
|
||||
) {
|
||||
await page
|
||||
.getByTestId(`compare-slot-${role}-add-input`)
|
||||
.setInputFiles(filePath);
|
||||
await expect(
|
||||
page.locator(`[data-testid="compare-slot-${role}"]`),
|
||||
).toHaveAttribute("data-slot-state", "filled", { timeout: 20_000 });
|
||||
// The upload modal's overlay outlives its close transition and eats clicks.
|
||||
await page
|
||||
.locator(".mantine-Modal-overlay")
|
||||
.waitFor({ state: "detached", timeout: 5_000 })
|
||||
.catch(() => {
|
||||
/* already gone */
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("engine capabilities", { tag: "@engine-capability" }, () => {
|
||||
test("extracts PDF text and completes a comparison", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const missingApis = recordMissingApiErrors(page);
|
||||
|
||||
await page.locator('[data-tour="tool-button-compare"]').first().click();
|
||||
await page.waitForSelector('[data-testid="compare-slot-base"]', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await fillCompareSlot(page, "base", PDF_A);
|
||||
await fillCompareSlot(page, "comparison", PDF_B);
|
||||
|
||||
// By test id: `name` matches as a substring, so "Compare" also hits the
|
||||
// tool button that opened this panel.
|
||||
await page.getByTestId("compare-execute").click();
|
||||
|
||||
// Counted results, not headings: an extraction returning nothing still
|
||||
// renders empty panes, which is how the WebKit failure looked like success.
|
||||
const deletions = page.getByText(/Deletions \((\d+)\)/);
|
||||
const additions = page.getByText(/Additions \((\d+)\)/);
|
||||
await expect(deletions).toBeVisible({ timeout: 60_000 });
|
||||
await expect(additions).toBeVisible();
|
||||
expect(await deletions.innerText()).not.toMatch(/\(0\)/);
|
||||
expect(await additions.innerText()).not.toMatch(/\(0\)/);
|
||||
|
||||
expect(missingApis, "no missing-API errors during comparison").toEqual([]);
|
||||
});
|
||||
|
||||
test("rasterises page thumbnails via the PDF engine", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const missingApis = recordMissingApiErrors(page);
|
||||
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
await dismissTourTooltip(page);
|
||||
|
||||
// A page thumbnail only exists if the WASM engine loaded, rendered and
|
||||
// encoded. When it fails the grid still renders, just with no <img>.
|
||||
await page.getByText("PDF Multi Tool", { exact: true }).first().click();
|
||||
|
||||
const thumbnail = page
|
||||
.locator("[data-page-id] img[data-original-rotation]")
|
||||
.first();
|
||||
await expect(thumbnail).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// An empty encode still yields a src; require enough payload to be real.
|
||||
const src = await thumbnail.getAttribute("src");
|
||||
expect(src ?? "").toMatch(/^data:image\//);
|
||||
expect(src?.length ?? 0).toBeGreaterThan(1_000);
|
||||
|
||||
expect(missingApis, "no missing-API errors during thumbnailing").toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("reads a stored file's bytes back after a reload", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const missingApis = recordMissingApiErrors(page);
|
||||
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Full reload: FileContext rehydrates from IndexedDB, not from memory.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
const restored = page.locator(".file-sidebar-file-item").first();
|
||||
await expect(restored).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Rendering it is the assertion that matters: the metadata record survives
|
||||
// even when the bytes were never stored, so a filename proves nothing.
|
||||
await restored.hover();
|
||||
await restored
|
||||
.locator(".file-sidebar-eye-btn")
|
||||
.click({ timeout: 15_000, force: true });
|
||||
|
||||
const firstPage = page.locator('[data-page-index="0"]').first();
|
||||
await expect(firstPage).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// A tile that decoded has non-zero naturalWidth. A blob stored but not
|
||||
// readable back resolves to nothing, and renders as an empty page.
|
||||
const tile = firstPage.locator('img[src^="blob:"]').first();
|
||||
await expect(tile).toBeAttached({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(missingApis, "no missing-API errors after rehydration").toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/** The sidebar and file pickers must reach a resting state even when storage
|
||||
* can't be read. Blocks the database for real, then asserts the UI recovers. */
|
||||
|
||||
import path from "path";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
const SAMPLE_PDF = path.join(
|
||||
import.meta.dirname,
|
||||
"../test-fixtures/sample.pdf",
|
||||
);
|
||||
|
||||
/** The app opens `stirling-pdf-files` at this version; block it from below. */
|
||||
const BLOCKING_VERSION = 8;
|
||||
|
||||
/** Park a connection on an older version and never yield it, the way a tab
|
||||
* running an older build does, so the app's upgrade can't proceed. */
|
||||
async function blockTheFilesDatabase(page: import("@playwright/test").Page) {
|
||||
await page.addInitScript((version: number) => {
|
||||
const request = indexedDB.open("stirling-pdf-files", version);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
// Deliberately NO onversionchange handler: hold the database open.
|
||||
(window as unknown as { __blocker?: IDBDatabase }).__blocker =
|
||||
request.result;
|
||||
};
|
||||
}, BLOCKING_VERSION);
|
||||
}
|
||||
|
||||
/** Closing the context is NOT enough: WebKit keeps origin databases between
|
||||
* contexts, so a stray v8 makes a random later spec fail. */
|
||||
async function unblockTheFilesDatabase(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(async () => {
|
||||
const holder = window as unknown as { __blocker?: IDBDatabase };
|
||||
holder.__blocker?.close();
|
||||
holder.__blocker = undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase("stirling-pdf-files");
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
request.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("the sidebar stops loading even when the file library is unreadable", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Allow for the manager's blocked-upgrade grace period plus app boot.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const warnings: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "warning" || message.type() === "error") {
|
||||
warnings.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await blockTheFilesDatabase(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
try {
|
||||
// Storage is not on the critical path for rendering the workbench.
|
||||
await page.waitForSelector('[data-tour="tool-button-compare"]', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The spinner is the whole bug: it must clear once the manager gives up.
|
||||
await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The reason must be discoverable - a silent console made the original
|
||||
// report impossible to diagnose.
|
||||
expect(
|
||||
warnings.some((text) => /blocked by another open connection/i.test(text)),
|
||||
`expected a blocked-database warning, got: ${JSON.stringify(warnings)}`,
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await unblockTheFilesDatabase(page);
|
||||
}
|
||||
});
|
||||
|
||||
// `loadRecentFiles` only fetches `/api/v1/storage/files` when app-config
|
||||
// advertises `storageEnabled`; without it the route below is never requested.
|
||||
test.describe("with server-side storage enabled", () => {
|
||||
test.use({ stubOptions: { storageEnabled: true } });
|
||||
|
||||
test("the picker's Workbench tab lists files while saved files are still loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// Hold the saved-files read open so the picker stays stuck all test.
|
||||
let sawSavedFilesRequest = false;
|
||||
await page.route("**/api/v1/storage/files", async () => {
|
||||
sawSavedFilesRequest = true;
|
||||
await new Promise(() => {
|
||||
/* never responds */
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector('[data-tour="tool-button-compare"]', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Put a file in the workbench so the Workbench tab has something to show.
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF);
|
||||
await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.locator('[data-tour="tool-button-compare"]').first().click();
|
||||
await page.getByTestId("compare-slot-base-add").click();
|
||||
|
||||
// The picker must actually be stuck, or the rest proves nothing.
|
||||
const picker = page.locator(".mantine-Popover-dropdown");
|
||||
await expect(picker).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => sawSavedFilesRequest, { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
|
||||
// Workbench lists from memory, so a stuck read must not hide it. Scope to
|
||||
// the popover: the sidebar row behind it matches the same locator.
|
||||
await picker
|
||||
.getByRole("button", { name: "Workbench", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
picker.getByRole("button", { name: /sample\.pdf/ }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user