Compare commits

...

7 Commits

Author SHA1 Message Date
Marcel
c641d704a8 merge: resolve conflict with origin/main + fix WCAG AA contrast + add API test
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m27s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 3m16s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
- Merge origin/main (resolved conflict in +page.svelte: use res.ok check from main)
- fix(transcription): bump button text from text-brand-navy/60 (3.83:1) to
  text-brand-navy/80 (6.75:1) to pass WCAG AA 4.5:1 for 12px text
- feat(api-tests): add Transcription.http with PUT /review-all entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 08:11:58 +02:00
Marcel
5b18b87450 test(security): add 403 permission test for annotation DELETE endpoint
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m4s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 3m0s
Confirms that DELETE /api/documents/{id}/annotations/{id} requires at
least ANNOTATE_ALL; a user with only READ_ALL receives 403 Forbidden.
Closes the permission audit raised during PR review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
bfa8b9c147 fix(viewer): move delete button inside annotation bounds to prevent edge clipping
Repositioning from top:-8px/right:-8px to top:4px/right:4px ensures the
44px touch target stays fully within the annotation shape. Annotations drawn
near the top or right edge of the PDF page no longer risk the button being
obscured or inaccessible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
3a94d62c74 test(viewer): verify delete button click does not bubble to onclick
Documents the stopPropagation guarantee: clicking the trash button must
not trigger the annotation's onclick (which opens the block detail panel)
while the delete confirm is in progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
163e99016a fix(viewer): check res.ok on orphaned annotation DELETE to surface errors
Without the guard, a failed DELETE (4xx/5xx) was silently swallowed and
annotationReloadKey was incremented anyway, leaving the annotation visible
and the user with no feedback. Now matches the deleteBlock() pattern
immediately above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
d6f3ca5c43 feat(viewer): show delete icon on annotation for direct block deletion (#339)
Adds a trash icon button (44×44 px touch target) directly on each annotation shape in transcription mode so users can delete a block without navigating through the sidebar. Includes keyboard support (Delete key), confirm dialog via ConfirmService, prop-chain wiring through DocumentViewer → PdfViewer → AnnotationLayer → AnnotationShape, and orphaned-annotation fallback (calls DELETE /annotations/{id} when no block is linked). Backend security regression test added for deleteBlock 403 on READ_ALL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:56:37 +02:00
Marcel
69ac183fe8 feat(transcription): add bulk "Alle als fertig markieren" action to transcription panel
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m31s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m1s
CI / Unit & Component Tests (pull_request) Failing after 3m13s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 2m57s
Adds a single-transaction backend endpoint PUT /api/documents/{id}/transcription-blocks/review-all
that marks all blocks as reviewed atomically. Emits N individual BLOCK_REVIEWED audit events (one
per previously-unreviewed block). The frontend button is disabled (not hidden) when all blocks are
already reviewed, and shows a spinner during the operation.

Closes #345

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 20:53:47 +02:00
15 changed files with 599 additions and 7 deletions

View File

@@ -0,0 +1,3 @@
### Mark all blocks as reviewed
PUT http://localhost:8080/api/documents/{{documentId}}/transcription-blocks/review-all
Authorization: Basic admin admin123

View File

@@ -90,6 +90,15 @@ public class TranscriptionBlockController {
return transcriptionService.reviewBlock(documentId, blockId, userId); return transcriptionService.reviewBlock(documentId, blockId, userId);
} }
@PutMapping("/review-all")
@RequirePermission(Permission.WRITE_ALL)
public List<TranscriptionBlock> markAllBlocksReviewed(
@PathVariable UUID documentId,
Authentication authentication) {
UUID userId = requireUserId(authentication);
return transcriptionService.markAllBlocksReviewed(documentId, userId);
}
@GetMapping("/{blockId}/history") @GetMapping("/{blockId}/history")
@RequirePermission(Permission.READ_ALL) @RequirePermission(Permission.READ_ALL)
public List<TranscriptionBlockVersion> getBlockHistory( public List<TranscriptionBlockVersion> getBlockHistory(

View File

@@ -205,6 +205,18 @@ public class TranscriptionService {
return saved; return saved;
} }
@Transactional
public List<TranscriptionBlock> markAllBlocksReviewed(UUID documentId, UUID userId) {
List<TranscriptionBlock> blocks = blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
for (TranscriptionBlock block : blocks) {
if (!block.isReviewed()) {
block.setReviewed(true);
auditService.logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, documentId, null);
}
}
return blockRepository.saveAll(blocks);
}
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) { public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
getBlock(documentId, blockId); getBlock(documentId, blockId);
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId); return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);

View File

@@ -154,6 +154,13 @@ class AnnotationControllerTest {
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteAnnotation_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {

View File

@@ -260,6 +260,13 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteBlock_returns403_whenUserHasOnlyReadAllPermission() throws Exception {
mockMvc.perform(delete(URL_BLOCK))
.andExpect(status().isForbidden());
}
@Test @Test
@WithMockUser(authorities = "WRITE_ALL") @WithMockUser(authorities = "WRITE_ALL")
void deleteBlock_returns204_whenAuthorised() throws Exception { void deleteBlock_returns204_whenAuthorised() throws Exception {
@@ -373,4 +380,63 @@ class TranscriptionBlockControllerTest {
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.reviewed").value(true)); .andExpect(jsonPath("$.reviewed").value(true));
} }
// ─── PUT .../review-all ───────────────────────────────────────────────────
private static final String URL_REVIEW_ALL = URL_BASE + "/review-all";
@Test
void markAllBlocksReviewed_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void markAllBlocksReviewed_returns403_whenMissingWriteAllPermission() throws Exception {
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withAllReviewedBlocks_whenAuthorised() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
TranscriptionBlock b1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 1").sortOrder(0).reviewed(true).build();
TranscriptionBlock b2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(UUID.randomUUID())
.text("Block 2").sortOrder(1).reviewed(true).build();
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of(b1, b2));
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].reviewed").value(true))
.andExpect(jsonPath("$[1].reviewed").value(true));
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns200_withEmptyList_whenNoBlocksExist() throws Exception {
when(userService.findByEmail(any())).thenReturn(mockUser());
when(transcriptionService.markAllBlocksReviewed(eq(DOC_ID), any()))
.thenReturn(List.of());
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$").isEmpty());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void markAllBlocksReviewed_returns401_whenUserNotFoundInDatabase() throws Exception {
when(userService.findByEmail(any())).thenReturn(null);
mockMvc.perform(put(URL_REVIEW_ALL))
.andExpect(status().isUnauthorized());
}
} }

View File

@@ -506,4 +506,86 @@ class TranscriptionServiceTest {
verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
} }
// ─── markAllBlocksReviewed ───────────────────────────────────────────────────
@Test
void markAllBlocksReviewed_setsAllUnreviewedBlocksToReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(List.of(block1, block2));
}
@Test
void markAllBlocksReviewed_isIdempotent_whenAllBlocksAlreadyReviewed() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).allMatch(TranscriptionBlock::isReviewed);
verify(blockRepository).saveAll(any());
}
@Test
void markAllBlocksReviewed_emitsBlockReviewedAuditEvent_forEachUnreviewedBlock() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock block1 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
TranscriptionBlock block2 = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(block1, block2));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(2)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_doesNotEmitAuditEvent_forAlreadyReviewedBlocks() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
TranscriptionBlock alreadyReviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(true).build();
TranscriptionBlock unreviewed = TranscriptionBlock.builder()
.id(UUID.randomUUID()).documentId(docId).reviewed(false).build();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId))
.thenReturn(List.of(alreadyReviewed, unreviewed));
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
transcriptionService.markAllBlocksReviewed(docId, userId);
verify(auditService, times(1)).logAfterCommit(AuditKind.BLOCK_REVIEWED, userId, docId, null);
}
@Test
void markAllBlocksReviewed_returnsEmptyList_whenNoBlocksExist() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of());
when(blockRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
List<TranscriptionBlock> result = transcriptionService.markAllBlocksReviewed(docId, userId);
assertThat(result).isEmpty();
}
} }

View File

@@ -18,7 +18,8 @@ let {
dimmed = false, dimmed = false,
flashAnnotationId = null, flashAnnotationId = null,
onDraw, onDraw,
onAnnotationClick onAnnotationClick,
onDeleteRequest
}: { }: {
annotations: Annotation[]; annotations: Annotation[];
canDraw: boolean; canDraw: boolean;
@@ -29,6 +30,7 @@ let {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void; onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void;
} = $props(); } = $props();
let drawStart = $state<{ x: number; y: number } | null>(null); let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -112,6 +114,8 @@ const containerStyle = $derived(
dimmed={dimmed} dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]} blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id} isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)} onclick={() => onAnnotationClick?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)} onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)} onpointerleave={() => (hoveredId = null)}

View File

@@ -98,7 +98,7 @@ describe('AnnotationLayer', () => {
expect(el2.style.opacity).toBe('1'); expect(el2.style.opacity).toBe('1');
}); });
it('does not show delete buttons (annotations owned by blocks)', async () => { it('does not show delete button when annotation is not hovered or active', async () => {
render(AnnotationLayer, { render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')], annotations: [makeAnnotation('ann-1')],
canDraw: true, canDraw: true,
@@ -107,6 +107,19 @@ describe('AnnotationLayer', () => {
}); });
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull(); expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when canDraw is false even if annotation is active', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canDraw: false,
color: '#00C7B1',
activeAnnotationId: 'ann-1',
onDraw: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
}); });
}); });

View File

@@ -11,6 +11,8 @@ let {
blockNumber = undefined, blockNumber = undefined,
isFlashing = false, isFlashing = false,
isResizable = false, isResizable = false,
showDelete = false,
onDeleteRequest,
onclick, onclick,
onpointerenter, onpointerenter,
onpointerleave onpointerleave
@@ -23,11 +25,15 @@ let {
blockNumber?: number | undefined; blockNumber?: number | undefined;
isFlashing?: boolean; isFlashing?: boolean;
isResizable?: boolean; isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void; onclick: () => void;
onpointerenter: () => void; onpointerenter: () => void;
onpointerleave: () => void; onpointerleave: () => void;
} = $props(); } = $props();
const deleteVisible = $derived(showDelete && (isHovered || isActive));
function hexToRgba(hex: string, alpha: number): string { function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16); const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16); const g = parseInt(hex.slice(3, 5), 16);
@@ -83,6 +89,7 @@ let shapeStyle = $derived(
onclick={onclick} onclick={onclick}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(); if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}} }}
onpointerenter={onpointerenter} onpointerenter={onpointerenter}
onpointerleave={onpointerleave} onpointerleave={onpointerleave}
@@ -112,6 +119,51 @@ let shapeStyle = $derived(
{blockNumber} {blockNumber}
</div> </div>
{/if} {/if}
{#if deleteVisible}
<button
data-testid="annotation-delete-{annotation.id}"
type="button"
aria-label="Löschen"
onclick={(e) => {
e.stopPropagation();
onDeleteRequest?.();
}}
style="
position: absolute;
top: 4px;
right: 4px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #fff;
border: 1px solid var(--color-error, #e53e3e);
color: var(--color-error, #e53e3e);
cursor: pointer;
pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
z-index: 10;
"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
{#if isResizable} {#if isResizable}
<AnnotationEditOverlay annotation={annotation} /> <AnnotationEditOverlay annotation={annotation} />
{/if} {/if}

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationShape from './AnnotationShape.svelte';
afterEach(cleanup);
function makeAnnotation(id = 'ann-1') {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#00C7B1',
createdAt: new Date().toISOString()
};
}
describe('AnnotationShape', () => {
it('renders the annotation element', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
});
it('does not show delete button when showDelete is false', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: false,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
});
it('shows delete button when showDelete is true and isHovered is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('shows delete button when showDelete is true and isActive is true', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
});
it('calls onDeleteRequest when delete button is clicked', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onclick when delete button is clicked', async () => {
const onclick = vi.fn();
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: true,
isActive: false,
showDelete: true,
onDeleteRequest,
onclick,
onpointerenter: () => {},
onpointerleave: () => {}
});
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
await deleteBtn.click();
expect(onclick).not.toHaveBeenCalled();
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).toHaveBeenCalledOnce();
});
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: false,
onDeleteRequest,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).not.toHaveBeenCalled();
});
});

View File

@@ -24,6 +24,7 @@ type Props = {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void; onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void; onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
}; };
let { let {
@@ -38,7 +39,8 @@ let {
annotationsDimmed = false, annotationsDimmed = false,
flashAnnotationId = null, flashAnnotationId = null,
onAnnotationClick, onAnnotationClick,
onTranscriptionDraw onTranscriptionDraw,
onDeleteAnnotationRequest
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -98,6 +100,7 @@ let {
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick} onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw} onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
documentFileHash={doc.fileHash ?? null} documentFileHash={doc.fileHash ?? null}
/> />
{:else if fileUrl} {:else if fileUrl}

View File

@@ -18,6 +18,7 @@ let {
activeAnnotationId = $bindable<string | null>(null), activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick, onAnnotationClick,
onTranscriptionDraw, onTranscriptionDraw,
onDeleteAnnotationRequest,
documentFileHash, documentFileHash,
annotationsDimmed = false, annotationsDimmed = false,
flashAnnotationId = null flashAnnotationId = null
@@ -30,6 +31,7 @@ let {
activeAnnotationId?: string | null; activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void; onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null; documentFileHash?: string | null;
annotationsDimmed?: boolean; annotationsDimmed?: boolean;
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
@@ -264,6 +266,7 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
onDraw={handleDraw} onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick} onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest}
/> />
{/if} {/if}
</div> </div>

View File

@@ -19,6 +19,7 @@ type Props = {
onSaveBlock: (blockId: string, text: string) => Promise<void>; onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>; onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>; onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void; onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
canWrite?: boolean; canWrite?: boolean;
trainingLabels?: string[]; trainingLabels?: string[];
@@ -37,6 +38,7 @@ let {
onSaveBlock, onSaveBlock,
onDeleteBlock, onDeleteBlock,
onReviewToggle, onReviewToggle,
onMarkAllReviewed,
onTriggerOcr, onTriggerOcr,
canWrite = false, canWrite = false,
trainingLabels = [], trainingLabels = [],
@@ -46,12 +48,14 @@ let {
let activeBlockId: string | null = $state(null); let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]); let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null); let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0); const hasBlocks = $derived(blocks.length > 0);
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length); const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
const totalCount = $derived(blocks.length); const totalCount = $derived(blocks.length);
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0); const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block // Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => { $effect(() => {
@@ -60,6 +64,16 @@ $effect(() => {
if (block) activeBlockId = block.id; if (block) activeBlockId = block.id;
}); });
async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return;
markingAllReviewed = true;
try {
await onMarkAllReviewed();
} finally {
markingAllReviewed = false;
}
}
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId }); const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
const dragDrop = createBlockDragDrop({ const dragDrop = createBlockDragDrop({
@@ -147,9 +161,56 @@ async function handleLabelToggle(label: string) {
{#if hasBlocks} {#if hasBlocks}
<!-- Sticky review progress header --> <!-- Sticky review progress header -->
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2"> <div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
<p class="font-sans text-xs text-ink-2"> <div class="flex items-center justify-between">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft <p class="font-sans text-xs text-ink-2">
</p> <span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
{#if onMarkAllReviewed}
<button
onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
>
{#if markingAllReviewed}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
Alle als fertig markieren
</button>
{/if}
</div>
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full"> <div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
<div <div
class="h-full rounded-full bg-brand-mint transition-all duration-300" class="h-full rounded-full bg-brand-mint transition-all duration-300"

View File

@@ -49,6 +49,11 @@ function renderView(overrides: Record<string, unknown> = {}, service = createCon
}; };
} }
const unreviewedBlock1 = { ...block1, reviewed: false };
const unreviewedBlock2 = { ...block2, reviewed: false };
const reviewedBlock1 = { ...block1, reviewed: true };
const reviewedBlock2 = { ...block2, reviewed: true };
describe('TranscriptionEditView — rendering', () => { describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => { it('renders blocks in sort order', async () => {
renderView(); renderView();
@@ -269,3 +274,61 @@ describe('TranscriptionEditView — review progress counter', () => {
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument(); await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
}); });
}); });
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
describe('TranscriptionEditView — mark all reviewed', () => {
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
it('disables button when all blocks are already reviewed', async () => {
renderView({
blocks: [reviewedBlock1, reviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
});
it('disables button while operation is in-flight', async () => {
let resolveMarkAll!: () => void;
const onMarkAllReviewed = vi
.fn()
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
await btn.click();
await expect.element(btn).toBeDisabled();
resolveMarkAll();
});
});

View File

@@ -13,9 +13,12 @@ import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte'; import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll'; import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
let { data } = $props(); let { data } = $props();
const { confirm } = getConfirmService();
const doc = $derived(data.document); const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false); const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null); const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
@@ -105,6 +108,26 @@ async function deleteBlock(blockId: string) {
annotationReloadKey++; annotationReloadKey++;
} }
async function handleAnnotationDeleteRequest(annotationId: string) {
const confirmed = await confirm({
title: m.transcription_block_delete_confirm(),
destructive: true
});
if (!confirmed) return;
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
await deleteBlock(block.id);
} else {
// Annotation has no linked block — delete the annotation directly
const res = await fetch(`/api/documents/${doc.id}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete annotation failed');
annotationReloadKey++;
}
}
async function reviewToggle(blockId: string) { async function reviewToggle(blockId: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, { const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
method: 'PUT' method: 'PUT'
@@ -114,6 +137,18 @@ async function reviewToggle(blockId: string) {
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b)); transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
} }
async function markAllReviewed() {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/review-all`, {
method: 'PUT'
});
if (!res.ok) return;
const updated = await res.json();
for (const b of updated) {
const existing = transcriptionBlocks.find((x) => x.id === b.id);
if (existing) existing.reviewed = b.reviewed;
}
}
async function toggleTrainingLabel(label: string, enrolled: boolean) { async function toggleTrainingLabel(label: string, enrolled: boolean) {
const res = await fetch(`/api/documents/${doc.id}/training-labels`, { const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
method: 'PATCH', method: 'PATCH',
@@ -381,6 +416,7 @@ onMount(() => {
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick} onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw} onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
/> />
</div> </div>
@@ -477,6 +513,7 @@ onMount(() => {
onSaveBlock={saveBlock} onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock} onDeleteBlock={deleteBlock}
onReviewToggle={reviewToggle} onReviewToggle={reviewToggle}
onMarkAllReviewed={markAllReviewed}
onTriggerOcr={triggerOcr} onTriggerOcr={triggerOcr}
onToggleTrainingLabel={toggleTrainingLabel} onToggleTrainingLabel={toggleTrainingLabel}
/> />