Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1da04920 |
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.2
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.2
|
||||
pkgver=2.14.1
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -510,7 +510,6 @@ jobs:
|
||||
# cargo output unsigned, so checking it produces false negatives.
|
||||
- name: Verify Windows Code Signature
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }}
|
||||
timeout-minutes: 15
|
||||
shell: pwsh
|
||||
run: |
|
||||
$allSigned = $true
|
||||
@@ -532,26 +531,11 @@ jobs:
|
||||
|
||||
# Extract MSI and verify the inner exe (the file that actually gets installed).
|
||||
# This is the critical check - AV flags the installed exe at runtime.
|
||||
# Use lessmsi, not `msiexec /a`: msiexec serializes on the global
|
||||
# _MSIExecute mutex and hangs forever on hosted runners when another
|
||||
# installer is busy. lessmsi reads MSI tables directly - no mutex, no service.
|
||||
$msi = $msiFiles[0].FullName
|
||||
$extractDir = Join-Path $env:RUNNER_TEMP "msi-verify"
|
||||
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
|
||||
|
||||
choco install lessmsi -y --no-progress --limit-output | Out-Null
|
||||
|
||||
# Bound the extraction and kill on hang (defence in depth over timeout-minutes).
|
||||
$proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow
|
||||
if (-not $proc.WaitForExit(120000)) {
|
||||
try { $proc.Kill() } catch {}
|
||||
Write-Host "[ERROR] MSI extraction timed out after 120s"
|
||||
$allSigned = $false
|
||||
} elseif ($proc.ExitCode -ne 0) {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
} else {
|
||||
$proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow
|
||||
if ($proc.ExitCode -eq 0) {
|
||||
$innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1
|
||||
if ($innerExe) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName
|
||||
@@ -564,6 +548,9 @@ jobs:
|
||||
Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI"
|
||||
$allSigned = $false
|
||||
}
|
||||
} else {
|
||||
Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))"
|
||||
$allSigned = $false
|
||||
}
|
||||
|
||||
if (-not $allSigned) {
|
||||
@@ -813,11 +800,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: v${{ needs.determine-matrix.outputs.version }}
|
||||
# Don't regenerate/append notes on re-runs, and don't force this into the
|
||||
# "Latest" slot - leave the release body and latest marker as they are.
|
||||
generate_release_notes: false
|
||||
append_body: false
|
||||
make_latest: false
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
# Installers + updater payloads + manifest. .sig contents are embedded
|
||||
# in latest.json so the .sig files themselves are not uploaded.
|
||||
|
||||
+16
-1
@@ -78,6 +78,16 @@ tasks:
|
||||
cmds:
|
||||
- npx tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
build:msi:
|
||||
desc: "Build Tauri desktop MSI installer with bundled JRE (Windows)"
|
||||
deps: [prepare]
|
||||
dir: editor
|
||||
platforms: [windows]
|
||||
cmds:
|
||||
# prepare -> jlink (real bundled JRE) + jar + frontend + provisioner.
|
||||
# No updater artifacts, so no signing key is required for a local MSI.
|
||||
- npx tauri build --bundles msi --config '{"bundle":{"createUpdaterArtifacts":false}}'
|
||||
|
||||
build:dev:linux:
|
||||
desc: "Build Tauri desktop AppImage (Linux)"
|
||||
deps: [prepare]
|
||||
@@ -194,7 +204,12 @@ tasks:
|
||||
# `desktop:build` run `jlink:clean` first to force a fresh build.
|
||||
- cmd: chmod -R u+w runtime/jre
|
||||
platforms: [linux, darwin]
|
||||
- cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }"
|
||||
# Single-quote the PowerShell so go-task's POSIX shell passes $_ and $false
|
||||
# through literally. With double quotes it expands $_ to the task path and
|
||||
# $false to empty, so read-only never clears and the next incremental build
|
||||
# dies with "Permission denied (os error 13)" staging the JRE. -File keeps
|
||||
# it to FileInfo (only files carry a settable IsReadOnly).
|
||||
- cmd: powershell -NoProfile -Command 'Get-ChildItem -Recurse -Force -File runtime/jre | ForEach-Object { $_.IsReadOnly = $false }'
|
||||
platforms: [windows]
|
||||
status:
|
||||
- test -f runtime/jre/release
|
||||
|
||||
@@ -208,18 +208,6 @@
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "The W3C License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.google.re2j:re2j",
|
||||
"moduleLicense": "Go License"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot:algebra",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": "com.hubspot.immutables:immutables-exceptions",
|
||||
"moduleLicense": null
|
||||
},
|
||||
{
|
||||
"moduleName": ".*",
|
||||
"moduleLicense": "UnRar License"
|
||||
|
||||
@@ -66,20 +66,6 @@ dependencies {
|
||||
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
|
||||
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
|
||||
// version directly so it is selected consistently (root build.gradle pins are the fallback).
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
runtimeOnly "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
|
||||
implementation("com.hubspot.jinjava:jinjava:${jinjavaVersion}") {
|
||||
// Compile-time-only annotation artifacts (class-retention annotations, not needed at
|
||||
// runtime) whose declared licences (LGPL / none) fail the licence compatibility check.
|
||||
exclude group: 'com.google.code.findbugs', module: 'annotations'
|
||||
exclude group: 'org.derive4j', module: 'derive4j-annotation'
|
||||
exclude group: 'com.hubspot.immutables', module: 'hubspot-style'
|
||||
exclude group: 'com.hubspot.immutables', module: 'immutable-collection-encodings'
|
||||
}
|
||||
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:${jwtVersion}"
|
||||
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Supplies the classification vocabulary the classify tool sends to the AI engine. The set is a
|
||||
* fixed, built-in list bundled with the application ({@code
|
||||
* classification/classification-labels.json}) and shared by everyone — there is no per-team
|
||||
* customization or database. Loaded once at startup.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ClassificationLabelProvider {
|
||||
|
||||
private static final String RESOURCE = "classification/classification-labels.json";
|
||||
|
||||
private final List<ClassificationLabel> labels;
|
||||
|
||||
// Explicit @Autowired: the class has a second (private) constructor for tests, so Spring
|
||||
// can't infer which to use without it.
|
||||
@Autowired
|
||||
public ClassificationLabelProvider(ObjectMapper objectMapper) {
|
||||
this(load(objectMapper));
|
||||
}
|
||||
|
||||
private ClassificationLabelProvider(List<ClassificationLabel> labels) {
|
||||
this.labels = List.copyOf(labels);
|
||||
}
|
||||
|
||||
/** Build a provider with an explicit label set (tests). */
|
||||
public static ClassificationLabelProvider withLabels(List<ClassificationLabel> labels) {
|
||||
return new ClassificationLabelProvider(labels);
|
||||
}
|
||||
|
||||
/** The built-in vocabulary, in file order. */
|
||||
public List<ClassificationLabel> labels() {
|
||||
return labels;
|
||||
}
|
||||
|
||||
private static List<ClassificationLabel> load(ObjectMapper objectMapper) {
|
||||
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
|
||||
ClassificationLabels parsed = objectMapper.readValue(in, ClassificationLabels.class);
|
||||
return parsed.labels();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load classification labels from {}", RESOURCE, e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.model.LabelsValidator;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.TeamLabelsEntity;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the team's classification label set — the flat vocabulary the document classifier runs
|
||||
* against. Shared and team-scoped exactly like policies: every user reads their own team's labels,
|
||||
* and only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it — gated only when login is enabled, since
|
||||
* single-user deployments trust the local operator. A team with no stored labels reads as {@code
|
||||
* 204}; that team has no vocabulary, so its documents are not classified (there is no built-in
|
||||
* default on the backend or the engine — the label data lives only in the frontend).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/labels")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification labels")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class ClassificationLabelsController {
|
||||
|
||||
private final ClassificationLabelStore labelStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification labels",
|
||||
description =
|
||||
"Returns the caller's team label set, or 204 when the team has none (its"
|
||||
+ " documents are then not classified).")
|
||||
public ResponseEntity<ClassificationLabels> getTeamLabels() {
|
||||
return labelStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification labels",
|
||||
description =
|
||||
"Validates and stores the label set for the caller's team, shared by everyone"
|
||||
+ " on the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationLabels> saveTeamLabels(
|
||||
@RequestBody ClassificationLabels labels) {
|
||||
requireEditingAllowed();
|
||||
validate(labels);
|
||||
ClassificationLabels saved = labelStore.save(currentTeamId(), labels, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification labels",
|
||||
description =
|
||||
"Removes the team's stored label set; its documents are then not classified"
|
||||
+ " until labels are saved again. Requires the policy-editor role for the"
|
||||
+ " team.")
|
||||
public ResponseEntity<Void> resetTeamLabels() {
|
||||
requireEditingAllowed();
|
||||
labelStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static void validate(ClassificationLabels labels) {
|
||||
try {
|
||||
LabelsValidator.validate(labels);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the team labels requires the editor role for the caller's team — the same gate
|
||||
* policies use (team leader on SaaS, global admin self-hosted). Single-user deployments (login
|
||||
* disabled) have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The team classification labels may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's team key. With login disabled the single operator owns the {@link
|
||||
* TeamLabelsEntity#NO_TEAM} sentinel row; with login enabled a caller with no resolvable team
|
||||
* is an error rather than being dropped into the shared sentinel bucket (which would let
|
||||
* unteamed users read and overwrite each other's "team" labels).
|
||||
*/
|
||||
private Long currentTeamId() {
|
||||
Long teamId = policyManagementAuthority.currentUserTeamId();
|
||||
if (teamId != null) {
|
||||
return teamId;
|
||||
}
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return TeamLabelsEntity.NO_TEAM;
|
||||
}
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.UNAUTHORIZED, "Could not resolve the current user's team");
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -3,10 +3,10 @@ package stirling.software.proprietary.classification.model;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned. The
|
||||
* classifier runs against these label names. The vocabulary is a fixed, built-in set shared by
|
||||
* everyone (see {@link stirling.software.proprietary.classification.ClassificationLabelProvider});
|
||||
* this record is the JSON parse target for that bundled resource.
|
||||
* A flat multi-label classification vocabulary — the set of labels a document may be assigned.
|
||||
* Stored per team (admin-edited, shared by everyone on the team); the classifier runs against these
|
||||
* label names. A team with no stored set has no vocabulary, so its documents are not classified —
|
||||
* neither the backend nor the engine holds a default of its own.
|
||||
*/
|
||||
public record ClassificationLabels(List<ClassificationLabel> labels) {
|
||||
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Structural validation for a user- or admin-supplied label set, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* non-blank ids and names, each unique within the set (ids exactly, names case-insensitively).
|
||||
*/
|
||||
public final class LabelsValidator {
|
||||
|
||||
private LabelsValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate label set is never blocked, but a single team or user
|
||||
// can't store an unbounded blob that would bloat the row, balloon the classifier prompt, or
|
||||
// exhaust memory on deserialize.
|
||||
static final int MAX_LABELS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
// Icon is a Material Symbols key (lowercase, digits, hyphens). Enforce the SHAPE server-side —
|
||||
// the exact allowlist lives in the frontend — so a client bypassing the UI can't store
|
||||
// arbitrary
|
||||
// text that would render as garbage (or worse) in every teammate's sidebar.
|
||||
private static final Pattern ICON_KEY = Pattern.compile("^[a-z0-9-]+$");
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the label set is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationLabels labels) {
|
||||
if (labels == null || labels.labels() == null) {
|
||||
throw new IllegalArgumentException("Labels are required");
|
||||
}
|
||||
if (labels.labels().size() > MAX_LABELS) {
|
||||
throw new IllegalArgumentException("Too many labels (max " + MAX_LABELS + ")");
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ClassificationLabel label : labels.labels()) {
|
||||
requireText(label.id(), "Label id");
|
||||
requireText(label.name(), "Label name");
|
||||
if (label.icon() != null && !label.icon().isEmpty()) {
|
||||
if (label.icon().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"Label icon is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
if (!ICON_KEY.matcher(label.icon()).matches()) {
|
||||
throw new IllegalArgumentException("Invalid label icon: " + label.icon());
|
||||
}
|
||||
}
|
||||
if (!ids.add(label.id().trim())) {
|
||||
throw new IllegalArgumentException("Duplicate label id: " + label.id());
|
||||
}
|
||||
if (!names.add(label.name().trim().toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException("Duplicate label name: " + label.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.trim().length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationLabels} set per team. A {@code null} teamId addresses the
|
||||
* unteamed set (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface ClassificationLabelStore {
|
||||
|
||||
/** The team's stored labels, or empty when it has none (callers then skip classification). */
|
||||
Optional<ClassificationLabels> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's labels. Returns the stored value. */
|
||||
ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy);
|
||||
|
||||
/** Remove the team's labels (reset to default). Returns whether a set existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
/**
|
||||
* In-memory {@link ClassificationLabelStore} for tests and any future no-database mode. {@link
|
||||
* JpaClassificationLabelStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final Map<Long, ClassificationLabels> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
byTeam.put(key(teamId), labels);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link ClassificationLabelStore} backed by JPA; the runtime store. Gated on {@code
|
||||
* policies.enabled} — stored labels only matter when the Classification policy can run — so it
|
||||
* shares the policy subsystem's on/off switch. Each label set is persisted as JSON via {@link
|
||||
* TeamLabelsEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaClassificationLabelStore implements ClassificationLabelStore {
|
||||
|
||||
private final TeamLabelsRepository teamRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationLabels> findByTeam(Long teamId) {
|
||||
return teamRepository
|
||||
.findById(key(teamId))
|
||||
.flatMap(entity -> parse(entity.getLabelsJson(), "team " + teamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationLabels save(Long teamId, ClassificationLabels labels, String updatedBy) {
|
||||
TeamLabelsEntity entity = new TeamLabelsEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setLabelsJson(objectMapper.writeValueAsString(labels));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
teamRepository.save(entity);
|
||||
return labels;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!teamRepository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
teamRepository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Optional<ClassificationLabels> parse(String json, String owner) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readValue(json, ClassificationLabels.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored label set that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller treats the team as having no labels (and skips
|
||||
// classification) rather than surfacing a 500 on every upload.
|
||||
log.warn("Discarding unparseable stored labels for {}: {}", owner, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TeamLabelsEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification labels — one row per team. The label set lives as JSON in
|
||||
* {@code labelsJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_labels")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TeamLabelsEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed label set (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "labels_json", columnDefinition = "text")
|
||||
private String labelsJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TeamLabelsRepository extends JpaRepository<TeamLabelsEntity, Long> {}
|
||||
+32
-14
@@ -31,9 +31,10 @@ import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -45,7 +46,7 @@ import tools.jackson.databind.node.ObjectNode;
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document against the built-in label set, and stores the engine's JSON
|
||||
* engine to classify the document against the caller's team label set, and stores the engine's JSON
|
||||
* answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key
|
||||
* {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct
|
||||
* client use.
|
||||
@@ -71,9 +72,13 @@ public class ClassifyLabelController {
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case there are no team labels to
|
||||
* classify against and the document is passed through unlabelled.
|
||||
*/
|
||||
private final ClassificationLabelProvider labelProvider;
|
||||
private final ClassificationLabelStore labelStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
public ClassifyLabelController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -82,16 +87,18 @@ public class ClassifyLabelController {
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.labelProvider = labelProvider;
|
||||
this.userService = userService;
|
||||
this.labelStore = labelStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -109,9 +116,9 @@ public class ClassifyLabelController {
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// No vocabulary to classify against: pass the file through unlabelled rather than
|
||||
// ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no labels; skipping", fileName);
|
||||
// No vocabulary to classify against (the team stored no labels): pass the file
|
||||
// through unlabelled rather than ask the engine to classify against nothing.
|
||||
log.debug("[classify-and-label] {} has no team labels; skipping", fileName);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
@@ -168,13 +175,24 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in vocabulary as {@code {id, name}} pairs, de-duplicated by id. The engine shows
|
||||
* the model the names and returns the ids (icons are presentational and never sent). The engine
|
||||
* holds no default vocabulary of its own, so this bundled set is the only source.
|
||||
* The allowed labels for the caller's team as {@code {id, name}} pairs, de-duplicated by id.
|
||||
* The engine shows the model the names and returns the ids (icons are presentational and never
|
||||
* sent). Returns an empty list — the caller then skips classification — when the policy
|
||||
* subsystem is disabled (no store) or the team has no stored labels. The engine holds no
|
||||
* default vocabulary of its own, so a team's stored labels are the only source.
|
||||
*/
|
||||
private List<EngineLabel> resolveAllowedLabels() {
|
||||
if (labelStore == null) {
|
||||
return List.of();
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
|
||||
Map<String, EngineLabel> byId = new LinkedHashMap<>();
|
||||
collectLabels(labelProvider.labels(), byId);
|
||||
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
|
||||
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
|
||||
+17
-38
@@ -8,14 +8,12 @@ import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
@@ -26,24 +24,18 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that converts an AI-generated document model to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated HTML string to a PDF via WeasyPrint.
|
||||
*
|
||||
* <p>Called by {@link stirling.software.proprietary.service.AiWorkflowService} when the engine
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The engine supplies the document as
|
||||
* structured fields; the HTML is built here from a fixed template.
|
||||
* emits a {@code CREATE_PDF_FROM_HTML_AGENT} plan step. The HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -56,9 +48,6 @@ public class CreatePdfAgentController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||
|
||||
/**
|
||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||
@@ -85,42 +74,32 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
description =
|
||||
"Accepts a structured document as a JSON parameter and returns a PDF. This"
|
||||
+ " endpoint is dispatched by the AI workflow orchestrator as a plan"
|
||||
+ " step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdf(
|
||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||
"Accepts an HTML document as a plain-text parameter and returns a PDF."
|
||||
+ " This endpoint is dispatched by the AI workflow orchestrator as a"
|
||||
+ " plan step; it is not intended for direct client use.")
|
||||
public ResponseEntity<Resource> createPdfFromHtml(
|
||||
@RequestParam("htmlContent") String htmlContent,
|
||||
@RequestParam("filename") String filename)
|
||||
throws Exception {
|
||||
|
||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
AiDocument model;
|
||||
try {
|
||||
model = objectMapper.readValue(document, AiDocument.class);
|
||||
} catch (JacksonException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
String html = htmlRenderer.render(model);
|
||||
|
||||
log.info(
|
||||
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
"[create-pdf-agent] converting HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), html, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(runtimePathConfig.getWeasyPrintPath());
|
||||
command.add("-e");
|
||||
command.add("utf-8");
|
||||
command.add("-v");
|
||||
// SSRF: the HTML is self-contained and the engine validates style colours, so no
|
||||
// external url() reaches WeasyPrint. For full isolation, run it network-isolated.
|
||||
command.add(htmlFile.getAbsolutePath());
|
||||
command.add(pdfFile.getAbsolutePath());
|
||||
|
||||
@@ -147,8 +126,8 @@ public class CreatePdfAgentController {
|
||||
// avoids materialising the whole document as a byte[] twice (read-all + re-serialise),
|
||||
// which matters for large generated documents.
|
||||
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
|
||||
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.ai.create;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AiDocument {
|
||||
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private String referenceNumber;
|
||||
private Style style;
|
||||
private List<Section> sections;
|
||||
|
||||
@Data
|
||||
public static class Style {
|
||||
private String primaryColor;
|
||||
private String backgroundColor;
|
||||
private String bodyTextColor;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Section {
|
||||
private String type;
|
||||
private String heading;
|
||||
private String body;
|
||||
private List<List<String>> pairs;
|
||||
private List<String> columns;
|
||||
private List<List<String>> rows;
|
||||
private List<String> totalRow;
|
||||
private List<String> items;
|
||||
private List<String> signatories;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -37,7 +37,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.ledger",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
"stirling.software.proprietary.integration.repository",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -49,7 +50,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.ledger",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model"
|
||||
"stirling.software.proprietary.integration.model",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.hubspot.jinjava.Jinjava;
|
||||
import com.hubspot.jinjava.JinjavaConfig;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
|
||||
/** Renders an {@link AiDocument} to HTML using a Jinja template loaded from the classpath. */
|
||||
@Component
|
||||
public class AiDocumentHtmlRenderer {
|
||||
|
||||
private static final String TEMPLATE_PATH = "templates/ai/create/document.html.jinja2";
|
||||
|
||||
private static final Pattern SAFE_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
|
||||
private final Jinjava jinjava;
|
||||
private final String template;
|
||||
|
||||
public AiDocumentHtmlRenderer() {
|
||||
JinjavaConfig config =
|
||||
JinjavaConfig.newBuilder().withNestedInterpretationEnabled(false).build();
|
||||
this.jinjava = new Jinjava(config);
|
||||
this.template = loadTemplate();
|
||||
}
|
||||
|
||||
public String render(AiDocument doc) {
|
||||
return jinjava.render(template, buildContext(doc));
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildContext(AiDocument doc) {
|
||||
Map<String, Object> context = new LinkedHashMap<>();
|
||||
context.put("title", doc.getTitle());
|
||||
context.put("subtitle", doc.getSubtitle());
|
||||
context.put("reference_number", doc.getReferenceNumber());
|
||||
|
||||
AiDocument.Style style = doc.getStyle();
|
||||
if (style != null) {
|
||||
context.put("style_primary", safeColor(style.getPrimaryColor()));
|
||||
context.put("style_background", safeColor(style.getBackgroundColor()));
|
||||
context.put("style_body", safeColor(style.getBodyTextColor()));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> sections = new ArrayList<>();
|
||||
if (doc.getSections() != null) {
|
||||
for (AiDocument.Section section : doc.getSections()) {
|
||||
if (section != null && section.getType() != null) {
|
||||
sections.add(buildSection(section));
|
||||
}
|
||||
}
|
||||
}
|
||||
context.put("sections", sections);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static Map<String, Object> buildSection(AiDocument.Section section) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("type", section.getType());
|
||||
node.put("heading", section.getHeading());
|
||||
switch (section.getType()) {
|
||||
case "text" -> node.put("paragraphs", paragraphs(section.getBody()));
|
||||
case "key_value" -> node.put("pairs", pairs(section.getPairs()));
|
||||
case "line_items" -> {
|
||||
node.put("columns", orEmpty(section.getColumns()));
|
||||
node.put("rows", orEmptyRows(section.getRows()));
|
||||
node.put("total_row", emptyToNull(section.getTotalRow()));
|
||||
}
|
||||
case "bullet_list" -> node.put("items", orEmpty(section.getItems()));
|
||||
case "signature" -> node.put("signatories", orEmpty(section.getSignatories()));
|
||||
default -> {}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
private static List<String> paragraphs(String body) {
|
||||
String text = body == null ? "" : body;
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String paragraph : text.split("\n\n")) {
|
||||
out.add(paragraph.replace("\n", " "));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<Map<String, String>> pairs(List<List<String>> pairs) {
|
||||
List<Map<String, String>> out = new ArrayList<>();
|
||||
if (pairs != null) {
|
||||
for (List<String> pair : pairs) {
|
||||
Map<String, String> node = new LinkedHashMap<>();
|
||||
node.put("label", pair.isEmpty() ? "" : pair.get(0));
|
||||
node.put("value", pair.size() < 2 ? "" : pair.get(1));
|
||||
out.add(node);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<String> orEmpty(List<String> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
|
||||
private static List<List<String>> orEmptyRows(List<List<String>> rows) {
|
||||
return rows == null ? List.of() : rows;
|
||||
}
|
||||
|
||||
private static List<String> emptyToNull(List<String> values) {
|
||||
return values == null || values.isEmpty() ? null : values;
|
||||
}
|
||||
|
||||
private static String safeColor(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return SAFE_COLOR.matcher(trimmed).matches() ? trimmed : null;
|
||||
}
|
||||
|
||||
private static String loadTemplate() {
|
||||
try {
|
||||
return new ClassPathResource(TEMPLATE_PATH).getContentAsString(StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.ClassificationLabelStore;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ClassificationLabelsController")
|
||||
class ClassificationLabelsControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private ClassificationLabelStore store;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private ClassificationLabelsController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new InProcessClassificationLabelStore();
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new ClassificationLabelsController(
|
||||
store, policyManagementAuthority, applicationProperties, userService);
|
||||
}
|
||||
|
||||
private static ClassificationLabels sample() {
|
||||
return new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null)));
|
||||
}
|
||||
|
||||
private void loginEnabled(boolean enabled) {
|
||||
applicationProperties.getSecurity().setEnableLogin(enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET returns 204 when the team has no labels")
|
||||
void getEmpty() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
ResponseEntity<ClassificationLabels> response = controller.getTeamLabels();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT then GET round-trips the team's labels (login disabled)")
|
||||
void saveThenGet() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
controller.saveTeamLabels(sample());
|
||||
ResponseEntity<ClassificationLabels> got = controller.getTeamLabels();
|
||||
|
||||
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(got.getBody()).isNotNull();
|
||||
assertThat(got.getBody().labels()).hasSize(2);
|
||||
assertThat(got.getBody().labels().getFirst().name()).isEqualTo("Invoice");
|
||||
assertThat(got.getBody().labels().getFirst().icon()).isEqualTo("receipt-long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is scoped per team")
|
||||
void perTeam() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTeamLabels(sample());
|
||||
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
|
||||
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is rejected for a non-editor when login is enabled")
|
||||
void putForbiddenForNonEditor() {
|
||||
loginEnabled(true);
|
||||
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTeamLabels(sample()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT rejects an invalid label set with 400")
|
||||
void putInvalid() {
|
||||
loginEnabled(false);
|
||||
ClassificationLabels duplicate =
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", null),
|
||||
new ClassificationLabel("invoice", "Invoice", null)));
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTeamLabels(duplicate))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE resets the team back to no stored labels")
|
||||
void deleteResets() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTeamLabels(sample());
|
||||
|
||||
ResponseEntity<Void> response = controller.resetTeamLabels();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(controller.getTeamLabels().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("LabelsValidator")
|
||||
class LabelsValidatorTest {
|
||||
|
||||
private static ClassificationLabels labels(ClassificationLabel... labels) {
|
||||
return new ClassificationLabels(List.of(labels));
|
||||
}
|
||||
|
||||
private static ClassificationLabel label(String name) {
|
||||
return new ClassificationLabel(slug(name), name, null);
|
||||
}
|
||||
|
||||
private static String slug(String name) {
|
||||
return name.trim()
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("(^-|-$)", "");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts a well-formed label set")
|
||||
void acceptsValid() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
label("Contract"));
|
||||
assertThatCode(() -> LabelsValidator.validate(set)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts an empty label set (reads as: use the default)")
|
||||
void acceptsEmpty() {
|
||||
assertThatCode(() -> LabelsValidator.validate(labels())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a null label set")
|
||||
void rejectsNull() {
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Labels are required");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate names (distinct ids)")
|
||||
void rejectsDuplicateNames() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice-a", "Invoice", null),
|
||||
new ClassificationLabel("invoice-b", "Invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label name");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate names differing only by case")
|
||||
void rejectsDuplicateNamesCaseInsensitive() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice-a", "Invoice", null),
|
||||
new ClassificationLabel("invoice-b", "INVOICE", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label name");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate ids")
|
||||
void rejectsDuplicateIds() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel("invoice", "Invoice", null),
|
||||
new ClassificationLabel("invoice", "Sales invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate label id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank name")
|
||||
void rejectsBlankName() {
|
||||
ClassificationLabels set = labels(new ClassificationLabel("blank", " ", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Label name must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank id")
|
||||
void rejectsBlankId() {
|
||||
ClassificationLabels set = labels(new ClassificationLabel(" ", "Invoice", null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Label id must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long name")
|
||||
void rejectsOverLongName() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel(
|
||||
"x", "x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1), null));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("too long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long icon (a null icon is fine)")
|
||||
void rejectsOverLongIcon() {
|
||||
ClassificationLabels set =
|
||||
labels(
|
||||
new ClassificationLabel(
|
||||
"invoice",
|
||||
"Invoice",
|
||||
"x".repeat(LabelsValidator.MAX_TEXT_LENGTH + 1)));
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(set))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("icon is too long");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects more labels than the cap")
|
||||
void rejectsTooManyLabels() {
|
||||
List<ClassificationLabel> tooMany =
|
||||
IntStream.rangeClosed(0, LabelsValidator.MAX_LABELS)
|
||||
.mapToObj(i -> label("label" + i))
|
||||
.toList();
|
||||
assertThatThrownBy(() -> LabelsValidator.validate(new ClassificationLabels(tooMany)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Too many labels");
|
||||
}
|
||||
}
|
||||
+34
-15
@@ -14,6 +14,7 @@ import static org.mockito.Mockito.when;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -26,8 +27,10 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||
import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
@@ -39,16 +42,22 @@ import tools.jackson.databind.json.JsonMapper;
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ClassifyLabelControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
private InProcessClassificationLabelStore labelStore;
|
||||
private ClassifyLabelController controller;
|
||||
|
||||
private void withLabels(List<ClassificationLabel> labels) {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
labelStore = new InProcessClassificationLabelStore();
|
||||
controller =
|
||||
new ClassifyLabelController(
|
||||
pdfDocumentFactory,
|
||||
@@ -57,8 +66,9 @@ class ClassifyLabelControllerTest {
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
ClassificationLabelProvider.withLabels(labels),
|
||||
null);
|
||||
null,
|
||||
labelStore,
|
||||
policyManagementAuthority);
|
||||
}
|
||||
|
||||
private void stubSinglePageDocument() throws Exception {
|
||||
@@ -88,7 +98,12 @@ class ClassifyLabelControllerTest {
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(new ClassificationLabel("invoice", "Invoice", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -103,12 +118,16 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
|
||||
withLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null)));
|
||||
void classifyAndLabel_sendsTeamLabelIdsAndNames() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null))),
|
||||
"admin");
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -133,13 +152,13 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
|
||||
withLabels(List.of());
|
||||
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
// No vocabulary, and the engine holds no default of its own, so the file is passed through
|
||||
// unlabelled: neither the engine nor the metadata write is invoked.
|
||||
// No team labels stored, and the engine holds no default of its own, so the file is passed
|
||||
// through unlabelled: neither the engine nor the metadata write is invoked.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), any());
|
||||
verify(pdfMetadataService, never())
|
||||
.setClassificationMetadata(any(PDDocument.class), anyString());
|
||||
|
||||
+2
-2
@@ -166,8 +166,8 @@ class PolicyExecutorTest {
|
||||
new PipelineStep(
|
||||
createPdf,
|
||||
Map.of(
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||
|
||||
class AiDocumentHtmlRendererTest {
|
||||
|
||||
private final AiDocumentHtmlRenderer renderer = new AiDocumentHtmlRenderer();
|
||||
|
||||
private static AiDocument.Section section(String type) {
|
||||
AiDocument.Section s = new AiDocument.Section();
|
||||
s.setType(type);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static AiDocument document(String title, List<AiDocument.Section> sections) {
|
||||
AiDocument doc = new AiDocument();
|
||||
doc.setTitle(title);
|
||||
doc.setSections(sections);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersAllSectionTypes() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("Some prose text.");
|
||||
AiDocument.Section kv = section("key_value");
|
||||
kv.setPairs(List.of(List.of("Key", "Value")));
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("A", "B"));
|
||||
items.setRows(List.of(List.of("1", "2")));
|
||||
AiDocument.Section bullets = section("bullet_list");
|
||||
bullets.setItems(List.of("item one"));
|
||||
AiDocument.Section sign = section("signature");
|
||||
sign.setSignatories(List.of("Alice"));
|
||||
|
||||
String html = renderer.render(document("All", List.of(text, kv, items, bullets, sign)));
|
||||
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
assertTrue(html.contains("Some prose text."));
|
||||
assertTrue(html.contains("Key") && html.contains("Value"));
|
||||
assertTrue(html.contains("<th>"));
|
||||
assertTrue(html.contains("item one"));
|
||||
assertTrue(html.contains("Alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersMarkupCharactersAsText() {
|
||||
AiDocument.Section text = section("text");
|
||||
text.setBody("a <b>x</b> & y");
|
||||
|
||||
String html = renderer.render(document("Doc", List.of(text)));
|
||||
|
||||
assertFalse(html.contains("<b>"));
|
||||
assertTrue(html.contains("<b>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowRenderedWhenPresent() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item", "Total"));
|
||||
items.setRows(List.of(List.of("Widget", "$10")));
|
||||
items.setTotalRow(List.of("Total", "$10"));
|
||||
|
||||
assertTrue(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void totalRowAbsentWhenNotProvided() {
|
||||
AiDocument.Section items = section("line_items");
|
||||
items.setColumns(List.of("Item"));
|
||||
items.setRows(List.of(List.of("Widget")));
|
||||
|
||||
assertFalse(
|
||||
renderer.render(document("Table", List.of(items)))
|
||||
.contains("<tr class=\"total-row\">"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersSubtitleAndReference() {
|
||||
AiDocument doc = document("My Doc", List.of());
|
||||
doc.setSubtitle("Subtitle Here");
|
||||
doc.setReferenceNumber("REF-42");
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("Subtitle Here"));
|
||||
assertTrue(html.contains("REF-42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesHexColourOverride() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("#ff00ff");
|
||||
style.setBackgroundColor("#111111");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertTrue(html.contains("--color-primary: #ff00ff"));
|
||||
assertTrue(html.contains("--color-bg: #111111"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresColourWithDisallowedCharacters() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("rgb(255, 0, 0)");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("rgb("));
|
||||
assertTrue(html.contains("<!DOCTYPE html>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonHexColour() {
|
||||
AiDocument doc = document("Styled", List.of());
|
||||
AiDocument.Style style = new AiDocument.Style();
|
||||
style.setPrimaryColor("magenta");
|
||||
style.setBackgroundColor("#fff");
|
||||
doc.setStyle(style);
|
||||
|
||||
String html = renderer.render(doc);
|
||||
|
||||
assertFalse(html.contains("--color-primary: magenta"));
|
||||
assertFalse(html.contains("--color-bg: #fff;"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Classification labels are now a fixed, built-in set bundled with the app and sent to the engine
|
||||
-- per request (see ClassificationLabelProvider); the per-team classification_labels table (created
|
||||
-- in V30) is no longer read or written. Drop it.
|
||||
--
|
||||
-- Forward migration: V30 is kept so any DB that already applied it still validates. This runs after
|
||||
-- V30 in every case, so it drops the table whether V30 just created it (fresh DB) or it was created
|
||||
-- and populated on an earlier deploy. IF EXISTS only guards the edge case where the table is already
|
||||
-- absent, keeping the migration safe to apply regardless of prior state.
|
||||
|
||||
DROP TABLE IF EXISTS classification_labels;
|
||||
+1
-10
@@ -36,8 +36,6 @@ ext {
|
||||
okhttpBomVersion = "5.3.2"
|
||||
gsonVersion = "2.14.0"
|
||||
guavaVersion = "33.6.0-jre"
|
||||
jinjavaVersion = "2.8.3"
|
||||
jackson2Version = "2.21.2"
|
||||
bucket4jVersion = "8.19.0"
|
||||
archunitVersion = "1.4.2"
|
||||
batikVersion = "1.19"
|
||||
@@ -93,7 +91,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.14.2'
|
||||
version = '2.14.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
|
||||
@@ -224,13 +222,6 @@ subprojects {
|
||||
resolutionStrategy.force "org.apache.commons:commons-lang3:${commonsLang3}"
|
||||
// CVE-2024-47554: commons-io DoS prevention
|
||||
resolutionStrategy.force "commons-io:commons-io:${commonsIoVersion}"
|
||||
// Jackson 2 is transitive-only here (jinjava, opensaml, jjwt request older versions);
|
||||
// pin the family to a current release and keep modules aligned.
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-core:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.core:jackson-databind:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${jackson2Version}"
|
||||
resolutionStrategy.force "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jackson2Version}"
|
||||
// Keep BouncyCastle modules aligned to avoid runtime linkage errors
|
||||
resolutionStrategy.force "org.bouncycastle:bcprov-jdk18on:${bouncycastleVersion}"
|
||||
resolutionStrategy.force "org.bouncycastle:bcpkix-jdk18on:${bouncycastleVersion}"
|
||||
|
||||
@@ -5,6 +5,7 @@ description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.116.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pgvector>=0.3.6",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pydantic>=2.0.0",
|
||||
|
||||
@@ -116,8 +116,8 @@ class DocumentClassifierAgent:
|
||||
)
|
||||
|
||||
async def classify(self, request: ClassifyDocumentRequest) -> ClassifyDocumentResponse:
|
||||
# The caller (the backend) always supplies the allowed vocabulary — its
|
||||
# fixed built-in label set — so the engine holds no vocabulary of its own.
|
||||
# The caller (the backend) always supplies the allowed vocabulary — the
|
||||
# team's stored labels — so the engine holds no vocabulary of its own.
|
||||
allowed = request.labels
|
||||
window = select_window(request.pages)
|
||||
prompt = self._build_prompt(request.file_name, allowed, window)
|
||||
|
||||
@@ -10,7 +10,7 @@ Flow:
|
||||
4. SectionWriterAgents (smart_model) run in parallel via asyncio.gather.
|
||||
Each returns a WrittenSections with fully populated DocumentSection objects.
|
||||
5. The assembler collects sections in plan order → GeneratedDocument.
|
||||
6. The assembled document is emitted as structured fields. The LLM never writes HTML.
|
||||
6. Jinja renders the document to HTML. The LLM never writes HTML.
|
||||
|
||||
The planner is split into two calls (meta then sections) so each LLM output schema
|
||||
stays small enough for grammar compilation on all model tiers including Haiku.
|
||||
@@ -22,7 +22,9 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.output import NativeOutput
|
||||
|
||||
@@ -49,6 +51,8 @@ from stirling.services import AppRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
# ── Token budget ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Conservative per-section token estimates mapped from planner-assigned depth.
|
||||
@@ -162,13 +166,10 @@ Analyse the user's request and produce a DocumentMeta with:
|
||||
document, if the user provides one. Leave empty if the user provides no such context.
|
||||
|
||||
- style_primary_color: accent and heading colour. Set ONLY when the user explicitly names a
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Express it as a 6-digit hex
|
||||
code in #RRGGBB format (map any named colour to its hex value yourself, e.g. "navy" →
|
||||
"#000080"). No other format is accepted. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
- style_body_text_color: body text colour, same #RRGGBB format. Set only if explicitly
|
||||
requested.
|
||||
colour or colour scheme (e.g. "make it red", "use navy blue"). Use CSS named colours
|
||||
(e.g. "magenta", "navy", "crimson") or hex values. Leave null if no colour is stated.
|
||||
- style_background_color: page background colour. Set only if explicitly requested.
|
||||
- style_body_text_color: body text colour. Set only if explicitly requested.
|
||||
|
||||
- cannot_do_reason: set this ONLY when the request is not asking to create a document at all
|
||||
(e.g. a question, a greeting, an edit request to an existing document). Never set it
|
||||
@@ -298,6 +299,15 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_jinja_env() -> Environment:
|
||||
return Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(title: str) -> str:
|
||||
slug = re.sub(r"[^\w\s-]", "", title.lower())
|
||||
slug = re.sub(r"[\s_-]+", "-", slug).strip("-")
|
||||
@@ -310,6 +320,7 @@ def _safe_filename(title: str) -> str:
|
||||
class PdfCreateAgent:
|
||||
def __init__(self, runtime: AppRuntime) -> None:
|
||||
self.runtime = runtime
|
||||
self._jinja_env = _build_jinja_env()
|
||||
|
||||
self._meta_planner: Agent[None, DocumentMeta] = Agent(
|
||||
model=runtime.smart_model,
|
||||
@@ -390,12 +401,14 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
filename,
|
||||
len(all_sections),
|
||||
len(html),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -404,7 +417,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
document=doc.model_dump_json(),
|
||||
html_content=html,
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -424,3 +437,7 @@ class PdfCreateAgent:
|
||||
len(result.output.sections),
|
||||
)
|
||||
return result.output
|
||||
|
||||
def _render(self, doc: GeneratedDocument) -> str:
|
||||
template = self._jinja_env.get_template("document.html.jinja2")
|
||||
return template.render(doc=doc)
|
||||
|
||||
+19
-21
@@ -1,4 +1,3 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -176,18 +175,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
{%- if doc.style %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- endif %}
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- endif %}
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -196,16 +195,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in sections %}
|
||||
{%- for section in doc.sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -213,8 +212,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -226,10 +225,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for pair in section.pairs %}
|
||||
{%- for label, value in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -300,4 +299,3 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Contracts for the PDF Create Agent.
|
||||
|
||||
The agent accepts a natural-language prompt and returns a single
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the rendered HTML.
|
||||
|
||||
Pipeline:
|
||||
1. PlannerAgent (smart_model) → DocumentPlan: structured skeleton, no body text.
|
||||
2. Python chunks the plan by token budget.
|
||||
3. SectionWriterAgents (smart_model, parallel) → WrittenSections per chunk.
|
||||
4. Assembler collects sections in plan order → GeneratedDocument.
|
||||
5. The document is emitted as structured fields. The LLM never writes HTML.
|
||||
5. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,12 +81,14 @@ type DocumentSection = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# Colours must be a 6-digit hex code (#RRGGBB); anything else is dropped to None.
|
||||
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
# Named colour or hex only — anything else is dropped so a colour can't inject CSS into the
|
||||
# <style> block (which would let WeasyPrint fetch an attacker-controlled url() → SSRF).
|
||||
_SAFE_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$|^[a-zA-Z]{1,30}$")
|
||||
|
||||
|
||||
class DocumentStyle(ApiModel):
|
||||
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
|
||||
"""Document colours, inferred by the meta planner and rendered into the engine's Jinja
|
||||
template (never sent to Java). Unsafe colours are dropped to ``None``."""
|
||||
|
||||
primary_color: str | None = Field(default=None)
|
||||
background_color: str | None = Field(default=None)
|
||||
@@ -101,7 +103,7 @@ class DocumentStyle(ApiModel):
|
||||
|
||||
|
||||
class GeneratedDocument(ApiModel):
|
||||
"""The full document model emitted for rendering."""
|
||||
"""The full document model passed to Jinja for HTML rendering."""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
|
||||
|
||||
|
||||
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||
document: str
|
||||
html_content: str
|
||||
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. orchestrate() emits the assembled document as structured JSON
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
3. _safe_filename produces clean slugs
|
||||
4. _make_chunks groups sections correctly by token budget
|
||||
5. orchestrate() produces the correct EditPlanResponse via planner + writer mocks
|
||||
@@ -12,8 +12,6 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
@@ -66,6 +64,37 @@ def agent(runtime: AppRuntime) -> PdfCreateAgent:
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _invoice_doc() -> GeneratedDocument:
|
||||
return GeneratedDocument(
|
||||
title="Invoice",
|
||||
subtitle="Acme Corp",
|
||||
reference_number="Invoice #INV-001",
|
||||
sections=[
|
||||
KeyValueSection(
|
||||
heading="Details",
|
||||
pairs=[("Date", "2026-05-06"), ("Due", "2026-06-06"), ("Currency", "USD")],
|
||||
),
|
||||
LineItemsSection(
|
||||
heading="Line Items",
|
||||
columns=["Description", "Qty", "Unit Price", "Total"],
|
||||
rows=[
|
||||
["Consulting services", "10", "$500.00", "$5,000.00"],
|
||||
["Expenses", "1", "$200.00", "$200.00"],
|
||||
],
|
||||
total_row=["Total", "", "", "$5,200.00"],
|
||||
),
|
||||
TextSection(
|
||||
heading="Payment Terms",
|
||||
body="Payment is due within 30 days.\n\nPlease reference the invoice number.",
|
||||
),
|
||||
SignatureSection(
|
||||
heading="Authorised By",
|
||||
signatories=["Jane Smith, CEO", "Bob Jones, CFO"],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _simple_meta() -> DocumentMeta:
|
||||
return DocumentMeta(
|
||||
title="Invoice",
|
||||
@@ -170,6 +199,82 @@ def test_generated_document_optional_fields() -> None:
|
||||
assert doc.reference_number is None
|
||||
|
||||
|
||||
# ── Jinja rendering ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_produces_html(agent: PdfCreateAgent) -> None:
|
||||
doc = _invoice_doc()
|
||||
html = agent._render(doc)
|
||||
assert "<!DOCTYPE html>" in html
|
||||
assert "Invoice" in html
|
||||
assert "INV-001" in html
|
||||
|
||||
|
||||
def test_render_includes_all_section_types(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="All Sections",
|
||||
sections=[
|
||||
TextSection(body="Some prose text."),
|
||||
KeyValueSection(pairs=[("Key", "Value")]),
|
||||
LineItemsSection(columns=["A", "B"], rows=[["1", "2"]]),
|
||||
BulletListSection(items=["item one"]),
|
||||
SignatureSection(signatories=["Alice"]),
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Some prose text." in html
|
||||
assert "Key" in html and "Value" in html
|
||||
assert "<th>" in html
|
||||
assert "item one" in html
|
||||
assert "Alice" in html
|
||||
|
||||
|
||||
def test_render_escapes_html_in_content(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="XSS Test",
|
||||
sections=[TextSection(body="<script>alert('xss')</script>")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_render_total_row_present(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[
|
||||
LineItemsSection(
|
||||
columns=["Item", "Total"],
|
||||
rows=[["Widget", "$10"]],
|
||||
total_row=["Total", "$10"],
|
||||
)
|
||||
],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "total-row" in html
|
||||
|
||||
|
||||
def test_render_no_total_row_skips_tfoot(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="Table",
|
||||
sections=[LineItemsSection(columns=["Item"], rows=[["Widget"]])],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "<tfoot>" not in html
|
||||
|
||||
|
||||
def test_render_subtitle_and_reference(agent: PdfCreateAgent) -> None:
|
||||
doc = GeneratedDocument(
|
||||
title="My Doc",
|
||||
subtitle="Subtitle Here",
|
||||
reference_number="REF-42",
|
||||
sections=[TextSection(body="Content.")],
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "Subtitle Here" in html
|
||||
assert "REF-42" in html
|
||||
|
||||
|
||||
# ── _safe_filename ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -291,9 +396,8 @@ async def test_orchestrate_returns_plan_step(agent: PdfCreateAgent) -> None:
|
||||
assert step.tool == AgentToolId.CREATE_PDF_FROM_HTML_AGENT
|
||||
assert isinstance(step.parameters, CreatePdfFromHtmlAgentParams)
|
||||
assert step.parameters.filename.endswith(".pdf")
|
||||
parsed = json.loads(step.parameters.document)
|
||||
assert parsed["title"] == "Invoice"
|
||||
assert parsed["sections"]
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -364,9 +468,9 @@ async def test_orchestrate_assembles_multiple_chunks(agent: PdfCreateAgent) -> N
|
||||
result = await agent.orchestrate(_orchestrator_request("Create a multi-chunk doc"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "Introduction text." in document
|
||||
assert "Details" in document
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
@@ -378,7 +482,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="#ff00ff",
|
||||
style_primary_color="magenta",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -395,35 +499,50 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
result = await agent.orchestrate(_orchestrator_request("Make an invoice, magenta styling"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
|
||||
|
||||
def test_document_style_keeps_only_six_digit_hex() -> None:
|
||||
"""Only #RRGGBB hex is kept; named colours and other formats drop to None."""
|
||||
safe = DocumentStyle(primary_color="#1e3a5f", background_color="#ffffff", body_text_color="#1A1A1A")
|
||||
def test_render_applies_style(agent: PdfCreateAgent) -> None:
|
||||
"""DocumentStyle fields are injected as CSS custom properties in the rendered HTML."""
|
||||
doc = GeneratedDocument(
|
||||
title="Styled",
|
||||
sections=[TextSection(body="Content.")],
|
||||
style=DocumentStyle(primary_color="magenta", background_color="#111111"),
|
||||
)
|
||||
html = agent._render(doc)
|
||||
assert "--color-primary: magenta" in html
|
||||
assert "--color-bg: #111111" in html
|
||||
|
||||
|
||||
def test_document_style_drops_unsafe_colors() -> None:
|
||||
"""Unsafe colours (not named/hex) are dropped, closing the <style> url() injection."""
|
||||
safe = DocumentStyle(primary_color="navy", background_color="#1e3a5f", body_text_color="#fff")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#ffffff",
|
||||
"#1A1A1A",
|
||||
"#fff",
|
||||
)
|
||||
|
||||
assert DocumentStyle(primary_color="navy").primary_color is None
|
||||
assert DocumentStyle(primary_color="#fff").primary_color is None
|
||||
assert DocumentStyle(primary_color="#1e3a5f00").primary_color is None
|
||||
assert DocumentStyle(primary_color="rgb(255, 0, 0)").primary_color is None
|
||||
assert DocumentStyle(background_color="teal darken-2").background_color is None
|
||||
unsafe = DocumentStyle(
|
||||
primary_color="red; background: url(http://evil.test/steal)",
|
||||
background_color="expression(alert(1))",
|
||||
body_text_color="navy; }",
|
||||
)
|
||||
assert unsafe.primary_color is None
|
||||
assert unsafe.background_color is None
|
||||
assert unsafe.body_text_color is None
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -> None:
|
||||
"""A non-hex colour inferred by the meta planner is dropped before the document is emitted."""
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="rgb(0, 0, 255)",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -440,6 +559,6 @@ async def test_orchestrate_drops_non_hex_planner_colour(agent: PdfCreateAgent) -
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "rgb(" not in document
|
||||
assert json.loads(document)["style"]["primaryColor"] is None
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
|
||||
Generated
+2
@@ -604,6 +604,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
@@ -630,6 +631,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||
{ name = "posthog", specifier = ">=3.0.0" },
|
||||
|
||||
@@ -3608,8 +3608,6 @@ lastSynced = "Last synced"
|
||||
leaveShare = "Remove from my list"
|
||||
leaveShareFailed = "Could not remove the shared file."
|
||||
leaveShareSuccess = "Removed from your shared list."
|
||||
loadFailed = "Couldn't load your recent files"
|
||||
loadFailedRetry = "Try again"
|
||||
loadingFiles = "Loading files..."
|
||||
localOnly = "Local only"
|
||||
makeCopy = "Make a copy"
|
||||
@@ -3657,6 +3655,7 @@ shareSelected = "Share Files"
|
||||
sharing = "Sharing"
|
||||
showAll = "Show All"
|
||||
showHistory = "Show History"
|
||||
showInFolder = "Show in folder"
|
||||
sortByDate = "Sort by Date"
|
||||
sortByName = "Sort by Name"
|
||||
sortBySize = "Sort by Size"
|
||||
@@ -3736,9 +3735,20 @@ uploadToServer = "Upload to server"
|
||||
versionHistory = "Version history"
|
||||
|
||||
[fileSidebar.groupsModal]
|
||||
add = "Add"
|
||||
addLabel = "Add a label…"
|
||||
categoryIconAria = "Choose an icon for {{name}}"
|
||||
createCategory = "Create category"
|
||||
delete = "Delete category"
|
||||
done = "Done"
|
||||
reset = "Show all"
|
||||
subtitle = "Show or hide categories in the files sidebar."
|
||||
hide = "Hide category"
|
||||
newCategory = "New category name…"
|
||||
removeLabel = "Remove {{name}}"
|
||||
rename = "Rename"
|
||||
reset = "Reset to defaults"
|
||||
search = "Search labels…"
|
||||
show = "Show category"
|
||||
subtitle = "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”."
|
||||
title = "Sidebar categories"
|
||||
|
||||
[filesPage]
|
||||
@@ -6005,11 +6015,31 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
|
||||
summaryTwo = "{{first}} and {{second}}"
|
||||
|
||||
[policies.labels]
|
||||
categoryCount = "categories"
|
||||
hideCategory = "Hide category"
|
||||
labelCount = "labels"
|
||||
sharedNote = "These labels are built in and shared across your whole team."
|
||||
showCategory = "Show category"
|
||||
add = "Add"
|
||||
addPlaceholder = "Add a label…"
|
||||
customNote = "Customized for your team."
|
||||
defaultNote = "Using the built-in default, shared with your team."
|
||||
duplicate = "\"{{name}}\" already exists."
|
||||
edit = "Edit labels"
|
||||
empty = "No labels yet."
|
||||
export = "Export JSON"
|
||||
iconAria = "Choose an icon for {{name}}"
|
||||
import = "Import JSON"
|
||||
importError = "Couldn't import that file."
|
||||
managedNote = "Team labels are managed by your team leader."
|
||||
modalSubtitle = "Shared with your whole team. The classifier picks the labels that fit each document."
|
||||
modalTitle = "Classification labels"
|
||||
pickIcon = "Use {{label}} icon"
|
||||
removeAria = "Remove {{name}}"
|
||||
resetToDefault = "Reset to default"
|
||||
saveForTeam = "Save for team"
|
||||
saving = "Saving…"
|
||||
sectionLabel = "Classification labels"
|
||||
startFromScratch = "Start from scratch"
|
||||
teamCount = "team labels"
|
||||
tooLong = "Labels can be at most {{max}} characters."
|
||||
ungrouped = "Ungrouped"
|
||||
view = "View labels"
|
||||
|
||||
[policies.pii]
|
||||
account = "Account numbers (labelled)"
|
||||
@@ -7422,11 +7452,6 @@ title = "Policies"
|
||||
[portal.policies.card]
|
||||
comingSoon = "Upgrade to Enterprise"
|
||||
notSetUp = "Not set up"
|
||||
requiresAiEngine = "Requires AI engine"
|
||||
|
||||
[portal.policies.categories.classification]
|
||||
desc = "Identify each document's type on upload and tag its metadata for filing and search."
|
||||
label = "Classification"
|
||||
|
||||
[portal.policies.categories.compliance]
|
||||
desc = "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document."
|
||||
@@ -7451,13 +7476,6 @@ label = "Security"
|
||||
[portal.policies.config]
|
||||
scopeAll = "All documents"
|
||||
|
||||
[portal.policies.config.classification]
|
||||
summary = "Classifies every uploaded document against your team's labels and tags its metadata."
|
||||
|
||||
[portal.policies.config.classification.rules]
|
||||
0 = "Classify"
|
||||
1 = "Tag metadata"
|
||||
|
||||
[portal.policies.config.compliance]
|
||||
summary = "Validates documents against regulatory frameworks before they leave the system."
|
||||
|
||||
@@ -7552,7 +7570,6 @@ title = "No activity yet"
|
||||
[portal.policies.endpoints]
|
||||
addWatermark = "Watermark"
|
||||
autoRedact = "Redact PII"
|
||||
classifyAndLabel = "Classify"
|
||||
compressPdf = "Compress"
|
||||
flatten = "Flatten"
|
||||
ocrPdf = "OCR"
|
||||
@@ -7595,10 +7612,6 @@ continue = "Continue"
|
||||
enablePolicy = "Enable policy"
|
||||
saveChanges = "Save changes"
|
||||
|
||||
[portal.policies.wizard.capability.classify]
|
||||
desc = "Identifies the document's type from your team's labels and tags it, so it files and searches by category."
|
||||
label = "Classify the document"
|
||||
|
||||
[portal.policies.wizard.capability.compress]
|
||||
desc = "Compresses the document to a smaller file size."
|
||||
label = "Reduce file size"
|
||||
@@ -7623,10 +7636,6 @@ label = "Strip active content"
|
||||
desc = "Stamps a visible mark (e.g. “Confidential”) across every page."
|
||||
label = "Apply a watermark"
|
||||
|
||||
[portal.policies.wizard.classification]
|
||||
description = "Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team."
|
||||
labelsHeading = "Classification labels"
|
||||
|
||||
[portal.policies.wizard.errors]
|
||||
noTools = "Enable at least one tool in the workflow first."
|
||||
saveFailed = "Couldn't save the policy. Please try again."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::utils::add_log;
|
||||
use std::sync::Mutex;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
// Store the opened file paths globally (supports multiple files)
|
||||
static OPENED_FILES: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
@@ -42,3 +43,41 @@ pub async fn pop_opened_files() -> Result<Vec<String>, String> {
|
||||
add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
|
||||
Ok(all_files)
|
||||
}
|
||||
|
||||
// Report whether a path still exists on disk (used to prune stale recent files).
|
||||
#[tauri::command]
|
||||
pub fn path_exists(path: String) -> bool {
|
||||
std::path::Path::new(&path).exists()
|
||||
}
|
||||
|
||||
// Reveal a file in the OS file manager (Explorer/Finder), highlighting it.
|
||||
#[tauri::command]
|
||||
pub async fn reveal_in_file_manager(app: tauri::AppHandle, path: String) -> Result<(), String> {
|
||||
app.opener()
|
||||
.reveal_item_in_dir(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn path_exists_tracks_real_disk_state() {
|
||||
let file = std::env::temp_dir()
|
||||
.join(format!("stirling_path_exists_{}.tmp", std::process::id()));
|
||||
let path = file.to_string_lossy().to_string();
|
||||
|
||||
// Absent before creation.
|
||||
assert!(!path_exists(path.clone()));
|
||||
|
||||
// Present once written.
|
||||
writeln!(std::fs::File::create(&file).unwrap(), "x").unwrap();
|
||||
assert!(path_exists(path.clone()));
|
||||
|
||||
// Gone again after deletion (the case that prunes a recent).
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert!(!path_exists(path));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ pub mod updater;
|
||||
pub mod window;
|
||||
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
|
||||
pub use files::{
|
||||
add_opened_file, clear_opened_files, get_opened_files, path_exists, pop_opened_files,
|
||||
reveal_in_file_manager,
|
||||
};
|
||||
pub use window::{
|
||||
forward_files_to_window,
|
||||
open_files_in_new_window,
|
||||
|
||||
@@ -19,8 +19,10 @@ use commands::{
|
||||
get_opened_files,
|
||||
open_files_in_new_window,
|
||||
open_in_new_window,
|
||||
path_exists,
|
||||
pop_opened_files,
|
||||
pop_window_file_ids,
|
||||
reveal_in_file_manager,
|
||||
get_refresh_token,
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
@@ -167,6 +169,8 @@ pub fn run() {
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
clear_opened_files,
|
||||
path_exists,
|
||||
reveal_in_file_manager,
|
||||
open_in_new_window,
|
||||
open_files_in_new_window,
|
||||
pop_window_file_ids,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling PDF",
|
||||
"mainBinaryName": "Stirling-PDF",
|
||||
"version": "2.14.2",
|
||||
"version": "2.14.1",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -42,8 +42,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading, loadError } =
|
||||
useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
// Get active file IDs from FileContext to show which files are already loaded
|
||||
@@ -241,7 +240,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
loadError={loadError}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Mock i18n so assertions can rely on the English default strings.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Isolate FileListArea's branching logic from its heavy children — we only
|
||||
// care which state it renders, not how each child looks.
|
||||
vi.mock("@app/components/fileManager/EmptyFilesState", () => ({
|
||||
default: () => <div>No recent files</div>,
|
||||
}));
|
||||
vi.mock("@app/components/fileManager/FileListItem", () => ({
|
||||
default: ({ file }: { file: { name: string } }) => <div>{file.name}</div>,
|
||||
}));
|
||||
vi.mock("@app/components/fileManager/FileHistoryGroup", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@app/ui/Button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => <button onClick={onClick}>{children}</button>,
|
||||
}));
|
||||
|
||||
const contextValue = vi.hoisted(() => ({
|
||||
current: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock("@app/contexts/FileManagerContext", () => ({
|
||||
useFileManagerContext: () => contextValue.current,
|
||||
}));
|
||||
|
||||
import FileListArea from "@app/components/fileManager/FileListArea";
|
||||
|
||||
function baseContext(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
activeSource: "recent",
|
||||
recentFiles: [],
|
||||
filteredFiles: [],
|
||||
selectedFilesSet: new Set(),
|
||||
expandedFileIds: new Set(),
|
||||
loadedHistoryFiles: new Map(),
|
||||
onFileSelect: vi.fn(),
|
||||
onFileRemove: vi.fn(),
|
||||
onHistoryFileRemove: vi.fn(),
|
||||
onFileDoubleClick: vi.fn(),
|
||||
onDownloadSingle: vi.fn(),
|
||||
isLoading: false,
|
||||
loadError: false,
|
||||
activeFileIds: [],
|
||||
refreshRecentFiles: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FileListArea recent-files states", () => {
|
||||
beforeEach(() => {
|
||||
contextValue.current = baseContext({});
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no files and no error", () => {
|
||||
contextValue.current = baseContext({ isLoading: false, loadError: false });
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("No recent files")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Try again")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loading state while loading with no files yet", () => {
|
||||
contextValue.current = baseContext({ isLoading: true });
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("Loading files...")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error state with a retry action when the load fails (not the empty state)", () => {
|
||||
const refreshRecentFiles = vi.fn();
|
||||
contextValue.current = baseContext({
|
||||
isLoading: false,
|
||||
loadError: true,
|
||||
refreshRecentFiles,
|
||||
});
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
// The failed load must be visually distinct from a genuinely empty history.
|
||||
expect(
|
||||
screen.getByText("Couldn't load your recent files"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
|
||||
|
||||
const retry = screen.getByText("Try again");
|
||||
fireEvent.click(retry);
|
||||
expect(refreshRecentFiles).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the file list when files are present even if a partial load error occurred", () => {
|
||||
contextValue.current = baseContext({
|
||||
recentFiles: [{ id: "a" }],
|
||||
filteredFiles: [{ id: "a", name: "doc.pdf" }],
|
||||
loadError: true,
|
||||
});
|
||||
render(
|
||||
<MantineProvider>
|
||||
<FileListArea scrollAreaHeight="200px" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("doc.pdf")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Couldn't load your recent files"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No recent files")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import React from "react";
|
||||
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
|
||||
import CloudIcon from "@mui/icons-material/Cloud";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileListItem from "@app/components/fileManager/FileListItem";
|
||||
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
|
||||
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -31,9 +29,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
refreshRecentFiles,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -48,7 +44,15 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length > 0 ? (
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("fileManager.loadingFiles", "Loading files...")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
@@ -80,39 +84,6 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
) : isLoading ? (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("fileManager.loadingFiles", "Loading files...")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : loadError ? (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<ErrorOutlineIcon
|
||||
style={{
|
||||
fontSize: "3rem",
|
||||
color: "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
/>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t(
|
||||
"fileManager.loadFailed",
|
||||
"Couldn't load your recent files",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void refreshRecentFiles();
|
||||
}}
|
||||
>
|
||||
{t("fileManager.loadFailedRetry", "Try again")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<EmptyFilesState />
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -19,6 +19,7 @@ import CloseIcon from "@mui/icons-material/Close";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import CloudDoneIcon from "@mui/icons-material/CloudDone";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
|
||||
import { FileId, StirlingFileStub } from "@app/types/fileContext";
|
||||
@@ -35,6 +36,10 @@ import ShareManagementModal from "@app/components/shared/ShareManagementModal";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { absoluteWithBasePath } from "@app/constants/app";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
desktopFileLinkingSupported,
|
||||
revealPathInFileManager,
|
||||
} from "@app/services/desktopFileLink";
|
||||
|
||||
interface FileListItemProps {
|
||||
file: StirlingFileStub;
|
||||
@@ -368,6 +373,18 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{desktopFileLinkingSupported && file.localFilePath && (
|
||||
<Menu.Item
|
||||
leftSection={<FolderOpenIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void revealPathInFileManager(file.localFilePath!);
|
||||
}}
|
||||
>
|
||||
{t("fileManager.showInFolder", "Show in folder")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{canUpload && (
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon style={{ fontSize: 16 }} />}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// Shared source of truth for a policy category's outline icon, keyed by category
|
||||
// id (not a parallel icon-name vocabulary). Used by the editor's policy
|
||||
// definitions and the portal's catalogue cards, summaries, and setup wizard.
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { SxProps, Theme } from "@mui/material";
|
||||
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined";
|
||||
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
|
||||
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
|
||||
|
||||
type MuiIcon = React.ComponentType<{ sx?: SxProps<Theme>; className?: string }>;
|
||||
|
||||
/** Policy category id → outline glyph. */
|
||||
const POLICY_CATEGORY_ICONS: Record<string, MuiIcon> = {
|
||||
ingestion: LayersOutlinedIcon,
|
||||
security: ShieldOutlinedIcon,
|
||||
classification: LabelOutlinedIcon,
|
||||
compliance: CheckCircleOutlinedIcon,
|
||||
routing: AltRouteOutlinedIcon,
|
||||
retention: ScheduleOutlinedIcon,
|
||||
};
|
||||
|
||||
const FALLBACK_ICON = LabelOutlinedIcon;
|
||||
|
||||
// Defaults to inheriting the surrounding font-size so a wrapping box controls size.
|
||||
export function policyCategoryIcon(
|
||||
categoryId: string,
|
||||
sx: SxProps<Theme> = { fontSize: "inherit" },
|
||||
className?: string,
|
||||
): ReactNode {
|
||||
const Icon = POLICY_CATEGORY_ICONS[categoryId] ?? FALLBACK_ICON;
|
||||
return <Icon sx={sx} className={className} />;
|
||||
}
|
||||
@@ -42,7 +42,6 @@ interface FileManagerContextValue {
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
isLoading: boolean;
|
||||
loadError: boolean;
|
||||
activeFileIds: FileId[];
|
||||
|
||||
// Handlers
|
||||
@@ -97,7 +96,6 @@ interface FileManagerProviderProps {
|
||||
modalHeight: string;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
loadError: boolean;
|
||||
activeFileIds: FileId[];
|
||||
maxSelectable?: number | null;
|
||||
}
|
||||
@@ -117,7 +115,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
modalHeight,
|
||||
refreshRecentFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
maxSelectable = null,
|
||||
}) => {
|
||||
@@ -1092,7 +1089,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
|
||||
// Handlers
|
||||
@@ -1135,7 +1131,6 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
handleSourceChange,
|
||||
handleStorageFilterChange,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
deserializeToolStep,
|
||||
getExecutableTools,
|
||||
serializeStepFromEndpoint,
|
||||
serializeToolStep,
|
||||
stepRequiresUpload,
|
||||
type WorkingToolStep,
|
||||
@@ -198,6 +199,41 @@ describe("serialize/deserialize round-trip", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeStepFromEndpoint", () => {
|
||||
test("maps a wizard step's UI params to the backend contract, filling defaults", () => {
|
||||
// The shape the policy setup wizard holds: an endpoint plus UI-shaped params
|
||||
// (redact's `wordsToRedact`), with several fields left to their defaults.
|
||||
const api = serializeStepFromEndpoint(
|
||||
"/api/v1/security/auto-redact",
|
||||
{ mode: "automatic", useRegex: true, wordsToRedact: ["ssn", "card"] },
|
||||
dynamicRegistry,
|
||||
);
|
||||
|
||||
expect(api.operation).toBe("/api/v1/security/auto-redact");
|
||||
// wordsToRedact -> listOfText (the field the backend actually reads), and the
|
||||
// frontend-only `mode` is dropped.
|
||||
expect(api.parameters).toMatchObject({ listOfText: "ssn\ncard" });
|
||||
expect(api.parameters).not.toHaveProperty("wordsToRedact");
|
||||
expect(api.parameters).not.toHaveProperty("mode");
|
||||
// Fields the wizard never set still get their defaults so the body is complete.
|
||||
expect(api.parameters).toHaveProperty("wholeWordSearch");
|
||||
expect(api.parameters).toHaveProperty("customPadding");
|
||||
});
|
||||
|
||||
test("passes an unmapped endpoint's params through unchanged", () => {
|
||||
expect(
|
||||
serializeStepFromEndpoint(
|
||||
"/api/v1/unknown/thing",
|
||||
{ keep: true },
|
||||
dynamicRegistry,
|
||||
),
|
||||
).toEqual({
|
||||
operation: "/api/v1/unknown/thing",
|
||||
parameters: { keep: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepRequiresUpload", () => {
|
||||
const step = (params: Record<string, unknown>): WorkingToolStep => ({
|
||||
toolId: "compress" as ToolId,
|
||||
|
||||
@@ -197,6 +197,31 @@ export function serializeToolStep(
|
||||
return { operation, parameters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a step held as an endpoint path plus frontend-shaped params - the form the policy setup
|
||||
* wizard keeps, where params match the tool's UI shape (e.g. redact's `wordsToRedact`) rather than
|
||||
* the backend contract - into the backend step contract, mapping params through the tool's
|
||||
* `toApiParams` (merged over its defaults, so fields the wizard never set still get their defaults).
|
||||
* The endpoint maps to a tool by path, so this works for dynamic-endpoint tools whose config
|
||||
* endpoint is a function. Endpoints that map to no known tool pass through unchanged.
|
||||
*/
|
||||
export function serializeStepFromEndpoint(
|
||||
operation: string,
|
||||
params: ErasedToolParams,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolApiStep {
|
||||
const match = findToolByEndpoint({ operation, parameters: params }, registry);
|
||||
const config = match?.[1].operationConfig;
|
||||
if (!config) return { operation, parameters: params };
|
||||
const merged = { ...(config.defaultParameters ?? {}), ...params };
|
||||
return {
|
||||
operation: resolveEndpoint(config, merged) ?? operation,
|
||||
parameters: config.toApiParams
|
||||
? (config.toApiParams(merged) as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the registry tool for a stored step's endpoint: exact match for static endpoints, else
|
||||
* membership in a dynamic tool's declared `endpoints` set (replaying its function can't recover a
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describeToolOperation } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
|
||||
interface Params {
|
||||
a: number;
|
||||
}
|
||||
|
||||
// A minimal config that type-checks against the flatten endpoint's model.
|
||||
const CONFIG = {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 } satisfies Params,
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
fromApiParams: (api: { renderDpi?: number }) => ({ a: api.renderDpi ?? 0 }),
|
||||
};
|
||||
|
||||
describe("describeToolOperation", () => {
|
||||
test("wraps the config's mappers and endpoint into a descriptor", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.endpoint).toBe("/api/v1/misc/flatten");
|
||||
expect(d.toApi({ a: 200 })).toEqual({ renderDpi: 200 });
|
||||
});
|
||||
|
||||
test("fromApi merges the mapped values over the defaults", () => {
|
||||
const d = describeToolOperation("/api/v1/misc/flatten", CONFIG);
|
||||
expect(d.fromApi({ renderDpi: 72 })).toEqual({ a: 72 });
|
||||
});
|
||||
|
||||
test("throws when the config lacks a mapper", () => {
|
||||
expect(() =>
|
||||
describeToolOperation("/api/v1/misc/flatten", {
|
||||
endpoint: "/api/v1/misc/flatten" as const,
|
||||
defaultParameters: { a: 1 },
|
||||
toApiParams: (p: Params) => ({ renderDpi: p.a }),
|
||||
}),
|
||||
).toThrow(/mappers/);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Typed wrapper over a tool's `toApiParams`/`fromApiParams` mappers, binding one endpoint to safe
|
||||
* frontend<->backend parameter conversion.
|
||||
*/
|
||||
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
|
||||
export interface ToolOperationDescriptor<E extends ToolEndpoint, TParams> {
|
||||
readonly endpoint: E;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): ToolApiParams[E];
|
||||
/** Backend model -> full frontend params (defaults merged under the mapped values). */
|
||||
fromApi(api: ToolApiParams[E]): TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural subset of a tool's config. `CE` is the config's declared endpoint type, inferred from
|
||||
* the `endpoint` field: the literal for static tools, or the whole `ToolEndpoint` union for
|
||||
* dynamic-endpoint tools (whose endpoint is a function typed against the union).
|
||||
*/
|
||||
export interface BidirectionalToolConfig<TParams, CE extends ToolEndpoint> {
|
||||
endpoint: CE | null | ((params: TParams) => CE | null);
|
||||
defaultParameters?: TParams;
|
||||
toApiParams?(params: TParams): ToolApiParams[CE];
|
||||
fromApiParams?(api: ToolApiParams[CE]): Partial<TParams>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a config to `endpoint` (passed explicitly, since dynamic-endpoint tools declare `endpoint` as
|
||||
* a function). `E extends CE` rejects pairing a static tool's config with the wrong endpoint, while
|
||||
* allowing a dynamic tool whose `CE` is the full union. Throws when mappers or defaults are missing.
|
||||
*/
|
||||
export function describeToolOperation<
|
||||
E extends CE,
|
||||
CE extends ToolEndpoint,
|
||||
TParams,
|
||||
>(
|
||||
endpoint: E,
|
||||
config: BidirectionalToolConfig<TParams, CE>,
|
||||
): ToolOperationDescriptor<E, TParams> {
|
||||
const { toApiParams, fromApiParams, defaultParameters } = config;
|
||||
if (!toApiParams || !fromApiParams || defaultParameters === undefined) {
|
||||
throw new Error(
|
||||
`describeToolOperation: "${endpoint}" is missing mappers or defaults`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
endpoint,
|
||||
defaultParameters,
|
||||
// A dynamic tool's mapper is typed against the union; narrow to this endpoint (sound - the
|
||||
// runtime mapper produces this endpoint's model).
|
||||
toApi: (params) => toApiParams(params) as ToolApiParams[E],
|
||||
fromApi: (api) =>
|
||||
({
|
||||
...defaultParameters,
|
||||
...fromApiParams(api as ToolApiParams[CE]),
|
||||
}) as TParams,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
|
||||
import { FileId } from "@app/types/fileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { captureException } from "@app/services/analytics";
|
||||
import {
|
||||
pruneMissingRecentFiles,
|
||||
reconcileServerBackedRecents,
|
||||
hasLocalRecord,
|
||||
} from "@app/services/pruneMissingRecentFiles";
|
||||
import { desktopFileLinkingSupported } from "@app/services/desktopFileLink";
|
||||
|
||||
interface StoredFileResponse {
|
||||
id: number;
|
||||
@@ -34,7 +39,6 @@ interface AccessedShareLinkResponse {
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
@@ -101,18 +105,16 @@ export const useFileManager = () => {
|
||||
|
||||
const loadRecentFiles = useCallback(async (): Promise<StirlingFileStub[]> => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
// Track whether any part of the load failed so callers can distinguish a
|
||||
// failed/partial load from a genuinely empty history instead of silently
|
||||
// falling through to an empty list.
|
||||
let encounteredError = false;
|
||||
try {
|
||||
if (!indexedDB) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Load only leaf files metadata (processed files that haven't been used as input for other tools)
|
||||
const stirlingFileStubs = await fileStorage.getLeafStirlingFileStubs();
|
||||
// On desktop, drop entries whose linked file no longer exists on disk.
|
||||
const stirlingFileStubs = await pruneMissingRecentFiles(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
);
|
||||
const remoteIdSet = new Set(
|
||||
stirlingFileStubs
|
||||
.map((stub) => stub.remoteStorageId)
|
||||
@@ -129,10 +131,6 @@ export const useFileManager = () => {
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
// Metadata listing, not a file transfer — bound the wait so a
|
||||
// stalled connection surfaces as an error/retry instead of an
|
||||
// indefinite "Loading files..." spinner.
|
||||
timeout: 30000,
|
||||
} as any,
|
||||
);
|
||||
const serverFiles = Array.isArray(response.data) ? response.data : [];
|
||||
@@ -144,7 +142,21 @@ export const useFileManager = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const updatedLocalStubs = stirlingFileStubs.map((stub) => {
|
||||
// Desktop: a server-uploaded recent whose local disk original was
|
||||
// deleted drops its local copy, becoming a pure server file — it
|
||||
// leaves recents but stays under My Files, downloadable. Gated on the
|
||||
// server confirming it holds the file, so the last copy is never lost.
|
||||
let localStubs = stirlingFileStubs;
|
||||
const demotedIds = await reconcileServerBackedRecents(
|
||||
stirlingFileStubs,
|
||||
(id) => serverMap.has(id),
|
||||
);
|
||||
if (demotedIds.length > 0) {
|
||||
const removedSet = new Set(demotedIds);
|
||||
localStubs = stirlingFileStubs.filter((s) => !removedSet.has(s.id));
|
||||
}
|
||||
|
||||
const updatedLocalStubs = localStubs.map((stub) => {
|
||||
if (!stub.remoteStorageId) {
|
||||
return stub;
|
||||
}
|
||||
@@ -253,8 +265,6 @@ export const useFileManager = () => {
|
||||
|
||||
combinedStubs = [...updatedLocalStubs, ...serverStubs];
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles.serverFiles" });
|
||||
console.warn("Failed to load server files:", error);
|
||||
}
|
||||
|
||||
@@ -265,8 +275,6 @@ export const useFileManager = () => {
|
||||
>("/api/v1/storage/share-links/accessed", {
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
// Bound the wait — see server-files fetch above.
|
||||
timeout: 30000,
|
||||
} as any);
|
||||
const sharedLinks = Array.isArray(sharedResponse.data)
|
||||
? sharedResponse.data
|
||||
@@ -361,26 +369,28 @@ export const useFileManager = () => {
|
||||
|
||||
combinedStubs = [...combinedStubs, ...sharedStubs];
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles.shareLinks" });
|
||||
console.warn("Failed to load shared links:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop recents show only files with a local IndexedDB record; files
|
||||
// that live ONLY on the server (ephemeral server-/shared- stubs, no IDB
|
||||
// row) belong in the My Files view, not here.
|
||||
const recentStubs = desktopFileLinkingSupported
|
||||
? combinedStubs.filter(hasLocalRecord)
|
||||
: combinedStubs;
|
||||
|
||||
// For now, only regular files - drafts will be handled separately in the future
|
||||
const sortedFiles = combinedStubs.sort(
|
||||
const sortedFiles = recentStubs.sort(
|
||||
(a, b) => (b.lastModified || 0) - (a.lastModified || 0),
|
||||
);
|
||||
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles" });
|
||||
console.error("Failed to load recent files:", error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadError(encounteredError);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
@@ -525,7 +535,6 @@ export const useFileManager = () => {
|
||||
|
||||
return {
|
||||
loading,
|
||||
loadError,
|
||||
convertToFile,
|
||||
loadRecentFiles,
|
||||
handleRemoveFile,
|
||||
|
||||
@@ -38,21 +38,3 @@ export function trackEditorOperation(toolId: string, fileCount: number): void {
|
||||
if (DEV) console.warn("[analytics] trackEditorOperation failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught error to PostHog so otherwise-swallowed failures become
|
||||
* visible as `$exception` events instead of vanishing into a console.warn.
|
||||
*/
|
||||
export function captureException(
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
try {
|
||||
if (!canCapture()) return;
|
||||
const normalized =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
posthog.captureException(normalized, context);
|
||||
} catch (captureError) {
|
||||
if (DEV) console.warn("[analytics] captureException failed", captureError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Seam for linking recent files to their real file on disk (desktop only).
|
||||
// Non-desktop builds resolve to this no-op default via the @app alias order,
|
||||
// so web/SaaS have no disk-linked recents to prune or reveal.
|
||||
|
||||
// False on web: the recent-file pruner and "Show in folder" action are skipped.
|
||||
export const desktopFileLinkingSupported = false;
|
||||
|
||||
// Assume present so the pruner never drops a recent file off the desktop app.
|
||||
export async function pathExistsOnDisk(_path: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No OS file manager to reveal into outside the desktop app.
|
||||
export async function revealPathInFileManager(_path: string): Promise<void> {}
|
||||
@@ -29,6 +29,7 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata {
|
||||
thumbnail?: string;
|
||||
thumbnailStoredAt?: number; // Epoch ms - sliding 30-day TTL
|
||||
url?: string; // For compatibility with existing components
|
||||
localFilePath?: string; // Original disk path (desktop) - persisted so recents stay disk-linked across reloads
|
||||
// Cached classification labels — mirrors the stub field so the sidebar can
|
||||
// group by label without re-reading PDF bytes, and it survives versioning.
|
||||
// See StirlingFileStub.classificationLabels.
|
||||
@@ -132,6 +133,7 @@ class FileStorageService {
|
||||
data: arrayBuffer,
|
||||
thumbnail: stub.thumbnailUrl,
|
||||
thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined,
|
||||
localFilePath: stub.localFilePath,
|
||||
isLeaf: stub.isLeaf ?? true,
|
||||
remoteStorageId: stub.remoteStorageId,
|
||||
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
|
||||
@@ -262,6 +264,7 @@ class FileStorageService {
|
||||
lastModified: record.lastModified,
|
||||
quickKey: record.quickKey,
|
||||
thumbnailUrl: fresh ? record.thumbnail : undefined,
|
||||
localFilePath: record.localFilePath,
|
||||
isLeaf: record.isLeaf,
|
||||
remoteStorageId: record.remoteStorageId,
|
||||
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
|
||||
@@ -322,6 +325,7 @@ class FileStorageService {
|
||||
lastModified: record.lastModified,
|
||||
quickKey: record.quickKey,
|
||||
thumbnailUrl: fresh ? record.thumbnail : undefined,
|
||||
localFilePath: record.localFilePath,
|
||||
isLeaf: record.isLeaf,
|
||||
remoteStorageId: record.remoteStorageId,
|
||||
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
|
||||
@@ -414,6 +418,7 @@ class FileStorageService {
|
||||
lastModified: record.lastModified,
|
||||
quickKey: record.quickKey,
|
||||
thumbnailUrl: fresh ? record.thumbnail : undefined,
|
||||
localFilePath: record.localFilePath,
|
||||
isLeaf: record.isLeaf,
|
||||
remoteStorageId: record.remoteStorageId,
|
||||
remoteStorageUpdatedAt: record.remoteStorageUpdatedAt,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
// Controllable seam + storage doubles.
|
||||
const desktopFileLink = vi.hoisted(() => ({
|
||||
desktopFileLinkingSupported: true,
|
||||
pathExistsOnDisk: vi.fn<(path: string) => Promise<boolean>>(),
|
||||
}));
|
||||
const deleteMultipleStirlingFiles = vi.hoisted(() =>
|
||||
vi.fn<(ids: FileId[]) => Promise<void>>(),
|
||||
);
|
||||
const updateFileMetadata = vi.hoisted(() =>
|
||||
vi.fn<(id: FileId, updates: object) => Promise<boolean>>(),
|
||||
);
|
||||
|
||||
vi.mock("@app/services/desktopFileLink", () => desktopFileLink);
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { deleteMultipleStirlingFiles, updateFileMetadata },
|
||||
}));
|
||||
|
||||
import {
|
||||
pruneMissingRecentFiles,
|
||||
reconcileServerBackedRecents,
|
||||
hasLocalRecord,
|
||||
} from "@app/services/pruneMissingRecentFiles";
|
||||
|
||||
const stub = (overrides: Partial<StirlingFileStub> = {}): StirlingFileStub => ({
|
||||
id: "file-1" as FileId,
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: 0,
|
||||
isLeaf: true,
|
||||
originalFileId: "file-1",
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
desktopFileLink.desktopFileLinkingSupported = true;
|
||||
desktopFileLink.pathExistsOnDisk.mockReset().mockResolvedValue(true);
|
||||
deleteMultipleStirlingFiles.mockReset().mockResolvedValue(undefined);
|
||||
updateFileMetadata.mockReset().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("pruneMissingRecentFiles (local-only files)", () => {
|
||||
test("deletes a pristine local-only file whose disk file is gone", async () => {
|
||||
const gone = stub({ id: "gone" as FileId, localFilePath: "/tmp/gone.pdf" });
|
||||
const kept = stub({ id: "kept" as FileId, localFilePath: "/tmp/kept.pdf" });
|
||||
desktopFileLink.pathExistsOnDisk.mockImplementation(
|
||||
async (p) => p === "/tmp/kept.pdf",
|
||||
);
|
||||
|
||||
const result = await pruneMissingRecentFiles([gone, kept]);
|
||||
|
||||
expect(result.map((s) => s.id)).toEqual(["kept"]);
|
||||
expect(deleteMultipleStirlingFiles).toHaveBeenCalledWith(["gone"]);
|
||||
expect(updateFileMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps in-app-edited local files but detaches the dead link", async () => {
|
||||
const dirty = stub({
|
||||
id: "dirty" as FileId,
|
||||
localFilePath: "/tmp/dirty.pdf",
|
||||
isDirty: true,
|
||||
});
|
||||
const edited = stub({
|
||||
id: "edited" as FileId,
|
||||
localFilePath: "/tmp/edited.pdf",
|
||||
versionNumber: 2,
|
||||
toolHistory: [{ toolId: "compress" as never, timestamp: 1 }],
|
||||
});
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(false);
|
||||
|
||||
const result = await pruneMissingRecentFiles([dirty, edited]);
|
||||
|
||||
expect(result.map((s) => s.id).sort()).toEqual(["dirty", "edited"]);
|
||||
expect(result.every((s) => s.localFilePath === undefined)).toBe(true);
|
||||
expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled();
|
||||
expect(updateFileMetadata).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("leaves server-backed files untouched (handled after the server fetch)", async () => {
|
||||
const remote = stub({
|
||||
id: "remote" as FileId,
|
||||
localFilePath: "/tmp/remote.pdf",
|
||||
remoteStorageId: 7,
|
||||
});
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(false);
|
||||
|
||||
const result = await pruneMissingRecentFiles([remote]);
|
||||
|
||||
expect(result).toEqual([remote]);
|
||||
expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled();
|
||||
expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled();
|
||||
expect(updateFileMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ignores files with no disk link (e.g. web/browser uploads)", async () => {
|
||||
const memoryOnly = stub({ id: "mem" as FileId });
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(false);
|
||||
|
||||
const result = await pruneMissingRecentFiles([memoryOnly]);
|
||||
|
||||
expect(result).toEqual([memoryOnly]);
|
||||
expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("is a no-op when desktop file linking is unsupported (web)", async () => {
|
||||
desktopFileLink.desktopFileLinkingSupported = false;
|
||||
const gone = stub({ id: "gone" as FileId, localFilePath: "/tmp/gone.pdf" });
|
||||
|
||||
const result = await pruneMissingRecentFiles([gone]);
|
||||
|
||||
expect(result).toEqual([gone]);
|
||||
expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasLocalRecord (desktop recents = local/IndexedDB only)", () => {
|
||||
test("keeps files with a real local IndexedDB id", () => {
|
||||
expect(hasLocalRecord(stub({ id: "uuid-abc" as FileId }))).toBe(true);
|
||||
// A locally-stored file that is ALSO uploaded still has a local record.
|
||||
expect(
|
||||
hasLocalRecord(stub({ id: "uuid-abc" as FileId, remoteStorageId: 5 })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("excludes ephemeral server-only and shared-only stubs", () => {
|
||||
expect(hasLocalRecord(stub({ id: "server-42" as FileId }))).toBe(false);
|
||||
expect(hasLocalRecord(stub({ id: "shared-tok" as FileId }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileServerBackedRecents (server-uploaded files)", () => {
|
||||
const serverHas = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
|
||||
test("demotes a server-confirmed file whose disk original is gone", async () => {
|
||||
const s = stub({
|
||||
id: "s1" as FileId,
|
||||
localFilePath: "/tmp/s1.pdf",
|
||||
remoteStorageId: 42,
|
||||
});
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(false);
|
||||
|
||||
const removed = await reconcileServerBackedRecents([s], serverHas([42]));
|
||||
|
||||
expect(removed).toEqual(["s1"]);
|
||||
// The local copy is removed; the file lives on as a pure server file.
|
||||
expect(deleteMultipleStirlingFiles).toHaveBeenCalledWith(["s1"]);
|
||||
});
|
||||
|
||||
test("does NOT demote when the server can't confirm the file (avoids data loss)", async () => {
|
||||
const s = stub({
|
||||
id: "s1" as FileId,
|
||||
localFilePath: "/tmp/s1.pdf",
|
||||
remoteStorageId: 42,
|
||||
});
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(false);
|
||||
|
||||
const removed = await reconcileServerBackedRecents([s], serverHas([]));
|
||||
|
||||
expect(removed).toEqual([]);
|
||||
expect(desktopFileLink.pathExistsOnDisk).not.toHaveBeenCalled();
|
||||
expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does NOT demote a file with unsaved local edits", async () => {
|
||||
const s = stub({
|
||||
id: "s1" as FileId,
|
||||
localFilePath: "/tmp/s1.pdf",
|
||||
remoteStorageId: 42,
|
||||
isDirty: true,
|
||||
});
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(false);
|
||||
|
||||
const removed = await reconcileServerBackedRecents([s], serverHas([42]));
|
||||
|
||||
expect(removed).toEqual([]);
|
||||
expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does NOT demote when the disk file still exists", async () => {
|
||||
const s = stub({
|
||||
id: "s1" as FileId,
|
||||
localFilePath: "/tmp/s1.pdf",
|
||||
remoteStorageId: 42,
|
||||
});
|
||||
desktopFileLink.pathExistsOnDisk.mockResolvedValue(true);
|
||||
|
||||
const removed = await reconcileServerBackedRecents([s], serverHas([42]));
|
||||
|
||||
expect(removed).toEqual([]);
|
||||
expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("is a no-op off desktop", async () => {
|
||||
desktopFileLink.desktopFileLinkingSupported = false;
|
||||
const s = stub({
|
||||
id: "s1" as FileId,
|
||||
localFilePath: "/tmp/s1.pdf",
|
||||
remoteStorageId: 42,
|
||||
});
|
||||
|
||||
const removed = await reconcileServerBackedRecents([s], serverHas([42]));
|
||||
|
||||
expect(removed).toEqual([]);
|
||||
expect(deleteMultipleStirlingFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { StirlingFileStub, FileId } from "@app/types/fileContext";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import {
|
||||
desktopFileLinkingSupported,
|
||||
pathExistsOnDisk,
|
||||
} from "@app/services/desktopFileLink";
|
||||
|
||||
/**
|
||||
* True for a recents entry backed by a real local IndexedDB record, false for
|
||||
* the ephemeral `server-`/`shared-` stubs synthesised from server storage (which
|
||||
* have no IDB row). Desktop recents show only files with a local record; the
|
||||
* server-only ones belong in the My Files view.
|
||||
*/
|
||||
export function hasLocalRecord(stub: StirlingFileStub): boolean {
|
||||
return !stub.id.startsWith("server-") && !stub.id.startsWith("shared-");
|
||||
}
|
||||
|
||||
/**
|
||||
* A local-only recent is a pristine passthrough of its disk file — identical
|
||||
* bytes on disk and in IndexedDB — only if it's a v1 with no in-app edits. When
|
||||
* its disk file disappears there's nothing unique to keep, so it's deleted.
|
||||
* Otherwise (unsaved edits, or a later/tool version) the IndexedDB copy may be
|
||||
* the only one left, so it's KEPT with just the dead disk link detached.
|
||||
*/
|
||||
function isPristineLocalPassthrough(stub: StirlingFileStub): boolean {
|
||||
return (
|
||||
!stub.isDirty &&
|
||||
(stub.versionNumber ?? 1) === 1 &&
|
||||
(stub.toolHistory?.length ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile LOCAL-ONLY desktop recents against the disk files they link to.
|
||||
* Server-backed recents are intentionally left untouched here — they're handled
|
||||
* after the server fetch by {@link reconcileServerBackedRecents}, which can
|
||||
* confirm the file still exists remotely before dropping the local copy.
|
||||
*
|
||||
* For each local-only stub whose `localFilePath` no longer exists on disk:
|
||||
* - pristine passthrough → removed from recents + IndexedDB.
|
||||
* - edited / dirty → KEPT, dead `localFilePath` cleared (no broken "Show
|
||||
* in folder"; Ctrl+S falls back to Save As).
|
||||
* No-op off the desktop app.
|
||||
*/
|
||||
export async function pruneMissingRecentFiles(
|
||||
stubs: StirlingFileStub[],
|
||||
): Promise<StirlingFileStub[]> {
|
||||
if (!desktopFileLinkingSupported) return stubs;
|
||||
|
||||
const linked = stubs.filter(
|
||||
(stub) => stub.localFilePath && !stub.remoteStorageId,
|
||||
);
|
||||
if (linked.length === 0) return stubs;
|
||||
|
||||
const present = await Promise.all(
|
||||
linked.map((stub) => pathExistsOnDisk(stub.localFilePath!)),
|
||||
);
|
||||
|
||||
const toDelete: FileId[] = [];
|
||||
const toDetach: FileId[] = [];
|
||||
linked.forEach((stub, i) => {
|
||||
if (present[i]) return;
|
||||
if (isPristineLocalPassthrough(stub)) toDelete.push(stub.id);
|
||||
else toDetach.push(stub.id);
|
||||
});
|
||||
|
||||
if (toDelete.length === 0 && toDetach.length === 0) return stubs;
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
try {
|
||||
await fileStorage.deleteMultipleStirlingFiles(toDelete);
|
||||
} catch (error) {
|
||||
console.error("[pruneMissingRecentFiles] delete failed:", error);
|
||||
}
|
||||
}
|
||||
if (toDetach.length > 0) {
|
||||
await Promise.all(
|
||||
toDetach.map((id) =>
|
||||
fileStorage
|
||||
.updateFileMetadata(id, { localFilePath: undefined })
|
||||
.catch((error) =>
|
||||
console.error("[pruneMissingRecentFiles] detach failed:", error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const deleteSet = new Set<FileId>(toDelete);
|
||||
const detachSet = new Set<FileId>(toDetach);
|
||||
return stubs
|
||||
.filter((stub) => !deleteSet.has(stub.id))
|
||||
.map((stub) =>
|
||||
detachSet.has(stub.id) ? { ...stub, localFilePath: undefined } : stub,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When a server-uploaded recent's local disk original has been deleted, drop its
|
||||
* LOCAL copy so the file becomes a pure server file: it leaves the (local)
|
||||
* recents but remains under My Files and stays downloadable from the server.
|
||||
* Only files the server CONFIRMS it still holds (via `serverHasFile`) and with
|
||||
* no unsaved local edits are demoted, so the last copy is never lost.
|
||||
*
|
||||
* Returns the deleted local ids so the caller can drop them from the list this
|
||||
* pass. No-op off the desktop app.
|
||||
*/
|
||||
export async function reconcileServerBackedRecents(
|
||||
stubs: StirlingFileStub[],
|
||||
serverHasFile: (remoteStorageId: number) => boolean,
|
||||
): Promise<FileId[]> {
|
||||
if (!desktopFileLinkingSupported) return [];
|
||||
|
||||
const candidates = stubs.filter(
|
||||
(stub) =>
|
||||
typeof stub.remoteStorageId === "number" &&
|
||||
serverHasFile(stub.remoteStorageId) &&
|
||||
stub.localFilePath &&
|
||||
!stub.isDirty,
|
||||
);
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
const present = await Promise.all(
|
||||
candidates.map((stub) => pathExistsOnDisk(stub.localFilePath!)),
|
||||
);
|
||||
const gone = candidates.filter((_, i) => !present[i]);
|
||||
if (gone.length === 0) return [];
|
||||
|
||||
const removedIds = gone.map((stub) => stub.id);
|
||||
try {
|
||||
await fileStorage.deleteMultipleStirlingFiles(removedIds);
|
||||
} catch (error) {
|
||||
// If we couldn't remove the local copy, don't drop it from the list either.
|
||||
console.error("[reconcileServerBackedRecents] delete failed:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return removedIds;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
|
||||
|
||||
const BASE_NO_LOGIN_CONFIG: AppConfig = {
|
||||
enableAnalytics: true,
|
||||
appVersion: "2.14.2",
|
||||
appVersion: "2.14.1",
|
||||
serverCertificateEnabled: false,
|
||||
enableAlphaFunctionality: false,
|
||||
serverPort: 8080,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Shared classification-label pill (team labels editor + sidebar category manager). */
|
||||
|
||||
.sui-labelchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0.2rem 0.3rem 0.2rem 0.25rem;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.sui-labelchip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.sui-labelchip-name {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sui-labelchip-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3);
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
.sui-labelchip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
|
||||
.sui-labelchip-remove:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-red);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
|
||||
const meta: Meta<typeof LabelChip> = {
|
||||
title: "Primitives/LabelChip",
|
||||
component: LabelChip,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: { label: "Invoice", icon: "receipt-long" },
|
||||
argTypes: {
|
||||
label: { control: "text" },
|
||||
icon: { control: "text" },
|
||||
count: { control: "number" },
|
||||
onRemove: { action: "removed" },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LabelChip>;
|
||||
|
||||
/** The classification-label pill shared by the labels editor and the sidebar category manager. */
|
||||
export const Playground: Story = {};
|
||||
|
||||
/** With a file count, as shown in the sidebar category manager. */
|
||||
export const WithCount: Story = {
|
||||
args: { label: "Contract", icon: "handshake", count: 12 },
|
||||
};
|
||||
|
||||
/** Removable — the trailing × appears when `onRemove` is set. */
|
||||
export const Removable: Story = {
|
||||
args: { label: "NDA", icon: "lock", onRemove: () => {} },
|
||||
};
|
||||
|
||||
/** Falls back to the default "sell" icon when none is given. */
|
||||
export const DefaultIcon: Story = {
|
||||
args: { label: "Uncategorised label", icon: undefined },
|
||||
};
|
||||
|
||||
/** Long names truncate rather than overflow the pill. */
|
||||
export const LongName: Story = {
|
||||
args: { label: "Memorandum of understanding and mutual agreement", count: 3 },
|
||||
};
|
||||
|
||||
export const Row: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", maxWidth: 420 }}>
|
||||
<LabelChip label="Invoice" icon="receipt-long" count={12} />
|
||||
<LabelChip label="Contract" icon="handshake" onRemove={() => {}} />
|
||||
<LabelChip label="Lab report" icon="science" count={2} />
|
||||
<LabelChip label="Payslip" icon="payments" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import "@app/ui/LabelChip.css";
|
||||
|
||||
export interface LabelChipProps {
|
||||
/** The label text. */
|
||||
label: string;
|
||||
/** Material Symbols key for a leading icon; ignored when `leading` is given. */
|
||||
icon?: string;
|
||||
/** Custom leading node (e.g. an icon picker), overriding `icon`. */
|
||||
leading?: ReactNode;
|
||||
/** Optional trailing count (e.g. how many files carry this label). */
|
||||
count?: number;
|
||||
/** Show a trailing `×`; called on click. */
|
||||
onRemove?: () => void;
|
||||
/** Accessible name for the remove button. */
|
||||
removeAriaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A classification-label pill: leading icon (or a custom control like an icon
|
||||
* picker) + name, with an optional count and remove button. The shared look for
|
||||
* every place labels are shown as chips — the team labels editor and the
|
||||
* sidebar category manager both render this so they stay visually identical.
|
||||
*/
|
||||
export function LabelChip({
|
||||
label,
|
||||
icon,
|
||||
leading,
|
||||
count,
|
||||
onRemove,
|
||||
removeAriaLabel,
|
||||
}: LabelChipProps) {
|
||||
return (
|
||||
<span className="sui-labelchip" role="listitem">
|
||||
{leading ?? (
|
||||
<span className="sui-labelchip-icon">
|
||||
<LocalIcon icon={icon || "sell"} width="1rem" />
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-labelchip-name" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
{count != null && count > 0 && (
|
||||
<span className="sui-labelchip-count">{count}</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="sui-labelchip-remove"
|
||||
onClick={onRemove}
|
||||
aria-label={removeAriaLabel ?? `Remove ${label}`}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "0.85rem" }} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOpenedFile } from "@app/hooks/useOpenedFile";
|
||||
import { fileOpenService } from "@app/services/fileOpenService";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { useFileManagement } from "@app/contexts/file/fileHooks";
|
||||
import { createQuickKey } from "@app/types/fileContext";
|
||||
|
||||
@@ -80,12 +81,18 @@ export function useAppInitialization(): void {
|
||||
);
|
||||
|
||||
const addedFiles = await addFiles(filesArray, { selectFiles: true });
|
||||
addedFiles.forEach((file) => {
|
||||
const localFilePath = quickKeyToPath.get(file.quickKey);
|
||||
if (localFilePath) {
|
||||
await Promise.all(
|
||||
addedFiles.map(async (file) => {
|
||||
const localFilePath = quickKeyToPath.get(file.quickKey);
|
||||
if (!localFilePath) return;
|
||||
updateStirlingFileStub(file.fileId, { localFilePath });
|
||||
}
|
||||
});
|
||||
// Persist too: addFiles stored the record before we knew the path,
|
||||
// so the disk link would otherwise be lost on reload.
|
||||
await fileStorage.updateFileMetadata(file.fileId, {
|
||||
localFilePath,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Desktop] ${loadedFiles.length} opened file(s) added to FileContext`,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
// Desktop implementation of the file-link seam. Overrides the core no-op in
|
||||
// desktop builds (see @app alias order) so recent files stay tied to disk.
|
||||
|
||||
export const desktopFileLinkingSupported = true;
|
||||
|
||||
// True if the path still exists on disk. On any error we assume it exists so a
|
||||
// transient failure never prunes a valid recent file.
|
||||
export async function pathExistsOnDisk(path: string): Promise<boolean> {
|
||||
if (!isTauri()) return true;
|
||||
try {
|
||||
return await invoke<boolean>("path_exists", { path });
|
||||
} catch (error) {
|
||||
console.error("[desktopFileLink] path_exists failed:", error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal the file in Explorer/Finder, highlighting it.
|
||||
export async function revealPathInFileManager(path: string): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
await invoke("reveal_in_file_manager", { path });
|
||||
} catch (error) {
|
||||
console.error("[desktopFileLink] reveal_in_file_manager failed:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
|
||||
// Stub ONLY the Tauri OS boundary. Everything below (real fileStorage on a real
|
||||
// IndexedDB, the real desktop desktopFileLink seam, the real pruner) runs for
|
||||
// real, so this exercises the full disk-linked-recents chain end to end.
|
||||
const { existsByPath, invoke } = vi.hoisted(() => ({
|
||||
existsByPath: new Map<string, boolean>(),
|
||||
invoke: vi.fn(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === "path_exists") {
|
||||
return existsByPath.get(args?.path as string) ?? false;
|
||||
}
|
||||
throw new Error(`unexpected command ${cmd}`);
|
||||
}),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/core", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@tauri-apps/api/core")>();
|
||||
return { ...actual, isTauri: () => true, invoke };
|
||||
});
|
||||
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import {
|
||||
pruneMissingRecentFiles,
|
||||
reconcileServerBackedRecents,
|
||||
} from "@app/services/pruneMissingRecentFiles";
|
||||
import {
|
||||
createStirlingFile,
|
||||
type StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
async function storeFile(
|
||||
id: string,
|
||||
overrides: Partial<StirlingFileStub> = {},
|
||||
): Promise<void> {
|
||||
const file = new File([`data-${id}`], `${id}.pdf`, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
const stirlingFile = createStirlingFile(file, id as FileId);
|
||||
const stub: StirlingFileStub = {
|
||||
id: id as FileId,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: file.size,
|
||||
lastModified: 0,
|
||||
quickKey: stirlingFile.quickKey,
|
||||
isLeaf: true,
|
||||
originalFileId: id,
|
||||
versionNumber: 1,
|
||||
...overrides,
|
||||
};
|
||||
await fileStorage.storeStirlingFile(stirlingFile, stub);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
existsByPath.clear();
|
||||
invoke.mockClear();
|
||||
// fake-indexeddb persists within a run; wipe the store for test isolation.
|
||||
const all = await fileStorage.getAllStirlingFileStubs();
|
||||
if (all.length > 0) {
|
||||
await fileStorage.deleteMultipleStirlingFiles(all.map((s) => s.id));
|
||||
}
|
||||
});
|
||||
|
||||
describe("desktop recent files ↔ disk link (real storage + seam)", () => {
|
||||
test("localFilePath round-trips through IndexedDB", async () => {
|
||||
await storeFile("rt", { localFilePath: "/docs/rt.pdf" });
|
||||
|
||||
const stubs = await fileStorage.getLeafStirlingFileStubs();
|
||||
const loaded = stubs.find((s) => s.id === "rt");
|
||||
|
||||
// Regression: before persisting localFilePath in the record, this was
|
||||
// undefined after a reload and the whole feature was inert.
|
||||
expect(loaded?.localFilePath).toBe("/docs/rt.pdf");
|
||||
});
|
||||
|
||||
test("localFilePath applied post-store (OS open-with / drag-drop path) persists", async () => {
|
||||
// useAppInitialization stores the opened file first, then applies the disk
|
||||
// path via updateFileMetadata — this is the drag-drop / "open with" path.
|
||||
await storeFile("dragged");
|
||||
await fileStorage.updateFileMetadata("dragged" as FileId, {
|
||||
localFilePath: "/docs/dragged.pdf",
|
||||
});
|
||||
|
||||
const stubs = await fileStorage.getLeafStirlingFileStubs();
|
||||
expect(stubs.find((s) => s.id === "dragged")?.localFilePath).toBe(
|
||||
"/docs/dragged.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
test("prunes a recent whose disk file is gone and deletes it from storage", async () => {
|
||||
await storeFile("gone", { localFilePath: "/docs/gone.pdf" });
|
||||
await storeFile("kept", { localFilePath: "/docs/kept.pdf" });
|
||||
existsByPath.set("/docs/gone.pdf", false);
|
||||
existsByPath.set("/docs/kept.pdf", true);
|
||||
|
||||
const stubs = await fileStorage.getLeafStirlingFileStubs();
|
||||
const survivors = await pruneMissingRecentFiles(stubs);
|
||||
|
||||
expect(survivors.map((s) => s.id).sort()).toEqual(["kept"]);
|
||||
// Really removed from IndexedDB, not just filtered from the array.
|
||||
expect(await fileStorage.getStirlingFileStub("gone" as FileId)).toBeNull();
|
||||
expect(
|
||||
await fileStorage.getStirlingFileStub("kept" as FileId),
|
||||
).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith("path_exists", {
|
||||
path: "/docs/gone.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
test("prune leaves server-backed files alone (server reconciliation owns them)", async () => {
|
||||
await storeFile("onserver", {
|
||||
localFilePath: "/docs/onserver.pdf",
|
||||
remoteStorageId: 42,
|
||||
});
|
||||
existsByPath.set("/docs/onserver.pdf", false);
|
||||
|
||||
const survivors = await pruneMissingRecentFiles(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
);
|
||||
|
||||
// Untouched by the pre-fetch prune: still present, link intact.
|
||||
expect(survivors.find((s) => s.id === "onserver")?.localFilePath).toBe(
|
||||
"/docs/onserver.pdf",
|
||||
);
|
||||
expect(
|
||||
await fileStorage.getStirlingFileStub("onserver" as FileId),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("demotes a server-confirmed file whose disk original is gone (removed locally, kept on server)", async () => {
|
||||
await storeFile("onserver", {
|
||||
localFilePath: "/docs/onserver.pdf",
|
||||
remoteStorageId: 42,
|
||||
});
|
||||
existsByPath.set("/docs/onserver.pdf", false);
|
||||
|
||||
const removed = await reconcileServerBackedRecents(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
(id) => id === 42, // server confirms it holds file 42
|
||||
);
|
||||
|
||||
expect(removed).toEqual(["onserver"]);
|
||||
// Local copy really removed from IndexedDB — it becomes a pure server file
|
||||
// (shown under My Files, downloadable), so nothing is lost.
|
||||
expect(
|
||||
await fileStorage.getStirlingFileStub("onserver" as FileId),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("does NOT demote a server-backed file the server can't confirm (no data loss)", async () => {
|
||||
await storeFile("stale", {
|
||||
localFilePath: "/docs/stale.pdf",
|
||||
remoteStorageId: 99,
|
||||
});
|
||||
existsByPath.set("/docs/stale.pdf", false);
|
||||
|
||||
const removed = await reconcileServerBackedRecents(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
() => false, // server no longer has it
|
||||
);
|
||||
|
||||
expect(removed).toEqual([]);
|
||||
// Local copy preserved — it may be the only remaining copy.
|
||||
expect(
|
||||
await fileStorage.getStirlingFileStub("stale" as FileId),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test("keeps an in-app-edited file whose disk path is gone, detaching the dead link", async () => {
|
||||
// A v2 with tool history: its edited bytes live only in IndexedDB once the
|
||||
// disk file is gone, so it must be KEPT (not deleted) — just detached.
|
||||
await storeFile("edited", {
|
||||
localFilePath: "/docs/edited.pdf",
|
||||
versionNumber: 2,
|
||||
toolHistory: [{ toolId: "compress" as never, timestamp: 1 }],
|
||||
});
|
||||
existsByPath.set("/docs/edited.pdf", false);
|
||||
|
||||
const survivors = await pruneMissingRecentFiles(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
);
|
||||
|
||||
expect(survivors.some((s) => s.id === "edited")).toBe(true);
|
||||
const stored = await fileStorage.getStirlingFileStub("edited" as FileId);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.localFilePath).toBeUndefined();
|
||||
});
|
||||
|
||||
test("leaves web/browser-uploaded files (no disk link) untouched", async () => {
|
||||
// Files added via the <input type=file> fallback have no localFilePath, so
|
||||
// they are never disk-checked, detached, or pruned.
|
||||
await storeFile("weblink");
|
||||
existsByPath.set("/docs/anything.pdf", false);
|
||||
|
||||
const survivors = await pruneMissingRecentFiles(
|
||||
await fileStorage.getLeafStirlingFileStubs(),
|
||||
);
|
||||
|
||||
expect(survivors.some((s) => s.id === "weblink")).toBe(true);
|
||||
expect(
|
||||
await fileStorage.getStirlingFileStub("weblink" as FileId),
|
||||
).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,6 @@ import type { TFunction } from "i18next";
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@app/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@app/policies/runs";
|
||||
import { policyStep, type PolicyToolStep } from "@app/policies/operations";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type {
|
||||
PolicyDecodedState,
|
||||
PolicyRunView,
|
||||
@@ -56,11 +54,11 @@ export interface PolicyField {
|
||||
export interface PolicyCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red";
|
||||
desc: string;
|
||||
providesClassification?: boolean;
|
||||
comingSoon?: boolean;
|
||||
requiresAiEngine?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyConfigDef {
|
||||
@@ -68,7 +66,7 @@ export interface PolicyConfigDef {
|
||||
rules: string[];
|
||||
scopeLabel: string;
|
||||
fields: PolicyField[];
|
||||
defaultOperations: PolicyToolStep[];
|
||||
defaultOperations: WirePipelineStep[];
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
@@ -130,32 +128,33 @@ export interface CatalogueEntry {
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Endpoint display labels */
|
||||
/* Tool → endpoint registry */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* i18n keys keyed by endpoint; labels stored steps in the detail view. Mostly
|
||||
* {@link ToolEndpoint}s, plus the AI classify endpoint, which isn't part of the generated union.
|
||||
*/
|
||||
export const ENDPOINT_LABELS: Partial<
|
||||
Record<ToolEndpoint | "/api/v1/ai/tools/classify-and-label", string>
|
||||
> = {
|
||||
export const TOOL_ENDPOINTS: Record<string, string> = {
|
||||
redact: "/api/v1/security/auto-redact",
|
||||
sanitize: "/api/v1/security/sanitize-pdf",
|
||||
watermark: "/api/v1/security/add-watermark",
|
||||
ocr: "/api/v1/misc/ocr-pdf",
|
||||
flatten: "/api/v1/misc/flatten",
|
||||
compress: "/api/v1/misc/compress-pdf",
|
||||
};
|
||||
|
||||
/** Values are i18n keys — render with t(). */
|
||||
export const ENDPOINT_LABELS: Record<string, string> = {
|
||||
"/api/v1/security/auto-redact": "portal.policies.endpoints.autoRedact",
|
||||
"/api/v1/security/sanitize-pdf": "portal.policies.endpoints.sanitizePdf",
|
||||
"/api/v1/security/add-watermark": "portal.policies.endpoints.addWatermark",
|
||||
"/api/v1/misc/ocr-pdf": "portal.policies.endpoints.ocrPdf",
|
||||
"/api/v1/misc/flatten": "portal.policies.endpoints.flatten",
|
||||
"/api/v1/misc/compress-pdf": "portal.policies.endpoints.compressPdf",
|
||||
"/api/v1/ai/tools/classify-and-label":
|
||||
"portal.policies.endpoints.classifyAndLabel",
|
||||
};
|
||||
|
||||
export function humanizeEndpoint(
|
||||
path: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const label = ENDPOINT_LABELS[path as ToolEndpoint];
|
||||
if (label) return t(label);
|
||||
if (ENDPOINT_LABELS[path]) return t(ENDPOINT_LABELS[path]);
|
||||
const last = path.split("/").filter(Boolean).pop() ?? path;
|
||||
return last
|
||||
.replace(/-/g, " ")
|
||||
@@ -177,6 +176,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "portal.policies.categories.ingestion.label",
|
||||
icon: "layers",
|
||||
tone: "blue",
|
||||
desc: "portal.policies.categories.ingestion.desc",
|
||||
providesClassification: true,
|
||||
@@ -185,20 +185,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "security",
|
||||
label: "portal.policies.categories.security.label",
|
||||
icon: "shield",
|
||||
tone: "purple",
|
||||
desc: "portal.policies.categories.security.desc",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "portal.policies.categories.classification.label",
|
||||
tone: "blue",
|
||||
desc: "portal.policies.categories.classification.desc",
|
||||
providesClassification: true,
|
||||
requiresAiEngine: true,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
label: "portal.policies.categories.compliance.label",
|
||||
icon: "check",
|
||||
tone: "amber",
|
||||
desc: "portal.policies.categories.compliance.desc",
|
||||
comingSoon: true,
|
||||
@@ -206,6 +200,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "routing",
|
||||
label: "portal.policies.categories.routing.label",
|
||||
icon: "route",
|
||||
tone: "green",
|
||||
desc: "portal.policies.categories.routing.desc",
|
||||
comingSoon: true,
|
||||
@@ -213,6 +208,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "retention",
|
||||
label: "portal.policies.categories.retention.label",
|
||||
icon: "schedule",
|
||||
tone: "neutral",
|
||||
desc: "portal.policies.categories.retention.desc",
|
||||
comingSoon: true,
|
||||
@@ -234,7 +230,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.ingestion.rules.3",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("ocr"), policyStep("flatten")],
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.ocr, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.ingestion.fields.minConfidence",
|
||||
@@ -261,29 +260,35 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [
|
||||
// Flatten to image so redactions can't be lifted off.
|
||||
policyStep("redact", {
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
}),
|
||||
// JavaScript removal only; the tool enables removeEmbeddedFiles by default, so turn it off.
|
||||
policyStep("sanitize", { removeEmbeddedFiles: false }),
|
||||
// Bake in via image so it can't be stripped.
|
||||
policyStep("watermark", { convertPDFToImage: true }),
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.redact,
|
||||
parameters: {
|
||||
mode: "automatic",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
wordsToRedact: DEFAULT_PII_PATTERNS,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.sanitize,
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: false,
|
||||
removeMetadata: false,
|
||||
removeLinks: false,
|
||||
removeFonts: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: TOOL_ENDPOINTS.watermark,
|
||||
// convertPDFToImage bakes the watermark in so it can't be stripped
|
||||
parameters: {
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
},
|
||||
classification: {
|
||||
summary: "portal.policies.config.classification.summary",
|
||||
rules: [
|
||||
"portal.policies.config.classification.rules.0",
|
||||
"portal.policies.config.classification.rules.1",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("classify")],
|
||||
fields: [],
|
||||
},
|
||||
compliance: {
|
||||
summary: "portal.policies.config.compliance.summary",
|
||||
rules: [
|
||||
@@ -292,7 +297,10 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.compliance.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("sanitize"), policyStep("flatten")],
|
||||
defaultOperations: [
|
||||
{ operation: TOOL_ENDPOINTS.sanitize, parameters: {} },
|
||||
{ operation: TOOL_ENDPOINTS.flatten, parameters: {} },
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.compliance.fields.frameworks",
|
||||
@@ -335,7 +343,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.routing.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("compress")],
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.routing.fields.destination",
|
||||
@@ -366,7 +374,7 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
"portal.policies.config.retention.rules.2",
|
||||
],
|
||||
scopeLabel: "portal.policies.config.scopeAll",
|
||||
defaultOperations: [policyStep("compress")],
|
||||
defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }],
|
||||
fields: [
|
||||
{
|
||||
label: "portal.policies.config.retention.fields.keepFor",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
} from "@portal/api/policies";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/components/PolicySummary.css";
|
||||
|
||||
/**
|
||||
@@ -64,7 +64,7 @@ export function PolicySummary() {
|
||||
render: ({ entry }) => (
|
||||
<div className="portal-policysum__cat">
|
||||
<span className="portal-policysum__icon" aria-hidden>
|
||||
{policyCategoryIcon(entry.category.id)}
|
||||
{policyIcon(entry.category.icon)}
|
||||
</span>
|
||||
<div className="portal-policysum__cat-text">
|
||||
<strong>{t(entry.category.label)}</strong>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string) => fallback ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// A stand-in tool-settings UI that uses the shared editor Tooltip. The Tooltip
|
||||
// pulls in the Preferences + Sidebar contexts, which the portal does not mount
|
||||
// app-wide — so this reproduces the "usePreferences must be used within a
|
||||
// PreferencesProvider" crash unless PipelineStepSettings supplies them.
|
||||
function TooltipSettings() {
|
||||
return (
|
||||
<Tooltip content="help">
|
||||
<button type="button">field</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const step = {
|
||||
support: "editable",
|
||||
toolId: "compress",
|
||||
params: {},
|
||||
} as unknown as WorkingToolStep;
|
||||
|
||||
const registry = {
|
||||
compress: { automationSettings: TooltipSettings },
|
||||
} as unknown as Partial<ToolRegistry>;
|
||||
|
||||
describe("PipelineStepSettings", () => {
|
||||
it("renders reused editor tool settings (which use the shared Tooltip) without app-wide Preferences/Sidebar providers", () => {
|
||||
expect(() =>
|
||||
render(
|
||||
<MantineProvider>
|
||||
<PipelineStepSettings
|
||||
step={step}
|
||||
registry={registry}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(screen.getByText("field")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner } from "@app/ui";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { type ErasedToolParams } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { type WorkingToolStep } from "@app/hooks/tools/shared/toolAutomation";
|
||||
@@ -48,18 +46,14 @@ export function PipelineStepSettings({
|
||||
}
|
||||
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
</SidebarProvider>
|
||||
</PreferencesProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Settings
|
||||
parameters={step.params}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...step.params, [key]: value })
|
||||
}
|
||||
disabled={false}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/* Read-only classification-vocabulary viewer in the policy wizard. */
|
||||
.classification-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.classification-summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.classification-summary-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Expandable category → labels list. */
|
||||
.classification-categories {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.classification-category {
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
.classification-category:last-child {
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.classification-category-header {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.classification-category-lead {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.classification-category-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.classification-category-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.classification-category-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
padding: 0 var(--space-2) var(--space-2) 2rem;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Read-only view of the classification vocabulary shown in the policy wizard. The labels and their
|
||||
// categories are a fixed, built-in set shared across the whole team — there's nothing to edit, but
|
||||
// the full vocabulary is browsable: expand a category to see the labels it groups.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import { Button, Card, Chip } from "@app/ui";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
LABEL_FAMILIES,
|
||||
} from "@app/data/classificationLabels";
|
||||
import "@portal/components/policies/ClassificationLabelsSection.css";
|
||||
|
||||
export function ClassificationLabelsSection() {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="classification-summary">
|
||||
<div className="classification-summary-stats">
|
||||
<span>
|
||||
<strong>{DEFAULT_CLASSIFICATION_LABELS.length}</strong>{" "}
|
||||
{t("policies.labels.labelCount", "labels")}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{LABEL_FAMILIES.length}</strong>{" "}
|
||||
{t("policies.labels.categoryCount", "categories")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="classification-categories">
|
||||
{LABEL_FAMILIES.map((family) => {
|
||||
const open = expanded.has(family.id);
|
||||
return (
|
||||
<li key={family.id} className="classification-category">
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="classification-category-header"
|
||||
aria-expanded={open}
|
||||
onClick={() => toggle(family.id)}
|
||||
leftSection={
|
||||
<span className="classification-category-lead">
|
||||
{open ? (
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
<LocalIcon icon={family.icon} width="1.1rem" />
|
||||
<span className="classification-category-name">
|
||||
{family.name}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
rightSection={
|
||||
<span className="classification-category-count">
|
||||
{family.labels.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{open && (
|
||||
<div className="classification-category-labels">
|
||||
{family.labels.map((label) => (
|
||||
<Chip key={label.id} accent="neutral" size="sm">
|
||||
{t(`classification.labels.${label.id}`, label.name)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<span className="classification-summary-note">
|
||||
{t(
|
||||
"policies.labels.sharedNote",
|
||||
"These labels are built in and shared across your whole team.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge } from "@app/ui";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicyCategoryCardProps {
|
||||
entry: CatalogueEntry;
|
||||
onOpen: (entry: CatalogueEntry) => void;
|
||||
/** Setup is unavailable (e.g. the AI engine is off): shown, but not openable. */
|
||||
locked?: boolean;
|
||||
/** Chip text explaining why setup is locked (e.g. "Requires AI engine"). */
|
||||
lockedLabel?: string;
|
||||
}
|
||||
|
||||
export function PolicyCategoryCard({
|
||||
entry,
|
||||
onOpen,
|
||||
locked = false,
|
||||
lockedLabel,
|
||||
}: PolicyCategoryCardProps) {
|
||||
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon && !locked;
|
||||
const openable = !comingSoon;
|
||||
const status = policy?.state.status;
|
||||
const enforces = config.rules.map((r) => t(r)).join(" · ");
|
||||
|
||||
@@ -30,7 +21,7 @@ export function PolicyCategoryCard({
|
||||
<Card
|
||||
className={
|
||||
"portal-policies__card" +
|
||||
(comingSoon || locked ? " portal-policies__card--locked" : "")
|
||||
(comingSoon ? " portal-policies__card--locked" : "")
|
||||
}
|
||||
interactive={openable}
|
||||
onClick={openable ? () => onOpen(entry) : undefined}
|
||||
@@ -48,7 +39,7 @@ export function PolicyCategoryCard({
|
||||
}
|
||||
>
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyCategoryIcon(category.id)}
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
|
||||
<div className="portal-policies__card-identity">
|
||||
@@ -62,10 +53,6 @@ export function PolicyCategoryCard({
|
||||
<Chip accent="neutral" size="sm">
|
||||
{t("portal.policies.card.comingSoon")}
|
||||
</Chip>
|
||||
) : locked ? (
|
||||
<Chip accent="neutral" size="sm">
|
||||
{lockedLabel ?? t("portal.policies.card.requiresAiEngine")}
|
||||
</Chip>
|
||||
) : policy ? (
|
||||
<div className="portal-policies__card-meta">
|
||||
<span className="portal-policies__card-statpair">
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const classification = POLICY_CATEGORIES.find(
|
||||
(c) => c.id === "classification",
|
||||
)!;
|
||||
|
||||
const meta: Meta<typeof PolicySetupWizard> = {
|
||||
title: "Portal/Policies/PolicySetupWizard",
|
||||
@@ -50,14 +47,3 @@ export const Edit: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Classification: the workflow step shows the team label editor, not tool toggles. */
|
||||
export const Classification: Story = {
|
||||
args: {
|
||||
entry: {
|
||||
category: classification,
|
||||
config: POLICY_CONFIG.classification,
|
||||
policy: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PolicySetupResult,
|
||||
type PipelineStep,
|
||||
} from "@portal/api/policies";
|
||||
|
||||
const render = (ui: Parameters<typeof baseRender>[0]) =>
|
||||
baseRender(ui, { wrapper: MantineProvider });
|
||||
|
||||
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
|
||||
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
// Second arg is a string fallback in some call sites and an interpolation object in others;
|
||||
// only treat a string as the fallback.
|
||||
t: (key: string, fallback?: unknown) =>
|
||||
typeof fallback === "string" ? fallback : key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
}));
|
||||
|
||||
const fetchSources = vi.fn();
|
||||
vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
}));
|
||||
|
||||
const CONTINUE = "portal.policies.wizard.actions.continue";
|
||||
const SAVE_CHANGES = "portal.policies.wizard.actions.saveChanges";
|
||||
const ENABLE = "portal.policies.wizard.actions.enablePolicy";
|
||||
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
const securityConfig = POLICY_CONFIG.security;
|
||||
|
||||
function editEntry(steps: PipelineStep[]): CatalogueEntry {
|
||||
const policy: DecoratedPolicy = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
runOn: "upload",
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
outputNamePosition: "suffix",
|
||||
maxRetries: 0,
|
||||
retryDelayMinutes: 0,
|
||||
backendId: "pol-1",
|
||||
isDefault: true,
|
||||
},
|
||||
steps,
|
||||
stats: { enforced: 0, dataProcessed: "-", activeFor: "-" },
|
||||
activity: [],
|
||||
};
|
||||
return { category: security, config: securityConfig, policy };
|
||||
}
|
||||
|
||||
/** Advance the wizard from the workflow tab to the settings tab and submit. */
|
||||
async function submitWizard(saveLabel: string) {
|
||||
fireEvent.click(await screen.findByRole("button", { name: CONTINUE }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: saveLabel }));
|
||||
}
|
||||
|
||||
describe("PolicySetupWizard", () => {
|
||||
beforeEach(() => {
|
||||
fetchSources.mockResolvedValue({ sources: [] });
|
||||
});
|
||||
|
||||
it("round-trips a saved step's backend params on edit", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry = editEntry([
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: { listOfText: "foo\nbar", useRegex: true },
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(SAVE_CHANGES);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
// Only the saved tool is enabled on edit, and its patterns survive the wire -> UI -> wire trip.
|
||||
expect(result.steps).toEqual([
|
||||
expect.objectContaining({
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: expect.objectContaining({ listOfText: "foo\nbar" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("seeds the preset chain for a new policy (redact + sanitize on, watermark off)", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
const entry: CatalogueEntry = {
|
||||
category: security,
|
||||
config: securityConfig,
|
||||
policy: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<PolicySetupWizard entry={entry} onClose={vi.fn()} onSubmit={onSubmit} />,
|
||||
);
|
||||
await submitWizard(ENABLE);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const result = onSubmit.mock.calls[0][1] as PolicySetupResult;
|
||||
const endpoints = result.steps.map((s) => s.operation);
|
||||
expect(endpoints).toEqual([
|
||||
"/api/v1/security/auto-redact",
|
||||
"/api/v1/security/sanitize-pdf",
|
||||
]);
|
||||
// Redact carries the preset PII patterns as the backend's listOfText.
|
||||
const redact = result.steps[0].parameters as { listOfText?: string };
|
||||
expect(redact.listOfText).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,5 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||
import StorageOutlinedIcon from "@mui/icons-material/StorageOutlined";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
@@ -18,43 +13,27 @@ import {
|
||||
} from "@app/ui";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import {
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
import type { ToolRegistry, ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import {
|
||||
policyEndpoint,
|
||||
policyStepFromWire,
|
||||
policyStepToWire,
|
||||
type PolicyParams,
|
||||
type PolicyToolId,
|
||||
type PolicyToolStep,
|
||||
} from "@app/policies/operations";
|
||||
deserializeToolStep,
|
||||
serializeStepFromEndpoint,
|
||||
} from "@app/hooks/tools/shared/toolAutomation";
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import { ClassificationLabelsSection } from "@portal/components/policies/ClassificationLabelsSection";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
/** Outline icon for a source tile, keyed by the backend source `type`. */
|
||||
function sourceIcon(type: string): ReactNode {
|
||||
const sx = { fontSize: "1.1rem" } as const;
|
||||
switch (type) {
|
||||
case "editor":
|
||||
return <EditOutlinedIcon sx={sx} />;
|
||||
case "folder":
|
||||
return <FolderOutlinedIcon sx={sx} />;
|
||||
case "s3":
|
||||
return <CloudOutlinedIcon sx={sx} />;
|
||||
default:
|
||||
return <StorageOutlinedIcon sx={sx} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
/** The category being configured, or null when closed. */
|
||||
entry: CatalogueEntry | null;
|
||||
@@ -68,8 +47,12 @@ interface PolicySetupWizardProps {
|
||||
|
||||
type Step = "workflow" | "settings";
|
||||
|
||||
/** A policy step plus whether it runs. */
|
||||
type ToolState = PolicyToolStep & { enabled: boolean };
|
||||
/** A configurable tool in the workflow step: whether it runs + its params. */
|
||||
interface ToolState {
|
||||
operation: string;
|
||||
enabled: boolean;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Resolve each field's effective value: saved override, else definition default. */
|
||||
function resolveFieldValues(
|
||||
@@ -86,8 +69,9 @@ function resolveFieldValues(
|
||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||
* starts enabled — the user toggles tools off in the workflow.
|
||||
*/
|
||||
// Temporary until the catalogue carries a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
// Temporary: tracks which tools start disabled until the tool registry lands in
|
||||
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
|
||||
/**
|
||||
* Policy-facing framing for each capability a policy can include. Labels and
|
||||
@@ -97,75 +81,73 @@ const DISABLED_BY_DEFAULT = new Set<PolicyToolId>(["watermark"]);
|
||||
* the humanised endpoint name with no description.
|
||||
*/
|
||||
const CAPABILITY_META: Record<
|
||||
PolicyToolId,
|
||||
string,
|
||||
{ labelKey: string; labelEn: string; descKey: string; descEn: string }
|
||||
> = {
|
||||
redact: {
|
||||
[TOOL_ENDPOINTS.redact]: {
|
||||
labelKey: "portal.policies.wizard.capability.redact.label",
|
||||
labelEn: "Redact sensitive information",
|
||||
descKey: "portal.policies.wizard.capability.redact.desc",
|
||||
descEn:
|
||||
"Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.",
|
||||
},
|
||||
sanitize: {
|
||||
[TOOL_ENDPOINTS.sanitize]: {
|
||||
labelKey: "portal.policies.wizard.capability.sanitize.label",
|
||||
labelEn: "Strip active content",
|
||||
descKey: "portal.policies.wizard.capability.sanitize.desc",
|
||||
descEn:
|
||||
"Removes hidden JavaScript so nothing can run automatically when the document is opened.",
|
||||
},
|
||||
watermark: {
|
||||
[TOOL_ENDPOINTS.watermark]: {
|
||||
labelKey: "portal.policies.wizard.capability.watermark.label",
|
||||
labelEn: "Apply a watermark",
|
||||
descKey: "portal.policies.wizard.capability.watermark.desc",
|
||||
descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
},
|
||||
ocr: {
|
||||
[TOOL_ENDPOINTS.ocr]: {
|
||||
labelKey: "portal.policies.wizard.capability.ocr.label",
|
||||
labelEn: "Make text searchable",
|
||||
descKey: "portal.policies.wizard.capability.ocr.desc",
|
||||
descEn: "Runs OCR so scanned pages become selectable, searchable text.",
|
||||
},
|
||||
flatten: {
|
||||
[TOOL_ENDPOINTS.flatten]: {
|
||||
labelKey: "portal.policies.wizard.capability.flatten.label",
|
||||
labelEn: "Flatten the document",
|
||||
descKey: "portal.policies.wizard.capability.flatten.desc",
|
||||
descEn:
|
||||
"Merges form fields and annotations into the page so they can't be edited.",
|
||||
},
|
||||
compress: {
|
||||
[TOOL_ENDPOINTS.compress]: {
|
||||
labelKey: "portal.policies.wizard.capability.compress.label",
|
||||
labelEn: "Reduce file size",
|
||||
descKey: "portal.policies.wizard.capability.compress.desc",
|
||||
descEn: "Compresses the document to a smaller file size.",
|
||||
},
|
||||
classify: {
|
||||
labelKey: "portal.policies.wizard.capability.classify.label",
|
||||
labelEn: "Classify the document",
|
||||
descKey: "portal.policies.wizard.capability.classify.desc",
|
||||
descEn:
|
||||
"Identifies the document's type from your team's labels and tags it, so it files and searches by category.",
|
||||
},
|
||||
};
|
||||
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
function seedTools(
|
||||
entry: CatalogueEntry,
|
||||
registry: Partial<ToolRegistry>,
|
||||
): ToolState[] {
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByTool = new Map<PolicyToolId, PolicyToolStep>();
|
||||
for (const wire of savedSteps) {
|
||||
const step = policyStepFromWire(wire);
|
||||
if (step) savedByTool.set(step.toolId, step);
|
||||
}
|
||||
// defaultOperations is the canonical list (so tools added later still show on edit); a saved
|
||||
// step's params win over the preset.
|
||||
return entry.config.defaultOperations.map((preset) => {
|
||||
const saved = savedByTool.get(preset.toolId);
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
// Always use defaultOperations as the canonical list so tools added after a
|
||||
// policy was first saved still appear when editing.
|
||||
return entry.config.defaultOperations.map((s) => {
|
||||
const saved = savedByOp.get(s.operation);
|
||||
return {
|
||||
...(saved ?? preset),
|
||||
operation: s.operation,
|
||||
enabled: saved
|
||||
? true
|
||||
: savedSteps.length > 0
|
||||
? false
|
||||
: !DISABLED_BY_DEFAULT.has(preset.toolId),
|
||||
: !DISABLED_BY_DEFAULT.has(s.operation),
|
||||
// Saved steps are in the backend contract shape; map them back to the UI
|
||||
// shape the config controls edit (e.g. `listOfText` -> `wordsToRedact`).
|
||||
// Presets are already authored in the UI shape, so use them as-is.
|
||||
parameters: saved
|
||||
? deserializeToolStep(saved, registry).params
|
||||
: s.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -203,21 +185,26 @@ function PolicySetupWizardBody({
|
||||
onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { allTools: toolRegistry } = useToolRegistry();
|
||||
|
||||
// Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs.
|
||||
// Build a reverse map so we can look up icons and display names by endpoint.
|
||||
const registryByEndpoint = useMemo(() => {
|
||||
const map = new Map<string, ToolRegistryEntry>();
|
||||
for (const entry of Object.values(toolRegistry)) {
|
||||
const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint;
|
||||
if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry);
|
||||
}
|
||||
return map;
|
||||
}, [toolRegistry]);
|
||||
|
||||
const { category, config, policy } = entry;
|
||||
const isEdit = policy != null;
|
||||
const isClassification = category.id === "classification";
|
||||
|
||||
const [step, setStep] = useState<Step>("workflow");
|
||||
const [tools, setTools] = useState<ToolState[]>(() => {
|
||||
const seeded = seedTools(entry);
|
||||
// Classification's single tool has no toggle in the workflow step, so keep it
|
||||
// enabled unconditionally — otherwise editing a policy whose saved steps
|
||||
// somehow lack it would strand submit with no way to re-enable it.
|
||||
return isClassification
|
||||
? seeded.map((t) => ({ ...t, enabled: true }))
|
||||
: seeded;
|
||||
});
|
||||
const [tools, setTools] = useState<ToolState[]>(() =>
|
||||
seedTools(entry, toolRegistry),
|
||||
);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -230,10 +217,7 @@ function PolicySetupWizardBody({
|
||||
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||
(s) => s.status !== "disabled",
|
||||
);
|
||||
// The editor is always an available source. The backend now returns it as a
|
||||
// virtual source too, so take that when present (avoids a duplicate tile) and
|
||||
// otherwise fall back to a synthetic one; keep it first, selected by default.
|
||||
const editorSource = backendSources.find((s) => s.id === "editor") ?? {
|
||||
const editorSource = {
|
||||
id: "editor",
|
||||
name: t("portal.sources.types.editor.label"),
|
||||
type: "editor",
|
||||
@@ -243,7 +227,7 @@ function PolicySetupWizardBody({
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources.filter((s) => s.id !== "editor")];
|
||||
return [editorSource, ...backendSources];
|
||||
}, [sourcesAsync.data, t]);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
@@ -272,20 +256,9 @@ function PolicySetupWizardBody({
|
||||
|
||||
const enabledTools = useMemo(() => tools.filter((tl) => tl.enabled), [tools]);
|
||||
|
||||
function setToolEnabled(toolId: PolicyToolId, enabled: boolean) {
|
||||
function patchTool(operation: string, patch: Partial<ToolState>) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) => (tl.toolId === toolId ? { ...tl, enabled } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
function setToolParams<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
params: PolicyParams<Id>,
|
||||
) {
|
||||
setTools((prev) =>
|
||||
prev.map((tl) =>
|
||||
tl.toolId === toolId ? ({ ...tl, params } as ToolState) : tl,
|
||||
),
|
||||
prev.map((tl) => (tl.operation === operation ? { ...tl, ...patch } : tl)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,8 +277,11 @@ function PolicySetupWizardBody({
|
||||
}
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
// Map each tool's UI-shaped params (e.g. redact's `wordsToRedact`) into the
|
||||
// backend step contract (e.g. `listOfText`) via its `toApiParams`; saving the
|
||||
// UI shape verbatim would drop those fields and the step would run with none.
|
||||
const steps: PipelineStep[] = enabledTools.map((tl) =>
|
||||
policyStepToWire(tl),
|
||||
serializeStepFromEndpoint(tl.operation, tl.parameters, toolRegistry),
|
||||
);
|
||||
try {
|
||||
await onSubmit(entry, {
|
||||
@@ -335,7 +311,7 @@ function PolicySetupWizardBody({
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyCategoryIcon(category.id)}
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.title.edit", {
|
||||
@@ -399,25 +375,7 @@ function PolicySetupWizardBody({
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "workflow" && isClassification && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
{t(
|
||||
"portal.policies.wizard.classification.description",
|
||||
"Every uploaded document is classified against the built-in labels and tagged with the types that fit. The label set is shared across your whole team.",
|
||||
)}
|
||||
</p>
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t(
|
||||
"portal.policies.wizard.classification.labelsHeading",
|
||||
"Classification labels",
|
||||
)}
|
||||
</h3>
|
||||
<ClassificationLabelsSection />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "workflow" && !isClassification && (
|
||||
{step === "workflow" && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
{t(
|
||||
@@ -428,16 +386,20 @@ function PolicySetupWizardBody({
|
||||
<Card padding="none">
|
||||
<div className="portal-policies__capabilities">
|
||||
{tools.map((tl) => {
|
||||
const meta = CAPABILITY_META[tl.toolId];
|
||||
const meta = CAPABILITY_META[tl.operation];
|
||||
const label = meta
|
||||
? t(meta.labelKey, meta.labelEn)
|
||||
: humanizeEndpoint(policyEndpoint(tl.toolId), t);
|
||||
: (registryByEndpoint.get(tl.operation)?.name ??
|
||||
humanizeEndpoint(tl.operation, t));
|
||||
const description = meta
|
||||
? t(meta.descKey, meta.descEn)
|
||||
: undefined;
|
||||
const hasConfig =
|
||||
tl.operation === TOOL_ENDPOINTS.redact ||
|
||||
tl.operation === TOOL_ENDPOINTS.watermark;
|
||||
return (
|
||||
<div
|
||||
key={tl.toolId}
|
||||
key={tl.operation}
|
||||
className="portal-policies__capability"
|
||||
data-on={tl.enabled || undefined}
|
||||
>
|
||||
@@ -449,27 +411,27 @@ function PolicySetupWizardBody({
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
onChange={(checked) =>
|
||||
setToolEnabled(tl.toolId, checked)
|
||||
patchTool(tl.operation, { enabled: checked })
|
||||
}
|
||||
label=""
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{tl.enabled && (
|
||||
{tl.enabled && hasConfig && (
|
||||
<div className="portal-policies__capability-config">
|
||||
{tl.toolId === "redact" && (
|
||||
{tl.operation === TOOL_ENDPOINTS.redact && (
|
||||
<PolicyRedactConfig
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("redact", params)
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tl.toolId === "watermark" && (
|
||||
{tl.operation === TOOL_ENDPOINTS.watermark && (
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tl.params}
|
||||
onChange={(params) =>
|
||||
setToolParams("watermark", params)
|
||||
parameters={tl.parameters}
|
||||
onChange={(parameters) =>
|
||||
patchTool(tl.operation, { parameters })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -513,41 +475,39 @@ function PolicySetupWizardBody({
|
||||
{t("portal.policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : (
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded list is never empty - no "no sources" state exists.
|
||||
// The editor is always an available source (unconditionally prepended
|
||||
// to availableSources), so the list is never empty — no "no sources"
|
||||
// state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{availableSources.map((src) => {
|
||||
const on = sources.includes(src.id);
|
||||
return (
|
||||
<Button
|
||||
key={src.id}
|
||||
variant={on ? "secondary" : "quiet"}
|
||||
justify="between"
|
||||
fullWidth
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(on ? " portal-policies__source--on" : "")
|
||||
}
|
||||
// The check keeps its slot when unselected (hidden) so the
|
||||
// icon + name stay put whether or not the tile is selected.
|
||||
rightSection={
|
||||
<CheckIcon
|
||||
sx={{
|
||||
fontSize: "1.1rem",
|
||||
visibility: on ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
aria-pressed={on}
|
||||
>
|
||||
{availableSources.map((src) => (
|
||||
// A selectable multi-line tile (icon + name + type + check).
|
||||
// Uses the shared Button (raw <button> is lint-banned); the tile
|
||||
// CSS overrides the Button's fixed height for the two-line layout.
|
||||
<Button
|
||||
key={src.id}
|
||||
variant="quiet"
|
||||
justify="start"
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(sources.includes(src.id)
|
||||
? " portal-policies__source--on"
|
||||
: "")
|
||||
}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
>
|
||||
<span className="portal-policies__source-icon" aria-hidden>
|
||||
{sourceTypeMeta(src.type).icon}
|
||||
</span>
|
||||
<span className="portal-policies__source-text">
|
||||
<span className="portal-policies__source-label">
|
||||
{sourceIcon(src.type)}
|
||||
{src.name}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<span className="portal-policies__source-desc">
|
||||
{src.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Glyphs for the policy catalogue's string icon keys. The catalogue model
|
||||
* carries semantic keys (e.g. "shield", "layers") rather than React nodes so
|
||||
* the data stays portable; the portal owns the rendering and maps each key to a
|
||||
* glyph here.
|
||||
*/
|
||||
export const POLICY_ICON_GLYPHS: Record<string, string> = {
|
||||
layers: "▤",
|
||||
shield: "🛡",
|
||||
check: "✓",
|
||||
route: "⇉",
|
||||
clock: "⏲",
|
||||
// Source icons.
|
||||
file: "▢",
|
||||
device: "▣",
|
||||
globe: "◍",
|
||||
cloud: "☁",
|
||||
mail: "✉",
|
||||
folder: "▤",
|
||||
};
|
||||
|
||||
/** Resolve an icon key to its glyph, falling back to a neutral dot. */
|
||||
export function policyIcon(key: string): string {
|
||||
return POLICY_ICON_GLYPHS[key] ?? "•";
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// AI-engine flag from the backend's public app-config. Gates Classification
|
||||
// setup: the card always shows but can't be enabled until the engine is on.
|
||||
// `loading` lets callers hold the decision rather than flash a locked card.
|
||||
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
|
||||
interface AppConfigShape {
|
||||
aiEngineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AiEngineState {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function useAiEngineEnabled(): AiEngineState {
|
||||
const state = useAsync<AppConfigShape>(
|
||||
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
|
||||
[],
|
||||
);
|
||||
return {
|
||||
enabled: Boolean(state.data?.aiEngineEnabled),
|
||||
loading: state.loading && state.data === null,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
|
||||
// The Classification policy only needs the app-config `aiEngineEnabled` flag to be
|
||||
// on; its label vocabulary is a fixed built-in set (no team endpoint anymore).
|
||||
|
||||
export const classificationHandlers = [
|
||||
http.get("/api/v1/config/app-config", () =>
|
||||
HttpResponse.json({ aiEngineEnabled: true }),
|
||||
),
|
||||
];
|
||||
@@ -12,7 +12,6 @@ import { usersHandlers } from "@portal/mocks/handlers/users";
|
||||
import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
|
||||
import { agentsHandlers } from "@portal/mocks/handlers/agents";
|
||||
import { policiesHandlers } from "@portal/mocks/handlers/policies";
|
||||
import { classificationHandlers } from "@portal/mocks/handlers/classification";
|
||||
import { documentsHandlers } from "@portal/mocks/handlers/documents";
|
||||
import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
|
||||
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
|
||||
@@ -33,7 +32,6 @@ export const handlers = [
|
||||
...teamSaasHandlers,
|
||||
...agentsHandlers,
|
||||
...policiesHandlers,
|
||||
...classificationHandlers,
|
||||
...documentsHandlers,
|
||||
...sdkComponentsHandlers,
|
||||
...editorDeployHandlers,
|
||||
|
||||
@@ -4,33 +4,13 @@
|
||||
* only builds seed data for the MSW handlers and tests.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PolicyRunView,
|
||||
WirePipelineStep,
|
||||
WirePolicy,
|
||||
} from "@app/policies/types";
|
||||
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
|
||||
import { POLICY_CONFIG } from "@portal/api/policies";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Seed data — real backend wire format */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Literal wire steps (not derived from the catalogue) so this fixtures module stays independent of
|
||||
// @portal/api/policies and its heavy tool-operation import graph.
|
||||
const SECURITY_STEPS: WirePipelineStep[] = [
|
||||
{
|
||||
operation: "/api/v1/security/auto-redact",
|
||||
parameters: {
|
||||
listOfText: "",
|
||||
useRegex: true,
|
||||
convertPDFToImage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: "/api/v1/security/sanitize-pdf",
|
||||
parameters: { removeJavaScript: true },
|
||||
},
|
||||
];
|
||||
|
||||
export function seedPolicies(): WirePolicy[] {
|
||||
return [
|
||||
{
|
||||
@@ -39,7 +19,7 @@ export function seedPolicies(): WirePolicy[] {
|
||||
owner: "security@acme.com",
|
||||
enabled: true,
|
||||
trigger: null,
|
||||
steps: SECURITY_STEPS,
|
||||
steps: POLICY_CONFIG.security.defaultOperations,
|
||||
output: {
|
||||
type: "inline",
|
||||
options: {
|
||||
|
||||
@@ -266,23 +266,116 @@
|
||||
}
|
||||
|
||||
/* Sources picker */
|
||||
/* Selectable source tiles: a vertical stack of full-width shared Buttons
|
||||
(icon · name · check). Layout is the Button's own leftSection/label/
|
||||
rightSection — only the selected border/tint is added here. */
|
||||
.portal-policies__sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.portal-policies__sources {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-policies__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
/* Shared Button pins a fixed control height and natural width; this tile is a
|
||||
full-width, two-line card, so fill the grid cell and grow to fit content. */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
font-weight: inherit;
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
/* The shared Button wraps children in an inner/label node. Let the inner grow
|
||||
(so the ::after check sits at the far right) and the label carry the
|
||||
icon + two-line-text row. */
|
||||
.portal-policies__source .mantine-Button-inner {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__source .mantine-Button-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.portal-policies__source::after {
|
||||
content: "";
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--color-border-strong);
|
||||
transition:
|
||||
border-color var(--motion-fast),
|
||||
background var(--motion-fast);
|
||||
}
|
||||
|
||||
.portal-policies__source:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.portal-policies__source--on {
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
|
||||
.portal-policies__source--on::after {
|
||||
content: "✓";
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-color: var(--color-blue);
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-policies__source-icon {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.portal-policies__source--on .portal-policies__source-icon {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.portal-policies__source-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portal-policies__source-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.portal-policies__source-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.portal-policies__link {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
|
||||
import { PolicyCategoryCard } from "@portal/components/policies/PolicyCategoryCard";
|
||||
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
|
||||
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
|
||||
import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
export function Policies() {
|
||||
@@ -35,18 +34,6 @@ export function Policies() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
|
||||
const { enabled: aiEngineEnabled, loading: aiEngineLoading } =
|
||||
useAiEngineEnabled();
|
||||
|
||||
function isLocked(entry: CatalogueEntry): boolean {
|
||||
return (
|
||||
entry.category.requiresAiEngine === true &&
|
||||
!aiEngineEnabled &&
|
||||
!aiEngineLoading &&
|
||||
!entry.policy
|
||||
);
|
||||
}
|
||||
|
||||
const catalogue = data?.catalogue ?? [];
|
||||
const refetch = useCallback(() => setVersion((v) => v + 1), []);
|
||||
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
|
||||
@@ -70,11 +57,6 @@ export function Policies() {
|
||||
}));
|
||||
|
||||
function openEntry(entry: CatalogueEntry) {
|
||||
// Block setup of an AI-required policy until the engine is confirmed on (so a
|
||||
// click during the app-config load can't open a wizard for a disabled
|
||||
// feature); a configured policy stays openable so it can be paused/deleted.
|
||||
if (entry.category.requiresAiEngine && !aiEngineEnabled && !entry.policy)
|
||||
return;
|
||||
if (entry.policy) setDetail(entry);
|
||||
else setWizard(entry);
|
||||
}
|
||||
@@ -175,8 +157,6 @@ export function Policies() {
|
||||
key={entry.category.id}
|
||||
entry={entry}
|
||||
onOpen={openEntry}
|
||||
locked={isLocked(entry)}
|
||||
lockedLabel={t("portal.policies.card.requiresAiEngine")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/* Category show/hide list (Files-sidebar visibility picker). */
|
||||
.category-visibility {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.category-visibility-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.25rem;
|
||||
padding: 0 var(--space-1);
|
||||
}
|
||||
.category-visibility-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.category-visibility-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.category-visibility-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
/* Hidden categories read as dimmed until re-shown. */
|
||||
.category-visibility-row--hidden .category-visibility-icon,
|
||||
.category-visibility-row--hidden .category-visibility-name,
|
||||
.category-visibility-row--hidden .category-visibility-count {
|
||||
opacity: 0.5;
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager";
|
||||
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
|
||||
|
||||
const CATEGORIES: SidebarCategory[] = [
|
||||
{ id: "finance", name: "Financial", icon: "payments", labelKeys: [] },
|
||||
{ id: "legal", name: "Legal", icon: "gavel", labelKeys: [] },
|
||||
{ id: "hr", name: "HR", icon: "badge", labelKeys: [] },
|
||||
{ id: "reports", name: "Reports", icon: "monitoring", labelKeys: [] },
|
||||
];
|
||||
|
||||
const COUNTS = new Map<string, number>([
|
||||
["finance", 12],
|
||||
["legal", 4],
|
||||
["reports", 7],
|
||||
]);
|
||||
|
||||
// The store is device-local; the story holds the hidden state so the toggle is live.
|
||||
function Harness() {
|
||||
const [categories, setCategories] = useState(CATEGORIES);
|
||||
return (
|
||||
<div style={{ maxWidth: 420 }}>
|
||||
<ClassificationCategoryManager
|
||||
categories={categories}
|
||||
counts={COUNTS}
|
||||
onToggleHidden={(id, hidden) =>
|
||||
setCategories((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, hidden } : c)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof Harness> = {
|
||||
title: "Policies/ClassificationCategoryManager",
|
||||
component: Harness,
|
||||
parameters: { layout: "padded" },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Harness>;
|
||||
|
||||
/** Show/hide the fixed, shared categories in the Files sidebar. */
|
||||
export const Default: Story = {};
|
||||
@@ -1,68 +0,0 @@
|
||||
// Uber-simple category visibility list for the Files sidebar. Categories are the fixed, shared set
|
||||
// (the built-in label families) — they can't be created, renamed, re-grouped, or have their labels
|
||||
// edited. The only choice is showing or hiding each one in this device's sidebar; a hidden category
|
||||
// forms no group, so its files fall to "Other".
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
|
||||
import "@app/components/policies/ClassificationCategoryManager.css";
|
||||
|
||||
interface ClassificationCategoryManagerProps {
|
||||
categories: SidebarCategory[];
|
||||
onToggleHidden: (id: string, hidden: boolean) => void;
|
||||
/** Optional per-category file counts. */
|
||||
counts?: Map<string, number>;
|
||||
}
|
||||
|
||||
export function ClassificationCategoryManager({
|
||||
categories,
|
||||
onToggleHidden,
|
||||
counts,
|
||||
}: ClassificationCategoryManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ul className="category-visibility">
|
||||
{categories.map((category) => {
|
||||
const count = counts?.get(category.id);
|
||||
return (
|
||||
<li
|
||||
key={category.id}
|
||||
className={
|
||||
category.hidden
|
||||
? "category-visibility-row category-visibility-row--hidden"
|
||||
: "category-visibility-row"
|
||||
}
|
||||
>
|
||||
<span className="category-visibility-icon">
|
||||
<LocalIcon icon={category.icon} width="1.1rem" />
|
||||
</span>
|
||||
<span className="category-visibility-name">{category.name}</span>
|
||||
{count !== undefined && (
|
||||
<span className="category-visibility-count">{count}</span>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
aria-pressed={!category.hidden}
|
||||
aria-label={
|
||||
category.hidden
|
||||
? t("policies.labels.showCategory", "Show category")
|
||||
: t("policies.labels.hideCategory", "Hide category")
|
||||
}
|
||||
onClick={() => onToggleHidden(category.id, !category.hidden)}
|
||||
>
|
||||
{category.hidden ? (
|
||||
<VisibilityOffIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<VisibilityIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The Classification policy's team-labels control, shown in its Edit-Settings
|
||||
* view: a compact summary (count + chips) with an Expand button that opens the
|
||||
* fat {@link LabelsEditorModal}. Team-shared; only users who can configure
|
||||
* policies may edit it. Owns the editable draft and the load/save/import/export
|
||||
* wiring via {@link useClassificationLabels}.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenInFullIcon from "@mui/icons-material/OpenInFull";
|
||||
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
|
||||
import { Card } from "@app/ui/Card";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Chip } from "@app/ui/Chip";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
|
||||
import { LabelsEditorModal } from "@app/components/policies/LabelsEditorModal";
|
||||
import {
|
||||
downloadLabels,
|
||||
parseLabelsFile,
|
||||
validateLabels,
|
||||
} from "@app/services/labelsFile";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
import "@app/components/policies/LabelsEditor.css";
|
||||
|
||||
/** Chips shown in the collapsed team summary before it gets noisy. */
|
||||
const SUMMARY_CHIP_COUNT = 12;
|
||||
|
||||
interface ClassificationLabelsSectionProps {
|
||||
canConfigure: boolean;
|
||||
}
|
||||
|
||||
export function ClassificationLabelsSection({
|
||||
canConfigure,
|
||||
}: ClassificationLabelsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { teamLabels, isCustom, loading, saving, error, saveTeam } =
|
||||
useClassificationLabels(true);
|
||||
|
||||
const [draft, setDraft] = useState<ClassificationLabel[]>(teamLabels);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
// Sync the draft to server truth whenever it changes (load / save / reset).
|
||||
// Local edits don't change `teamLabels`, so this never clobbers them mid-edit.
|
||||
useEffect(() => setDraft(teamLabels), [teamLabels]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(draft) !== JSON.stringify(teamLabels),
|
||||
[draft, teamLabels],
|
||||
);
|
||||
|
||||
const close = () => {
|
||||
setDraft(teamLabels);
|
||||
setLocalError(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onImportFile = (file: File) => {
|
||||
setLocalError(null);
|
||||
void parseLabelsFile(file)
|
||||
.then(setDraft)
|
||||
.catch((e: unknown) =>
|
||||
setLocalError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("policies.labels.importError", "Couldn't import that file."),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const errors = validateLabels({ labels: draft });
|
||||
if (errors.length > 0) {
|
||||
setLocalError(errors[0]);
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
void saveTeam(draft).then(() => setOpen(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="labels-summary">
|
||||
<p className="pol-section-label">
|
||||
{t("policies.labels.sectionLabel", "Classification labels")}
|
||||
</p>
|
||||
<Card>
|
||||
{loading ? (
|
||||
<span className="labels-empty">{t("loading", "Loading…")}</span>
|
||||
) : (
|
||||
<div className="labels-summary">
|
||||
<div className="labels-summary-stats">
|
||||
<span>
|
||||
<strong>{teamLabels.length}</strong>{" "}
|
||||
{t("policies.labels.teamCount", "team labels")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="labels-chips">
|
||||
{teamLabels.slice(0, SUMMARY_CHIP_COUNT).map((label) => (
|
||||
<Chip key={label.id} accent="neutral" size="sm">
|
||||
{label.name}
|
||||
</Chip>
|
||||
))}
|
||||
{teamLabels.length > SUMMARY_CHIP_COUNT && (
|
||||
<Chip accent="neutral" size="sm">
|
||||
+{teamLabels.length - SUMMARY_CHIP_COUNT}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<span className="labels-summary-note">
|
||||
{isCustom
|
||||
? t("policies.labels.customNote", "Customized for your team.")
|
||||
: t(
|
||||
"policies.labels.defaultNote",
|
||||
"Using the built-in default, shared with your team.",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<OpenInFullIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
{canConfigure
|
||||
? t("policies.labels.edit", "Edit labels")
|
||||
: t("policies.labels.view", "View labels")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!canConfigure && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={t(
|
||||
"policies.labels.managedNote",
|
||||
"Team labels are managed by your team leader.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && !open && <Banner tone="danger" description={error} />}
|
||||
|
||||
<LabelsEditorModal
|
||||
open={open}
|
||||
onClose={close}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onImportFile={onImportFile}
|
||||
onExport={() => downloadLabels(draft)}
|
||||
onReset={() => {
|
||||
// Stage the built-in default into the draft — reversible via Cancel,
|
||||
// only persisted on Save (no immediate destructive server delete).
|
||||
setLocalError(null);
|
||||
setDraft(DEFAULT_CLASSIFICATION_LABELS);
|
||||
}}
|
||||
onClear={() => {
|
||||
// Stage an empty list to build from scratch — also reversible until Save.
|
||||
setLocalError(null);
|
||||
setDraft([]);
|
||||
}}
|
||||
onSave={onSave}
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
readOnly={!canConfigure}
|
||||
error={localError ?? error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Small popover picker for a classification label's icon: a trigger button
|
||||
* showing the current icon, opening a grid of the curated
|
||||
* {@link LABEL_ICON_OPTIONS}. Icons render via {@link LocalIcon} (Material
|
||||
* Symbols). No search yet — the palette is small enough to scan.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, SimpleGrid, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
import { LABEL_ICON_OPTIONS, DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
|
||||
interface LabelIconPickerProps {
|
||||
/** Current icon key, or undefined to show the default placeholder. */
|
||||
value?: string;
|
||||
onChange: (icon: string) => void;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
export function LabelIconPicker({
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: LabelIconPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={open}
|
||||
onChange={setOpen}
|
||||
onDismiss={() => setOpen(false)}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
trapFocus
|
||||
withinPortal
|
||||
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="labels-icon-pick"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<LocalIcon icon={value || DEFAULT_LABEL_ICON} width="1.15rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs" style={{ maxHeight: 280, overflowY: "auto" }}>
|
||||
<SimpleGrid cols={10} spacing={4} verticalSpacing={4}>
|
||||
{LABEL_ICON_OPTIONS.map((option) => {
|
||||
const iconLabel = t(
|
||||
`policies.labels.iconName.${option.icon}`,
|
||||
option.label,
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
key={option.icon}
|
||||
label={iconLabel}
|
||||
withArrow
|
||||
openDelay={300}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className={`labels-icon-option${option.icon === value ? " is-selected" : ""}`}
|
||||
onClick={() => {
|
||||
onChange(option.icon);
|
||||
setOpen(false);
|
||||
}}
|
||||
aria-label={t(
|
||||
"policies.labels.pickIcon",
|
||||
"Use {{label}} icon",
|
||||
{
|
||||
label: iconLabel,
|
||||
},
|
||||
)}
|
||||
aria-pressed={option.icon === value}
|
||||
>
|
||||
<LocalIcon icon={option.icon} width="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* Classification-labels editor: add box, chip grid, icon picker, and the
|
||||
summary/modal chrome around them. */
|
||||
|
||||
.labels-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Add box ---- */
|
||||
.labels-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-add-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
color: var(--color-text-1);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--motion-fast);
|
||||
}
|
||||
.labels-add-input:focus {
|
||||
border-color: var(--color-blue);
|
||||
}
|
||||
.labels-add-error {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-red);
|
||||
}
|
||||
.labels-empty {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ---- Chip grid (chips themselves are the shared @app/ui/LabelChip) ---- */
|
||||
.labels-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---- Icon picker (trigger + popover options) ---- */
|
||||
.labels-icon-pick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.labels-icon-pick:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.labels-icon-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
}
|
||||
.labels-icon-option:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.labels-icon-option.is-selected {
|
||||
background: var(--color-blue-light);
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
/* ---- Section summary (Edit-Settings card) ---- */
|
||||
.labels-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-summary-stats {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.labels-summary-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* ---- Fat modal ---- */
|
||||
.labels-modal {
|
||||
width: min(1100px, 94vw);
|
||||
max-width: min(1100px, 94vw);
|
||||
}
|
||||
.labels-modal-body {
|
||||
max-height: min(70vh, 640px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
min-height: 0;
|
||||
}
|
||||
.labels-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
.labels-footer-left,
|
||||
.labels-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Grouped mode: chips under collapsible parent categories ---- */
|
||||
.labels-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.labels-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.labels-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1, inherit);
|
||||
}
|
||||
.labels-group-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.labels-group-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-3, rgba(128, 128, 128, 0.9));
|
||||
}
|
||||
.labels-group-ungrouped {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2, inherit);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Editor for a classification-label list: an add box on top, then the labels as chips (icon picker + remove), duplicate names rejected case-insensitively (optionally also against `reservedNames`). In `grouped` mode the chips are organised under collapsible parent categories (the device-local sidebar categories), matching the sidebar's category picker; labels in no category fall under "Ungrouped". Grouping is presentational — editing still mutates the flat list.
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
|
||||
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
import {
|
||||
getSidebarCategories,
|
||||
subscribeSidebarCategories,
|
||||
} from "@app/services/fileSidebarCategories";
|
||||
import {
|
||||
labelId,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
|
||||
const MAX_TEXT_LENGTH = 128;
|
||||
|
||||
interface LabelsEditorProps {
|
||||
value: ClassificationLabel[];
|
||||
onChange: (next: ClassificationLabel[]) => void;
|
||||
readOnly?: boolean;
|
||||
/** Names that can't be added here because another set already owns them. */
|
||||
reservedNames?: string[];
|
||||
addPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
/** Render chips under collapsible parent categories (like the sidebar picker). */
|
||||
grouped?: boolean;
|
||||
}
|
||||
|
||||
export function LabelsEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
reservedNames,
|
||||
addPlaceholder,
|
||||
emptyText,
|
||||
grouped = false,
|
||||
}: LabelsEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pending, setPending] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
|
||||
const add = () => {
|
||||
const name = pending.trim();
|
||||
if (!name) return;
|
||||
if (name.length > MAX_TEXT_LENGTH) {
|
||||
setAddError(
|
||||
t(
|
||||
"policies.labels.tooLong",
|
||||
"Labels can be at most {{max}} characters.",
|
||||
{ max: MAX_TEXT_LENGTH },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
const taken =
|
||||
value.some((label) => label.name.toLowerCase() === key) ||
|
||||
(reservedNames ?? []).some((reserved) => reserved.toLowerCase() === key);
|
||||
if (taken) {
|
||||
setAddError(
|
||||
t("policies.labels.duplicate", '"{{name}}" already exists.', { name }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAddError(null);
|
||||
setPending("");
|
||||
onChange([...value, { id: labelId(name), name }]);
|
||||
};
|
||||
|
||||
const removeById = (id: string) =>
|
||||
onChange(value.filter((label) => label.id !== id));
|
||||
|
||||
const setIconById = (id: string, icon: string) =>
|
||||
onChange(
|
||||
value.map((label) => (label.id === id ? { ...label, icon } : label)),
|
||||
);
|
||||
|
||||
const renderChip = (label: ClassificationLabel) => {
|
||||
const key = label.id;
|
||||
return (
|
||||
<LabelChip
|
||||
key={key}
|
||||
label={label.name}
|
||||
leading={
|
||||
readOnly ? (
|
||||
<span className="sui-labelchip-icon">
|
||||
<LocalIcon icon={label.icon || DEFAULT_LABEL_ICON} width="1rem" />
|
||||
</span>
|
||||
) : (
|
||||
<LabelIconPicker
|
||||
value={label.icon}
|
||||
onChange={(icon) => setIconById(key, icon)}
|
||||
ariaLabel={t(
|
||||
"policies.labels.iconAria",
|
||||
"Choose an icon for {{name}}",
|
||||
{ name: label.name },
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onRemove={readOnly ? undefined : () => removeById(key)}
|
||||
removeAriaLabel={t("policies.labels.removeAria", "Remove {{name}}", {
|
||||
name: label.name,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="labels-editor">
|
||||
{!readOnly && (
|
||||
<div className="labels-add">
|
||||
<input
|
||||
className="labels-add-input"
|
||||
value={pending}
|
||||
maxLength={MAX_TEXT_LENGTH + 1}
|
||||
placeholder={
|
||||
addPlaceholder ??
|
||||
t("policies.labels.addPlaceholder", "Add a label…")
|
||||
}
|
||||
onChange={(e) => {
|
||||
setPending(e.target.value);
|
||||
if (addError) setAddError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AddIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={add}
|
||||
disabled={!pending.trim()}
|
||||
>
|
||||
{t("policies.labels.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{addError && <p className="labels-add-error">{addError}</p>}
|
||||
|
||||
{value.length === 0 ? (
|
||||
<p className="labels-empty">
|
||||
{emptyText ?? t("policies.labels.empty", "No labels yet.")}
|
||||
</p>
|
||||
) : grouped ? (
|
||||
<GroupedLabels value={value} renderChip={renderChip} />
|
||||
) : (
|
||||
<div className="labels-chips" role="list">
|
||||
{value.map(renderChip)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupedLabelsProps {
|
||||
value: ClassificationLabel[];
|
||||
renderChip: (label: ClassificationLabel) => ReactNode;
|
||||
}
|
||||
|
||||
/** Chips laid out under collapsible parent categories (device-local structure). */
|
||||
function GroupedLabels({ value, renderChip }: GroupedLabelsProps) {
|
||||
const { t } = useTranslation();
|
||||
const categories = useSyncExternalStore(
|
||||
subscribeSidebarCategories,
|
||||
getSidebarCategories,
|
||||
);
|
||||
|
||||
// Category sections that actually contain labels from `value`, plus the leftovers.
|
||||
const { sections, ungrouped } = useMemo(() => {
|
||||
const byId = new Map(value.map((l) => [l.id, l]));
|
||||
const claimed = new Set<string>();
|
||||
const sections = categories
|
||||
.map((category) => {
|
||||
const members = category.labelKeys
|
||||
.map((id) => byId.get(id))
|
||||
.filter((l): l is ClassificationLabel => !!l);
|
||||
members.forEach((m) => claimed.add(m.id));
|
||||
return { category, members };
|
||||
})
|
||||
.filter((s) => s.members.length > 0);
|
||||
const ungrouped = value.filter((l) => !claimed.has(l.id));
|
||||
return { sections, ungrouped };
|
||||
}, [value, categories]);
|
||||
|
||||
// Only the first section starts expanded — a scannable overview.
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(
|
||||
() => new Set(sections.length > 0 ? [sections[0].category.id] : []),
|
||||
);
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="labels-groups">
|
||||
{sections.map(({ category, members }) => {
|
||||
const isOpen = expanded.has(category.id);
|
||||
return (
|
||||
<section key={category.id} className="labels-group">
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="labels-group-header"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => toggle(category.id)}
|
||||
leftSection={
|
||||
<>
|
||||
{isOpen ? (
|
||||
<KeyboardArrowDownIcon sx={{ fontSize: "1.1rem" }} />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
)}
|
||||
<LocalIcon icon={category.icon} width="1.05rem" />
|
||||
</>
|
||||
}
|
||||
rightSection={
|
||||
<span className="labels-group-count">{members.length}</span>
|
||||
}
|
||||
>
|
||||
<span className="labels-group-name">{category.name}</span>
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className="labels-chips" role="list">
|
||||
{members.map(renderChip)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="labels-group">
|
||||
<p className="labels-group-ungrouped">
|
||||
{t("policies.labels.ungrouped", "Ungrouped")}
|
||||
</p>
|
||||
<div className="labels-chips" role="list">
|
||||
{ungrouped.map(renderChip)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Full-screen ("fat") editor for the team's classification labels — the roomy
|
||||
* view the settings summary's Edit button opens. Hosts the {@link LabelsEditor}
|
||||
* chip grid plus an Import/Export toolbar and a footer holding the destructive
|
||||
* actions (reset / start-from-scratch) and the Save/Cancel buttons. Editing is
|
||||
* staged: nothing is persisted until Save, which stays disabled until the draft
|
||||
* actually changes. The draft is owned by the caller so the settings summary
|
||||
* reflects saved changes.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { Modal } from "@app/ui/Modal";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { LabelsEditor } from "@app/components/policies/LabelsEditor";
|
||||
import type { ClassificationLabel } from "@app/data/classificationLabels";
|
||||
|
||||
interface LabelsEditorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
draft: ClassificationLabel[];
|
||||
onDraftChange: (next: ClassificationLabel[]) => void;
|
||||
onImportFile: (file: File) => void;
|
||||
onExport: () => void;
|
||||
/** Stage the built-in default into the draft. */
|
||||
onReset: () => void;
|
||||
/** Stage an empty list into the draft (build from scratch). */
|
||||
onClear: () => void;
|
||||
onSave: () => void;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
readOnly: boolean;
|
||||
/** Save (server) or import (file) failure to surface, if any. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function LabelsEditorModal({
|
||||
open,
|
||||
onClose,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onImportFile,
|
||||
onExport,
|
||||
onReset,
|
||||
onClear,
|
||||
onSave,
|
||||
dirty,
|
||||
saving,
|
||||
readOnly,
|
||||
error,
|
||||
}: LabelsEditorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
// Reset the input so picking the same file twice still fires onChange.
|
||||
e.target.value = "";
|
||||
if (file) onImportFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="xl"
|
||||
className="labels-modal"
|
||||
title={t("policies.labels.modalTitle", "Classification labels")}
|
||||
subtitle={t(
|
||||
"policies.labels.modalSubtitle",
|
||||
"Shared with your whole team. The classifier picks the labels that fit each document.",
|
||||
)}
|
||||
footer={
|
||||
<div className="labels-footer">
|
||||
<div className="labels-footer-left">
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<DeleteSweepOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onClear}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.labels.startFromScratch", "Start from scratch")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("policies.labels.resetToDefault", "Reset to default")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="labels-footer-right">
|
||||
<Button variant="tertiary" size="sm" onClick={onClose}>
|
||||
{readOnly ? t("close", "Close") : t("cancel", "Cancel")}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving
|
||||
? t("policies.labels.saving", "Saving…")
|
||||
: t("policies.labels.saveForTeam", "Save for team")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="labels-modal-body">
|
||||
{error && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={error}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="labels-toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<FileUploadOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{t("policies.labels.import", "Import JSON")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={
|
||||
<FileDownloadOutlinedIcon sx={{ fontSize: "1rem" }} />
|
||||
}
|
||||
onClick={onExport}
|
||||
>
|
||||
{t("policies.labels.export", "Export JSON")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
hidden
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<LabelsEditor
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
readOnly={readOnly}
|
||||
grouped
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MultiSelect } from "@app/ui/MultiSelect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PII_PRESETS } from "@app/data/policyDefinitions";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
/** The set of preset regexes — used to separate preset words from custom ones. */
|
||||
export const PRESET_PATTERNS = new Set(PII_PRESETS.map((p) => p.pattern));
|
||||
@@ -9,8 +8,8 @@ const PATTERN_BY_VALUE = new Map(PII_PRESETS.map((p) => [p.value, p.pattern]));
|
||||
const VALUE_BY_PATTERN = new Map(PII_PRESETS.map((p) => [p.pattern, p.value]));
|
||||
|
||||
interface PolicyPiiFieldProps {
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,7 +26,9 @@ export function PolicyPiiField({
|
||||
disabled,
|
||||
}: PolicyPiiFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const words = parameters.wordsToRedact;
|
||||
const words = Array.isArray(parameters.wordsToRedact)
|
||||
? (parameters.wordsToRedact as string[])
|
||||
: [];
|
||||
const selected = words
|
||||
.map((w) => VALUE_BY_PATTERN.get(w))
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { PolicyPiiField } from "@app/components/policies/PolicyPiiField";
|
||||
import type { RedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
|
||||
|
||||
interface PolicyRedactConfigProps {
|
||||
parameters: RedactParameters;
|
||||
onChange: (parameters: RedactParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import AddWatermarkSingleStepSettings from "@app/components/tools/addWatermark/A
|
||||
import type { AddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
|
||||
interface PolicyWatermarkConfigProps {
|
||||
parameters: AddWatermarkParameters;
|
||||
onChange: (parameters: AddWatermarkParameters) => void;
|
||||
parameters: Record<string, unknown>;
|
||||
onChange: (parameters: Record<string, unknown>) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function PolicyWatermarkConfig({
|
||||
disabled,
|
||||
}: PolicyWatermarkConfigProps) {
|
||||
useEffect(() => {
|
||||
const patch: Partial<AddWatermarkParameters> = {};
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true;
|
||||
// Policies only support text watermarks.
|
||||
if (parameters.watermarkType !== "text") patch.watermarkType = "text";
|
||||
@@ -29,7 +29,7 @@ export function PolicyWatermarkConfig({
|
||||
|
||||
return (
|
||||
<AddWatermarkSingleStepSettings
|
||||
parameters={parameters}
|
||||
parameters={parameters as unknown as AddWatermarkParameters}
|
||||
onParameterChange={(key, value) =>
|
||||
onChange({ ...parameters, [key]: value })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* The fixed, configurable tool chain per policy category — the locked set the
|
||||
* config page shows (one section per tool). Tools can be configured + toggled
|
||||
* on/off, but not added or removed. Each id is a frontend tool-registry key (and
|
||||
* maps to that tool's backend endpoint via the registry's operationConfig).
|
||||
*/
|
||||
export const POLICY_TOOL_CHAINS: Record<string, string[]> = {
|
||||
// Security: redact PII + watermark + sanitize (strips JS). Which are enabled
|
||||
// by default comes from the preset's defaultOperations, not this list.
|
||||
security: ["redact", "watermark", "sanitize"],
|
||||
// Classification: a single backend step that classifies the document and
|
||||
// writes the result into its metadata.
|
||||
classification: ["classify"],
|
||||
};
|
||||
|
||||
/** The configurable tool chain for a category, or null if it has none yet. */
|
||||
export function getPolicyToolChain(categoryId: string): string[] | null {
|
||||
return POLICY_TOOL_CHAINS[categoryId] ?? null;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
|
||||
|
||||
// The classifier vocabulary is bundled in TWO places that must not drift:
|
||||
// - this frontend JSON (source of truth for sidebar categories + display names)
|
||||
// - a backend copy the classify tool sends to the AI engine per request
|
||||
// (app/proprietary/src/main/resources/classification/classification-labels.json).
|
||||
// The backend sends label IDS the engine stores on the document and the frontend
|
||||
// then displays, so a divergence means the classifier picks a label the UI can't
|
||||
// render (or the UI advertises labels the classifier never uses). This guards it.
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../../../..");
|
||||
const BACKEND_LABELS_FILE = path.join(
|
||||
REPO_ROOT,
|
||||
"app/proprietary/src/main/resources/classification/classification-labels.json",
|
||||
);
|
||||
|
||||
interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
function readBackendLabels(): Label[] {
|
||||
const raw = JSON.parse(fs.readFileSync(BACKEND_LABELS_FILE, "utf8")) as {
|
||||
labels: Label[];
|
||||
};
|
||||
return raw.labels;
|
||||
}
|
||||
|
||||
describe("classification label vocabulary (frontend ↔ backend)", () => {
|
||||
const frontend = DEFAULT_CLASSIFICATION_LABELS;
|
||||
const backend = readBackendLabels();
|
||||
|
||||
it("has the identical set of label ids on both sides", () => {
|
||||
const frontendIds = [...new Set(frontend.map((l) => l.id))].sort();
|
||||
const backendIds = [...new Set(backend.map((l) => l.id))].sort();
|
||||
expect(backendIds).toEqual(frontendIds);
|
||||
});
|
||||
|
||||
it("has matching name and icon per id on both sides", () => {
|
||||
const backendById = new Map(backend.map((l) => [l.id, l]));
|
||||
for (const label of frontend) {
|
||||
const other = backendById.get(label.id);
|
||||
expect(other, `backend missing label "${label.id}"`).toBeDefined();
|
||||
expect({ name: other!.name, icon: other!.icon ?? null }).toEqual({
|
||||
name: label.name,
|
||||
icon: label.icon ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
// Classification labels. The SOURCE OF TRUTH is the co-located static JSON
|
||||
// (`classificationLabels.json`), imported here and shaped into typed objects —
|
||||
// edit THAT file, not this one. It's a fixed, built-in vocabulary shared by
|
||||
// everyone (no per-team customization); it drives the sidebar's grouping, icons,
|
||||
// and display names.
|
||||
// Default classification labels. The SOURCE OF TRUTH is the co-located static
|
||||
// JSON (`classificationLabels.json`), imported here and shaped into typed
|
||||
// objects — edit THAT file, not this one. This is the ONLY copy of the label
|
||||
// data: it seeds a team's editable set and drives the sidebar's grouping,
|
||||
// icons, and display names. Neither the backend nor the engine keeps a copy.
|
||||
//
|
||||
// The backend keeps a SECOND copy of this list
|
||||
// (`app/proprietary/src/main/resources/classification/classification-labels.json`)
|
||||
// which it sends to the engine per classify request (the engine holds no
|
||||
// vocabulary of its own). The two copies must not drift — `classificationLabels.drift.test.ts`
|
||||
// guards that. When you edit the labels here, update the backend copy too.
|
||||
// NOTE: this is only the built-in default vocabulary. A team's own
|
||||
// (admin-editable) labels live in the backend store and are what the backend
|
||||
// sends to the engine per classify request — the engine holds no vocabulary of
|
||||
// its own. Edits to a team's set reach Python on the next run, independent of
|
||||
// this file.
|
||||
//
|
||||
// `labels` is the flat set: each has a stable `id` (slug — the value on the wire,
|
||||
// in storage and keyed on) and a human `name` (display, translatable via
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* Curated palette of icons the classification labels use, rendered in the file
|
||||
* sidebar's label/category groups. Keys are Material Symbols names rendered via
|
||||
* {@link LocalIcon}.
|
||||
* Curated palette of icons a classification label can use, shown in the label
|
||||
* icon picker and rendered in the file sidebar's label groups. Keys are
|
||||
* Material Symbols names rendered via {@link LocalIcon}.
|
||||
*
|
||||
* IMPORTANT — this list is also the icon BUNDLING MANIFEST for classification.
|
||||
* The label/family icons live in `classificationLabels.json`, but the bundler
|
||||
* (`scripts/generate-icons.js`) only regex-scans `.ts/.tsx` for `icon: "…"`
|
||||
* literals, not JSON — so every icon those labels use must appear here as a
|
||||
* literal or it falls back to the CDN and renders blank offline. Do NOT delete
|
||||
* `LABEL_ICON_OPTIONS` as "unused": nothing imports it, but it's load-bearing
|
||||
* for offline icons. After editing, run `task frontend:prepare:icons`.
|
||||
* IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler
|
||||
* (`scripts/generate-icons.js`, which regex-scans for icon literals) picks every
|
||||
* one up and bundles it — otherwise a picked icon would fall back to the CDN and
|
||||
* render blank offline. After adding/removing entries run `task frontend:prepare:icons`.
|
||||
*
|
||||
* Search is intentionally omitted for now (no synonyms to maintain); the palette
|
||||
* is small enough to eyeball.
|
||||
*/
|
||||
|
||||
export interface LabelIconOption {
|
||||
/** Material Symbols key (no `material-symbols:` prefix). */
|
||||
icon: string;
|
||||
/** Short English name for the icon (documentation only). */
|
||||
/** Short English name — the en-US default for the icon's tooltip/aria; the
|
||||
* picker translates it via `policies.labels.iconName.<icon>`. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -187,3 +188,8 @@ export const LABEL_ICON_OPTIONS: LabelIconOption[] = [
|
||||
{ icon: "recycling", label: "Recycling" },
|
||||
{ icon: "agriculture", label: "Agriculture" },
|
||||
];
|
||||
|
||||
/** Set of valid palette keys, for validating a stored/imported icon. */
|
||||
export const LABEL_ICON_KEYS: ReadonlySet<string> = new Set(
|
||||
LABEL_ICON_OPTIONS.map((option) => option.icon),
|
||||
);
|
||||
|
||||
@@ -5,13 +5,18 @@
|
||||
* user's real files, not defined here.
|
||||
*/
|
||||
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import ShieldIcon from "@mui/icons-material/Shield";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import ComputerIcon from "@mui/icons-material/Computer";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import CloudIcon from "@mui/icons-material/Cloud";
|
||||
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
@@ -25,7 +30,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "Ingestion",
|
||||
icon: policyCategoryIcon("ingestion", ICON_SX),
|
||||
icon: <LayersIcon sx={ICON_SX} />,
|
||||
desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.",
|
||||
// The classifier the wizard's "Set up Classification" action routes to.
|
||||
providesClassification: true,
|
||||
@@ -35,13 +40,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: policyCategoryIcon("security", ICON_SX),
|
||||
icon: <ShieldIcon sx={ICON_SX} />,
|
||||
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "Classification",
|
||||
icon: policyCategoryIcon("classification", ICON_SX),
|
||||
icon: <LabelOutlinedIcon sx={ICON_SX} />,
|
||||
desc: "Identify each document's type on upload and tag its metadata for filing and search.",
|
||||
// Needs the AI engine to classify; hidden from the policy list when it's off.
|
||||
requiresAiEngine: true,
|
||||
@@ -49,21 +54,21 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "compliance",
|
||||
label: "Compliance",
|
||||
icon: policyCategoryIcon("compliance", ICON_SX),
|
||||
icon: <CheckCircleIcon sx={ICON_SX} />,
|
||||
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "routing",
|
||||
label: "Routing",
|
||||
icon: policyCategoryIcon("routing", ICON_SX),
|
||||
icon: <ArrowForwardIcon sx={ICON_SX} />,
|
||||
desc: "Auto-route documents to the right team, folder, or system.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "retention",
|
||||
label: "Retention",
|
||||
icon: policyCategoryIcon("retention", ICON_SX),
|
||||
icon: <StorageIcon sx={ICON_SX} />,
|
||||
desc: "Set how long documents are kept, when to archive, and when to delete.",
|
||||
comingSoon: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Loads and persists the team's classification labels
|
||||
* (`/api/v1/classification/labels`) — one server-truth set shared by the whole
|
||||
* team. When the team has none, this shows the built-in default as a starting
|
||||
* point for the editor/sidebar; note the classifier itself does NOT use that
|
||||
* default — the backend only classifies against a team's saved set (an unsaved
|
||||
* team is not classified). Editing is gated to team leaders / admins by the
|
||||
* backend — callers pass `canConfigure` (the policy gate) to keep read-only
|
||||
* users out of the save path.
|
||||
*
|
||||
* `teamLabels` is also what the sidebar/editor display uses — the team set, or
|
||||
* the built-in default when the team has none.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
import { fetchTeamLabels, saveTeamLabels } from "@app/services/labelsBackend";
|
||||
|
||||
export interface UseClassificationLabels {
|
||||
/** Server-truth team labels (or the built-in default when the team has none). */
|
||||
teamLabels: ClassificationLabel[];
|
||||
/** Whether the team has a stored set (vs. the built-in default). */
|
||||
isCustom: boolean;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
/** Last save failure, cleared on the next attempt. */
|
||||
error: string | null;
|
||||
/** Persist the team set; resolves once server state is updated. */
|
||||
saveTeam: (next: ClassificationLabel[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useClassificationLabels(
|
||||
enabled: boolean,
|
||||
): UseClassificationLabels {
|
||||
const [teamLabels, setTeamLabels] = useState<ClassificationLabel[]>(
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
);
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
const team = await fetchTeamLabels().catch(() => null);
|
||||
if (cancelled) return;
|
||||
setTeamLabels(team ?? DEFAULT_CLASSIFICATION_LABELS);
|
||||
setIsCustom(team != null);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
const saveTeam = useCallback(async (next: ClassificationLabel[]) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = await saveTeamLabels(next);
|
||||
setTeamLabels(saved);
|
||||
setIsCustom(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Couldn't save the labels.");
|
||||
throw e;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
teamLabels,
|
||||
isCustom,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
saveTeam,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
removePolicy,
|
||||
} from "@app/services/policyBackend";
|
||||
import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi";
|
||||
import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend";
|
||||
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
|
||||
import type { PolicyToStore } from "@app/services/policyPipeline";
|
||||
import type {
|
||||
PoliciesByCategory,
|
||||
@@ -137,6 +139,13 @@ export function usePolicies() {
|
||||
async (id: string, result: PolicyWizardResult) => {
|
||||
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
|
||||
if (!category) throw new Error(`Unknown policy category: ${id}`);
|
||||
// A policy whose chain classifies runs against the team's stored label set
|
||||
// (the engine has no default). Seed it with the built-in defaults now so
|
||||
// the very first enforced file has a vocabulary to classify against; no-op
|
||||
// if the team already has a set (never clobbers admin edits).
|
||||
if (getPolicyToolChain(id)?.includes("classify")) {
|
||||
await seedTeamLabelsIfEmpty();
|
||||
}
|
||||
// One policy per category, ever: reuse any existing backend record.
|
||||
const existingBackendId =
|
||||
loadPolicies()[id]?.backendId ??
|
||||
@@ -216,6 +225,12 @@ export function usePolicies() {
|
||||
async (id: string, result: PolicyConfigResult) => {
|
||||
const category = loadPolicyCatalog().categories.find((c) => c.id === id);
|
||||
if (!category) throw new Error(`Unknown policy category: ${id}`);
|
||||
// Seed the team's default label set on first classification-policy setup
|
||||
// (see enablePolicy) — the engine has no default, so the stored set is the
|
||||
// only vocabulary. No-op once the team has any set.
|
||||
if (getPolicyToolChain(id)?.includes("classify")) {
|
||||
await seedTeamLabelsIfEmpty();
|
||||
}
|
||||
const current = loadPolicies()[id];
|
||||
// One policy per category, ever: reuse the existing backend record (even
|
||||
// if the local link was lost) so a save never creates a duplicate.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user