Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca52e0b352 | ||
|
|
7f01bcdc44 | ||
|
|
0570c4c4d9 |
@@ -208,6 +208,18 @@
|
||||
"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,6 +66,20 @@ 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
@@ -0,0 +1,61 @@
|
||||
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
@@ -1,136 +0,0 @@
|
||||
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.
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
public record ClassificationLabels(List<ClassificationLabel> labels) {
|
||||
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
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
@@ -1,22 +0,0 @@
|
||||
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
@@ -1,36 +0,0 @@
|
||||
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
@@ -1,76 +0,0 @@
|
||||
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
@@ -1,47 +0,0 @@
|
||||
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
@@ -1,7 +0,0 @@
|
||||
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> {}
|
||||
+14
-32
@@ -31,10 +31,9 @@ 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;
|
||||
|
||||
@@ -46,7 +45,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 caller's team label set, and stores the engine's JSON
|
||||
* engine to classify the document against the built-in 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.
|
||||
@@ -72,13 +71,9 @@ public class ClassifyLabelController {
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The fixed, built-in vocabulary shared by everyone — see {@link ClassificationLabelProvider}.
|
||||
*/
|
||||
private final ClassificationLabelStore labelStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ClassificationLabelProvider labelProvider;
|
||||
|
||||
public ClassifyLabelController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -87,18 +82,16 @@ public class ClassifyLabelController {
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
ClassificationLabelProvider labelProvider,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
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)
|
||||
@@ -116,9 +109,9 @@ public class ClassifyLabelController {
|
||||
|
||||
List<EngineLabel> allowed = resolveAllowedLabels();
|
||||
if (allowed.isEmpty()) {
|
||||
// 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);
|
||||
// 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);
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
|
||||
@@ -175,24 +168,13 @@ public class ClassifyLabelController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private List<EngineLabel> resolveAllowedLabels() {
|
||||
if (labelStore == null) {
|
||||
return List.of();
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
|
||||
Map<String, EngineLabel> byId = new LinkedHashMap<>();
|
||||
labelStore.findByTeam(teamId).ifPresent(labels -> collectLabels(labels.labels(), byId));
|
||||
|
||||
collectLabels(labelProvider.labels(), byId);
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
|
||||
+38
-17
@@ -8,12 +8,14 @@ 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;
|
||||
@@ -24,18 +26,24 @@ 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 HTML string to a PDF via WeasyPrint.
|
||||
* Dispatchable tool that converts an AI-generated document model 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 HTML comes from a trusted Jinja
|
||||
* template so sanitization is intentionally skipped.
|
||||
* 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.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@@ -48,6 +56,9 @@ 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
|
||||
@@ -74,32 +85,42 @@ public class CreatePdfAgentController {
|
||||
value = "/create-pdf-from-html-agent",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Convert AI-generated HTML to a PDF",
|
||||
summary = "Convert an AI-generated document to a PDF",
|
||||
description =
|
||||
"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)
|
||||
"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)
|
||||
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 HTML to PDF via WeasyPrint — html_bytes={}",
|
||||
htmlContent.length());
|
||||
"[create-pdf-agent] converting document to PDF via WeasyPrint — html_bytes={}",
|
||||
html.length());
|
||||
|
||||
try (TempFile htmlFile = tempFileManager.createManagedTempFile(".html");
|
||||
TempFile pdfFile = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
|
||||
Files.writeString(htmlFile.getPath(), htmlContent, StandardCharsets.UTF_8);
|
||||
Files.writeString(htmlFile.getPath(), html, 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());
|
||||
|
||||
@@ -126,8 +147,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 document = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
document.save(tempOut.getPath().toFile());
|
||||
try (PDDocument pdDocument = pdfDocumentFactory.load(pdfFile.getPath())) {
|
||||
pdDocument.save(tempOut.getPath().toFile());
|
||||
} catch (Exception e) {
|
||||
tempOut.close();
|
||||
throw e;
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -37,8 +37,7 @@ 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.classification.store"
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -50,8 +49,7 @@ 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.classification.store"
|
||||
"stirling.software.proprietary.integration.model"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
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
+21
-19
@@ -1,3 +1,4 @@
|
||||
{%- autoescape true -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -175,18 +176,18 @@
|
||||
color: var(--color-label);
|
||||
}
|
||||
</style>
|
||||
{%- if doc.style %}
|
||||
{%- if style_primary or style_background or style_body %}
|
||||
<style>
|
||||
:root {
|
||||
{%- if doc.style.primary_color %}
|
||||
--color-primary: {{ doc.style.primary_color }};
|
||||
{%- if style_primary %}
|
||||
--color-primary: {{ style_primary }};
|
||||
{%- endif %}
|
||||
{%- if doc.style.background_color %}
|
||||
--color-bg: {{ doc.style.background_color }};
|
||||
{%- if style_background %}
|
||||
--color-bg: {{ style_background }};
|
||||
{%- endif %}
|
||||
{%- if doc.style.body_text_color %}
|
||||
--color-body: {{ doc.style.body_text_color }};
|
||||
--color-label: {{ doc.style.body_text_color }};
|
||||
{%- if style_body %}
|
||||
--color-body: {{ style_body }};
|
||||
--color-label: {{ style_body }};
|
||||
{%- endif %}
|
||||
}
|
||||
</style>
|
||||
@@ -195,16 +196,16 @@
|
||||
<body>
|
||||
|
||||
<div class="doc-header">
|
||||
<div class="doc-title">{{ doc.title }}</div>
|
||||
{%- if doc.subtitle %}
|
||||
<div class="doc-subtitle">{{ doc.subtitle }}</div>
|
||||
<div class="doc-title">{{ title }}</div>
|
||||
{%- if subtitle %}
|
||||
<div class="doc-subtitle">{{ subtitle }}</div>
|
||||
{%- endif %}
|
||||
{%- if doc.reference_number %}
|
||||
<div class="doc-reference">{{ doc.reference_number }}</div>
|
||||
{%- if reference_number %}
|
||||
<div class="doc-reference">{{ reference_number }}</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
{%- for section in doc.sections %}
|
||||
{%- for section in sections %}
|
||||
|
||||
{%- if section.type == "text" %}
|
||||
<section>
|
||||
@@ -212,8 +213,8 @@
|
||||
<h2>{{ section.heading }}</h2>
|
||||
{%- endif %}
|
||||
<div class="text-body">
|
||||
{%- for para in section.body.split('\n\n') %}
|
||||
<p>{{ para | replace('\n', ' ') }}</p>
|
||||
{%- for para in section.paragraphs %}
|
||||
<p>{{ para }}</p>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -225,10 +226,10 @@
|
||||
{%- endif %}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{%- for label, value in section.pairs %}
|
||||
{%- for pair in section.pairs %}
|
||||
<tr>
|
||||
<td class="kv-label">{{ label }}</td>
|
||||
<td class="kv-value">{{ value }}</td>
|
||||
<td class="kv-label">{{ pair.label }}</td>
|
||||
<td class="kv-value">{{ pair.value }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
@@ -299,3 +300,4 @@
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{%- endautoescape %}
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
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
@@ -1,146 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
+15
-34
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -27,10 +26,8 @@ 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;
|
||||
|
||||
@@ -42,22 +39,16 @@ 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;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
labelStore = new InProcessClassificationLabelStore();
|
||||
private void withLabels(List<ClassificationLabel> labels) {
|
||||
controller =
|
||||
new ClassifyLabelController(
|
||||
pdfDocumentFactory,
|
||||
@@ -66,9 +57,8 @@ class ClassifyLabelControllerTest {
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
null,
|
||||
labelStore,
|
||||
policyManagementAuthority);
|
||||
ClassificationLabelProvider.withLabels(labels),
|
||||
null);
|
||||
}
|
||||
|
||||
private void stubSinglePageDocument() throws Exception {
|
||||
@@ -98,12 +88,7 @@ class ClassifyLabelControllerTest {
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
labelStore.save(
|
||||
TEAM,
|
||||
new ClassificationLabels(
|
||||
List.of(new ClassificationLabel("invoice", "Invoice", null))),
|
||||
"admin");
|
||||
withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null)));
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -118,16 +103,12 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
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");
|
||||
void classifyAndLabel_sendsLabelIdsAndNames() throws Exception {
|
||||
withLabels(
|
||||
List.of(
|
||||
new ClassificationLabel("invoice", "Invoice", "receipt-long"),
|
||||
new ClassificationLabel("contract", "Contract", null),
|
||||
new ClassificationLabel("timesheet", "Timesheet", null)));
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
@@ -152,13 +133,13 @@ class ClassifyLabelControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndLabel_skipsClassificationWhenNothingStored() throws Exception {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
void classifyAndLabel_skipsClassificationWhenNoLabels() throws Exception {
|
||||
withLabels(List.of());
|
||||
|
||||
stubSinglePageDocument();
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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(
|
||||
"htmlContent",
|
||||
"<p>hi</p>",
|
||||
"document",
|
||||
"{\"title\":\"PO\",\"sections\":[]}",
|
||||
"filename",
|
||||
"purchase-order.pdf"))),
|
||||
PolicyInputs.of(List.of()),
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
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;"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 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;
|
||||
@@ -36,6 +36,8 @@ 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"
|
||||
@@ -222,6 +224,13 @@ 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,7 +5,6 @@ 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 — the
|
||||
# team's stored labels — so the engine holds no vocabulary of its own.
|
||||
# The caller (the backend) always supplies the allowed vocabulary — its
|
||||
# fixed built-in label set — 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. Jinja renders the document to HTML. The LLM never writes HTML.
|
||||
6. The assembled document is emitted as structured fields. 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,9 +22,7 @@ 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
|
||||
|
||||
@@ -51,8 +49,6 @@ 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.
|
||||
@@ -166,10 +162,13 @@ 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"). 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.
|
||||
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.
|
||||
|
||||
- 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
|
||||
@@ -299,15 +298,6 @@ 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("-")
|
||||
@@ -320,7 +310,6 @@ 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,
|
||||
@@ -401,14 +390,12 @@ class PdfCreateAgent:
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
# ── Phase 6: render ────────────────────────────────────────────────────
|
||||
logger.info("[pdf-create] phase 6/6: rendering HTML")
|
||||
html = self._render(doc)
|
||||
# ── Phase 6: emit ──────────────────────────────────────────────────────
|
||||
filename = _safe_filename(plan.title)
|
||||
logger.info(
|
||||
"[pdf-create] done — filename=%r html_bytes=%d",
|
||||
"[pdf-create] done — filename=%r sections=%d",
|
||||
filename,
|
||||
len(html),
|
||||
len(all_sections),
|
||||
)
|
||||
|
||||
return EditPlanResponse(
|
||||
@@ -417,7 +404,7 @@ class PdfCreateAgent:
|
||||
ToolOperationStep(
|
||||
tool=AgentToolId.CREATE_PDF_FROM_HTML_AGENT,
|
||||
parameters=CreatePdfFromHtmlAgentParams(
|
||||
html_content=html,
|
||||
document=doc.model_dump_json(),
|
||||
filename=filename,
|
||||
),
|
||||
)
|
||||
@@ -437,7 +424,3 @@ 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)
|
||||
|
||||
@@ -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 rendered HTML.
|
||||
CREATE_PDF_FROM_HTML_AGENT plan step carrying the assembled document.
|
||||
|
||||
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. Jinja renders GeneratedDocument → HTML. The LLM never writes HTML.
|
||||
5. The document is emitted as structured fields. The LLM never writes HTML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,14 +81,12 @@ type DocumentSection = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# 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}$")
|
||||
# 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}$")
|
||||
|
||||
|
||||
class DocumentStyle(ApiModel):
|
||||
"""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``."""
|
||||
"""Document colours, inferred by the meta planner. Non-hex values are dropped to ``None``."""
|
||||
|
||||
primary_color: str | None = Field(default=None)
|
||||
background_color: str | None = Field(default=None)
|
||||
@@ -103,7 +101,7 @@ class DocumentStyle(ApiModel):
|
||||
|
||||
|
||||
class GeneratedDocument(ApiModel):
|
||||
"""The full document model passed to Jinja for HTML rendering."""
|
||||
"""The full document model emitted for rendering."""
|
||||
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
|
||||
@@ -29,7 +29,7 @@ class PdfCommentAgentParams(ApiModel):
|
||||
|
||||
|
||||
class CreatePdfFromHtmlAgentParams(ApiModel):
|
||||
html_content: str
|
||||
document: str
|
||||
filename: str = Field(pattern=r"^.+\.pdf$")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Coverage:
|
||||
1. Section model validation (each section type round-trips correctly)
|
||||
2. Jinja rendering (_render produces valid HTML for each section type)
|
||||
2. orchestrate() emits the assembled document as structured JSON
|
||||
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,6 +12,8 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from conftest import build_app_settings
|
||||
from pydantic_ai.models.test import TestModel
|
||||
@@ -64,37 +66,6 @@ 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",
|
||||
@@ -199,82 +170,6 @@ 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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -396,8 +291,9 @@ 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")
|
||||
assert "<!DOCTYPE html>" in step.parameters.html_content
|
||||
assert "Invoice" in step.parameters.html_content
|
||||
parsed = json.loads(step.parameters.document)
|
||||
assert parsed["title"] == "Invoice"
|
||||
assert parsed["sections"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -468,9 +364,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)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "Introduction text." in html
|
||||
assert "Details" in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "Introduction text." in document
|
||||
assert "Details" in document
|
||||
|
||||
|
||||
# ── Style inference ───────────────────────────────────────────────────────────────────────────────
|
||||
@@ -482,7 +378,7 @@ async def test_orchestrate_applies_planner_inferred_style(agent: PdfCreateAgent)
|
||||
meta = DocumentMeta(
|
||||
title="Styled Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="magenta",
|
||||
style_primary_color="#ff00ff",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -499,50 +395,35 @@ 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)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "magenta" in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert json.loads(document)["style"]["primaryColor"] == "#ff00ff"
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
assert (safe.primary_color, safe.background_color, safe.body_text_color) == (
|
||||
"navy",
|
||||
"#1e3a5f",
|
||||
"#fff",
|
||||
"#ffffff",
|
||||
"#1A1A1A",
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
# A trailing newline must not slip a value through (fullmatch, not $-before-newline).
|
||||
assert DocumentStyle(primary_color="navy\n").primary_color is None
|
||||
assert DocumentStyle(primary_color="#1e3a5f\n").primary_color is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) -> None:
|
||||
"""An unsafe colour inferred by the meta planner never reaches the rendered HTML."""
|
||||
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."""
|
||||
meta = DocumentMeta(
|
||||
title="Doc",
|
||||
tone_brief="Professional.",
|
||||
style_primary_color="blue; background: url(http://evil.test/)",
|
||||
style_primary_color="rgb(0, 0, 255)",
|
||||
)
|
||||
sections = _simple_sections()
|
||||
written = _written_sections()
|
||||
@@ -559,6 +440,6 @@ async def test_orchestrate_drops_unsafe_planner_color(agent: PdfCreateAgent) ->
|
||||
result = await agent.orchestrate(_orchestrator_request("make it blue"))
|
||||
|
||||
assert isinstance(result, EditPlanResponse)
|
||||
html = result.steps[0].parameters.html_content # type: ignore[union-attr]
|
||||
assert "evil.test" not in html
|
||||
assert "url(" not in html
|
||||
document = result.steps[0].parameters.document # type: ignore[union-attr]
|
||||
assert "rgb(" not in document
|
||||
assert json.loads(document)["style"]["primaryColor"] is None
|
||||
|
||||
Generated
-2
@@ -604,7 +604,6 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
@@ -631,7 +630,6 @@ 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,6 +3608,8 @@ 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"
|
||||
@@ -3734,20 +3736,9 @@ 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"
|
||||
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”."
|
||||
reset = "Show all"
|
||||
subtitle = "Show or hide categories in the files sidebar."
|
||||
title = "Sidebar categories"
|
||||
|
||||
[filesPage]
|
||||
@@ -6014,31 +6005,11 @@ summaryMore = "{{first}}, {{second}} and {{more}} more"
|
||||
summaryTwo = "{{first}} and {{second}}"
|
||||
|
||||
[policies.labels]
|
||||
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"
|
||||
categoryCount = "categories"
|
||||
hideCategory = "Hide category"
|
||||
labelCount = "labels"
|
||||
sharedNote = "These labels are built in and shared across your whole team."
|
||||
showCategory = "Show category"
|
||||
|
||||
[policies.pii]
|
||||
account = "Account numbers (labelled)"
|
||||
@@ -7451,6 +7422,11 @@ 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."
|
||||
@@ -7475,6 +7451,13 @@ 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."
|
||||
|
||||
@@ -7569,6 +7552,7 @@ title = "No activity yet"
|
||||
[portal.policies.endpoints]
|
||||
addWatermark = "Watermark"
|
||||
autoRedact = "Redact PII"
|
||||
classifyAndLabel = "Classify"
|
||||
compressPdf = "Compress"
|
||||
flatten = "Flatten"
|
||||
ocrPdf = "OCR"
|
||||
@@ -7611,6 +7595,10 @@ 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"
|
||||
@@ -7635,6 +7623,10 @@ 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."
|
||||
|
||||
@@ -42,7 +42,8 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading, loadError } =
|
||||
useFileManager();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
// Get active file IDs from FileContext to show which files are already loaded
|
||||
@@ -240,6 +241,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
loadError={loadError}
|
||||
activeFileIds={activeFileIds}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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,10 +1,12 @@
|
||||
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 {
|
||||
@@ -29,7 +31,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
refreshRecentFiles,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -44,15 +48,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={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>
|
||||
) : (
|
||||
{recentFiles.length > 0 ? (
|
||||
filteredFiles.map((file, index) => {
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
@@ -84,6 +80,39 @@ 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>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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,6 +42,7 @@ interface FileManagerContextValue {
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
isLoading: boolean;
|
||||
loadError: boolean;
|
||||
activeFileIds: FileId[];
|
||||
|
||||
// Handlers
|
||||
@@ -96,6 +97,7 @@ interface FileManagerProviderProps {
|
||||
modalHeight: string;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
loadError: boolean;
|
||||
activeFileIds: FileId[];
|
||||
maxSelectable?: number | null;
|
||||
}
|
||||
@@ -115,6 +117,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
modalHeight,
|
||||
refreshRecentFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
maxSelectable = null,
|
||||
}) => {
|
||||
@@ -1089,6 +1092,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
|
||||
// Handlers
|
||||
@@ -1131,6 +1135,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
loadError,
|
||||
activeFileIds,
|
||||
handleSourceChange,
|
||||
handleStorageFilterChange,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
|
||||
interface StoredFileResponse {
|
||||
id: number;
|
||||
@@ -33,6 +34,7 @@ interface AccessedShareLinkResponse {
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const indexedDB = useIndexedDB();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
@@ -99,6 +101,11 @@ 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 [];
|
||||
@@ -122,6 +129,10 @@ 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 : [];
|
||||
@@ -242,6 +253,8 @@ export const useFileManager = () => {
|
||||
|
||||
combinedStubs = [...updatedLocalStubs, ...serverStubs];
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles.serverFiles" });
|
||||
console.warn("Failed to load server files:", error);
|
||||
}
|
||||
|
||||
@@ -252,6 +265,8 @@ 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
|
||||
@@ -346,6 +361,8 @@ export const useFileManager = () => {
|
||||
|
||||
combinedStubs = [...combinedStubs, ...sharedStubs];
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles.shareLinks" });
|
||||
console.warn("Failed to load shared links:", error);
|
||||
}
|
||||
}
|
||||
@@ -358,9 +375,12 @@ export const useFileManager = () => {
|
||||
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
encounteredError = true;
|
||||
captureException(error, { context: "loadRecentFiles" });
|
||||
console.error("Failed to load recent files:", error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoadError(encounteredError);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
@@ -505,6 +525,7 @@ export const useFileManager = () => {
|
||||
|
||||
return {
|
||||
loading,
|
||||
loadError,
|
||||
convertToFile,
|
||||
loadRecentFiles,
|
||||
handleRemoveFile,
|
||||
|
||||
@@ -38,3 +38,21 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
),
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -56,11 +56,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 {
|
||||
@@ -133,14 +133,21 @@ export interface CatalogueEntry {
|
||||
/* Endpoint display labels */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** i18n keys keyed by {@link ToolEndpoint}; labels stored steps in the detail view. */
|
||||
export const ENDPOINT_LABELS: Partial<Record<ToolEndpoint, string>> = {
|
||||
/**
|
||||
* 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>
|
||||
> = {
|
||||
"/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(
|
||||
@@ -170,7 +177,6 @@ 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,
|
||||
@@ -179,14 +185,20 @@ 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,
|
||||
@@ -194,7 +206,6 @@ 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,
|
||||
@@ -202,7 +213,6 @@ 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,
|
||||
@@ -264,6 +274,16 @@ export const POLICY_CONFIG: Record<string, PolicyConfigDef> = {
|
||||
],
|
||||
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: [
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CatalogueEntry,
|
||||
type PoliciesResponse,
|
||||
} from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
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>
|
||||
{policyIcon(entry.category.icon)}
|
||||
{policyCategoryIcon(entry.category.id)}
|
||||
</span>
|
||||
<div className="portal-policysum__cat-text">
|
||||
<strong>{t(entry.category.label)}</strong>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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,19 +1,28 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge } from "@app/ui";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
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 }: PolicyCategoryCardProps) {
|
||||
export function PolicyCategoryCard({
|
||||
entry,
|
||||
onOpen,
|
||||
locked = false,
|
||||
lockedLabel,
|
||||
}: PolicyCategoryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon;
|
||||
const openable = !comingSoon && !locked;
|
||||
const status = policy?.state.status;
|
||||
const enforces = config.rules.map((r) => t(r)).join(" · ");
|
||||
|
||||
@@ -21,7 +30,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
<Card
|
||||
className={
|
||||
"portal-policies__card" +
|
||||
(comingSoon ? " portal-policies__card--locked" : "")
|
||||
(comingSoon || locked ? " portal-policies__card--locked" : "")
|
||||
}
|
||||
interactive={openable}
|
||||
onClick={openable ? () => onOpen(entry) : undefined}
|
||||
@@ -39,7 +48,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
}
|
||||
>
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
{policyCategoryIcon(category.id)}
|
||||
</span>
|
||||
|
||||
<div className="portal-policies__card-identity">
|
||||
@@ -53,6 +62,10 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
<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,6 +8,9 @@ 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",
|
||||
@@ -47,3 +50,14 @@ 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,5 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type ReactNode } 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,
|
||||
@@ -29,12 +34,27 @@ import {
|
||||
import { fetchSources } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
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;
|
||||
@@ -119,6 +139,13 @@ const CAPABILITY_META: Record<
|
||||
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[] {
|
||||
@@ -179,9 +206,18 @@ function PolicySetupWizardBody({
|
||||
|
||||
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[]>(() => seedTools(entry));
|
||||
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 [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
@@ -190,11 +226,25 @@ function PolicySetupWizardBody({
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
const availableSources = useMemo(
|
||||
() =>
|
||||
(sourcesAsync.data?.sources ?? []).filter((s) => s.status !== "disabled"),
|
||||
[sourcesAsync.data],
|
||||
);
|
||||
const availableSources = useMemo(() => {
|
||||
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") ?? {
|
||||
id: "editor",
|
||||
name: t("portal.sources.types.editor.label"),
|
||||
type: "editor",
|
||||
status: "active" as const,
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources.filter((s) => s.id !== "editor")];
|
||||
}, [sourcesAsync.data, t]);
|
||||
// Document-type scoping has no UI; preserve any saved scope on edit and
|
||||
// default new policies to all document types.
|
||||
const [scopeTypes] = useState<string[]>(policy?.state.scopeTypes ?? []);
|
||||
@@ -285,7 +335,7 @@ function PolicySetupWizardBody({
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
{policyCategoryIcon(category.id)}
|
||||
</span>
|
||||
{isEdit
|
||||
? t("portal.policies.wizard.title.edit", {
|
||||
@@ -349,7 +399,25 @@ function PolicySetupWizardBody({
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "workflow" && (
|
||||
{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 && (
|
||||
<div className="portal-policies__wizard-section">
|
||||
<p className="portal-policies__wizard-desc">
|
||||
{t(
|
||||
@@ -448,35 +516,38 @@ function PolicySetupWizardBody({
|
||||
// The backend always returns the editor as a virtual source, so the
|
||||
// loaded list is never empty - no "no sources" state exists.
|
||||
<div className="portal-policies__sources">
|
||||
{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">
|
||||
{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}
|
||||
>
|
||||
<span className="portal-policies__source-label">
|
||||
{sourceIcon(src.type)}
|
||||
{src.name}
|
||||
</span>
|
||||
<span className="portal-policies__source-desc">
|
||||
{src.type}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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] ?? "•";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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,6 +12,7 @@ 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";
|
||||
@@ -32,6 +33,7 @@ export const handlers = [
|
||||
...teamSaasHandlers,
|
||||
...agentsHandlers,
|
||||
...policiesHandlers,
|
||||
...classificationHandlers,
|
||||
...documentsHandlers,
|
||||
...sdkComponentsHandlers,
|
||||
...editorDeployHandlers,
|
||||
|
||||
@@ -266,116 +266,23 @@
|
||||
}
|
||||
|
||||
/* 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: 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;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-policies__link {
|
||||
|
||||
@@ -20,6 +20,7 @@ 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() {
|
||||
@@ -34,6 +35,18 @@ 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),
|
||||
@@ -57,6 +70,11 @@ 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);
|
||||
}
|
||||
@@ -157,6 +175,8 @@ export function Policies() {
|
||||
key={entry.category.id}
|
||||
entry={entry}
|
||||
onOpen={openEntry}
|
||||
locked={isLocked(entry)}
|
||||
lockedLabel={t("portal.policies.card.requiresAiEngine")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 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
@@ -0,0 +1,46 @@
|
||||
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 = {};
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 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,19 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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 @@
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// `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,22 +1,21 @@
|
||||
/**
|
||||
* 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}.
|
||||
* 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}.
|
||||
*
|
||||
* 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.
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
export interface LabelIconOption {
|
||||
/** Material Symbols key (no `material-symbols:` prefix). */
|
||||
icon: string;
|
||||
/** Short English name — the en-US default for the icon's tooltip/aria; the
|
||||
* picker translates it via `policies.labels.iconName.<icon>`. */
|
||||
/** Short English name for the icon (documentation only). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -188,8 +187,3 @@ 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,18 +5,13 @@
|
||||
* 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 LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
|
||||
import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
@@ -30,7 +25,7 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "ingestion",
|
||||
label: "Ingestion",
|
||||
icon: <LayersIcon sx={ICON_SX} />,
|
||||
icon: policyCategoryIcon("ingestion", 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,
|
||||
@@ -40,13 +35,13 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "security",
|
||||
label: "Security",
|
||||
icon: <ShieldIcon sx={ICON_SX} />,
|
||||
icon: policyCategoryIcon("security", ICON_SX),
|
||||
desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.",
|
||||
},
|
||||
{
|
||||
id: "classification",
|
||||
label: "Classification",
|
||||
icon: <LabelOutlinedIcon sx={ICON_SX} />,
|
||||
icon: policyCategoryIcon("classification", 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,
|
||||
@@ -54,21 +49,21 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [
|
||||
{
|
||||
id: "compliance",
|
||||
label: "Compliance",
|
||||
icon: <CheckCircleIcon sx={ICON_SX} />,
|
||||
icon: policyCategoryIcon("compliance", ICON_SX),
|
||||
desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "routing",
|
||||
label: "Routing",
|
||||
icon: <ArrowForwardIcon sx={ICON_SX} />,
|
||||
icon: policyCategoryIcon("routing", ICON_SX),
|
||||
desc: "Auto-route documents to the right team, folder, or system.",
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: "retention",
|
||||
label: "Retention",
|
||||
icon: <StorageIcon sx={ICON_SX} />,
|
||||
icon: policyCategoryIcon("retention", ICON_SX),
|
||||
desc: "Set how long documents are kept, when to archive, and when to delete.",
|
||||
comingSoon: true,
|
||||
},
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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,8 +35,6 @@ 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,
|
||||
@@ -139,13 +137,6 @@ 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 ??
|
||||
@@ -225,12 +216,6 @@ 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.
|
||||
|
||||
@@ -13,8 +13,9 @@ const ALL_TOOL_IDS = Object.keys(POLICY_OPERATIONS) as PolicyToolId[];
|
||||
|
||||
describe("POLICY_OPERATIONS", () => {
|
||||
test("every category operation is a typed descriptor with a known endpoint", () => {
|
||||
// The catalogue uses these six across all categories; each must be wired.
|
||||
// The catalogue uses these across all categories; each must be wired.
|
||||
expect(ALL_TOOL_IDS.sort()).toEqual([
|
||||
"classify",
|
||||
"compress",
|
||||
"flatten",
|
||||
"ocr",
|
||||
|
||||
@@ -11,10 +11,39 @@ import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAd
|
||||
import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
|
||||
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
|
||||
import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import type { ToolOperationDescriptor } from "@app/hooks/tools/shared/toolOperationDescriptor";
|
||||
import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type { ToolEndpoint } from "@app/types/toolApiTypes";
|
||||
import type { WirePipelineStep } from "@app/policies/types";
|
||||
|
||||
/**
|
||||
* Endpoints for AI-dispatched policy tools. Intentionally NOT part of the generated
|
||||
* {@link ToolEndpoint} union: the backend controllers are `@Hidden` and `/api/v1/ai/tools/` is
|
||||
* deliberately excluded from tool-model generation, so they can't be typed like standard tools.
|
||||
*/
|
||||
export type AiPolicyEndpoint = "/api/v1/ai/tools/classify-and-label";
|
||||
|
||||
/** An AI tool usable in a policy — the AI-endpoint analogue of a standard tool descriptor. */
|
||||
export interface AiToolDescriptor<TParams> {
|
||||
readonly endpoint: AiPolicyEndpoint;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): Record<string, unknown>;
|
||||
fromApi(api: Record<string, unknown>): TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe an AI-dispatched policy tool. AI tools carry no tunable parameters today, so params are
|
||||
* empty and the (de)serializers are identity over an empty object.
|
||||
*/
|
||||
export function describeAiToolOperation(
|
||||
endpoint: AiPolicyEndpoint,
|
||||
): AiToolDescriptor<Record<string, never>> {
|
||||
return {
|
||||
endpoint,
|
||||
defaultParameters: {},
|
||||
toApi: () => ({}),
|
||||
fromApi: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
export const POLICY_OPERATIONS = {
|
||||
redact: describeToolOperation(
|
||||
"/api/v1/security/auto-redact",
|
||||
@@ -37,17 +66,13 @@ export const POLICY_OPERATIONS = {
|
||||
"/api/v1/misc/compress-pdf",
|
||||
compressOperationConfig,
|
||||
),
|
||||
classify: describeAiToolOperation("/api/v1/ai/tools/classify-and-label"),
|
||||
} as const;
|
||||
|
||||
export type PolicyToolId = keyof typeof POLICY_OPERATIONS;
|
||||
|
||||
export type PolicyParams<Id extends PolicyToolId> =
|
||||
(typeof POLICY_OPERATIONS)[Id] extends ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
infer P
|
||||
>
|
||||
? P
|
||||
: never;
|
||||
(typeof POLICY_OPERATIONS)[Id]["defaultParameters"];
|
||||
|
||||
/** Discriminated on `toolId` so `params` matches the tool. */
|
||||
export type PolicyToolStep = {
|
||||
@@ -65,7 +90,10 @@ const TOOL_ID_BY_ENDPOINT = new Map<string, PolicyToolId>(
|
||||
POLICY_TOOL_IDS.map((id) => [POLICY_OPERATIONS[id].endpoint, id]),
|
||||
);
|
||||
|
||||
export function policyEndpoint(toolId: PolicyToolId): ToolEndpoint {
|
||||
/** A policy step's endpoint: a standard {@link ToolEndpoint} or an {@link AiPolicyEndpoint}. */
|
||||
export type PolicyEndpoint = ToolEndpoint | AiPolicyEndpoint;
|
||||
|
||||
export function policyEndpoint(toolId: PolicyToolId): PolicyEndpoint {
|
||||
return POLICY_OPERATIONS[toolId].endpoint;
|
||||
}
|
||||
|
||||
@@ -90,19 +118,29 @@ export function policyStepToWire(step: PolicyToolStep): WirePipelineStep {
|
||||
return serializeStep(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal runtime shape shared by standard tool descriptors and {@link AiToolDescriptor}s — enough
|
||||
* to (de)serialize a step at the wire boundary regardless of how its endpoint is typed.
|
||||
*/
|
||||
interface PolicyOperation<TParams> {
|
||||
readonly endpoint: string;
|
||||
readonly defaultParameters: TParams;
|
||||
toApi(params: TParams): Record<string, unknown>;
|
||||
fromApi(api: Record<string, unknown>): TParams;
|
||||
}
|
||||
|
||||
// Generic over the id so `params` stays correlated with the descriptor; TS can't do that through
|
||||
// the union, so `op` is widened here (a contained cast at the wire boundary).
|
||||
function serializeStep<Id extends PolicyToolId>(step: {
|
||||
toolId: Id;
|
||||
params: PolicyParams<Id>;
|
||||
}): WirePipelineStep {
|
||||
const op = POLICY_OPERATIONS[step.toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
const op = POLICY_OPERATIONS[step.toolId] as unknown as PolicyOperation<
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
return {
|
||||
operation: op.endpoint,
|
||||
parameters: op.toApi(step.params) as Record<string, unknown>,
|
||||
parameters: op.toApi(step.params),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,13 +157,10 @@ function deserializeStep<Id extends PolicyToolId>(
|
||||
toolId: Id,
|
||||
parameters: Record<string, unknown>,
|
||||
): PolicyToolStepOf<Id> {
|
||||
const op = POLICY_OPERATIONS[toolId] as ToolOperationDescriptor<
|
||||
ToolEndpoint,
|
||||
const op = POLICY_OPERATIONS[toolId] as unknown as PolicyOperation<
|
||||
PolicyParams<Id>
|
||||
>;
|
||||
// Wire params are untyped JSON; this is the one point they enter the typed model.
|
||||
const params = op.fromApi(
|
||||
parameters as unknown as ToolApiParams[ToolEndpoint],
|
||||
);
|
||||
const params = op.fromApi(parameters);
|
||||
return { toolId, params } as unknown as PolicyToolStepOf<Id>;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,82 @@
|
||||
// Device-local (localStorage) category structure for the Files sidebar: which parent
|
||||
// categories exist, their name/icon/order, and which labels roll up into each. It's an editable
|
||||
// override of the built-in LABEL_FAMILIES default — until the user customizes it, the default is
|
||||
// used verbatim (so the store stays empty and the default can evolve). A label may sit in more
|
||||
// than one category (multi-membership); a category with `hidden` set stays defined but isn't shown
|
||||
// as a sidebar group. This is presentational only — the classifier never sees categories.
|
||||
// The Files-sidebar categories are a fixed, built-in set shared by everyone (derived from
|
||||
// LABEL_FAMILIES) — the team can't create, rename, or re-group them. The only per-user choice is
|
||||
// which categories to SHOW or HIDE in the sidebar, kept device-local in localStorage. A hidden
|
||||
// category isn't rendered as a group; its files fall to "Other". Presentational only — the
|
||||
// classifier never sees categories.
|
||||
|
||||
import { LABEL_FAMILIES } from "@app/data/classificationLabels";
|
||||
|
||||
export interface SidebarCategory {
|
||||
/** Stable id — built-ins reuse their family id; custom ones get `custom:<n>`. */
|
||||
/** Stable id (the label family's id). */
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
/** Label ids in this category (matches a file's stored classification ids). */
|
||||
labelKeys: string[];
|
||||
/** Defined but not rendered as a sidebar group. */
|
||||
/** Hidden from the sidebar (device-local, personal). */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
// v2: labelKeys hold label ids (was lower-cased names in v1); bumping discards
|
||||
// stale name-keyed prefs so they don't silently stop matching.
|
||||
const STORAGE_KEY = "stirling.fileSidebarCategories.v2";
|
||||
const HIDDEN_STORAGE_KEY = "stirling.fileSidebarHiddenCategories.v1";
|
||||
|
||||
/** The built-in default, derived from LABEL_FAMILIES. Fresh copy per call (callers may mutate). */
|
||||
export function defaultCategories(): SidebarCategory[] {
|
||||
return LABEL_FAMILIES.map((family) => ({
|
||||
/** The fixed, shared category set — never mutated. */
|
||||
const BASE_CATEGORIES: readonly Omit<SidebarCategory, "hidden">[] =
|
||||
LABEL_FAMILIES.map((family) => ({
|
||||
id: family.id,
|
||||
name: family.name,
|
||||
icon: family.icon,
|
||||
labelKeys: family.labels.map((label) => label.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function readStorage(): SidebarCategory[] | null {
|
||||
function readHidden(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed
|
||||
.filter((c): c is SidebarCategory => {
|
||||
const cat = c as Partial<SidebarCategory>;
|
||||
return (
|
||||
typeof cat.id === "string" &&
|
||||
typeof cat.name === "string" &&
|
||||
typeof cat.icon === "string" &&
|
||||
Array.isArray(cat.labelKeys)
|
||||
);
|
||||
})
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
icon: c.icon,
|
||||
labelKeys: c.labelKeys.filter((k) => typeof k === "string"),
|
||||
hidden: c.hidden === true,
|
||||
}));
|
||||
return Array.isArray(parsed)
|
||||
? new Set(parsed.filter((id): id is string => typeof id === "string"))
|
||||
: new Set();
|
||||
} catch {
|
||||
return null;
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
// null = using the built-in default (not yet customized). Cached so useSyncExternalStore sees a
|
||||
// stable reference between writes.
|
||||
let stored: SidebarCategory[] | null = readStorage();
|
||||
let hiddenIds = readHidden();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// Effective list, recomputed only on write so its identity is stable for memo/useSyncExternalStore.
|
||||
let effective: SidebarCategory[] = stored ?? defaultCategories();
|
||||
// Recomputed only on write so its identity is stable for memo/useSyncExternalStore.
|
||||
let effective: SidebarCategory[] = compute();
|
||||
|
||||
function recompute() {
|
||||
effective = stored ?? defaultCategories();
|
||||
function compute(): SidebarCategory[] {
|
||||
return BASE_CATEGORIES.map((c) => ({
|
||||
...c,
|
||||
labelKeys: [...c.labelKeys],
|
||||
hidden: hiddenIds.has(c.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function write(next: SidebarCategory[]) {
|
||||
stored = next;
|
||||
recompute();
|
||||
function persist() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...hiddenIds]));
|
||||
} catch {
|
||||
// Quota/private-mode failures degrade to session-only categories.
|
||||
// Quota/private-mode failures degrade to session-only visibility.
|
||||
}
|
||||
effective = compute();
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Mutate the current effective list (snapshotting the default on first edit). */
|
||||
function mutate(fn: (categories: SidebarCategory[]) => SidebarCategory[]) {
|
||||
write(fn(effective.map((c) => ({ ...c, labelKeys: [...c.labelKeys] }))));
|
||||
}
|
||||
|
||||
/** The shared categories, each flagged with the user's device-local visibility. */
|
||||
export function getSidebarCategories(): SidebarCategory[] {
|
||||
return effective;
|
||||
}
|
||||
|
||||
/** Show or hide a category in this device's sidebar. */
|
||||
export function setCategoryHidden(id: string, hidden: boolean) {
|
||||
if (hidden) hiddenIds.add(id);
|
||||
else hiddenIds.delete(id);
|
||||
persist();
|
||||
}
|
||||
|
||||
export function subscribeSidebarCategories(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
@@ -99,98 +84,8 @@ export function subscribeSidebarCategories(listener: () => void) {
|
||||
};
|
||||
}
|
||||
|
||||
export function isCustomized(): boolean {
|
||||
return stored !== null;
|
||||
}
|
||||
|
||||
/** Map each label key to the ids of every VISIBLE category it belongs to. */
|
||||
export function labelCategoryMap(
|
||||
categories: SidebarCategory[],
|
||||
): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const category of categories) {
|
||||
if (category.hidden) continue;
|
||||
for (const key of category.labelKeys) {
|
||||
const ids = map.get(key);
|
||||
if (ids) ids.push(category.id);
|
||||
else map.set(key, [category.id]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Set of every label key that belongs to any category (visible or not). */
|
||||
export function categorizedLabelKeys(
|
||||
categories: SidebarCategory[],
|
||||
): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const category of categories) {
|
||||
for (const key of category.labelKeys) keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ---- editing ----
|
||||
|
||||
/** Create a new empty category; returns its id. */
|
||||
export function addCategory(name: string, icon: string): string {
|
||||
const id = `custom:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${
|
||||
effective.length
|
||||
}`;
|
||||
mutate((categories) => [...categories, { id, name, icon, labelKeys: [] }]);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function renameCategory(id: string, name: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) => (c.id === id ? { ...c, name } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
export function setCategoryIcon(id: string, icon: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) => (c.id === id ? { ...c, icon } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
export function setCategoryHidden(id: string, hidden: boolean) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) => (c.id === id ? { ...c, hidden } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCategory(id: string) {
|
||||
mutate((categories) => categories.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
export function addLabelToCategory(id: string, labelId: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) =>
|
||||
c.id === id && !c.labelKeys.includes(labelId)
|
||||
? { ...c, labelKeys: [...c.labelKeys, labelId] }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeLabelFromCategory(id: string, labelId: string) {
|
||||
mutate((categories) =>
|
||||
categories.map((c) =>
|
||||
c.id === id
|
||||
? { ...c, labelKeys: c.labelKeys.filter((k) => k !== labelId) }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Restore the built-in default and clear the customized flag (undoes any prior `mutate`). */
|
||||
export function resetSidebarCategories() {
|
||||
stored = null;
|
||||
recompute();
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Quota/private-mode failures degrade to session-only categories.
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
/** Show every category again (clear the device-local hidden set). */
|
||||
export function resetHiddenCategories() {
|
||||
hiddenIds = new Set();
|
||||
persist();
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels";
|
||||
import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend";
|
||||
|
||||
vi.mock("@app/services/apiClient");
|
||||
|
||||
const get = vi.mocked(apiClient.get);
|
||||
const put = vi.mocked(apiClient.put);
|
||||
|
||||
// The service only reads `status`/`data` off the axios response; a partial shape
|
||||
// is all these tests need, so cast the mock values through this helper.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = (value: object): any => value;
|
||||
|
||||
// 204 No Content (nothing stored) comes back as an empty body.
|
||||
const emptyResponse = res({ status: 204, data: "" });
|
||||
const storedResponse = res({
|
||||
status: 200,
|
||||
data: { labels: [{ id: "invoice", name: "Invoice", icon: "receipt" }] },
|
||||
});
|
||||
const putEcho = res({ data: { labels: DEFAULT_CLASSIFICATION_LABELS } });
|
||||
|
||||
describe("seedTeamLabelsIfEmpty", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("seeds the built-in defaults when the team has no set", async () => {
|
||||
get.mockResolvedValue(emptyResponse);
|
||||
put.mockResolvedValue(putEcho);
|
||||
|
||||
await seedTeamLabelsIfEmpty();
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
expect(put).toHaveBeenCalledWith("/api/v1/classification/labels", {
|
||||
labels: DEFAULT_CLASSIFICATION_LABELS,
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when the team already has a set (never clobbers)", async () => {
|
||||
get.mockResolvedValue(storedResponse);
|
||||
|
||||
await seedTeamLabelsIfEmpty();
|
||||
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rides out a transient fetch failure, then seeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
get
|
||||
.mockRejectedValueOnce(new Error("network blip"))
|
||||
.mockResolvedValueOnce(emptyResponse);
|
||||
put.mockResolvedValue(putEcho);
|
||||
|
||||
const pending = seedTeamLabelsIfEmpty();
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rides out a transient write failure, then seeds", async () => {
|
||||
vi.useFakeTimers();
|
||||
get.mockResolvedValue(emptyResponse);
|
||||
put
|
||||
.mockRejectedValueOnce(new Error("network blip"))
|
||||
.mockResolvedValueOnce(putEcho);
|
||||
|
||||
const pending = seedTeamLabelsIfEmpty();
|
||||
await vi.runAllTimersAsync();
|
||||
await pending;
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws after a persistent fetch failure without writing (clobber-safe)", async () => {
|
||||
vi.useFakeTimers();
|
||||
get.mockRejectedValue(new Error("backend down"));
|
||||
|
||||
const pending = seedTeamLabelsIfEmpty();
|
||||
const assertion = expect(pending).rejects.toThrow("backend down");
|
||||
await vi.runAllTimersAsync();
|
||||
await assertion;
|
||||
|
||||
// Never writes when the fetch can't confirm the team has no set.
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("throws after a persistent write failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
get.mockResolvedValue(emptyResponse);
|
||||
put.mockRejectedValue(new Error("write failed"));
|
||||
|
||||
const pending = seedTeamLabelsIfEmpty();
|
||||
const assertion = expect(pending).rejects.toThrow("write failed");
|
||||
await vi.runAllTimersAsync();
|
||||
await assertion;
|
||||
|
||||
expect(put).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Backend layer for the team's classification labels
|
||||
* (`/api/v1/classification/labels`) — one shared, server-truth list, editable
|
||||
* only by a team leader (SaaS) / admin (self-hosted). A team with none reads as
|
||||
* 204 → `null`, and callers fall back to the built-in
|
||||
* {@link DEFAULT_CLASSIFICATION_LABELS}.
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import {
|
||||
DEFAULT_CLASSIFICATION_LABELS,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
|
||||
const TEAM_ENDPOINT = "/api/v1/classification/labels";
|
||||
|
||||
/** Wire shape shared with the backend: the label list wrapped in an object. */
|
||||
interface LabelsPayload {
|
||||
labels: ClassificationLabel[];
|
||||
}
|
||||
|
||||
async function fetchLabels(
|
||||
endpoint: string,
|
||||
): Promise<ClassificationLabel[] | null> {
|
||||
const res = await apiClient.get<LabelsPayload | "">(endpoint, {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
// 204 No Content (nothing stored) comes back as an empty body. Only an
|
||||
// explicit 204 / empty string means "none"; anything else is a real payload.
|
||||
if (res.status === 204 || res.data === "") return null;
|
||||
return (res.data as LabelsPayload).labels ?? [];
|
||||
}
|
||||
|
||||
async function saveLabels(
|
||||
endpoint: string,
|
||||
labels: ClassificationLabel[],
|
||||
): Promise<ClassificationLabel[]> {
|
||||
const res = await apiClient.put<LabelsPayload>(endpoint, { labels });
|
||||
return res.data.labels ?? [];
|
||||
}
|
||||
|
||||
/** The team's stored labels, or `null` when it has none (use the default). */
|
||||
export function fetchTeamLabels(): Promise<ClassificationLabel[] | null> {
|
||||
return fetchLabels(TEAM_ENDPOINT);
|
||||
}
|
||||
|
||||
/** Persist the team's labels; returns the stored value. */
|
||||
export function saveTeamLabels(
|
||||
labels: ClassificationLabel[],
|
||||
): Promise<ClassificationLabel[]> {
|
||||
return saveLabels(TEAM_ENDPOINT, labels);
|
||||
}
|
||||
|
||||
/** Seed-write retry budget: the seed is a hard prerequisite (see below), so ride
|
||||
* out a transient blip rather than fail setup on the first hiccup. */
|
||||
const SEED_MAX_ATTEMPTS = 3;
|
||||
const SEED_RETRY_MS = 400;
|
||||
|
||||
async function withRetry<T>(op: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < SEED_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await op();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < SEED_MAX_ATTEMPTS - 1) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, SEED_RETRY_MS * (attempt + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the team's label set with the built-in defaults when it has none yet.
|
||||
*
|
||||
* The engine holds no default vocabulary, so the backend can only send what the
|
||||
* team has stored — a team with an empty set classifies nothing (documents come
|
||||
* back unlabelled and don't group). This writes the frontend's single default
|
||||
* copy into the team set the first time a classification policy is set up, so
|
||||
* classification works out of the box; the backend gates the write to admins.
|
||||
*
|
||||
* The seed is a hard prerequisite of enabling a classification policy, so it must
|
||||
* land or fail loudly — never silently leave an empty set behind a created
|
||||
* policy. Both the fetch and the write retry a transient failure; if either
|
||||
* ultimately fails this THROWS, and the caller aborts the setup (the admin sees
|
||||
* the error and retries) instead of shipping a policy that classifies nothing —
|
||||
* the same way a failed policy save already aborts setup. Still clobber-safe: it
|
||||
* writes only when the fetch DEFINITIVELY reports no set (204 → null), so it
|
||||
* never overwrites a team's real (possibly customised) labels, and it's a no-op
|
||||
* once any set exists (later admin edits are the source of truth).
|
||||
*/
|
||||
export async function seedTeamLabelsIfEmpty(): Promise<void> {
|
||||
const existing = await withRetry(fetchTeamLabels);
|
||||
if (existing != null) return;
|
||||
await withRetry(() => saveTeamLabels(DEFAULT_CLASSIFICATION_LABELS));
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Client-side import/export + validation for a classification-labels JSON file
|
||||
* (`{"labels":[{"id":"invoice","name":"Invoice","icon":"receipt-long"}, …]}`).
|
||||
* Sharing a label set between teams is done by exporting the JSON here and
|
||||
* importing it on another team. Validation mirrors the backend
|
||||
* (`LabelsValidator`) so a malformed file is caught before it's uploaded — the
|
||||
* backend re-validates as the authority. A file may omit `id` (e.g. an older
|
||||
* export); it is then derived from the name.
|
||||
*/
|
||||
|
||||
import { downloadJsonAsFile } from "@app/utils/downloadUtils";
|
||||
import { LABEL_ICON_KEYS } from "@app/data/labelIcons";
|
||||
import {
|
||||
labelId,
|
||||
type ClassificationLabel,
|
||||
} from "@app/data/classificationLabels";
|
||||
|
||||
// Kept in sync with the backend LabelsValidator (the authority); enforced here
|
||||
// too so an oversized import is rejected before upload.
|
||||
const MAX_LABELS = 500;
|
||||
const MAX_TEXT_LENGTH = 128;
|
||||
|
||||
interface LabelsFileShape {
|
||||
labels: ClassificationLabel[];
|
||||
}
|
||||
|
||||
/** A label's effective id: the provided one, else derived from the name. */
|
||||
function effectiveId(label: Partial<ClassificationLabel>): string {
|
||||
const provided = typeof label.id === "string" ? label.id.trim() : "";
|
||||
return provided || labelId(label.name?.trim() ?? "");
|
||||
}
|
||||
|
||||
/** Human-readable problems with a candidate labels file; empty means valid. */
|
||||
export function validateLabels(value: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return ["File is not a labels object."];
|
||||
}
|
||||
const { labels } = value as Partial<LabelsFileShape>;
|
||||
if (!Array.isArray(labels)) {
|
||||
return ['File must have a "labels" list.'];
|
||||
}
|
||||
if (labels.length > MAX_LABELS) {
|
||||
errors.push(`Too many labels (max ${MAX_LABELS}).`);
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
for (const label of labels) {
|
||||
if (!isText(label?.name)) {
|
||||
errors.push("Every label needs a non-empty name.");
|
||||
continue;
|
||||
}
|
||||
const name = label.name.trim();
|
||||
if (name.length > MAX_TEXT_LENGTH) {
|
||||
errors.push(`Label "${name}" is over ${MAX_TEXT_LENGTH} characters.`);
|
||||
}
|
||||
const id = effectiveId(label);
|
||||
if (!id) {
|
||||
errors.push(`Label "${name}" has no usable id.`);
|
||||
continue;
|
||||
}
|
||||
if (seenIds.has(id)) errors.push(`Duplicate label: ${name}`);
|
||||
seenIds.add(id);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Coerce a validated value into a normalized label list (trims, drops extras). */
|
||||
export function normalizeLabels(
|
||||
labels: ClassificationLabel[],
|
||||
): ClassificationLabel[] {
|
||||
return labels.map((label): ClassificationLabel => {
|
||||
const name = label.name.trim();
|
||||
const id = effectiveId(label);
|
||||
// Keep the icon only if it's a known palette key, so a hand-crafted import
|
||||
// can't set an unbundled key that renders blank.
|
||||
return label.icon && LABEL_ICON_KEYS.has(label.icon)
|
||||
? { id, name, icon: label.icon }
|
||||
: { id, name };
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse + validate a picked file, resolving to a normalized label list. */
|
||||
export async function parseLabelsFile(
|
||||
file: File,
|
||||
): Promise<ClassificationLabel[]> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await file.text());
|
||||
} catch {
|
||||
throw new Error("That file isn't valid JSON.");
|
||||
}
|
||||
const errors = validateLabels(parsed);
|
||||
if (errors.length > 0) throw new Error(errors[0]);
|
||||
return normalizeLabels((parsed as LabelsFileShape).labels);
|
||||
}
|
||||
|
||||
/** Trigger a download of the labels as a pretty-printed JSON file. */
|
||||
export function downloadLabels(
|
||||
labels: ClassificationLabel[],
|
||||
fileName = "classification-labels.json",
|
||||
): void {
|
||||
downloadJsonAsFile({ labels }, fileName);
|
||||
}
|
||||
|
||||
function isText(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
/* Category-manager modal: editable parent categories with member-label chips.
|
||||
Matches the labels editor's width so the two read as a set. */
|
||||
.fsg-modal {
|
||||
width: min(1100px, 94vw);
|
||||
max-width: min(1100px, 94vw);
|
||||
}
|
||||
|
||||
/* Category show/hide modal (Files-sidebar visibility picker). */
|
||||
.fsg-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -14,119 +8,6 @@
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.fsg-cat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fsg-cat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, inherit);
|
||||
}
|
||||
|
||||
.fsg-cat-toggle {
|
||||
display: inline-flex;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fsg-chevron {
|
||||
font-size: 1.1rem !important;
|
||||
color: var(--text-muted, rgba(128, 128, 128, 0.9));
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.fsg-cat-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.fsg-cat-name:hover {
|
||||
background: var(--hover-bg, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
|
||||
.fsg-rename {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fsg-cat-action {
|
||||
display: inline-flex;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, rgba(128, 128, 128, 0.9));
|
||||
}
|
||||
|
||||
.fsg-cat-action:hover {
|
||||
background: var(--hover-bg, rgba(128, 128, 128, 0.12));
|
||||
color: var(--text-primary, inherit);
|
||||
}
|
||||
|
||||
.fsg-cat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
/* Indent past the chevron so the hierarchy reads at a glance. */
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
/* Member chips are the shared @app/ui/LabelChip. */
|
||||
.fsg-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fsg-count {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, rgba(128, 128, 128, 0.9));
|
||||
}
|
||||
|
||||
.fsg-add,
|
||||
.fsg-new {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fsg-new {
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.2));
|
||||
}
|
||||
|
||||
.fsg-add-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.35));
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, inherit);
|
||||
}
|
||||
|
||||
.fsg-add-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #4f46e5);
|
||||
}
|
||||
|
||||
.fsg-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,74 +1,46 @@
|
||||
// The Files-sidebar category manager: a "tune" button opening a modal where you shape the parent categories your files group under. Each category (collapsible, busiest first) has an editable name + icon, a hide toggle, delete, and its member-label chips; add existing team labels to a category, create new categories, reset to the built-in defaults. All device-local (grouping only — it never changes the team's label vocabulary); files in no visible category fall back to "Other".
|
||||
// The Files-sidebar category picker: a "tune" button opening a modal that lists the fixed, shared
|
||||
// categories and lets the user show or hide each one in their own sidebar. Device-local and
|
||||
// presentational only — it never changes the shared categories or the label vocabulary; files in a
|
||||
// hidden (or no) category fall back to "Other".
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { Modal } from "@app/ui/Modal";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { LabelChip } from "@app/ui/LabelChip";
|
||||
import { LabelIconPicker } from "@app/components/policies/LabelIconPicker";
|
||||
import { ClassificationCategoryManager } from "@app/components/policies/ClassificationCategoryManager";
|
||||
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
|
||||
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
|
||||
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
import { bucketStubsByLabel } from "@app/components/shared/fileSidebarGroupingLogic";
|
||||
import {
|
||||
addCategory,
|
||||
addLabelToCategory,
|
||||
deleteCategory,
|
||||
getSidebarCategories,
|
||||
removeLabelFromCategory,
|
||||
renameCategory,
|
||||
resetSidebarCategories,
|
||||
resetHiddenCategories,
|
||||
setCategoryHidden,
|
||||
setCategoryIcon,
|
||||
subscribeSidebarCategories,
|
||||
} from "@app/services/fileSidebarCategories";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import "@app/components/shared/FileSidebarGroupControls.css";
|
||||
|
||||
interface FileSidebarGroupControlsProps {
|
||||
/** The files currently listed, for live per-label counts. */
|
||||
/** The files currently listed, for live per-category counts. */
|
||||
stubs: StirlingFileStub[];
|
||||
}
|
||||
|
||||
/** New categories start with a neutral folder icon the user can change. */
|
||||
const NEW_CATEGORY_ICON = "folder";
|
||||
|
||||
export function FileSidebarGroupControls({
|
||||
stubs,
|
||||
}: FileSidebarGroupControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const enabled = useClassificationEnabled();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const categories = useSyncExternalStore(
|
||||
subscribeSidebarCategories,
|
||||
getSidebarCategories,
|
||||
);
|
||||
// Only fetch the team label set while the picker is open.
|
||||
const { teamLabels: labelSet } = useClassificationLabels(open);
|
||||
|
||||
// Bucketed once and reused for both the per-label and per-category counts below.
|
||||
const byLabel = useMemo(() => bucketStubsByLabel(stubs), [stubs]);
|
||||
|
||||
// Per-label file counts from the same bucketing the sidebar groups use.
|
||||
const labelCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const [key, bucket] of byLabel) counts.set(key, bucket.stubs.length);
|
||||
return counts;
|
||||
}, [byLabel]);
|
||||
|
||||
// Files per category (deduped across its labels).
|
||||
// Files per category (deduped across its labels), from the same bucketing the sidebar groups use.
|
||||
const categoryCounts = useMemo(() => {
|
||||
const byLabel = bucketStubsByLabel(stubs);
|
||||
const counts = new Map<string, number>();
|
||||
for (const category of categories) {
|
||||
const ids = new Set<string>();
|
||||
@@ -80,90 +52,7 @@ export function FileSidebarGroupControls({
|
||||
counts.set(category.id, ids.size);
|
||||
}
|
||||
return counts;
|
||||
}, [byLabel, categories]);
|
||||
|
||||
// Translated display name + icon per label id, plus a name→id lookup for the
|
||||
// add-label input (which matches on the visible/canonical text).
|
||||
const vocab = useMemo(() => {
|
||||
const byId = new Map<string, { display: string; icon?: string }>();
|
||||
const idByName = new Map<string, string>();
|
||||
for (const label of labelSet) {
|
||||
const display = t(`classification.labels.${label.id}`, label.name);
|
||||
byId.set(label.id, { display, icon: label.icon });
|
||||
idByName.set(display.toLowerCase(), label.id);
|
||||
idByName.set(label.name.toLowerCase(), label.id);
|
||||
}
|
||||
return { byId, idByName };
|
||||
}, [labelSet, t]);
|
||||
|
||||
const labelDisplay = (id: string) => vocab.byId.get(id)?.display ?? id;
|
||||
const labelIcon = (id: string) =>
|
||||
vocab.byId.get(id)?.icon ?? DEFAULT_LABEL_ICON;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = (text: string) => q === "" || text.toLowerCase().includes(q);
|
||||
|
||||
// Busiest categories first (ties keep declaration order).
|
||||
const sortedCategories = useMemo(
|
||||
() =>
|
||||
[...categories].sort(
|
||||
(a, b) =>
|
||||
(categoryCounts.get(b.id) ?? 0) - (categoryCounts.get(a.id) ?? 0),
|
||||
),
|
||||
[categories, categoryCounts],
|
||||
);
|
||||
|
||||
// Per-category collapse; on open only the busiest starts expanded.
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
|
||||
const toggleExpanded = (id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Inline rename + add-label drafts, keyed by category id.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [addDraft, setAddDraft] = useState<Record<string, string>>({});
|
||||
const [newCategory, setNewCategory] = useState("");
|
||||
|
||||
const openPicker = () => {
|
||||
setQuery("");
|
||||
setRenaming(null);
|
||||
setNewCategory("");
|
||||
setExpanded(
|
||||
new Set(sortedCategories.length > 0 ? [sortedCategories[0].id] : []),
|
||||
);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const commitRename = (id: string) => {
|
||||
const name = renameDraft.trim();
|
||||
if (name) renameCategory(id, name);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
// Add an existing team label to a category (device-local grouping only — categories don't change
|
||||
// the team vocabulary). A name that isn't a known team label is ignored; the input's datalist
|
||||
// steers callers to real ones.
|
||||
const addLabel = (categoryId: string) => {
|
||||
const name = (addDraft[categoryId] ?? "").trim();
|
||||
if (!name) return;
|
||||
const labelId = vocab.idByName.get(name.toLowerCase());
|
||||
if (!labelId) return;
|
||||
addLabelToCategory(categoryId, labelId);
|
||||
setAddDraft((prev) => ({ ...prev, [categoryId]: "" }));
|
||||
};
|
||||
|
||||
const createCategory = () => {
|
||||
const name = newCategory.trim();
|
||||
if (!name) return;
|
||||
const id = addCategory(name, NEW_CATEGORY_ICON);
|
||||
setExpanded((prev) => new Set(prev).add(id));
|
||||
setNewCategory("");
|
||||
};
|
||||
}, [stubs, categories]);
|
||||
|
||||
// Classification off (non-AI SaaS tenant) → no "customize groups" affordance,
|
||||
// matching the flat, ungrouped list. (All hooks above run unconditionally.)
|
||||
@@ -175,7 +64,7 @@ export function FileSidebarGroupControls({
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="file-sidebar-section-btn file-sidebar-section-btn-external"
|
||||
onClick={openPicker}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t("fileSidebar.customizeGroups", "Customize groups")}
|
||||
data-testid="customize-groups"
|
||||
>
|
||||
@@ -185,12 +74,11 @@ export function FileSidebarGroupControls({
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
width="xl"
|
||||
className="fsg-modal"
|
||||
width="md"
|
||||
title={t("fileSidebar.groupsModal.title", "Sidebar categories")}
|
||||
subtitle={t(
|
||||
"fileSidebar.groupsModal.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”.",
|
||||
"Show or hide categories in the files sidebar.",
|
||||
)}
|
||||
footer={
|
||||
<div className="fsg-footer">
|
||||
@@ -198,9 +86,9 @@ export function FileSidebarGroupControls({
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
leftSection={<RestartAltIcon sx={{ fontSize: "1rem" }} />}
|
||||
onClick={resetSidebarCategories}
|
||||
onClick={resetHiddenCategories}
|
||||
>
|
||||
{t("fileSidebar.groupsModal.reset", "Reset to defaults")}
|
||||
{t("fileSidebar.groupsModal.reset", "Show all")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => setOpen(false)}>
|
||||
{t("fileSidebar.groupsModal.done", "Done")}
|
||||
@@ -209,201 +97,11 @@ export function FileSidebarGroupControls({
|
||||
}
|
||||
>
|
||||
<div className="fsg-body">
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
placeholder={t("fileSidebar.groupsModal.search", "Search labels…")}
|
||||
size="sm"
|
||||
data-autofocus
|
||||
<ClassificationCategoryManager
|
||||
categories={categories}
|
||||
onToggleHidden={setCategoryHidden}
|
||||
counts={categoryCounts}
|
||||
/>
|
||||
|
||||
{sortedCategories.map((category) => {
|
||||
const memberKeys = category.labelKeys.filter((key) =>
|
||||
matches(labelDisplay(key)),
|
||||
);
|
||||
// Show the category if its name matches, or any member label matches.
|
||||
if (!matches(category.name) && memberKeys.length === 0) return null;
|
||||
const isExpanded = q !== "" || expanded.has(category.id);
|
||||
const suggestions = [...vocab.byId.keys()]
|
||||
.filter((id) => !category.labelKeys.includes(id))
|
||||
.map((id) => labelDisplay(id));
|
||||
return (
|
||||
<section key={category.id} className="fsg-cat">
|
||||
<div className="fsg-cat-header">
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="fsg-cat-toggle"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={category.name}
|
||||
onClick={() => toggleExpanded(category.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<KeyboardArrowDownIcon className="fsg-chevron" />
|
||||
) : (
|
||||
<KeyboardArrowRightIcon className="fsg-chevron" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<LabelIconPicker
|
||||
value={category.icon}
|
||||
onChange={(icon) => setCategoryIcon(category.id, icon)}
|
||||
ariaLabel={t(
|
||||
"fileSidebar.groupsModal.categoryIconAria",
|
||||
"Choose an icon for {{name}}",
|
||||
{ name: category.name },
|
||||
)}
|
||||
/>
|
||||
{renaming === category.id ? (
|
||||
<TextInput
|
||||
className="fsg-rename"
|
||||
size="xs"
|
||||
value={renameDraft}
|
||||
autoFocus
|
||||
onChange={(e) => setRenameDraft(e.currentTarget.value)}
|
||||
onBlur={() => commitRename(category.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename(category.id);
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
justify="start"
|
||||
className="fsg-cat-name"
|
||||
title={t("fileSidebar.groupsModal.rename", "Rename")}
|
||||
onClick={() => {
|
||||
setRenaming(category.id);
|
||||
setRenameDraft(category.name);
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</Button>
|
||||
)}
|
||||
<span className="fsg-count">
|
||||
{categoryCounts.get(category.id) ?? 0}
|
||||
</span>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="fsg-cat-action"
|
||||
aria-label={
|
||||
category.hidden
|
||||
? t("fileSidebar.groupsModal.show", "Show category")
|
||||
: t("fileSidebar.groupsModal.hide", "Hide category")
|
||||
}
|
||||
aria-pressed={!category.hidden}
|
||||
onClick={() =>
|
||||
setCategoryHidden(category.id, !category.hidden)
|
||||
}
|
||||
>
|
||||
{category.hidden ? (
|
||||
<VisibilityOffIcon sx={{ fontSize: "1rem" }} />
|
||||
) : (
|
||||
<VisibilityIcon sx={{ fontSize: "1rem" }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="fsg-cat-action"
|
||||
aria-label={t(
|
||||
"fileSidebar.groupsModal.delete",
|
||||
"Delete category",
|
||||
)}
|
||||
onClick={() => deleteCategory(category.id)}
|
||||
>
|
||||
<DeleteOutlineIcon sx={{ fontSize: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="fsg-cat-body">
|
||||
<div className="fsg-chips">
|
||||
{memberKeys.map((key) => (
|
||||
<LabelChip
|
||||
key={key}
|
||||
label={labelDisplay(key)}
|
||||
icon={labelIcon(key)}
|
||||
count={labelCounts.get(key)}
|
||||
onRemove={() =>
|
||||
removeLabelFromCategory(category.id, key)
|
||||
}
|
||||
removeAriaLabel={t(
|
||||
"fileSidebar.groupsModal.removeLabel",
|
||||
"Remove {{name}}",
|
||||
{ name: labelDisplay(key) },
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="fsg-add">
|
||||
<input
|
||||
className="fsg-add-input"
|
||||
list={`fsg-vocab-${category.id}`}
|
||||
value={addDraft[category.id] ?? ""}
|
||||
placeholder={t(
|
||||
"fileSidebar.groupsModal.addLabel",
|
||||
"Add a label…",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setAddDraft((prev) => ({
|
||||
...prev,
|
||||
[category.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addLabel(category.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id={`fsg-vocab-${category.id}`}>
|
||||
{suggestions.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AddIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={() => addLabel(category.id)}
|
||||
disabled={!(addDraft[category.id] ?? "").trim()}
|
||||
>
|
||||
{t("fileSidebar.groupsModal.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="fsg-new">
|
||||
<input
|
||||
className="fsg-add-input"
|
||||
value={newCategory}
|
||||
placeholder={t(
|
||||
"fileSidebar.groupsModal.newCategory",
|
||||
"New category name…",
|
||||
)}
|
||||
onChange={(e) => setNewCategory(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
createCategory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftSection={<AddIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={createCategory}
|
||||
disabled={!newCategory.trim()}
|
||||
>
|
||||
{t("fileSidebar.groupsModal.createCategory", "Create category")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("buildLabelGroups", () => {
|
||||
stub("b", ["contract"]),
|
||||
stub("c", ["nda", "contract"]),
|
||||
],
|
||||
[],
|
||||
t,
|
||||
[LEGAL],
|
||||
)!;
|
||||
@@ -44,20 +43,15 @@ describe("buildLabelGroups", () => {
|
||||
expect(legal.stubs.map((s) => s.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("gives a label not in any category its own group", () => {
|
||||
const groups = buildLabelGroups(
|
||||
[stub("a", ["weird-one"])],
|
||||
[{ id: "weird-one", name: "Weird one" }],
|
||||
t,
|
||||
[LEGAL],
|
||||
)!;
|
||||
const group = groups.find((g) => g.id === "label:weird-one")!;
|
||||
expect(group.label).toBe("Weird one");
|
||||
expect(group.stubs.map((s) => s.id)).toEqual(["a"]);
|
||||
it("puts a label in no category into Other", () => {
|
||||
const groups = buildLabelGroups([stub("a", ["weird-one"])], t, [LEGAL])!;
|
||||
expect(groups.some((g) => g.id.startsWith("category:"))).toBe(false);
|
||||
expect(groups.at(-1)!.id).toBe("other");
|
||||
expect(groups.at(-1)!.stubs.map((s) => s.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("moves a hidden category's files into Other", () => {
|
||||
const groups = buildLabelGroups([stub("a", ["invoice"])], [], t, [
|
||||
const groups = buildLabelGroups([stub("a", ["invoice"])], t, [
|
||||
cat("finance", "Financial", ["invoice"], true),
|
||||
])!;
|
||||
expect(groups.some((g) => g.id === "category:finance")).toBe(false);
|
||||
@@ -66,7 +60,7 @@ describe("buildLabelGroups", () => {
|
||||
});
|
||||
|
||||
it("a file with labels in two categories appears in both", () => {
|
||||
const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], [], t, [
|
||||
const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], t, [
|
||||
LEGAL,
|
||||
FINANCE,
|
||||
])!;
|
||||
@@ -82,7 +76,6 @@ describe("buildLabelGroups", () => {
|
||||
it("puts unlabelled files in Other at the bottom", () => {
|
||||
const groups = buildLabelGroups(
|
||||
[stub("a", ["invoice"]), stub("b"), stub("c", [])],
|
||||
[],
|
||||
t,
|
||||
[FINANCE],
|
||||
)!;
|
||||
@@ -91,7 +84,7 @@ describe("buildLabelGroups", () => {
|
||||
expect(other.stubs.map((s) => s.id)).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("orders groups: Recent, groups alphabetically, Other last", () => {
|
||||
it("orders groups: Recent, categories alphabetically, Other last", () => {
|
||||
const groups = buildLabelGroups(
|
||||
[
|
||||
stub("a", ["invoice"]),
|
||||
@@ -99,20 +92,19 @@ describe("buildLabelGroups", () => {
|
||||
stub("c", ["zzz"]),
|
||||
stub("d"),
|
||||
],
|
||||
[],
|
||||
t,
|
||||
[LEGAL, FINANCE],
|
||||
)!;
|
||||
// "zzz" is in no category, so its file falls to Other with the unlabelled one.
|
||||
expect(groups.map((g) => g.id)).toEqual([
|
||||
"recent",
|
||||
"category:finance",
|
||||
"category:legal",
|
||||
"label:zzz",
|
||||
"other",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns null for an empty library", () => {
|
||||
expect(buildLabelGroups([], [], t, [LEGAL])).toBeNull();
|
||||
expect(buildLabelGroups([], t, [LEGAL])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (device-local, editable — default from the built-in label families), a standalone group for any label not yet in a category, then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap.
|
||||
// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (the fixed, shared label families; each can be hidden device-local), then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap.
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
|
||||
import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled";
|
||||
import { useClassificationLabels } from "@app/hooks/useClassificationLabels";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { readStubClassificationLabels } from "@app/services/fileClassification";
|
||||
import { hasInFlightPolicyRuns } from "@app/components/policies/policyRunStore";
|
||||
@@ -55,7 +54,6 @@ export function useFileSidebarGroups(
|
||||
// like core, and don't fetch team labels or backfill from metadata. Gates the
|
||||
// whole feature so an AI-off SaaS tenant sees no Recent/Other/category chrome.
|
||||
const enabled = useClassificationEnabled();
|
||||
const { teamLabels: labelSet } = useClassificationLabels(enabled);
|
||||
const { bumpRevision } = useIndexedDB();
|
||||
// Attempted reads keyed by id+lastModified: a re-classified file (new version bumps lastModified) is re-read and leaves "Other" on its own, while a truly-unlabelled file keeps a stable key and is read once.
|
||||
const attempted = useRef<Set<string>>(new Set());
|
||||
@@ -113,7 +111,7 @@ export function useFileSidebarGroups(
|
||||
getSidebarCategories,
|
||||
);
|
||||
return useMemo(
|
||||
() => (enabled ? buildLabelGroups(stubs, labelSet, t, categories) : null),
|
||||
[enabled, stubs, labelSet, t, categories],
|
||||
() => (enabled ? buildLabelGroups(stubs, t, categories) : null),
|
||||
[enabled, stubs, t, categories],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons";
|
||||
import { accentColor, accentCycleColor } from "@app/utils/accentColors";
|
||||
import {
|
||||
categorizedLabelKeys,
|
||||
type SidebarCategory,
|
||||
} from "@app/services/fileSidebarCategories";
|
||||
import type { SidebarCategory } from "@app/services/fileSidebarCategories";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping";
|
||||
|
||||
@@ -30,37 +27,22 @@ export function bucketStubsByLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then visible groups sorted
|
||||
* alphabetically, then Other (files in no visible group) last. Visible groups are the non-hidden
|
||||
* {@link SidebarCategory} entries with ≥1 file (a file lands in every category it has a label in),
|
||||
* plus a standalone group for any label on a file that isn't in a category yet.
|
||||
* Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then one group per VISIBLE
|
||||
* category with ≥1 file (a file lands in every category it has a label in), sorted alphabetically,
|
||||
* then Other (files in no visible category) last. Categories are the fixed, shared set; a category
|
||||
* the user has hidden forms no group, so its files fall to Other.
|
||||
*/
|
||||
export function buildLabelGroups(
|
||||
stubs: StirlingFileStub[],
|
||||
labelSet: readonly { id: string; name: string; icon?: string }[],
|
||||
t: (key: string, fallback: string) => string,
|
||||
categories: SidebarCategory[],
|
||||
): FileSidebarGroup[] | null {
|
||||
if (stubs.length === 0) return null;
|
||||
|
||||
// Display name + icon per label id from the effective set; ids no longer in the
|
||||
// set still group, resolving to the id text and the default icon.
|
||||
const nameById = new Map<string, string>();
|
||||
const iconById = new Map<string, string | undefined>();
|
||||
for (const label of labelSet) {
|
||||
nameById.set(label.id, label.name);
|
||||
iconById.set(label.id, label.icon);
|
||||
}
|
||||
const labelName = (id: string) =>
|
||||
t(`classification.labels.${id}`, nameById.get(id) ?? id);
|
||||
|
||||
const recent = [...stubs]
|
||||
.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0))
|
||||
.slice(0, RECENT_COUNT);
|
||||
|
||||
const byLabel = bucketStubsByLabel(stubs);
|
||||
const inACategory = categorizedLabelKeys(categories);
|
||||
|
||||
const visible: Omit<FileSidebarGroup, "color">[] = [];
|
||||
|
||||
// One group per visible category: every file carrying any of its labels, in the input's order
|
||||
@@ -81,19 +63,6 @@ export function buildLabelGroups(
|
||||
});
|
||||
}
|
||||
|
||||
// A label on a file but not in any category still gets its own group, so nothing is stranded
|
||||
// until the user files it into a category.
|
||||
for (const [labelId, bucket] of byLabel) {
|
||||
if (inACategory.has(labelId)) continue;
|
||||
visible.push({
|
||||
id: `label:${labelId}`,
|
||||
label: labelName(labelId),
|
||||
icon: iconById.get(labelId) ?? DEFAULT_LABEL_ICON,
|
||||
stubs: bucket.stubs,
|
||||
defaultExpanded: false,
|
||||
});
|
||||
}
|
||||
|
||||
visible.sort((a, b) =>
|
||||
a.label.localeCompare(b.label, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user