merge(frontend): resolve conflicts with main — integrate fileHash feature into panel architecture
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m21s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 28m37s
CI / Unit & Component Tests (push) Successful in 2m26s
CI / Backend Unit Tests (push) Successful in 2m14s
CI / E2E Tests (push) Has started running
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 2m21s
CI / Backend Unit Tests (pull_request) Successful in 2m11s
CI / E2E Tests (pull_request) Failing after 28m37s
CI / Unit & Component Tests (push) Successful in 2m26s
CI / Backend Unit Tests (push) Successful in 2m14s
CI / E2E Tests (push) Has started running
Keep the new bottom-panel / AnnotationSidePanel architecture from this branch while pulling in the documentFileHash / visibleAnnotations filter that was added on main. Thread documentFileHash through DocumentViewer so outdated-annotation filtering works end-to-end. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #63.
This commit is contained in:
@@ -41,4 +41,10 @@ public class AdminController {
|
|||||||
documentService.getDocumentsWithoutVersions());
|
documentService.getDocumentsWithoutVersions());
|
||||||
return ResponseEntity.ok(new BackfillResult(count));
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/backfill-file-hashes")
|
||||||
|
public ResponseEntity<BackfillResult> backfillFileHashes() {
|
||||||
|
int count = documentService.backfillFileHashes();
|
||||||
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
@@ -23,6 +25,7 @@ import java.util.UUID;
|
|||||||
public class AnnotationController {
|
public class AnnotationController {
|
||||||
|
|
||||||
private final AnnotationService annotationService;
|
private final AnnotationService annotationService;
|
||||||
|
private final DocumentService documentService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -38,7 +41,8 @@ public class AnnotationController {
|
|||||||
@RequestBody CreateAnnotationDTO dto,
|
@RequestBody CreateAnnotationDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
UUID userId = resolveUserId(authentication);
|
UUID userId = resolveUserId(authentication);
|
||||||
return annotationService.createAnnotation(documentId, dto, userId);
|
Document doc = documentService.getDocumentById(documentId);
|
||||||
|
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{annotationId}")
|
@DeleteMapping("/{annotationId}")
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ public class Document {
|
|||||||
@Column(name = "content_type")
|
@Column(name = "content_type")
|
||||||
private String contentType;
|
private String contentType;
|
||||||
|
|
||||||
|
// SHA-256 hash of the uploaded file — used to link annotations to a file version
|
||||||
|
@Column(name = "file_hash", length = 64)
|
||||||
|
private String fileHash;
|
||||||
|
|
||||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||||
@Column(name = "original_filename", nullable = false)
|
@Column(name = "original_filename", nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ public class DocumentAnnotation {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String color;
|
private String color;
|
||||||
|
|
||||||
|
@Column(name = "file_hash", length = 64)
|
||||||
|
private String fileHash;
|
||||||
|
|
||||||
@Column(name = "created_by")
|
@Column(name = "created_by")
|
||||||
private UUID createdBy;
|
private UUID createdBy;
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ public interface AnnotationRepository extends JpaRepository<DocumentAnnotation,
|
|||||||
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
||||||
|
|
||||||
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||||
|
|
||||||
|
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||||
List<Document> findDocumentsWithoutVersions();
|
List<Document> findDocumentsWithoutVersions();
|
||||||
|
|
||||||
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class AnnotationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
|
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
|
||||||
List<DocumentAnnotation> existing =
|
List<DocumentAnnotation> existing =
|
||||||
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ public class AnnotationService {
|
|||||||
.width(dto.getWidth())
|
.width(dto.getWidth())
|
||||||
.height(dto.getHeight())
|
.height(dto.getHeight())
|
||||||
.color(dto.getColor())
|
.color(dto.getColor())
|
||||||
|
.fileHash(fileHash)
|
||||||
.createdBy(userId)
|
.createdBy(userId)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -61,6 +62,14 @@ public class AnnotationService {
|
|||||||
annotationRepository.delete(annotation);
|
annotationRepository.delete(annotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
|
||||||
|
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
|
||||||
|
a.setFileHash(fileHash);
|
||||||
|
annotationRepository.save(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -38,6 +40,7 @@ public class DocumentService {
|
|||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
|
private final AnnotationService annotationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
@@ -64,10 +67,11 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delegate Storage to FileService
|
// 2. Delegate Storage to FileService
|
||||||
String s3Key = fileService.uploadFile(file, originalFilename);
|
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
|
||||||
|
|
||||||
// 3. Update Database
|
// 3. Update Database
|
||||||
document.setFilePath(s3Key);
|
document.setFilePath(upload.s3Key());
|
||||||
|
document.setFileHash(upload.fileHash());
|
||||||
document.setContentType(file.getContentType());
|
document.setContentType(file.getContentType());
|
||||||
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
||||||
document.setStatus(DocumentStatus.UPLOADED);
|
document.setStatus(DocumentStatus.UPLOADED);
|
||||||
@@ -120,8 +124,9 @@ public class DocumentService {
|
|||||||
|
|
||||||
// Datei
|
// Datei
|
||||||
if (file != null && !file.isEmpty()) {
|
if (file != null && !file.isEmpty()) {
|
||||||
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(upload.s3Key());
|
||||||
|
doc.setFileHash(upload.fileHash());
|
||||||
doc.setContentType(file.getContentType());
|
doc.setContentType(file.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
@@ -170,12 +175,9 @@ public class DocumentService {
|
|||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||||
if (newFile != null && !newFile.isEmpty()) {
|
if (newFile != null && !newFile.isEmpty()) {
|
||||||
// Alte Datei könnte man hier theoretisch löschen (optional)
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
|
doc.setFilePath(upload.s3Key());
|
||||||
// Neue Datei hochladen
|
doc.setFileHash(upload.fileHash());
|
||||||
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
|
||||||
|
|
||||||
doc.setFilePath(s3Key);
|
|
||||||
doc.setOriginalFilename(newFile.getOriginalFilename());
|
doc.setOriginalFilename(newFile.getOriginalFilename());
|
||||||
doc.setContentType(newFile.getContentType());
|
doc.setContentType(newFile.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
@@ -283,4 +285,39 @@ public class DocumentService {
|
|||||||
});
|
});
|
||||||
tagService.delete(tagId);
|
tagService.delete(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int backfillFileHashes() {
|
||||||
|
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
int count = 0;
|
||||||
|
for (Document doc : docs) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
|
||||||
|
String hash = sha256Hex(bytes);
|
||||||
|
doc.setFileHash(hash);
|
||||||
|
documentRepository.save(doc);
|
||||||
|
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
|
||||||
|
count++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String sha256Hex(byte[] bytes) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(bytes);
|
||||||
|
StringBuilder sb = new StringBuilder(64);
|
||||||
|
for (byte b : hash) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.core.io.InputStreamResource;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -29,10 +32,14 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a file to S3/MinIO and returns the generated object key.
|
* Uploads a file to S3/MinIO.
|
||||||
|
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
|
||||||
|
* hash of the file content. The hash is used to link annotations to the
|
||||||
|
* specific file version they were created against.
|
||||||
*/
|
*/
|
||||||
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||||
// Generate secure unique path: "documents/UUID_filename"
|
byte[] bytes = file.getBytes();
|
||||||
|
String fileHash = sha256Hex(bytes);
|
||||||
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -42,11 +49,10 @@ public class FileService {
|
|||||||
.contentType(file.getContentType())
|
.contentType(file.getContentType())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
s3Client.putObject(putObjectRequest,
|
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
|
||||||
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
|
||||||
|
|
||||||
log.info("Uploaded file to S3: {}", s3Key);
|
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
|
||||||
return s3Key;
|
return new UploadResult(s3Key, fileHash);
|
||||||
} catch (S3Exception e) {
|
} catch (S3Exception e) {
|
||||||
log.error("S3 Upload Error", e);
|
log.error("S3 Upload Error", e);
|
||||||
throw new IOException("Failed to upload file to storage", e);
|
throw new IOException("Failed to upload file to storage", e);
|
||||||
@@ -66,7 +72,6 @@ public class FileService {
|
|||||||
|
|
||||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||||
|
|
||||||
// Use whatever content type S3 has stored (set at upload time)
|
|
||||||
String contentType = s3Object.response().contentType();
|
String contentType = s3Object.response().contentType();
|
||||||
if (contentType == null || contentType.isBlank()) {
|
if (contentType == null || contentType.isBlank()) {
|
||||||
contentType = "application/octet-stream";
|
contentType = "application/octet-stream";
|
||||||
@@ -80,10 +85,51 @@ public class FileService {
|
|||||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Helper Record to carry the stream and metadata back to the controller
|
|
||||||
|
/**
|
||||||
|
* Downloads a file from S3/MinIO and returns its raw bytes.
|
||||||
|
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
|
||||||
|
*/
|
||||||
|
public byte[] downloadFileBytes(String s3Key) throws IOException {
|
||||||
|
try {
|
||||||
|
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.key(s3Key)
|
||||||
|
.build();
|
||||||
|
try (InputStream in = s3Client.getObject(getObjectRequest)) {
|
||||||
|
return in.readAllBytes();
|
||||||
|
}
|
||||||
|
} catch (NoSuchKeyException e) {
|
||||||
|
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||||
|
} catch (S3Exception e) {
|
||||||
|
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String sha256Hex(byte[] bytes) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(bytes);
|
||||||
|
StringBuilder sb = new StringBuilder(64);
|
||||||
|
for (byte b : hash) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── result types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Carries the S3 object key and the content hash back to the caller. */
|
||||||
|
public record UploadResult(String s3Key, String fileHash) {}
|
||||||
|
|
||||||
|
/** Carries the download stream and content type. */
|
||||||
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
||||||
|
|
||||||
// Custom Exception
|
|
||||||
public static class StorageFileNotFoundException extends RuntimeException {
|
public static class StorageFileNotFoundException extends RuntimeException {
|
||||||
public StorageFileNotFoundException(String message) { super(message); }
|
public StorageFileNotFoundException(String message) { super(message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add content-based file hash to documents for annotation versioning
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN file_hash VARCHAR(64);
|
||||||
|
|
||||||
|
-- Each annotation remembers which file version it was created against
|
||||||
|
ALTER TABLE document_annotations
|
||||||
|
ADD COLUMN file_hash VARCHAR(64);
|
||||||
@@ -58,4 +58,29 @@ class AdminControllerTest {
|
|||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
.andExpect(jsonPath("$.count").value(1));
|
.andExpect(jsonPath("$.count").value(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(roles = "USER")
|
||||||
|
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||||
|
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.AnnotationService;
|
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
|
|||||||
@Autowired MockMvc mockMvc;
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
@MockitoBean AnnotationService annotationService;
|
@MockitoBean AnnotationService annotationService;
|
||||||
|
@MockitoBean DocumentService documentService;
|
||||||
@MockitoBean UserService userService;
|
@MockitoBean UserService userService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
|
|||||||
DocumentAnnotation saved = DocumentAnnotation.builder()
|
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void createAnnotation_returns409_whenOverlap() throws Exception {
|
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||||
when(annotationService.createAnnotation(any(), any(), any()))
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
when(annotationService.createAnnotation(any(), any(), any(), any()))
|
||||||
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class AnnotationServiceTest {
|
|||||||
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
||||||
.thenReturn(List.of(existing));
|
.thenReturn(List.of(existing));
|
||||||
|
|
||||||
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
|
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ class AnnotationServiceTest {
|
|||||||
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
||||||
when(annotationRepository.save(any())).thenReturn(saved);
|
when(annotationRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||||
|
|
||||||
assertThat(result).isEqualTo(saved);
|
assertThat(result).isEqualTo(saved);
|
||||||
verify(annotationRepository).save(any());
|
verify(annotationRepository).save(any());
|
||||||
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
|
|||||||
verify(annotationRepository).delete(annotation);
|
verify(annotationRepository).delete(annotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_setsFileHash_whenProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||||
|
String fileHash = "abc123";
|
||||||
|
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
|
||||||
|
|
||||||
|
assertThat(result.getFileHash()).isEqualTo(fileHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_setsNullFileHash_whenNoneProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||||
|
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||||
|
|
||||||
|
assertThat(result.getFileHash()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── listAnnotations ──────────────────────────────────────────────────────
|
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -128,4 +157,30 @@ class AnnotationServiceTest {
|
|||||||
|
|
||||||
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
String hash = "abc123";
|
||||||
|
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).build();
|
||||||
|
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
|
||||||
|
|
||||||
|
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
|
||||||
|
|
||||||
|
assertThat(a.getFileHash()).isEqualTo(hash);
|
||||||
|
verify(annotationRepository).save(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
|
||||||
|
|
||||||
|
verify(annotationRepository, never()).save(any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -31,6 +32,7 @@ class DocumentServiceTest {
|
|||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock DocumentVersionService documentVersionService;
|
@Mock DocumentVersionService documentVersionService;
|
||||||
|
@Mock AnnotationService annotationService;
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||||
@@ -135,6 +137,48 @@ class DocumentServiceTest {
|
|||||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── file hash propagation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
|
||||||
|
|
||||||
|
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(savedDoc);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
documentService.createDocument(dto, file);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(id).title("Alt").originalFilename("old.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).build();
|
||||||
|
org.springframework.mock.web.MockMultipartFile newFile =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
|
||||||
|
|
||||||
|
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── versioning ───────────────────────────────────────────────────────────
|
// ─── versioning ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -167,4 +211,59 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
|
||||||
|
Document noFile = Document.builder().id(UUID.randomUUID()).build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
|
||||||
|
|
||||||
|
int count = documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
assertThat(count).isZero();
|
||||||
|
verify(fileService, never()).downloadFileBytes(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||||
|
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
|
||||||
|
verify(documentRepository).save(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||||
|
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
|
||||||
|
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
|
||||||
|
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
int count = documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class FileServiceTest {
|
||||||
|
|
||||||
|
private S3Client s3Client;
|
||||||
|
private FileService fileService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
s3Client = mock(S3Client.class);
|
||||||
|
fileService = new FileService(s3Client, "test-bucket");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_returnsS3Key() throws IOException {
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||||
|
|
||||||
|
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
|
||||||
|
|
||||||
|
assertThat(result.s3Key()).startsWith("documents/");
|
||||||
|
assertThat(result.s3Key()).endsWith("_test.pdf");
|
||||||
|
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
|
||||||
|
byte[] content = "hello pdf content".getBytes();
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "doc.pdf", "application/pdf", content);
|
||||||
|
|
||||||
|
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
|
||||||
|
|
||||||
|
// Compute expected hash independently
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hashBytes = digest.digest(content);
|
||||||
|
StringBuilder expected = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
expected.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(result.fileHash()).isEqualTo(expected.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
|
||||||
|
MockMultipartFile file1 = new MockMultipartFile(
|
||||||
|
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||||
|
MockMultipartFile file2 = new MockMultipartFile(
|
||||||
|
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
|
||||||
|
|
||||||
|
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
|
||||||
|
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
|
||||||
|
|
||||||
|
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_sameContents_produceSameHash() throws IOException {
|
||||||
|
byte[] content = new byte[]{10, 20, 30};
|
||||||
|
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
|
||||||
|
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
|
||||||
|
|
||||||
|
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
|
||||||
|
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
|
||||||
|
|
||||||
|
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,3 +216,35 @@ test.describe('Admin — tag management', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Admin system tab — backfill file hashes', () => {
|
||||||
|
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
// Create a document via API so there is at least one without a hash
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Backfill Hash Test' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Navigate to System tab
|
||||||
|
await page.getByRole('button', { name: /system/i }).click();
|
||||||
|
|
||||||
|
// Click the backfill hashes button
|
||||||
|
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
|
||||||
|
await expect(btn).toBeVisible();
|
||||||
|
await btn.click();
|
||||||
|
|
||||||
|
// Success message must appear (count >= 0)
|
||||||
|
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -347,13 +347,142 @@ test.describe('PDF annotations — admin', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
||||||
|
|
||||||
|
test.describe('PDF annotations — file hash versioning', () => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
||||||
|
|
||||||
|
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
// 1. Create document and upload original PDF
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Hash Test — version' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
// 2. Create an annotation via API
|
||||||
|
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
||||||
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
|
||||||
|
});
|
||||||
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||||
|
|
||||||
|
// 3. Verify annotation appears before re-upload
|
||||||
|
await page.goto(`${baseURL}/documents/${doc.id}`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Upload a different file (different hash)
|
||||||
|
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal2.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
|
||||||
|
|
||||||
|
// 5. Reload — annotation must be hidden and notice shown
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
|
||||||
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
// 1. Create document and upload original PDF
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Hash Test — restore' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const originalBytes = fs.readFileSync(PDF_FIXTURE);
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
// 2. Create annotation
|
||||||
|
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
||||||
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
|
||||||
|
});
|
||||||
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||||
|
|
||||||
|
// 3. Replace with different file
|
||||||
|
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal2.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
|
||||||
|
|
||||||
|
// 4. Re-upload original file (restoring the hash)
|
||||||
|
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
||||||
|
|
||||||
|
// 5. Verify annotation reappears and notice is gone
|
||||||
|
await page.goto(`${baseURL}/documents/${doc.id}`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('PDF annotations — read-only user', () => {
|
test.describe('PDF annotations — read-only user', () => {
|
||||||
// Isolated session — does not share the admin storage state
|
// Isolated session — does not share the admin storage state
|
||||||
test.use({ storageState: { cookies: [], origins: [] } });
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
test('read-only user sees a disabled Annotieren button', async ({ page }) => {
|
test('read-only user does not see the Annotieren button', async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Benutzername').fill('reader');
|
await page.getByLabel('Benutzername').fill('reader');
|
||||||
@@ -365,12 +494,10 @@ test.describe('PDF annotations — read-only user', () => {
|
|||||||
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
// Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown.
|
|
||||||
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 });
|
|
||||||
|
|
||||||
const disabledBtn = page.getByRole('button', { name: /annotieren/i });
|
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
||||||
await expect(disabledBtn).toBeVisible({ timeout: 5000 });
|
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
|
||||||
await expect(disabledBtn).toBeDisabled();
|
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||||
});
|
});
|
||||||
|
|||||||
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<</Type/Catalog/Pages 2 0 R>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer
|
||||||
|
<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
190
|
||||||
|
%%EOF
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
||||||
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
||||||
|
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
|
||||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||||
@@ -241,6 +242,10 @@
|
|||||||
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
||||||
"admin_system_backfill_btn": "Jetzt auffüllen",
|
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||||
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||||
|
"admin_system_backfill_hashes_heading": "Datei-Hashes berechnen",
|
||||||
|
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
|
||||||
|
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
|
||||||
|
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
|
||||||
"comp_expandable_show_more": "Mehr anzeigen",
|
"comp_expandable_show_more": "Mehr anzeigen",
|
||||||
"comp_expandable_show_less": "Weniger anzeigen",
|
"comp_expandable_show_less": "Weniger anzeigen",
|
||||||
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Annotation not found.",
|
"error_annotation_not_found": "Annotation not found.",
|
||||||
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
||||||
|
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
|
||||||
"error_document_not_found": "Document not found.",
|
"error_document_not_found": "Document not found.",
|
||||||
"error_document_no_file": "No file is associated with this document.",
|
"error_document_no_file": "No file is associated with this document.",
|
||||||
"error_file_not_found": "The file could not be found in storage.",
|
"error_file_not_found": "The file could not be found in storage.",
|
||||||
@@ -241,6 +242,10 @@
|
|||||||
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
||||||
"admin_system_backfill_btn": "Backfill now",
|
"admin_system_backfill_btn": "Backfill now",
|
||||||
"admin_system_backfill_success": "{count} documents were backfilled.",
|
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||||
|
"admin_system_backfill_hashes_heading": "Compute file hashes",
|
||||||
|
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
|
||||||
|
"admin_system_backfill_hashes_btn": "Compute file hashes",
|
||||||
|
"admin_system_backfill_hashes_success": "{count} documents were updated.",
|
||||||
"comp_expandable_show_more": "Show more",
|
"comp_expandable_show_more": "Show more",
|
||||||
"comp_expandable_show_less": "Show less",
|
"comp_expandable_show_less": "Show less",
|
||||||
"error_comment_not_found": "The comment could not be found.",
|
"error_comment_not_found": "The comment could not be found.",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
"error_annotation_not_found": "Anotación no encontrada.",
|
"error_annotation_not_found": "Anotación no encontrada.",
|
||||||
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
||||||
|
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
|
||||||
"error_document_not_found": "Documento no encontrado.",
|
"error_document_not_found": "Documento no encontrado.",
|
||||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||||
@@ -241,6 +242,10 @@
|
|||||||
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
||||||
"admin_system_backfill_btn": "Completar ahora",
|
"admin_system_backfill_btn": "Completar ahora",
|
||||||
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||||
|
"admin_system_backfill_hashes_heading": "Calcular hashes de archivo",
|
||||||
|
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
|
||||||
|
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
|
||||||
|
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
|
||||||
"comp_expandable_show_more": "Mostrar más",
|
"comp_expandable_show_more": "Mostrar más",
|
||||||
"comp_expandable_show_less": "Mostrar menos",
|
"comp_expandable_show_less": "Mostrar menos",
|
||||||
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type Doc = {
|
|||||||
id: string;
|
id: string;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
|
fileHash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -83,6 +84,7 @@ let {
|
|||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
|
documentFileHash={doc.fileHash ?? null}
|
||||||
/>
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ let {
|
|||||||
annotateMode = $bindable(false),
|
annotateMode = $bindable(false),
|
||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
activeAnnotationPage = $bindable<number | null>(null),
|
activeAnnotationPage = $bindable<number | null>(null),
|
||||||
onAnnotationClick
|
onAnnotationClick,
|
||||||
|
documentFileHash
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
@@ -19,6 +20,7 @@ let {
|
|||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
activeAnnotationPage?: number | null;
|
activeAnnotationPage?: number | null;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
|
documentFileHash?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
@@ -51,6 +53,7 @@ type Annotation = {
|
|||||||
height: number;
|
height: number;
|
||||||
color: string;
|
color: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
fileHash?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
@@ -58,6 +61,11 @@ let annotateColor = $state('#ffff00');
|
|||||||
let commentCounts = new SvelteMap<string, number>();
|
let commentCounts = new SvelteMap<string, number>();
|
||||||
let showAnnotations = $state(true);
|
let showAnnotations = $state(true);
|
||||||
|
|
||||||
|
const visibleAnnotations = $derived(
|
||||||
|
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
||||||
|
);
|
||||||
|
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||||
const [lib, { default: workerUrl }] = await Promise.all([
|
const [lib, { default: workerUrl }] = await Promise.all([
|
||||||
@@ -306,6 +314,27 @@ function zoomOut() {
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||||
|
{#if outdatedCount > 0}
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
|
||||||
|
data-testid="annotation-outdated-notice"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0 text-amber-400"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||||
@@ -466,7 +495,7 @@ function zoomOut() {
|
|||||||
></div>
|
></div>
|
||||||
{#if showAnnotations}
|
{#if showAnnotations}
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||||
canAnnotate={annotateMode}
|
canAnnotate={annotateMode}
|
||||||
color={annotateColor}
|
color={annotateColor}
|
||||||
onDraw={handleAnnotationDraw}
|
onDraw={handleAnnotationDraw}
|
||||||
|
|||||||
@@ -180,6 +180,86 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/comments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getDocumentComments"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["postDocumentComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/comments/{commentId}/replies": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["replyToDocumentComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/annotations": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listAnnotations"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createAnnotation"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getAnnotationComments"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["postAnnotationComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["replyToAnnotationComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/auth/reset-password": {
|
"/api/auth/reset-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -260,6 +340,22 @@ export interface paths {
|
|||||||
patch: operations["updateGroup"];
|
patch: operations["updateGroup"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/comments/{commentId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteComment"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["editComment"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/tags": {
|
"/api/tags": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -420,6 +516,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteAnnotation"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -510,6 +622,7 @@ export interface components {
|
|||||||
title: string;
|
title: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
fileHash?: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||||
@@ -548,6 +661,63 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
CreateCommentDTO: {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
DocumentComment: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
documentId: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
annotationId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
parentId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
authorId?: string;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
|
replies: components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
CreateAnnotationDTO: {
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber?: number;
|
||||||
|
/** Format: double */
|
||||||
|
x?: number;
|
||||||
|
/** Format: double */
|
||||||
|
y?: number;
|
||||||
|
/** Format: double */
|
||||||
|
width?: number;
|
||||||
|
/** Format: double */
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
DocumentAnnotation: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
documentId: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber: number;
|
||||||
|
/** Format: double */
|
||||||
|
x: number;
|
||||||
|
/** Format: double */
|
||||||
|
y: number;
|
||||||
|
/** Format: double */
|
||||||
|
width: number;
|
||||||
|
/** Format: double */
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
fileHash?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
createdBy?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
ResetPasswordRequest: {
|
ResetPasswordRequest: {
|
||||||
token?: string;
|
token?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
@@ -1062,6 +1232,205 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getDocumentComments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
postDocumentComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
replyToDocumentComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listAnnotations: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentAnnotation"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createAnnotation: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateAnnotationDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentAnnotation"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getAnnotationComments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
annotationId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
postAnnotationComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
annotationId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
replyToAnnotationComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
resetPassword: {
|
resetPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1192,6 +1561,54 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
editComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
searchTags: {
|
searchTags: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1422,4 +1839,25 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteAnnotation: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
annotationId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,16 +250,6 @@ $effect(() => {
|
|||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Status Badge -->
|
|
||||||
<span
|
|
||||||
class="ml-3 inline-flex items-center rounded-full border px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase
|
|
||||||
{doc.status === 'UPLOADED'
|
|
||||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
|
||||||
: 'border-yellow-200 bg-yellow-50 text-yellow-700'}"
|
|
||||||
>
|
|
||||||
{doc.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Row -->
|
<!-- Metadata Row -->
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ let editingTagName = $state('');
|
|||||||
let editingGroupId: string | null = $state(null);
|
let editingGroupId: string | null = $state(null);
|
||||||
let backfillResult: number | null = $state(null);
|
let backfillResult: number | null = $state(null);
|
||||||
let backfillLoading = $state(false);
|
let backfillLoading = $state(false);
|
||||||
|
let backfillHashesResult: number | null = $state(null);
|
||||||
|
let backfillHashesLoading = $state(false);
|
||||||
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
@@ -45,6 +47,20 @@ async function backfillVersions() {
|
|||||||
backfillLoading = false;
|
backfillLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function backfillFileHashes() {
|
||||||
|
backfillHashesLoading = true;
|
||||||
|
backfillHashesResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
backfillHashesResult = data.count;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backfillHashesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
@@ -535,5 +551,24 @@ async function backfillVersions() {
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-700">
|
||||||
|
{m.admin_system_backfill_hashes_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_hashes_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillFileHashes}
|
||||||
|
disabled={backfillHashesLoading}
|
||||||
|
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
||||||
|
</button>
|
||||||
|
{#if backfillHashesResult !== null}
|
||||||
|
<p class="mt-4 text-sm font-medium text-brand-navy">
|
||||||
|
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user