diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java index 51d9c55888..1da5e5a8f8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java +++ b/app/core/src/main/java/stirling/software/SPDF/exception/GlobalExceptionHandler.java @@ -9,6 +9,7 @@ import java.util.List; import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.core.env.Environment; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; @@ -1003,6 +1004,52 @@ public class GlobalExceptionHandler { * @param request the HTTP servlet request * @return ProblemDetail with HTTP 400 BAD_REQUEST */ + /** + * Handle {@link DataIntegrityViolationException} — typically a unique-constraint violation or a + * PK collision surfacing from a Spring Data repository write. Mapped to {@code 409 Conflict} so + * clients can tell the difference between "bad input" (400) and "that key is already taken by + * someone else" (409). + * + * @param ex the DataIntegrityViolationException + * @param request the HTTP servlet request + * @return ProblemDetail with 409 Conflict status + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolation( + DataIntegrityViolationException ex, HttpServletRequest request) { + log.warn("Data integrity violation at {}: {}", request.getRequestURI(), ex.getMessage()); + + String title = + getLocalizedMessage( + "error.conflict.title", ErrorTitles.CONFLICT_DEFAULT); + + // Do NOT leak the underlying SQL / constraint message — the root cause often includes DB + // identifiers that we'd rather not echo back to the caller. A fixed, neutral detail is + // enough for the client to understand the outcome. + ProblemDetail problemDetail = + createBaseProblemDetail( + HttpStatus.CONFLICT, + "The request conflicts with existing data. The resource already exists or" + + " violates a uniqueness constraint.", + request); + problemDetail.setType(URI.create(ErrorTypes.CONFLICT)); + problemDetail.setTitle(title); + problemDetail.setProperty("title", title); + addStandardHints( + problemDetail, + "error.conflict.hints", + List.of( + "Check whether a resource with the same identifier already exists.", + "Retry with a different identifier, or update the existing resource" + + " instead.", + "If this was a concurrent write, re-fetch the latest state and retry.")); + problemDetail.setProperty("actionRequired", "Resolve the conflict and retry."); + + return ResponseEntity.status(HttpStatus.CONFLICT) + .contentType(PROBLEM_JSON) + .body(problemDetail); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument( IllegalArgumentException ex, HttpServletRequest request) { @@ -1378,6 +1425,7 @@ public class GlobalExceptionHandler { static final String INVALID_ARGUMENT = "/errors/invalid-argument"; static final String IO_ERROR = "/errors/io-error"; static final String UNEXPECTED = "/errors/unexpected"; + static final String CONFLICT = "/errors/conflict"; } /** Constants for default error titles. */ @@ -1405,5 +1453,6 @@ public class GlobalExceptionHandler { static final String INVALID_ARGUMENT_DEFAULT = "Invalid Argument"; static final String IO_ERROR_DEFAULT = "File Processing Error"; static final String UNEXPECTED_DEFAULT = "Internal Server Error"; + static final String CONFLICT_DEFAULT = "Conflict"; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/WatchFolderController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/WatchFolderController.java new file mode 100644 index 0000000000..fd624b3add --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/WatchFolderController.java @@ -0,0 +1,100 @@ +package stirling.software.proprietary.controller.api; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Size; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.model.WatchFolder; +import stirling.software.proprietary.model.WatchFolderFile; +import stirling.software.proprietary.model.WatchFolderRun; +import stirling.software.proprietary.service.WatchFolderService; + +@RestController +@RequestMapping("/api/v1/watch-folders") +@PreAuthorize("isAuthenticated()") +@RequiredArgsConstructor +public class WatchFolderController { + + /** Upper bound for /runs/batch payloads, to stop a single request from inserting millions. */ + private static final int RUNS_BATCH_MAX = 500; + + private final WatchFolderService service; + + // ── Folder CRUD ──────────────────────────────────────────────────────── + + @GetMapping + public List list() { + return service.listFolders(); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable String id) { + return service.getFolder(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody WatchFolder folder) { + return ResponseEntity.status(HttpStatus.CREATED).body(service.createFolder(folder)); + } + + @PutMapping("/{id}") + public ResponseEntity update( + @PathVariable String id, @Valid @RequestBody WatchFolder folder) { + return ResponseEntity.ok(service.updateFolder(id, folder)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + service.deleteFolder(id); + return ResponseEntity.noContent().build(); + } + + // ── Folder files ─────────────────────────────────────────────────────── + + @GetMapping("/{folderId}/files") + public List listFiles(@PathVariable String folderId) { + return service.listFiles(folderId); + } + + @PutMapping("/{folderId}/files") + public WatchFolderFile upsertFile( + @PathVariable String folderId, @Valid @RequestBody WatchFolderFile file) { + return service.upsertFile(folderId, file); + } + + @DeleteMapping("/{folderId}/files") + public ResponseEntity deleteFiles(@PathVariable String folderId) { + service.deleteFiles(folderId); + return ResponseEntity.noContent().build(); + } + + // ── Folder runs ──────────────────────────────────────────────────────── + + @GetMapping("/{folderId}/runs") + public List listRuns(@PathVariable String folderId) { + return service.listRuns(folderId); + } + + @PostMapping("/{folderId}/runs") + public ResponseEntity addRun( + @PathVariable String folderId, @Valid @RequestBody WatchFolderRun run) { + return ResponseEntity.status(HttpStatus.CREATED).body(service.addRun(folderId, run)); + } + + @PostMapping("/{folderId}/runs/batch") + public ResponseEntity> addRuns( + @PathVariable String folderId, + @Valid @RequestBody @Size(max = RUNS_BATCH_MAX) List runs) { + return ResponseEntity.status(HttpStatus.CREATED).body(service.addRuns(folderId, runs)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolder.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolder.java new file mode 100644 index 0000000000..c56211af21 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolder.java @@ -0,0 +1,140 @@ +package stirling.software.proprietary.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.*; + +import stirling.software.proprietary.model.watchfolder.FolderScope; +import stirling.software.proprietary.model.watchfolder.InputSource; +import stirling.software.proprietary.model.watchfolder.OutputMode; +import stirling.software.proprietary.model.watchfolder.OutputNamePosition; +import stirling.software.proprietary.model.watchfolder.ProcessingMode; +import stirling.software.proprietary.security.model.User; + +@Entity +@Table( + name = "watch_folders", + indexes = { + @Index(name = "idx_wf_owner", columnList = "owner_id"), + @Index(name = "idx_wf_scope", columnList = "scope"), + }) +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +public class WatchFolder implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Client-supplied identifier (matches the IndexedDB folder id used by the React frontend). + * Using a String PK — rather than an IDENTITY {@code Long} like other entities — keeps the + * same opaque id across the local IDB cache and the server, so the frontend can round-trip a + * folder between offline/local mode and server mode without remapping. Callers are expected + * to supply a UUID (or equivalently collision-resistant string). + */ + @Id + @EqualsAndHashCode.Include + @NotBlank + @Size(max = 64) + @Column(name = "id", length = 64) + private String id; + + @NotBlank + @Size(max = 255) + @Column(name = "name", nullable = false) + private String name; + + @Size(max = 1024) + @Column(name = "description", length = 1024) + private String description; + + /** JSON-serialised automation operations array — the full pipeline definition. */ + @Size(max = 65_536) + @Column(name = "automation_config", columnDefinition = "TEXT") + private String automationConfig; + + @Size(max = 64) + @Column(name = "icon", length = 64) + private String icon; + + @Size(max = 16) + @Column(name = "accent_color", length = 16) + private String accentColor; + + /** Visibility scope. Never null — defaulted to {@link FolderScope#PERSONAL}. */ + @Column(name = "scope", nullable = false, length = 16) + private FolderScope scope = FolderScope.PERSONAL; + + /** Owner of this folder. Null for ORGANISATION-scoped folders. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id") + @JsonIgnore + private User owner; + + @Column(name = "order_index") + private Integer orderIndex; + + @Column(name = "is_default", nullable = false) + private Boolean isDefault = false; + + @Column(name = "is_paused", nullable = false) + private Boolean isPaused = false; + + @Column(name = "input_source", nullable = false, length = 32) + private InputSource inputSource = InputSource.IDB; + + @Column(name = "processing_mode", nullable = false, length = 16) + private ProcessingMode processingMode = ProcessingMode.LOCAL; + + @Column(name = "output_mode", nullable = false, length = 16) + private OutputMode outputMode = OutputMode.NEW_FILE; + + @Size(max = 255) + @Column(name = "output_name", length = 255) + private String outputName; + + @Column(name = "output_name_position", nullable = false, length = 16) + private OutputNamePosition outputNamePosition = OutputNamePosition.PREFIX; + + @Column(name = "output_ttl_hours") + private Integer outputTtlHours; + + @Column(name = "delete_output_on_download", nullable = false) + private Boolean deleteOutputOnDownload = false; + + @Column(name = "max_retries", nullable = false) + private Integer maxRetries = 3; + + @Column(name = "retry_delay_minutes", nullable = false) + private Integer retryDelayMinutes = 5; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "folder", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore + private List files = new ArrayList<>(); + + @OneToMany(mappedBy = "folder", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonIgnore + private List runs = new ArrayList<>(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolderFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolderFile.java new file mode 100644 index 0000000000..26f479cc73 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolderFile.java @@ -0,0 +1,103 @@ +package stirling.software.proprietary.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.*; + +import stirling.software.proprietary.model.watchfolder.FileStatus; + +@Entity +@Table( + name = "watch_folder_files", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_wff_folder_file", + columnNames = {"folder_id", "file_id"}) + }, + indexes = { + @Index(name = "idx_wff_folder", columnList = "folder_id"), + @Index(name = "idx_wff_file_id", columnList = "file_id"), + @Index(name = "idx_wff_status", columnList = "status"), + }) +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +public class WatchFolderFile implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id", nullable = false) + @JsonIgnore + private WatchFolder folder; + + /** Client-side file identifier (matches the IDB file id). */ + @NotBlank + @Size(max = 128) + @Column(name = "file_id", nullable = false, length = 128) + private String fileId; + + @Column(name = "status", nullable = false, length = 16) + private FileStatus status = FileStatus.PENDING; + + @Size(max = 1024) + @Column(name = "name", length = 1024) + private String name; + + @Size(max = 4096) + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "failed_attempts", nullable = false) + private Integer failedAttempts = 0; + + /** + * True when this row represents a file that the folder itself created / ingested (e.g. dropped + * on the folder from disk) and therefore owns — on folder deletion these files can be cleaned + * up. False when the file comes from the shared sidebar store and must be left alone. + */ + @Column(name = "owned_by_folder", nullable = false) + private Boolean ownedByFolder = false; + + /** + * True while the file has been uploaded to the server-side watch folder and is awaiting + * processing by the pipeline directory processor (only meaningful when the owning folder's + * input source is {@code server-folder}). + */ + @Column(name = "pending_on_server", nullable = false) + private Boolean pendingOnServer = false; + + /** JSON array of output file ids. */ + @Size(max = 65_536) + @Column(name = "display_file_ids", columnDefinition = "TEXT") + private String displayFileIds; + + /** JSON array of server-side output filenames. */ + @Size(max = 65_536) + @Column(name = "server_output_filenames", columnDefinition = "TEXT") + private String serverOutputFilenames; + + @CreationTimestamp + @Column(name = "added_at", updatable = false) + private LocalDateTime addedAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolderRun.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolderRun.java new file mode 100644 index 0000000000..bfb16fbfad --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/WatchFolderRun.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.*; + +import stirling.software.proprietary.model.watchfolder.RunStatus; + +@Entity +@Table( + name = "watch_folder_runs", + indexes = { + @Index(name = "idx_wfr_folder", columnList = "folder_id"), + }) +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +public class WatchFolderRun implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @EqualsAndHashCode.Include + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "folder_id", nullable = false) + @JsonIgnore + private WatchFolder folder; + + @NotBlank + @Size(max = 128) + @Column(name = "input_file_id", nullable = false, length = 128) + private String inputFileId; + + @Size(max = 128) + @Column(name = "display_file_id", length = 128) + private String displayFileId; + + /** JSON array of all output file ids. */ + @Size(max = 65_536) + @Column(name = "display_file_ids", columnDefinition = "TEXT") + private String displayFileIds; + + @Column(name = "status", nullable = false, length = 16) + private RunStatus status = RunStatus.PROCESSING; + + @Column(name = "processed_at") + private LocalDateTime processedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/FileStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/FileStatus.java new file mode 100644 index 0000000000..ad8ac9e976 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/FileStatus.java @@ -0,0 +1,51 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Lifecycle status of a single file tracked inside a watch folder. Wire/DB values are lowercase and + * match the frontend {@code FolderFileMetadata.status} union. + */ +public enum FileStatus { + PENDING("pending"), + PROCESSING("processing"), + PROCESSED("processed"), + ERROR("error"); + + private final String wire; + + FileStatus(String wire) { + this.wire = wire; + } + + @JsonValue + public String wireValue() { + return wire; + } + + @JsonCreator + public static FileStatus fromWire(String value) { + if (value == null) return null; + for (FileStatus s : values()) { + if (s.wire.equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown FileStatus: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(FileStatus attribute) { + return attribute == null ? null : attribute.wire; + } + + @Override + public FileStatus convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/FolderScope.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/FolderScope.java new file mode 100644 index 0000000000..cfabda3aca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/FolderScope.java @@ -0,0 +1,48 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Visibility scope of a {@code WatchFolder}. + * + *
    + *
  • {@link #PERSONAL} — only the owner can see / modify the folder.
  • + *
  • {@link #ORGANISATION} — visible to every authenticated user; only admins may create or + * modify.
  • + *
+ */ +public enum FolderScope { + PERSONAL, + ORGANISATION; + + @JsonValue + public String wireValue() { + return name(); + } + + @JsonCreator + public static FolderScope fromWire(String value) { + if (value == null) return null; + for (FolderScope s : values()) { + if (s.name().equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown FolderScope: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(FolderScope attribute) { + return attribute == null ? null : attribute.name(); + } + + @Override + public FolderScope convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/InputSource.java new file mode 100644 index 0000000000..4db75fdbc1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/InputSource.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Where input files for a watch folder come from. + * + *
    + *
  • {@link #IDB} — files dropped / picked in the browser, stored in IndexedDB.
  • + *
  • {@link #LOCAL_FOLDER} — a real folder on the user's machine (desktop build).
  • + *
  • {@link #SERVER_FOLDER} — a directory watched on the server.
  • + *
+ */ +public enum InputSource { + IDB("idb"), + LOCAL_FOLDER("local-folder"), + SERVER_FOLDER("server-folder"); + + private final String wire; + + InputSource(String wire) { + this.wire = wire; + } + + @JsonValue + public String wireValue() { + return wire; + } + + @JsonCreator + public static InputSource fromWire(String value) { + if (value == null) return null; + for (InputSource s : values()) { + if (s.wire.equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown InputSource: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(InputSource attribute) { + return attribute == null ? null : attribute.wire; + } + + @Override + public InputSource convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/OutputMode.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/OutputMode.java new file mode 100644 index 0000000000..8c56c6bce2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/OutputMode.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * How automation output files are produced. + * + *
    + *
  • {@link #NEW_FILE} — always produce a new, separately-named file.
  • + *
  • {@link #NEW_VERSION} — produce a new version of the input file (replacing / versioning + * semantics handled client-side).
  • + *
+ */ +public enum OutputMode { + NEW_FILE("new_file"), + NEW_VERSION("new_version"); + + private final String wire; + + OutputMode(String wire) { + this.wire = wire; + } + + @JsonValue + public String wireValue() { + return wire; + } + + @JsonCreator + public static OutputMode fromWire(String value) { + if (value == null) return null; + for (OutputMode s : values()) { + if (s.wire.equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown OutputMode: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(OutputMode attribute) { + return attribute == null ? null : attribute.wire; + } + + @Override + public OutputMode convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/OutputNamePosition.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/OutputNamePosition.java new file mode 100644 index 0000000000..98fce0e59a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/OutputNamePosition.java @@ -0,0 +1,50 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Where the configured {@code outputName} string is placed relative to the input filename when + * building an output file name. {@link #AUTO_NUMBER} appends a monotonically increasing counter. + */ +public enum OutputNamePosition { + PREFIX("prefix"), + SUFFIX("suffix"), + AUTO_NUMBER("auto-number"); + + private final String wire; + + OutputNamePosition(String wire) { + this.wire = wire; + } + + @JsonValue + public String wireValue() { + return wire; + } + + @JsonCreator + public static OutputNamePosition fromWire(String value) { + if (value == null) return null; + for (OutputNamePosition s : values()) { + if (s.wire.equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown OutputNamePosition: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(OutputNamePosition attribute) { + return attribute == null ? null : attribute.wire; + } + + @Override + public OutputNamePosition convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/ProcessingMode.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/ProcessingMode.java new file mode 100644 index 0000000000..99990b7725 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/ProcessingMode.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Where the automation pipeline runs. + * + *
    + *
  • {@link #LOCAL} — runs entirely in the user's browser.
  • + *
  • {@link #SERVER} — runs on the server (forced when {@link InputSource#SERVER_FOLDER} is in + * use).
  • + *
+ */ +public enum ProcessingMode { + LOCAL("local"), + SERVER("server"); + + private final String wire; + + ProcessingMode(String wire) { + this.wire = wire; + } + + @JsonValue + public String wireValue() { + return wire; + } + + @JsonCreator + public static ProcessingMode fromWire(String value) { + if (value == null) return null; + for (ProcessingMode s : values()) { + if (s.wire.equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown ProcessingMode: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(ProcessingMode attribute) { + return attribute == null ? null : attribute.wire; + } + + @Override + public ProcessingMode convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/RunStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/RunStatus.java new file mode 100644 index 0000000000..d4587be4f3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/watchfolder/RunStatus.java @@ -0,0 +1,49 @@ +package stirling.software.proprietary.model.watchfolder; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +/** + * Status of a single run entry — a completed (or in-flight) execution of the folder's automation + * over one input file. Wire/DB values match the frontend {@code SmartFolderRunEntry.status} union. + */ +public enum RunStatus { + PROCESSING("processing"), + PROCESSED("processed"); + + private final String wire; + + RunStatus(String wire) { + this.wire = wire; + } + + @JsonValue + public String wireValue() { + return wire; + } + + @JsonCreator + public static RunStatus fromWire(String value) { + if (value == null) return null; + for (RunStatus s : values()) { + if (s.wire.equalsIgnoreCase(value)) return s; + } + throw new IllegalArgumentException("Unknown RunStatus: " + value); + } + + @Converter(autoApply = true) + public static class DbConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(RunStatus attribute) { + return attribute == null ? null : attribute.wire; + } + + @Override + public RunStatus convertToEntityAttribute(String dbData) { + return fromWire(dbData); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderFileRepository.java new file mode 100644 index 0000000000..2e580da367 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderFileRepository.java @@ -0,0 +1,26 @@ +package stirling.software.proprietary.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.proprietary.model.WatchFolderFile; + +@Repository +public interface WatchFolderFileRepository extends JpaRepository { + + List findByFolderIdOrderByAddedAtDesc(String folderId); + + Optional findByFolderIdAndFileId(String folderId, String fileId); + + @Modifying + @Transactional + @Query("DELETE FROM WatchFolderFile f WHERE f.folder.id = :folderId") + int deleteAllByFolderId(@Param("folderId") String folderId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderRepository.java new file mode 100644 index 0000000000..cbf89b16ba --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderRepository.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.model.WatchFolder; +import stirling.software.proprietary.model.watchfolder.FolderScope; + +@Repository +public interface WatchFolderRepository extends JpaRepository { + + List findByOwnerIdOrderByOrderIndexAscCreatedAtAsc(Long ownerId); + + List findByScopeOrderByOrderIndexAscCreatedAtAsc(FolderScope scope); + + /** + * Return all folders visible to a user: those they own plus any folder with the given scope + * (normally {@link FolderScope#ORGANISATION}). A secondary sort on {@code createdAt} keeps + * output stable across databases when {@code orderIndex} is null for multiple rows. + */ + @Query( + "SELECT f FROM WatchFolder f " + + "WHERE (f.owner IS NOT NULL AND f.owner.id = :ownerId) " + + "OR f.scope = :scope " + + "ORDER BY f.orderIndex ASC, f.createdAt ASC") + List findVisibleToUser( + @Param("ownerId") Long ownerId, @Param("scope") FolderScope scope); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderRunRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderRunRepository.java new file mode 100644 index 0000000000..30922fd5ac --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/repository/WatchFolderRunRepository.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.proprietary.model.WatchFolderRun; + +@Repository +public interface WatchFolderRunRepository extends JpaRepository { + + List findByFolderIdOrderByProcessedAtDesc(String folderId); + + @Modifying + @Transactional + @Query("DELETE FROM WatchFolderRun r WHERE r.folder.id = :folderId") + int deleteAllByFolderId(@Param("folderId") String folderId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/WatchFolderService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/WatchFolderService.java new file mode 100644 index 0000000000..aeb4816ff0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/WatchFolderService.java @@ -0,0 +1,294 @@ +package stirling.software.proprietary.service; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.WatchFolder; +import stirling.software.proprietary.model.WatchFolderFile; +import stirling.software.proprietary.model.WatchFolderRun; +import stirling.software.proprietary.model.watchfolder.FolderScope; +import stirling.software.proprietary.repository.WatchFolderFileRepository; +import stirling.software.proprietary.repository.WatchFolderRepository; +import stirling.software.proprietary.repository.WatchFolderRunRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WatchFolderService { + + private final WatchFolderRepository folderRepo; + private final WatchFolderFileRepository fileRepo; + private final WatchFolderRunRepository runRepo; + private final UserRepository userRepo; + private final UserService userService; + + // ── Folder CRUD ──────────────────────────────────────────────────────── + + /** Get all folders the current user can see (their own + organisation). */ + @Transactional(readOnly = true) + public List listFolders() { + User user = currentUser(); + if (user == null) return List.of(); + return folderRepo.findVisibleToUser(user.getId(), FolderScope.ORGANISATION); + } + + @Transactional(readOnly = true) + public Optional getFolder(String id) { + return folderRepo.findById(id).filter(this::canRead); + } + + @Transactional + public WatchFolder createFolder(WatchFolder folder) { + // Reject id collisions explicitly rather than letting JpaRepository.save() silently merge + // into someone else's folder row. This closes a takeover vector where a caller POSTs a + // payload whose id matches an existing admin-owned ORGANISATION folder. + if (folder.getId() != null && folderRepo.existsById(folder.getId())) { + throw new DataIntegrityViolationException( + "Watch folder with id '" + folder.getId() + "' already exists"); + } + + if (FolderScope.ORGANISATION.equals(folder.getScope())) { + requireAdmin(); + folder.setOwner(null); + } else { + // Force any missing / non-ORGANISATION scope to PERSONAL owned by the caller. This also + // prevents a non-admin client from "promoting" by omitting scope and relying on the + // default — createFolder is the server's chance to establish ownership. + folder.setScope(FolderScope.PERSONAL); + folder.setOwner(requireCurrentUser()); + } + return folderRepo.save(folder); + } + + @Transactional + public WatchFolder updateFolder(String id, WatchFolder updates) { + WatchFolder existing = + folderRepo + .findById(id) + .orElseThrow( + () -> new IllegalArgumentException("Folder not found: " + id)); + requireWriteAccess(existing); + + existing.setName(updates.getName()); + existing.setDescription(updates.getDescription()); + existing.setAutomationConfig(updates.getAutomationConfig()); + existing.setIcon(updates.getIcon()); + existing.setAccentColor(updates.getAccentColor()); + existing.setOrderIndex(updates.getOrderIndex()); + existing.setIsPaused(updates.getIsPaused()); + existing.setInputSource(updates.getInputSource()); + existing.setProcessingMode(updates.getProcessingMode()); + existing.setOutputMode(updates.getOutputMode()); + existing.setOutputName(updates.getOutputName()); + existing.setOutputNamePosition(updates.getOutputNamePosition()); + existing.setOutputTtlHours(updates.getOutputTtlHours()); + existing.setDeleteOutputOnDownload(updates.getDeleteOutputOnDownload()); + existing.setMaxRetries(updates.getMaxRetries()); + existing.setRetryDelayMinutes(updates.getRetryDelayMinutes()); + + // Scope change: only admin can promote to organisation + if (FolderScope.ORGANISATION.equals(updates.getScope()) + && !FolderScope.ORGANISATION.equals(existing.getScope())) { + requireAdmin(); + existing.setScope(FolderScope.ORGANISATION); + existing.setOwner(null); + } + + return folderRepo.save(existing); + } + + @Transactional + public void deleteFolder(String id) { + WatchFolder folder = + folderRepo + .findById(id) + .orElseThrow( + () -> new IllegalArgumentException("Folder not found: " + id)); + requireWriteAccess(folder); + + // Don't rely on CascadeType.ALL to remove children one-row-at-a-time — for a folder with + // tens of thousands of files and runs that loads every child into memory. Use the bulk + // @Modifying queries instead, then remove the parent row. + fileRepo.deleteAllByFolderId(id); + runRepo.deleteAllByFolderId(id); + folderRepo.deleteById(id); + } + + // ── Folder files ─────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public List listFiles(String folderId) { + requireReadAccess(folderId); + return fileRepo.findByFolderIdOrderByAddedAtDesc(folderId); + } + + /** + * Create or update a file row keyed by {@code (folderId, fileId)}. Any client-supplied {@code + * id} on the payload is discarded — otherwise an attacker could pass the primary key of a row + * in a folder they don't own and have Hibernate re-parent it into their folder on save. + * + *

Idempotent under concurrent callers: if two requests for the same pair race past the + * lookup, the DB's unique constraint fires on one, which is then retried through the merge + * branch. + */ + @Transactional + public WatchFolderFile upsertFile(String folderId, WatchFolderFile file) { + requireWriteAccess(folderId); + WatchFolder folder = folderRepo.getReferenceById(folderId); + + // IMPORTANT: drop the client-supplied primary key. We identify existing rows by the + // (folderId, fileId) unique pair, never by the numeric id from the JSON body. + file.setId(null); + file.setFolder(folder); + + if (file.getFileId() != null) { + Optional existing = + fileRepo.findByFolderIdAndFileId(folderId, file.getFileId()); + if (existing.isPresent()) { + return mergeFile(existing.get(), file); + } + } + + try { + return fileRepo.save(file); + } catch (DataIntegrityViolationException race) { + // Another writer inserted the same (folderId, fileId) between our lookup and save. + // Re-read and merge. If the row has vanished again (e.g. concurrent delete), let the + // exception propagate as 409 Conflict. + log.debug( + "upsertFile race for folder={} fileId={}; retrying via merge", + folderId, + file.getFileId()); + return fileRepo.findByFolderIdAndFileId(folderId, file.getFileId()) + .map(existing -> mergeFile(existing, file)) + .orElseThrow(() -> race); + } + } + + private WatchFolderFile mergeFile(WatchFolderFile existing, WatchFolderFile incoming) { + existing.setStatus(incoming.getStatus()); + existing.setName(incoming.getName()); + existing.setErrorMessage(incoming.getErrorMessage()); + existing.setFailedAttempts(incoming.getFailedAttempts()); + existing.setOwnedByFolder(incoming.getOwnedByFolder()); + existing.setPendingOnServer(incoming.getPendingOnServer()); + existing.setDisplayFileIds(incoming.getDisplayFileIds()); + existing.setServerOutputFilenames(incoming.getServerOutputFilenames()); + existing.setProcessedAt(incoming.getProcessedAt()); + return fileRepo.save(existing); + } + + @Transactional + public void deleteFiles(String folderId) { + requireWriteAccess(folderId); + fileRepo.deleteAllByFolderId(folderId); + } + + // ── Folder runs ──────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public List listRuns(String folderId) { + requireReadAccess(folderId); + return runRepo.findByFolderIdOrderByProcessedAtDesc(folderId); + } + + @Transactional + public WatchFolderRun addRun(String folderId, WatchFolderRun run) { + requireWriteAccess(folderId); + WatchFolder folder = folderRepo.getReferenceById(folderId); + // Discard any client-supplied primary key so we always INSERT — otherwise a caller could + // UPDATE an existing run row in a different folder by guessing its id. + run.setId(null); + run.setFolder(folder); + return runRepo.save(run); + } + + @Transactional + public List addRuns(String folderId, List runs) { + requireWriteAccess(folderId); + WatchFolder folder = folderRepo.getReferenceById(folderId); + runs.stream() + .filter(Objects::nonNull) + .forEach( + r -> { + r.setId(null); + r.setFolder(folder); + }); + return runRepo.saveAll(runs); + } + + // ── Auth helpers ─────────────────────────────────────────────────────── + + private User currentUser() { + String username = userService.getCurrentUsername(); + if (username == null) return null; + return userRepo.findByUsernameIgnoreCase(username).orElse(null); + } + + private User requireCurrentUser() { + User user = currentUser(); + if (user == null) throw new AccessDeniedException("Authentication required"); + return user; + } + + private void requireAdmin() { + if (!userService.isCurrentUserAdmin()) { + throw new AccessDeniedException("Admin access required"); + } + } + + private boolean canRead(WatchFolder folder) { + if (FolderScope.ORGANISATION.equals(folder.getScope())) return true; + User user = currentUser(); + return user != null + && folder.getOwner() != null + && user.getId().equals(folder.getOwner().getId()); + } + + private void requireReadAccess(String folderId) { + WatchFolder folder = + folderRepo + .findById(folderId) + .orElseThrow( + () -> + new IllegalArgumentException( + "Folder not found: " + folderId)); + if (!canRead(folder)) { + throw new AccessDeniedException("Access denied to folder: " + folderId); + } + } + + private void requireWriteAccess(WatchFolder folder) { + if (FolderScope.ORGANISATION.equals(folder.getScope())) { + requireAdmin(); + } else { + User user = requireCurrentUser(); + if (folder.getOwner() == null || !user.getId().equals(folder.getOwner().getId())) { + throw new AccessDeniedException("Access denied to folder: " + folder.getId()); + } + } + } + + private void requireWriteAccess(String folderId) { + WatchFolder folder = + folderRepo + .findById(folderId) + .orElseThrow( + () -> + new IllegalArgumentException( + "Folder not found: " + folderId)); + requireWriteAccess(folder); + } +} diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 660f7a3d43..813687db0e 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -25,6 +25,8 @@ import AppConfigLoader from '@app/components/shared/AppConfigLoader'; import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; import { FolderFileContextProvider } from "@app/contexts/FolderFileContext"; +import { WatchFolderStorageProvider } from "@app/contexts/WatchFolderStorageContext"; +import { idbBackend } from "@app/services/watchFolderIdbBackend"; // Component to initialize scarf tracking (must be inside AppConfigProvider) function ScarfTrackingInitializer() { @@ -124,9 +126,11 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - {children} - + + + {children} + + diff --git a/frontend/src/core/contexts/WatchFolderStorageContext.tsx b/frontend/src/core/contexts/WatchFolderStorageContext.tsx new file mode 100644 index 0000000000..6eb54c40d8 --- /dev/null +++ b/frontend/src/core/contexts/WatchFolderStorageContext.tsx @@ -0,0 +1,63 @@ +/** + * Context for abstracting Watch Folder storage. + * + * Core provides a default IDB-only implementation. + * Proprietary can override with a server-backed implementation + * that uses IDB as a local cache. + */ + +import React, { createContext, useContext } from 'react'; +import { SmartFolder, FolderRecord } from '@app/types/smartFolders'; +import { SmartFolderRunEntry } from '@app/types/smartFolders'; + +// ── Storage interface ────────────────────────────────────────────────────── + +export interface WatchFolderStorageBackend { + // Folder CRUD + getAllFolders(): Promise; + getFolder(id: string): Promise; + createFolder(data: Omit): Promise; + createFolderWithId(folder: SmartFolder): Promise; + updateFolder(folder: SmartFolder): Promise; + deleteFolder(id: string): Promise; + + // File metadata + getFolderData(folderId: string): Promise; + updateFileMetadata(folderId: string, fileId: string, meta: Partial): Promise; + addFileToFolder(folderId: string, fileId: string, meta?: Partial): Promise; + clearFolder(folderId: string): Promise; + + // Run state + getFolderRunState(folderId: string): Promise; + addFolderRunEntries(folderId: string, entries: SmartFolderRunEntry[]): Promise; + clearFolderRunState(folderId: string): Promise; + + /** Subscribe to storage change events. Returns unsubscribe function. */ + onChange(callback: () => void): () => void; +} + +// ── Context ──────────────────────────────────────────────────────────────── + +const WatchFolderStorageContext = createContext(null); + +export function WatchFolderStorageProvider({ + backend, + children, +}: { + backend: WatchFolderStorageBackend; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +/** + * Returns the storage backend from context, or null if none is provided + * (meaning hooks should fall back to direct IDB imports). + */ +export function useWatchFolderStorage(): WatchFolderStorageBackend | null { + return useContext(WatchFolderStorageContext); +} diff --git a/frontend/src/core/hooks/useAllSmartFolders.ts b/frontend/src/core/hooks/useAllSmartFolders.ts index 495a6a50e8..3484560334 100644 --- a/frontend/src/core/hooks/useAllSmartFolders.ts +++ b/frontend/src/core/hooks/useAllSmartFolders.ts @@ -7,23 +7,31 @@ import { useState, useEffect } from 'react'; import { SmartFolder } from '@app/types/smartFolders'; import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage'; +import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext'; export function useAllSmartFolders(): SmartFolder[] { + const backend = useWatchFolderStorage(); const [folders, setFolders] = useState([]); useEffect(() => { const load = async () => { try { - setFolders(await smartFolderStorage.getAllFolders()); + const all = backend + ? await backend.getAllFolders() + : await smartFolderStorage.getAllFolders(); + setFolders(all); } catch (err) { console.error('Failed to load smart folders:', err); } }; load(); + if (backend) { + return backend.onChange(load); + } window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load); return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, load); - }, []); + }, [backend]); return folders; } diff --git a/frontend/src/core/hooks/useFolderData.ts b/frontend/src/core/hooks/useFolderData.ts index 181b30542b..1e80090afb 100644 --- a/frontend/src/core/hooks/useFolderData.ts +++ b/frontend/src/core/hooks/useFolderData.ts @@ -5,6 +5,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { FolderFileMetadata, FolderRecord } from '@app/types/smartFolders'; import { folderStorage } from '@app/services/folderStorage'; +import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext'; interface UseFolderDataReturn { folderRecord: FolderRecord | null; @@ -22,17 +23,20 @@ interface UseFolderDataReturn { } export function useFolderData(folderId: string): UseFolderDataReturn { + const backend = useWatchFolderStorage(); const [folderRecord, setFolderRecord] = useState(null); const refresh = useCallback(async () => { if (!folderId) return; try { - const record = await folderStorage.getFolderData(folderId); + const record = backend + ? await backend.getFolderData(folderId) + : await folderStorage.getFolderData(folderId); setFolderRecord(record); } catch (error) { console.error('Failed to load folder data:', error); } - }, [folderId]); + }, [folderId, backend]); useEffect(() => { refresh(); @@ -55,9 +59,13 @@ export function useFolderData(folderId: string): UseFolderDataReturn { const addFile = useCallback( async (fileId: string, metadata?: Partial) => { - await folderStorage.addFileToFolder(folderId, fileId, metadata); + if (backend) { + await backend.addFileToFolder(folderId, fileId, metadata); + } else { + await folderStorage.addFileToFolder(folderId, fileId, metadata); + } }, - [folderId] + [folderId, backend] ); const removeFile = useCallback( @@ -69,14 +77,22 @@ export function useFolderData(folderId: string): UseFolderDataReturn { const updateFileMetadata = useCallback( async (fileId: string, updates: Partial) => { - await folderStorage.updateFileMetadata(folderId, fileId, updates); + if (backend) { + await backend.updateFileMetadata(folderId, fileId, updates); + } else { + await folderStorage.updateFileMetadata(folderId, fileId, updates); + } }, - [folderId] + [folderId, backend] ); const clearFolder = useCallback(async () => { - await folderStorage.clearFolder(folderId); - }, [folderId]); + if (backend) { + await backend.clearFolder(folderId); + } else { + await folderStorage.clearFolder(folderId); + } + }, [folderId, backend]); const getFileMetadata = useCallback( (fileId: string): FolderFileMetadata | null => { diff --git a/frontend/src/core/hooks/useFolderRunStatuses.ts b/frontend/src/core/hooks/useFolderRunStatuses.ts index de94808245..e2ce1c46ce 100644 --- a/frontend/src/core/hooks/useFolderRunStatuses.ts +++ b/frontend/src/core/hooks/useFolderRunStatuses.ts @@ -6,6 +6,7 @@ import { useState, useEffect, useRef } from 'react'; import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders'; import { folderRunStateStorage } from '@app/services/folderRunStateStorage'; +import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext'; export type FolderRunStatus = 'idle' | 'processing' | 'done'; @@ -19,6 +20,7 @@ function deriveStatus(runs: SmartFolderRunEntry[]): FolderRunStatus { } export function useFolderRunStatuses(folders: SmartFolder[]): Record { + const backend = useWatchFolderStorage(); const [statuses, setStatuses] = useState>({}); const doneTimersRef = useRef>>(new Map()); const foldersRef = useRef(folders); @@ -31,7 +33,9 @@ export function useFolderRunStatuses(folders: SmartFolder[]): Record { try { - const runs = await folderRunStateStorage.getFolderRunState(folder.id); + const runs = backend + ? await backend.getFolderRunState(folder.id) + : await folderRunStateStorage.getFolderRunState(folder.id); return [folder.id, deriveStatus(runs)] as const; } catch { return [folder.id, 'idle' as FolderRunStatus] as const; diff --git a/frontend/src/core/hooks/useSmartFolders.ts b/frontend/src/core/hooks/useSmartFolders.ts index 13f8743b9b..9abebc684e 100644 --- a/frontend/src/core/hooks/useSmartFolders.ts +++ b/frontend/src/core/hooks/useSmartFolders.ts @@ -12,6 +12,7 @@ import { deleteServerFolder } from '@app/services/serverFolderApiService'; import { folderRetryScheduleStorage } from '@app/services/folderRetryScheduleStorage'; import { folderSeenFilesStorage } from '@app/services/folderSeenFilesStorage'; import { folderDirectoryHandleStorage } from '@app/services/folderDirectoryHandleStorage'; +import { useWatchFolderStorage } from '@app/contexts/WatchFolderStorageContext'; import { FileId } from '@app/types/fileContext'; interface UseSmartFoldersReturn { @@ -24,52 +25,67 @@ interface UseSmartFoldersReturn { } export function useSmartFolders(): UseSmartFoldersReturn { + const backend = useWatchFolderStorage(); const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const refreshFolders = useCallback(async () => { try { - const all = await smartFolderStorage.getAllFolders(); + const store = backend ?? { getAllFolders: () => smartFolderStorage.getAllFolders() }; + const all = await store.getAllFolders(); setFolders(all); } catch (error) { console.error('Failed to load smart folders:', error); } finally { setLoading(false); } - }, []); + }, [backend]); useEffect(() => { refreshFolders(); }, [refreshFolders]); useEffect(() => { + if (backend) { + return backend.onChange(() => refreshFolders()); + } const handler = () => { refreshFolders(); }; window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, handler); return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, handler); - }, [refreshFolders]); + }, [backend, refreshFolders]); const createFolder = useCallback( async (data: Omit): Promise => { + if (backend) return backend.createFolder(data); return smartFolderStorage.createFolder(data); }, - [] + [backend] ); const updateFolder = useCallback( async (folder: SmartFolder): Promise => { + if (backend) return backend.updateFolder(folder); return smartFolderStorage.updateFolder(folder); }, - [] + [backend] ); const deleteFolder = useCallback(async (id: string): Promise => { + const store = backend ?? { + getFolder: (fid: string) => smartFolderStorage.getFolder(fid), + deleteFolder: (fid: string) => smartFolderStorage.deleteFolder(fid), + getFolderData: (fid: string) => folderStorage.getFolderData(fid), + clearFolder: (fid: string) => folderStorage.clearFolder(fid), + clearFolderRunState: (fid: string) => folderRunStateStorage.clearFolderRunState(fid), + }; + // Clean up server watch folder first (best-effort — don't block if server is down) - const folderMeta = await smartFolderStorage.getFolder(id); + const folderMeta = await store.getFolder(id); if (folderMeta && isServerFolderInput(folderMeta)) { await deleteServerFolder(id).catch(() => {}); } - const record = await folderStorage.getFolderData(id); + const record = await (backend ? backend.getFolderData(id) : folderStorage.getFolderData(id)); if (record) { // Only delete input files the folder created from disk — never touch sidebar-sourced files. const ownedInputIds = Object.entries(record.files) @@ -81,16 +97,28 @@ export function useSmartFolders(): UseSmartFoldersReturn { const toDelete = [...new Set([...ownedInputIds, ...outputIds])]; await Promise.all(toDelete.map(fid => fileStorage.deleteStirlingFile(fid as FileId).catch(() => {}))); } - await folderStorage.clearFolder(id); - await folderRunStateStorage.clearFolderRunState(id); + + if (backend) { + await backend.clearFolder(id); + await backend.clearFolderRunState(id); + } else { + await folderStorage.clearFolder(id); + await folderRunStateStorage.clearFolderRunState(id); + } + // These are always IDB-only (browser API objects / ephemeral state) await folderRetryScheduleStorage.clearFolder(id).catch(() => {}); await folderSeenFilesStorage.clearFolder(id).catch(() => {}); await folderDirectoryHandleStorage.remove(id).catch(() => {}); await folderDirectoryHandleStorage.removeInput(id).catch(() => {}); - await smartFolderStorage.deleteFolder(id); + + if (backend) { + await backend.deleteFolder(id); + } else { + await smartFolderStorage.deleteFolder(id); + } // Notify the sidebar file list that files have been removed. window.dispatchEvent(new CustomEvent('stirling:files-changed')); - }, []); + }, [backend]); return { folders, loading, createFolder, updateFolder, deleteFolder, refreshFolders }; } diff --git a/frontend/src/core/services/folderStorage.ts b/frontend/src/core/services/folderStorage.ts index 9b172526a5..66133a8121 100644 --- a/frontend/src/core/services/folderStorage.ts +++ b/frontend/src/core/services/folderStorage.ts @@ -138,6 +138,21 @@ class FolderStorage { }); } + /** Overwrite the entire folder record (used by sync from server). */ + async setFolderData(folderId: string, record: FolderRecord): Promise { + const db = await this.ensureDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.recordsStore], 'readwrite'); + const store = transaction.objectStore(this.recordsStore); + const request = store.put(record); + request.onsuccess = () => { + this.dispatchChange(folderId); + resolve(); + }; + request.onerror = () => reject(new Error('Failed to set folder data')); + }); + } + async clearFolder(folderId: string): Promise { const db = await this.ensureDB(); return new Promise((resolve, reject) => { diff --git a/frontend/src/core/services/watchFolderIdbBackend.ts b/frontend/src/core/services/watchFolderIdbBackend.ts new file mode 100644 index 0000000000..20cfec7130 --- /dev/null +++ b/frontend/src/core/services/watchFolderIdbBackend.ts @@ -0,0 +1,39 @@ +/** + * IDB-only implementation of WatchFolderStorageBackend. + * + * Wraps the existing IDB singletons into the storage interface + * so core hooks can use them through the context. + */ + +import type { WatchFolderStorageBackend } from '@app/contexts/WatchFolderStorageContext'; +import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage'; +import { folderStorage } from '@app/services/folderStorage'; +import { folderRunStateStorage } from '@app/services/folderRunStateStorage'; +import type { SmartFolder, FolderRecord, FolderFileMetadata, SmartFolderRunEntry } from '@app/types/smartFolders'; + +export const idbBackend: WatchFolderStorageBackend = { + // Folder CRUD + getAllFolders: () => smartFolderStorage.getAllFolders(), + getFolder: (id) => smartFolderStorage.getFolder(id), + createFolder: (data) => smartFolderStorage.createFolder(data), + createFolderWithId: (folder) => smartFolderStorage.createFolderWithId(folder), + updateFolder: (folder) => smartFolderStorage.updateFolder(folder), + deleteFolder: (id) => smartFolderStorage.deleteFolder(id), + + // File metadata + getFolderData: (folderId) => folderStorage.getFolderData(folderId), + updateFileMetadata: (folderId, fileId, meta) => folderStorage.updateFileMetadata(folderId, fileId, meta), + addFileToFolder: (folderId, fileId, meta) => folderStorage.addFileToFolder(folderId, fileId, meta), + clearFolder: (folderId) => folderStorage.clearFolder(folderId), + + // Run state + getFolderRunState: (folderId) => folderRunStateStorage.getFolderRunState(folderId), + addFolderRunEntries: (folderId, entries) => folderRunStateStorage.appendRunEntries(folderId, entries), + clearFolderRunState: (folderId) => folderRunStateStorage.clearFolderRunState(folderId), + + // Events + onChange(callback) { + window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback); + return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback); + }, +}; diff --git a/frontend/src/proprietary/App.tsx b/frontend/src/proprietary/App.tsx index e7bae0c053..f13ac0c841 100644 --- a/frontend/src/proprietary/App.tsx +++ b/frontend/src/proprietary/App.tsx @@ -13,6 +13,7 @@ import InviteAccept from "@app/routes/InviteAccept"; import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; import SmartFoldersRegistration from "@app/components/smartFolders/SmartFoldersRegistration"; +import { WatchFolderServerProvider } from "@proprietary/components/WatchFolderServerProvider"; // Import global styles import "@app/styles/tailwind.css"; import "@app/styles/cookieconsent.css"; @@ -52,6 +53,7 @@ export default function App() { path="*" element={ + {/* Auth routes - no nested providers needed */} @@ -66,6 +68,7 @@ export default function App() { + } /> diff --git a/frontend/src/proprietary/components/WatchFolderServerProvider.tsx b/frontend/src/proprietary/components/WatchFolderServerProvider.tsx new file mode 100644 index 0000000000..ab593f6759 --- /dev/null +++ b/frontend/src/proprietary/components/WatchFolderServerProvider.tsx @@ -0,0 +1,26 @@ +/** + * Proprietary wrapper that overrides the core IDB-only WatchFolderStorageProvider + * with the server-backed implementation when premium is enabled. + */ + +import React from 'react'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { WatchFolderStorageProvider } from '@app/contexts/WatchFolderStorageContext'; +import { serverBackend } from '@proprietary/services/watchFolderServerBackend'; + +export function WatchFolderServerProvider({ children }: { children: React.ReactNode }) { + const { config } = useAppConfig(); + const isPremium = config?.premiumEnabled === true; + + if (!isPremium) { + // Core's IDB provider is already in place — just pass through + return <>{children}; + } + + // Override with server-backed storage + return ( + + {children} + + ); +} diff --git a/frontend/src/proprietary/services/watchFolderApiService.ts b/frontend/src/proprietary/services/watchFolderApiService.ts new file mode 100644 index 0000000000..67f72ac6f6 --- /dev/null +++ b/frontend/src/proprietary/services/watchFolderApiService.ts @@ -0,0 +1,120 @@ +/** + * REST client for the server-side Watch Folder persistence API. + * + * These endpoints store folder configs, file metadata, and run history + * in the server database — the source of truth for proprietary deployments. + */ + +import apiClient from '@app/services/apiClient'; + +// ── Types matching the JPA entities ──────────────────────────────────────── + +export interface WatchFolderDTO { + id: string; + name: string; + description?: string; + automationConfig?: string; // JSON-stringified operations array + icon?: string; + accentColor?: string; + scope: 'PERSONAL' | 'ORGANISATION'; + orderIndex?: number; + isDefault?: boolean; + isPaused?: boolean; + inputSource?: string; + processingMode?: string; + outputMode?: string; + outputName?: string; + outputNamePosition?: string; + outputTtlHours?: number | null; + deleteOutputOnDownload?: boolean; + maxRetries?: number; + retryDelayMinutes?: number; + createdAt?: string; + updatedAt?: string; +} + +export interface WatchFolderFileDTO { + id?: number; + fileId: string; + status: string; + name?: string; + errorMessage?: string; + failedAttempts?: number; + ownedByFolder?: boolean; + pendingOnServer?: boolean; + displayFileIds?: string; // JSON array + serverOutputFilenames?: string; // JSON array + addedAt?: string; + processedAt?: string; +} + +export interface WatchFolderRunDTO { + id?: number; + inputFileId: string; + displayFileId?: string; + displayFileIds?: string; // JSON array + status: string; + processedAt?: string; +} + +// ── API calls ────────────────────────────────────────────────────────────── + +const BASE = '/api/v1/watch-folders'; + +export const watchFolderApi = { + // Folders + async list(): Promise { + const res = await apiClient.get(BASE); + return res.data; + }, + + async get(id: string): Promise { + const res = await apiClient.get(`${BASE}/${id}`); + return res.data; + }, + + async create(folder: WatchFolderDTO): Promise { + const res = await apiClient.post(BASE, folder); + return res.data; + }, + + async update(id: string, folder: Partial): Promise { + const res = await apiClient.put(`${BASE}/${id}`, folder); + return res.data; + }, + + async remove(id: string): Promise { + await apiClient.delete(`${BASE}/${id}`); + }, + + // Files + async listFiles(folderId: string): Promise { + const res = await apiClient.get(`${BASE}/${folderId}/files`); + return res.data; + }, + + async upsertFile(folderId: string, file: WatchFolderFileDTO): Promise { + const res = await apiClient.put(`${BASE}/${folderId}/files`, file); + return res.data; + }, + + async deleteFiles(folderId: string): Promise { + await apiClient.delete(`${BASE}/${folderId}/files`); + }, + + // Runs + async listRuns(folderId: string): Promise { + const res = await apiClient.get(`${BASE}/${folderId}/runs`); + return res.data; + }, + + async addRun(folderId: string, run: WatchFolderRunDTO): Promise { + const res = await apiClient.post(`${BASE}/${folderId}/runs`, run); + return res.data; + }, + + async addRuns(folderId: string, runs: WatchFolderRunDTO[]): Promise { + const res = await apiClient.post(`${BASE}/${folderId}/runs/batch`, runs); + return res.data; + }, +}; diff --git a/frontend/src/proprietary/services/watchFolderServerBackend.ts b/frontend/src/proprietary/services/watchFolderServerBackend.ts new file mode 100644 index 0000000000..4f691a10ae --- /dev/null +++ b/frontend/src/proprietary/services/watchFolderServerBackend.ts @@ -0,0 +1,294 @@ +/** + * Server-backed implementation of WatchFolderStorageBackend. + * + * Writes go to the server API first (source of truth), then mirror to IDB for fast reads. + * Reads come from IDB (populated on init and after writes). + * Falls back to IDB-only if the server is unreachable. + */ + +import type { WatchFolderStorageBackend } from '@app/contexts/WatchFolderStorageContext'; +import { smartFolderStorage, SMART_FOLDER_STORAGE_CHANGE_EVENT } from '@app/services/smartFolderStorage'; +import { folderStorage } from '@app/services/folderStorage'; +import { folderRunStateStorage } from '@app/services/folderRunStateStorage'; +import type { SmartFolder, FolderRecord, FolderFileMetadata, SmartFolderRunEntry } from '@app/types/smartFolders'; +import { watchFolderApi, WatchFolderDTO, WatchFolderFileDTO, WatchFolderRunDTO } from './watchFolderApiService'; + +// ── DTO ↔ Domain conversions ─────────────────────────────────────────────── + +function toSmartFolder(dto: WatchFolderDTO): SmartFolder { + return { + id: dto.id, + name: dto.name, + description: dto.description ?? '', + automationId: '', // automation config is inlined — hooks resolve this + icon: dto.icon ?? 'FolderIcon', + accentColor: dto.accentColor ?? '#3b82f6', + createdAt: dto.createdAt ?? new Date().toISOString(), + updatedAt: dto.updatedAt ?? new Date().toISOString(), + order: dto.orderIndex, + isDefault: dto.isDefault, + isPaused: dto.isPaused, + inputSource: (dto.inputSource as SmartFolder['inputSource']) ?? 'idb', + processingMode: (dto.processingMode as SmartFolder['processingMode']) ?? 'local', + outputMode: (dto.outputMode as SmartFolder['outputMode']) ?? 'new_file', + outputName: dto.outputName, + outputNamePosition: (dto.outputNamePosition as SmartFolder['outputNamePosition']) ?? 'prefix', + outputTtlHours: dto.outputTtlHours, + deleteOutputOnDownload: dto.deleteOutputOnDownload, + maxRetries: dto.maxRetries, + retryDelayMinutes: dto.retryDelayMinutes, + }; +} + +function toDTO(folder: SmartFolder & { scope?: string }): WatchFolderDTO { + return { + id: folder.id, + name: folder.name, + description: folder.description, + icon: folder.icon, + accentColor: folder.accentColor, + scope: (folder as any).scope ?? 'PERSONAL', + orderIndex: folder.order, + isDefault: folder.isDefault, + isPaused: folder.isPaused, + inputSource: folder.inputSource, + processingMode: folder.processingMode, + outputMode: folder.outputMode, + outputName: folder.outputName, + outputNamePosition: folder.outputNamePosition, + outputTtlHours: folder.outputTtlHours, + deleteOutputOnDownload: folder.deleteOutputOnDownload, + maxRetries: folder.maxRetries, + retryDelayMinutes: folder.retryDelayMinutes, + }; +} + +function dispatchChange() { + window.dispatchEvent(new Event(SMART_FOLDER_STORAGE_CHANGE_EVENT)); +} + +// ── Sync helper: pull server state into IDB ──────────────────────────────── + +async function syncFoldersToIdb(): Promise { + const dtos = await watchFolderApi.list(); + const folders = dtos.map(toSmartFolder); + // Overwrite IDB with server state + const existing = await smartFolderStorage.getAllFolders(); + const serverIds = new Set(folders.map(f => f.id)); + // Remove folders from IDB that no longer exist on server + for (const f of existing) { + if (!serverIds.has(f.id)) { + await smartFolderStorage.deleteFolder(f.id).catch(() => {}); + } + } + // Upsert server folders into IDB + for (const f of folders) { + await smartFolderStorage.createFolderWithId(f).catch(() => {}); + } + dispatchChange(); + return folders; +} + +// ── Backend implementation ───────────────────────────────────────────────── + +export const serverBackend: WatchFolderStorageBackend = { + async getAllFolders() { + try { + return await syncFoldersToIdb(); + } catch { + // Server unreachable — fall back to IDB + return smartFolderStorage.getAllFolders(); + } + }, + + async getFolder(id) { + try { + const dto = await watchFolderApi.get(id); + const folder = toSmartFolder(dto); + await smartFolderStorage.createFolderWithId(folder).catch(() => {}); + return folder; + } catch { + return smartFolderStorage.getFolder(id); + } + }, + + async createFolder(data) { + const timestamp = new Date().toISOString(); + const folder: SmartFolder = { + id: crypto.randomUUID(), + ...data, + createdAt: timestamp, + updatedAt: timestamp, + }; + try { + const created = await watchFolderApi.create(toDTO(folder)); + const result = toSmartFolder(created); + await smartFolderStorage.createFolderWithId(result).catch(() => {}); + dispatchChange(); + return result; + } catch { + // Offline — create in IDB only + return smartFolderStorage.createFolder(data); + } + }, + + async createFolderWithId(folder) { + try { + const created = await watchFolderApi.create(toDTO(folder)); + const result = toSmartFolder(created); + await smartFolderStorage.createFolderWithId(result).catch(() => {}); + dispatchChange(); + return result; + } catch { + return smartFolderStorage.createFolderWithId(folder); + } + }, + + async updateFolder(folder) { + try { + const updated = await watchFolderApi.update(folder.id, toDTO(folder)); + const result = toSmartFolder(updated); + await smartFolderStorage.createFolderWithId(result).catch(() => {}); + dispatchChange(); + return result; + } catch { + return smartFolderStorage.updateFolder(folder); + } + }, + + async deleteFolder(id) { + try { + await watchFolderApi.remove(id); + } catch { + // best-effort server delete + } + await smartFolderStorage.deleteFolder(id); + }, + + // File metadata — server-backed with IDB cache + async getFolderData(folderId) { + try { + const files = await watchFolderApi.listFiles(folderId); + const record: FolderRecord = { + folderId, + files: {}, + lastUpdated: Date.now(), + }; + for (const f of files) { + record.files[f.fileId] = { + addedAt: f.addedAt ? new Date(f.addedAt) : new Date(), + status: f.status as FolderFileMetadata['status'], + name: f.name, + errorMessage: f.errorMessage, + failedAttempts: f.failedAttempts, + ownedByFolder: f.ownedByFolder, + pendingOnServerFolder: f.pendingOnServer, + displayFileIds: f.displayFileIds ? JSON.parse(f.displayFileIds) : undefined, + serverOutputFilenames: f.serverOutputFilenames ? JSON.parse(f.serverOutputFilenames) : undefined, + processedAt: f.processedAt ? new Date(f.processedAt) : undefined, + }; + } + // Mirror to IDB + await folderStorage.setFolderData(folderId, record).catch(() => {}); + return record; + } catch { + return folderStorage.getFolderData(folderId); + } + }, + + async updateFileMetadata(folderId, fileId, meta) { + // Update IDB immediately for fast UI + await folderStorage.updateFileMetadata(folderId, fileId, meta); + // Sync to server + try { + const existing = await folderStorage.getFolderData(folderId); + const fileMeta = existing?.files[fileId]; + if (fileMeta) { + await watchFolderApi.upsertFile(folderId, { + fileId, + status: fileMeta.status, + name: fileMeta.name, + errorMessage: fileMeta.errorMessage, + failedAttempts: fileMeta.failedAttempts, + ownedByFolder: fileMeta.ownedByFolder, + pendingOnServer: fileMeta.pendingOnServerFolder, + displayFileIds: fileMeta.displayFileIds ? JSON.stringify(fileMeta.displayFileIds) : undefined, + serverOutputFilenames: fileMeta.serverOutputFilenames ? JSON.stringify(fileMeta.serverOutputFilenames) : undefined, + addedAt: fileMeta.addedAt?.toISOString(), + processedAt: fileMeta.processedAt?.toISOString(), + }); + } + } catch { + // Server sync failed — IDB is still up to date + } + }, + + async addFileToFolder(folderId, fileId, meta) { + await folderStorage.addFileToFolder(folderId, fileId, meta); + try { + await watchFolderApi.upsertFile(folderId, { + fileId, + status: meta?.status ?? 'pending', + name: meta?.name, + ownedByFolder: meta?.ownedByFolder, + addedAt: meta?.addedAt?.toISOString() ?? new Date().toISOString(), + }); + } catch { + // offline — IDB has the data + } + }, + + async clearFolder(folderId) { + try { + await watchFolderApi.deleteFiles(folderId); + } catch { /* best-effort */ } + await folderStorage.clearFolder(folderId); + }, + + // Run state + async getFolderRunState(folderId) { + try { + const runs = await watchFolderApi.listRuns(folderId); + const entries: SmartFolderRunEntry[] = runs.map(r => ({ + inputFileId: r.inputFileId, + displayFileId: r.displayFileId ?? '', + displayFileIds: r.displayFileIds ? JSON.parse(r.displayFileIds) : undefined, + status: r.status as SmartFolderRunEntry['status'], + processedAt: r.processedAt ? new Date(r.processedAt) : undefined, + })); + return entries; + } catch { + return folderRunStateStorage.getFolderRunState(folderId); + } + }, + + async addFolderRunEntries(folderId, entries) { + // IDB first + await folderRunStateStorage.appendRunEntries(folderId, entries); + // Server sync + try { + await watchFolderApi.addRuns( + folderId, + entries.map(e => ({ + inputFileId: e.inputFileId, + displayFileId: e.displayFileId, + displayFileIds: e.displayFileIds ? JSON.stringify(e.displayFileIds) : undefined, + status: e.status, + processedAt: e.processedAt?.toISOString(), + })) + ); + } catch { /* offline */ } + }, + + async clearFolderRunState(folderId) { + try { + // No dedicated endpoint yet — runs are deleted with the folder + } catch { /* best-effort */ } + await folderRunStateStorage.clearFolderRunState(folderId); + }, + + onChange(callback) { + window.addEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback); + return () => window.removeEventListener(SMART_FOLDER_STORAGE_CHANGE_EVENT, callback); + }, +};