diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index d9a3a30f..517eb1da 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -15,11 +15,6 @@ public enum ErrorCode { ALIAS_NOT_FOUND, /** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */ INVALID_PERSON_TYPE, - /** A concurrent edit on a referenced transcription block prevented the rename - * from committing (optimistic-lock conflict). The whole rename rolls back; the - * client should refetch and retry. 409 */ - PERSON_RENAME_CONFLICT, - // --- Documents --- /** A document with the given ID does not exist. 404 */ DOCUMENT_NOT_FOUND, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java deleted file mode 100644 index 1b748c1a..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.raddatz.familienarchiv.model; - -import java.util.UUID; - -/** - * Published by PersonService when a save changes Person.getDisplayName() — i.e. - * any mutation to the fields that DisplayNameFormatter consumes (title, - * firstName, lastName). Listeners on the transcription side rewrite block text - * and sidecar entries that reference the old name. - * - *

This is the first custom application event in the codebase. The previous - * only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's - * built-in ApplicationReadyEvent. Future cross-domain decoupling should follow - * the same shape: record-typed event in model/, listener in the consuming - * domain's service/ package, synchronous @EventListener inside the publisher's - * transaction unless the workload genuinely needs to defer. - */ -public record PersonDisplayNameChangedEvent( - UUID personId, - String oldDisplayName, - String newDisplayName -) { -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java index 79b232d6..2ca2033e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java @@ -26,5 +26,6 @@ public class PersonMention { @Size(max = 200) @Column(name = "display_name", nullable = false, length = 200) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + // Archival: the text the transcriber typed after @. Never updated on person rename. private String displayName; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java deleted file mode 100644 index a3c03206..00000000 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.raddatz.familienarchiv.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; -import org.raddatz.familienarchiv.model.PersonMention; -import org.raddatz.familienarchiv.model.TranscriptionBlock; -import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When - * Person.getDisplayName() flips during a rename, this listener rewrites every - * transcription block whose sidecar references the renamed person — both the - * literal "@OldName" inside block.text and the displayName carried in the - * {@link PersonMention} entries. - * - *

Synchronous on purpose: the rename and the propagation must commit as one - * transaction so a half-applied rewrite never reaches the archive. If the - * archive grows past tens of thousands of blocks, switch to - * {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation - * change. - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class PersonMentionPropagationListener { - - private final TranscriptionBlockRepository blockRepository; - - @EventListener - @Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT) - public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) { - List blocks = - blockRepository.findByPersonIdWithMentionsFetched(event.personId()); - if (blocks.isEmpty()) { - return; - } - - String oldNeedle = "@" + event.oldDisplayName(); - String newNeedle = "@" + event.newDisplayName(); - Pattern boundary = Pattern.compile( - Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))"); - String replacement = Matcher.quoteReplacement(newNeedle); - - for (TranscriptionBlock block : blocks) { - rewriteBlockText(block, boundary, replacement); - for (PersonMention mention : block.getMentionedPersons()) { - if (mention.getPersonId().equals(event.personId())) { - mention.setDisplayName(event.newDisplayName()); - } - } - } - - blockRepository.saveAllAndFlush(blocks); - - log.info("Propagated rename {} → {} across {} block(s) for person {}", - event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId()); - } - - // Match @OldName only at a token boundary: not followed by a letter/digit/hyphen - // (catches @Hans-Peter when renaming Hans) AND not followed by " " - // (catches @Hans Müller when renaming the single-name @Hans). False negatives — - // e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the - // conservative trade-off; the alternative (corruption) is irrecoverable. - private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) { - if (block.getText() != null) { - block.setText(boundary.matcher(block.getText()).replaceAll(replacement)); - } - } -} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 33873459..e1adbdba 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -1,7 +1,6 @@ package org.raddatz.familienarchiv.service; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -13,14 +12,11 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.raddatz.familienarchiv.model.PersonType; import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +30,6 @@ public class PersonService { private final PersonRepository personRepository; private final PersonNameAliasRepository aliasRepository; - private final ApplicationEventPublisher eventPublisher; public List findAll(String q) { if (q == null) { @@ -161,7 +156,6 @@ public class PersonService { validateYears(dto.getBirthYear(), dto.getDeathYear()); Person person = personRepository.findById(id) .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); - String oldDisplayName = person.getDisplayName(); person.setPersonType(dto.getPersonType()); person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim()); person.setFirstName(dto.getFirstName()); @@ -170,17 +164,7 @@ public class PersonService { person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim()); person.setBirthYear(dto.getBirthYear()); person.setDeathYear(dto.getDeathYear()); - Person saved = personRepository.save(person); - String newDisplayName = saved.getDisplayName(); - if (!Objects.equals(oldDisplayName, newDisplayName)) { - try { - eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName)); - } catch (OptimisticLockingFailureException e) { - throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT, - "A referenced transcription block was modified concurrently — rename rolled back"); - } - } - return saved; + return personRepository.save(person); } @Transactional diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index 41a4a0ac..9de8a3a1 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -333,21 +333,6 @@ class PersonControllerTest { .andExpect(jsonPath("$.lastName").value("Müller")); } - @Test - @WithMockUser(authorities = "WRITE_ALL") - void updatePerson_returns409_whenRenameConflict() throws Exception { - UUID id = UUID.randomUUID(); - when(personService.updatePerson(eq(id), any())) - .thenThrow(DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT, - "Concurrent block edit during rename")); - - mockMvc.perform(put("/api/persons/{id}", id) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"firstName\":\"Augusta\",\"lastName\":\"Raddatz\",\"personType\":\"PERSON\"}")) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("PERSON_RENAME_CONFLICT")); - } - // ─── POST /api/persons/{id}/merge ───────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java deleted file mode 100644 index c6b89716..00000000 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java +++ /dev/null @@ -1,227 +0,0 @@ -package org.raddatz.familienarchiv.service; - -import jakarta.persistence.EntityManager; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.PostgresContainerConfig; -import org.raddatz.familienarchiv.config.FlywayConfig; -import org.raddatz.familienarchiv.model.Document; -import org.raddatz.familienarchiv.model.DocumentAnnotation; -import org.raddatz.familienarchiv.model.DocumentStatus; -import org.raddatz.familienarchiv.model.Person; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; -import org.raddatz.familienarchiv.model.PersonMention; -import org.raddatz.familienarchiv.model.TranscriptionBlock; -import org.raddatz.familienarchiv.repository.AnnotationRepository; -import org.raddatz.familienarchiv.repository.DocumentRepository; -import org.raddatz.familienarchiv.repository.PersonRepository; -import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; -import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; -import org.springframework.context.annotation.Import; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; - -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import({PostgresContainerConfig.class, FlywayConfig.class}) -class PersonMentionPropagationListenerTest { - - @Autowired TranscriptionBlockRepository blockRepository; - @Autowired DocumentRepository documentRepository; - @Autowired AnnotationRepository annotationRepository; - @Autowired PersonRepository personRepository; - @Autowired EntityManager em; - - private PersonMentionPropagationListener listener; - - private UUID documentId; - private UUID annotationId; - - @BeforeEach - void setUp() { - listener = new PersonMentionPropagationListener(blockRepository); - - Document doc = documentRepository.save(Document.builder() - .title("Letter").originalFilename("letter.pdf") - .status(DocumentStatus.UPLOADED).build()); - documentId = doc.getId(); - DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder() - .documentId(documentId).pageNumber(1) - .x(0.1).y(0.2).width(0.3).height(0.4) - .color("#00C7B1").build()); - annotationId = annotation.getId(); - } - - private TranscriptionBlock saveBlock(String text, List mentions) { - return blockRepository.saveAndFlush(TranscriptionBlock.builder() - .annotationId(annotationId).documentId(documentId) - .text(text).sortOrder(0) - .mentionedPersons(mentions).build()); - } - - private UUID savedPersonId(String firstName, String lastName) { - Person p = personRepository.save(Person.builder() - .firstName(firstName).lastName(lastName).build()); - return p.getId(); - } - - @Test - void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - TranscriptionBlock saved = saveBlock( - "Liebe Tante @Auguste Raddatz, danke für den Brief.", - List.of(new PersonMention(personId, "Auguste Raddatz"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getDisplayName) - .containsExactly("Augusta Raddatz"); - } - - @Test - void doesNotMatchPartialName_whenAnotherMentionShares_a_substring_with_renamed_person() { - UUID hansPeterId = savedPersonId("Hans-Peter", "Müller"); - UUID hansId = savedPersonId("Hans", "Müller"); - TranscriptionBlock saved = saveBlock( - "Heute hat @Hans-Peter Müller wieder mit @Hans Müller gesprochen.", - List.of( - new PersonMention(hansPeterId, "Hans-Peter Müller"), - new PersonMention(hansId, "Hans Müller"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(hansId, "Hans Müller", "Hans Schmidt")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()) - .isEqualTo("Heute hat @Hans-Peter Müller wieder mit @Hans Schmidt gesprochen."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getPersonId, PersonMention::getDisplayName) - .containsExactlyInAnyOrder( - org.assertj.core.groups.Tuple.tuple(hansPeterId, "Hans-Peter Müller"), - org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt")); - } - - @Test - void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() { - UUID hansMüllerId = savedPersonId("Hans", "Müller"); - UUID hansId = savedPersonId(null, "Hans"); - TranscriptionBlock saved = saveBlock( - "@Hans Müller schrieb. Auch @Hans hat geschrieben.", - List.of( - new PersonMention(hansMüllerId, "Hans Müller"), - new PersonMention(hansId, "Hans"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()) - .isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getPersonId, PersonMention::getDisplayName) - .containsExactlyInAnyOrder( - org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"), - org.assertj.core.groups.Tuple.tuple(hansId, "Henry")); - } - - @Test - void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - TranscriptionBlock saved = saveBlock( - "Heute hat @Auguste Raddatz geschrieben, dann hat @Auguste Raddatz nochmal geschrieben.", - List.of(new PersonMention(personId, "Auguste Raddatz"))); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()) - .isEqualTo("Heute hat @Augusta Raddatz geschrieben, dann hat @Augusta Raddatz nochmal geschrieben."); - assertThat(reloaded.getMentionedPersons()) - .extracting(PersonMention::getDisplayName) - .containsExactly("Augusta Raddatz"); - } - - @Test - void propagatesAcross200Blocks_inUnderFiveSeconds_latencyFloor() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - List blockIds = new ArrayList<>(); - for (int i = 0; i < 200; i++) { - TranscriptionBlock saved = blockRepository.save(TranscriptionBlock.builder() - .annotationId(annotationId).documentId(documentId) - .text("Block " + i + " mentions @Auguste Raddatz here.") - .sortOrder(i) - .mentionedPersons(List.of(new PersonMention(personId, "Auguste Raddatz"))) - .build()); - blockIds.add(saved.getId()); - } - blockRepository.flush(); - em.clear(); - - long start = System.nanoTime(); - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - long elapsedMs = (System.nanoTime() - start) / 1_000_000; - - assertThat(elapsedMs) - .as("Propagation across 200 blocks must stay under 5s — merge-blocking regression floor") - .isLessThan(5000L); - - em.clear(); - TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow(); - assertThat(first.getText()).contains("@Augusta Raddatz"); - } - - @Test - void doesNotThrow_whenBlockTextIsNull() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - saveBlock(null, List.of(new PersonMention(personId, "Auguste Raddatz"))); - em.clear(); - - assertThatCode(() -> listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"))) - .doesNotThrowAnyException(); - } - - @Test - void leavesUnrelatedBlockUntouched_whenNoSidecarReferencesPerson() { - UUID personId = savedPersonId("Auguste", "Raddatz"); - TranscriptionBlock saved = saveBlock( - "Plain text without any mentions.", - List.of()); - em.clear(); - - listener.onPersonDisplayNameChanged( - new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); - blockRepository.flush(); - em.clear(); - - TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); - assertThat(reloaded.getText()).isEqualTo("Plain text without any mentions."); - assertThat(reloaded.getMentionedPersons()).isEmpty(); - } -} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index b502ac7c..24827177 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.service; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -10,22 +9,14 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO; import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; -import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Person; -import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; -import org.raddatz.familienarchiv.model.PersonMention; import org.raddatz.familienarchiv.model.PersonNameAlias; import org.raddatz.familienarchiv.model.PersonNameAliasType; import org.raddatz.familienarchiv.model.PersonType; -import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.repository.PersonNameAliasRepository; import org.raddatz.familienarchiv.repository.PersonRepository; -import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.server.ResponseStatusException; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -33,14 +24,16 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class PersonServiceTest { @Mock PersonRepository personRepository; @Mock PersonNameAliasRepository aliasRepository; - @Mock ApplicationEventPublisher eventPublisher; @InjectMocks PersonService personService; // ─── getById ───────────────────────────────────────────────────────────── @@ -252,121 +245,6 @@ class PersonServiceTest { assertThat(result.getAlias()).isEqualTo("Anna Alt"); } - // ─── updatePerson (display-name change event) ──────────────────────────── - - @Test - void updatePerson_publishesEvent_whenTitleChanges() { - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).title("Herr").firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).build(); - String oldName = existing.getDisplayName(); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz"); - - personService.updatePerson(id, dto); - - ArgumentCaptor captor = - ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class); - verify(eventPublisher).publishEvent(captor.capture()); - - PersonDisplayNameChangedEvent event = captor.getValue(); - assertThat(event.personId()).isEqualTo(id); - assertThat(event.oldDisplayName()).isEqualTo(oldName); - assertThat(event.newDisplayName()) - .isNotEqualTo(oldName) - .contains("Frau"); - } - - @Test - void updatePerson_doesNotPublishEvent_whenDisplayNameFieldsUnchanged() { - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).alias("old alias").build(); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setFirstName("Auguste"); dto.setLastName("Raddatz"); - dto.setAlias("new alias"); - - personService.updatePerson(id, dto); - - verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class)); - } - - @Test - void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() { - // Wire a real PersonMentionPropagationListener with a mocked block repository - // that throws on saveAllAndFlush. The publisher mock routes events to the - // listener so the catch path traverses the same call chain as production: - // PersonService → publishEvent → listener → saveAllAndFlush throws → catch. - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).build(); - - TranscriptionBlock referencingBlock = TranscriptionBlock.builder() - .id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID()) - .text("Brief von @Auguste Raddatz").sortOrder(0) - .mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz")))) - .build(); - - TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class); - when(blockRepo.findByPersonIdWithMentionsFetched(id)) - .thenReturn(List.of(referencingBlock)); - when(blockRepo.saveAllAndFlush(any())) - .thenThrow(new ObjectOptimisticLockingFailureException( - TranscriptionBlock.class, referencingBlock.getId())); - - PersonMentionPropagationListener realListener = - new PersonMentionPropagationListener(blockRepo); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - doAnswer(inv -> { - realListener.onPersonDisplayNameChanged(inv.getArgument(0)); - return null; - }).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setFirstName("Augusta"); dto.setLastName("Raddatz"); - - assertThatThrownBy(() -> personService.updatePerson(id, dto)) - .isInstanceOf(DomainException.class) - .matches(e -> ((DomainException) e).getCode() == ErrorCode.PERSON_RENAME_CONFLICT) - .matches(e -> ((DomainException) e).getStatus().value() == 409); - } - - @Test - void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() { - UUID id = UUID.randomUUID(); - Person existing = Person.builder() - .id(id).firstName("Auguste").lastName("Raddatz") - .personType(PersonType.PERSON).notes("first note").build(); - - when(personRepository.findById(id)).thenReturn(Optional.of(existing)); - when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - - PersonUpdateDTO dto = new PersonUpdateDTO(); - dto.setPersonType(PersonType.PERSON); - dto.setFirstName("Auguste"); dto.setLastName("Raddatz"); - dto.setNotes("revised note"); - - personService.updatePerson(id, dto); - - verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class)); - } - // ─── findOrCreateByAlias ───────────────────────────────────────────────── @Test diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 8ef11f81..bad61019 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -427,6 +427,8 @@ "person_mention_popup_empty": "Keine Personen gefunden", "person_mention_btn_label": "Person verlinken", "person_mention_create_new": "Neue Person anlegen", + "transcription_editor_aria_label": "Transkriptionstext", + "person_born_name_prefix": "geb.", "page_title_home": "Archiv", "page_title_persons": "Personen", "page_title_admin": "Administration", @@ -549,7 +551,6 @@ "person_alias_btn_delete": "Entfernen", "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", - "error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.", "validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c0909263..ca99ea8f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -427,6 +427,8 @@ "person_mention_popup_empty": "No persons found", "person_mention_btn_label": "Link person", "person_mention_create_new": "Create new person", + "transcription_editor_aria_label": "Transcription text", + "person_born_name_prefix": "née", "page_title_home": "Archive", "page_title_persons": "Persons", "page_title_admin": "Administration", @@ -549,7 +551,6 @@ "person_alias_btn_delete": "Remove", "error_alias_not_found": "The name alias was not found.", "error_invalid_person_type": "The specified person type is not valid.", - "error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.", "validation_last_name_required": "Last name is required.", "validation_first_name_required": "First name is required.", "error_ocr_service_unavailable": "The OCR service is not available.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 4b2fcdaf..3630d405 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -427,6 +427,8 @@ "person_mention_popup_empty": "No se encontraron personas", "person_mention_btn_label": "Vincular persona", "person_mention_create_new": "Crear nueva persona", + "transcription_editor_aria_label": "Texto de transcripción", + "person_born_name_prefix": "n.", "page_title_home": "Archivo", "page_title_persons": "Personas", "page_title_admin": "Administración", @@ -549,7 +551,6 @@ "person_alias_btn_delete": "Eliminar", "error_alias_not_found": "No se encontro el alias de nombre.", "error_invalid_person_type": "El tipo de persona especificado no es válido.", - "error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.", "validation_last_name_required": "El apellido es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 01031480..e39be73d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.1", "dependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/extension-mention": "3.22.5", + "@tiptap/starter-kit": "3.22.5", "diff": "^8.0.3", "openapi-fetch": "^0.13.5", "pdfjs-dist": "^5.5.207" @@ -2188,6 +2191,403 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@tiptap/core": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", + "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz", + "integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz", + "integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz", + "integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz", + "integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz", + "integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz", + "integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz", + "integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz", + "integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz", + "integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz", + "integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz", + "integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz", + "integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz", + "integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", + "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz", + "integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz", + "integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-mention": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz", + "integrity": "sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5", + "@tiptap/suggestion": "3.22.5" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz", + "integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz", + "integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz", + "integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz", + "integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz", + "integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", + "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", + "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz", + "integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.22.5", + "@tiptap/extension-blockquote": "^3.22.5", + "@tiptap/extension-bold": "^3.22.5", + "@tiptap/extension-bullet-list": "^3.22.5", + "@tiptap/extension-code": "^3.22.5", + "@tiptap/extension-code-block": "^3.22.5", + "@tiptap/extension-document": "^3.22.5", + "@tiptap/extension-dropcursor": "^3.22.5", + "@tiptap/extension-gapcursor": "^3.22.5", + "@tiptap/extension-hard-break": "^3.22.5", + "@tiptap/extension-heading": "^3.22.5", + "@tiptap/extension-horizontal-rule": "^3.22.5", + "@tiptap/extension-italic": "^3.22.5", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-list": "^3.22.5", + "@tiptap/extension-list-item": "^3.22.5", + "@tiptap/extension-list-keymap": "^3.22.5", + "@tiptap/extension-ordered-list": "^3.22.5", + "@tiptap/extension-paragraph": "^3.22.5", + "@tiptap/extension-strike": "^3.22.5", + "@tiptap/extension-text": "^3.22.5", + "@tiptap/extension-underline": "^3.22.5", + "@tiptap/extensions": "^3.22.5", + "@tiptap/pm": "^3.22.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz", + "integrity": "sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4270,6 +4670,12 @@ "node": ">=10" } }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -4499,6 +4905,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4934,6 +5346,135 @@ } } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5044,6 +5585,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -5761,6 +6308,12 @@ "vitest": "^4.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index e7170513..542411e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,9 @@ "generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts" }, "dependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/extension-mention": "3.22.5", + "@tiptap/starter-kit": "3.22.5", "diff": "^8.0.3", "openapi-fetch": "^0.13.5", "pdfjs-dist": "^5.5.207" diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte new file mode 100644 index 00000000..d86e912a --- /dev/null +++ b/frontend/src/lib/components/MentionDropdown.svelte @@ -0,0 +1,170 @@ + + + +

+ {#if model.items.length === 0} +

+ {m.person_mention_popup_empty()} +

+ + e.preventDefault()} + > + {m.person_mention_create_new()} + + + {:else} + {#each model.items as person, i (person.id)} +
{ + // Prevent blur on the editor before the selection fires. + e.preventDefault(); + selectItem(person); + }} + > + {person.displayName} + {#if formatLifeDateRange(person.birthYear, person.deathYear)} + + {formatLifeDateRange(person.birthYear, person.deathYear)} + + {/if} +
+ {/each} + {/if} +
diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte index 36a4989d..aac4f883 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte +++ b/frontend/src/lib/components/PersonHoverCard.svelte @@ -109,7 +109,10 @@ const ariaBusy = $derived(state.status === 'loading');
{dateRange}
{/if} {#if state.person.alias} -
geb. {state.person.alias}
+
+ {m.person_born_name_prefix()} + {state.person.alias} +
{/if} {#if familyChips.length > 0} diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index df686334..3a9188c1 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -1,263 +1,279 @@ -
- - - {#if popupOpen} -
- {#if loading} -

{m.comp_typeahead_loading()}

- {:else if results.length === 0} -
-

{m.person_mention_popup_empty()}

- - {m.person_mention_create_new()} → - -
- {:else} - {#each results as person, i (person.id)} -
{ - e.preventDefault(); - selectPerson(person); - }} - > - {person.displayName} - {#if formatLifeDateRange(person.birthYear, person.deathYear)} - - {formatLifeDateRange(person.birthYear, person.deathYear)} - - {/if} -
- {/each} - {/if} -
- {/if} -
+
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index ea8df7fe..bf4b59ea 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -1,26 +1,19 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +/** + * PersonMentionEditor — Tiptap-based component tests. + * + * All old tests used document.querySelector('textarea') which is dead after + * the Tiptap migration. These tests drive the contenteditable via + * userEvent.type() and inspect the serialized output from the test host. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte'; import type { components } from '$lib/generated/api'; type Person = components['schemas']['Person']; type PersonMention = components['schemas']['PersonMention']; -// Editor's internal search debounce is 200ms — drive it via fake timers -// so tests are deterministic and fast (Tester #5506 §1). -const DEBOUNCE_MS = 200; - -async function flushDebounce() { - await vi.advanceTimersByTimeAsync(DEBOUNCE_MS); - // Let the awaited fetch resolve and the resulting state assignments flush. - await vi.runAllTimersAsync(); -} - -async function tick() { - await vi.advanceTimersByTimeAsync(0); -} - const AUGUSTE: Person = { id: 'p-aug', firstName: 'Auguste', @@ -39,39 +32,24 @@ const ANNA: Person = { } as unknown as Person; function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) { - const fetchMock = vi - .fn() - .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) }) + ); } function mockFetchEmpty() { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -function mockFetchRejects() { - const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; -} - -function getTextarea(): HTMLTextAreaElement { - return document.querySelector('textarea')!; -} - -function clickOption(personId: string) { - const opt = document.querySelector( - `[role="option"][data-test-person-id="${personId}"]` - ) as HTMLElement; - opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); } type Snapshot = { value: string; mentionedPersons: PersonMention[] }; -function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) { +function renderHost( + initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {} +) { let snapshot: Snapshot = { value: initial.value ?? '', mentionedPersons: initial.mentionedPersons ?? [] @@ -79,6 +57,7 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[ render(PersonMentionEditorHost, { initialValue: initial.value ?? '', initialMentions: initial.mentionedPersons ?? [], + disabled: initial.disabled ?? false, onChange: (snap: Snapshot) => { snapshot = snap; } @@ -90,279 +69,300 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[ }; } -beforeEach(() => { - vi.useFakeTimers({ shouldAdvanceTime: true }); -}); - afterEach(() => { cleanup(); vi.unstubAllGlobals(); - vi.useRealTimers(); }); // ─── Rendering ──────────────────────────────────────────────────────────────── describe('PersonMentionEditor — rendering', () => { - it('renders the textarea with placeholder', async () => { + it('renders the editor as a textbox (ARIA role from editorProps)', async () => { render(PersonMentionEditorHost, { initialValue: '', initialMentions: [], - placeholder: 'Transkription…', onChange: () => {} }); - await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument(); + await expect.element(page.getByRole('textbox')).toBeInTheDocument(); }); - it('reflects bound initial value', async () => { + it('reflects bound initial value as visible text', async () => { render(PersonMentionEditorHost, { initialValue: 'Hallo Welt', initialMentions: [], onChange: () => {} }); - await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt'); + await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument(); }); }); // ─── Typeahead opens on @ ───────────────────────────────────────────────────── describe('PersonMentionEditor — typeahead', () => { - it('opens the popup when typing @ + query and shows results', async () => { + it('opens the dropdown when typing @ + query and shows results', async () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await flushDebounce(); - - await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); }); it('hits /api/persons?q= with the typed query', async () => { - const fetchMock = mockFetchWithPersons(); + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) }); + vi.stubGlobal('fetch', fetchMock); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await flushDebounce(); - - expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug')); + }); }); it('shows life dates next to the name in the dropdown', async () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument(); + }); }); it('shows empty state when no persons match', async () => { mockFetchEmpty(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@xyz'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@xyz'); - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); + }); }); - it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => { - mockFetchRejects(); + it('offers a "create new person" link in the empty state', async () => { + mockFetchEmpty(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@xyz'); - await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument(); - }); - - it('keeps the popup open when the query has a trailing space (multi-word names)', async () => { - mockFetchWithPersons(); - renderHost(); - - const ta = getTextarea(); - ta.focus(); - ta.value = '@Auguste '; - ta.selectionStart = 9; - ta.selectionEnd = 9; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); - - await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + await vi.waitFor(async () => { + const link = page.getByRole('link', { name: /Neue Person anlegen/ }); + await expect.element(link).toBeVisible(); + await expect.element(link).toHaveAttribute('href', '/persons/new'); + }); }); }); -// ─── Selection writes text + sidecar ───────────────────────────────────────── +// ─── AC-1: typed text becomes displayName, not DB name ─────────────────────── -describe('PersonMentionEditor — selecting a person', () => { - it('inserts @DisplayName followed by a trailing space into the textarea', async () => { +describe('PersonMentionEditor — AC-1: typed text as displayName', () => { + it('stores the typed query as displayName, not the person DB name', async () => { mockFetchWithPersons(); const host = renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + // User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.value).toBe('@Auguste Raddatz '); + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + expect(host.snapshot.mentionedPersons[0]).toEqual({ + personId: 'p-aug', + displayName: 'Aug' // typed text, not "Auguste Raddatz" + }); + }); }); - it('pushes {personId, displayName} into the bound mentionedPersons array', async () => { + it('regression: text value contains the typed query, not the full DB name', async () => { mockFetchWithPersons(); const host = renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.mentionedPersons).toEqual([ - { personId: 'p-aug', displayName: 'Auguste Raddatz' } - ]); + await vi.waitFor(() => { + // Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz " + expect(host.snapshot.value).toContain('@Aug'); + expect(host.snapshot.value).not.toContain('@Auguste Raddatz'); + }); + }); + + it('pushes {personId, displayName} into mentionedPersons sidecar', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]); + }); }); it('does not duplicate the sidecar entry when the same person is selected twice', async () => { mockFetchWithPersons(); const host = renderHost({ - value: '@Auguste Raddatz ', - mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }] + value: '@Aug ', + mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }] }); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Auguste Raddatz @Aug'; - ta.selectionStart = ta.value.length; - ta.selectionEnd = ta.value.length; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - clickOption('p-aug'); - await tick(); + await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); - expect(host.snapshot.mentionedPersons).toHaveLength(1); + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); }); }); -// ─── Keyboard navigation (B11b) ────────────────────────────────────────────── +// ─── Keyboard navigation ────────────────────────────────────────────────────── -describe('PersonMentionEditor — keyboard navigation (B11b)', () => { - it('ArrowDown / ArrowUp cycle the highlighted result', async () => { +describe('PersonMentionEditor — keyboard navigation', () => { + it('Enter selects the highlighted result', async () => { + mockFetchWithPersons(); + const host = renderHost(); + + await userEvent.type(page.getByRole('textbox'), '@A'); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); + + await userEvent.keyboard('{Enter}'); + + await vi.waitFor(() => { + expect(host.snapshot.mentionedPersons).toHaveLength(1); + }); + }); + + it('ArrowDown moves the highlight to the next result', async () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@A'; - ta.selectionStart = 2; - ta.selectionEnd = 2; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@A'); - const optAuguste = document.querySelector( - '[role="option"][data-test-person-id="p-aug"]' - ) as HTMLElement; - const optAnna = document.querySelector( - '[role="option"][data-test-person-id="p-anna"]' - ) as HTMLElement; + await vi.waitFor(async () => { + await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); + }); - expect(optAuguste.getAttribute('aria-selected')).toBe('true'); - expect(optAnna.getAttribute('aria-selected')).toBe('false'); + await userEvent.keyboard('{ArrowDown}'); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); - await tick(); - - expect(optAuguste.getAttribute('aria-selected')).toBe('false'); - expect(optAnna.getAttribute('aria-selected')).toBe('true'); - - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); - await tick(); - - expect(optAuguste.getAttribute('aria-selected')).toBe('true'); - expect(optAnna.getAttribute('aria-selected')).toBe('false'); + await vi.waitFor(async () => { + const annaOption = page.getByRole('option', { name: /Anna Schmidt/ }); + await expect.element(annaOption).toHaveAttribute('aria-selected', 'true'); + }); }); - it('Enter selects the currently highlighted result', async () => { + it('Escape closes the dropdown without inserting', async () => { mockFetchWithPersons(); const host = renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@A'; - ta.selectionStart = 2; - ta.selectionEnd = 2; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); - await tick(); - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - await tick(); + await vi.waitFor(async () => { + await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument(); + }); - expect(host.snapshot.mentionedPersons).toEqual([ - { personId: 'p-anna', displayName: 'Anna Schmidt' } - ]); - }); + await userEvent.keyboard('{Escape}'); - it('Escape closes the popup without inserting anything', async () => { - mockFetchWithPersons(); - const host = renderHost(); + await vi.waitFor(async () => { + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); + }); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); - - ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - await tick(); - - await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument(); - expect(host.snapshot.value).toBe('@Aug'); expect(host.snapshot.mentionedPersons).toEqual([]); }); }); +// ─── Disabled state (WCAG 2.1.1 — keyboard users) ──────────────────────────── + +describe('PersonMentionEditor — disabled state', () => { + it('sets contenteditable=false on the editor when disabled', async () => { + renderHost({ value: 'Bestehender Text', disabled: true }); + + await vi.waitFor(() => { + const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; + expect(textbox).not.toBeNull(); + expect(textbox!.getAttribute('contenteditable')).toBe('false'); + }); + }); + + it('exposes aria-disabled=true on the editor wrapper when disabled', async () => { + renderHost({ disabled: true }); + + await vi.waitFor(() => { + const wrapper = document.querySelector('[aria-disabled="true"]'); + expect(wrapper).not.toBeNull(); + }); + }); + + it('keeps the editor editable (contenteditable=true) when not disabled', async () => { + renderHost({ disabled: false }); + + await vi.waitFor(() => { + const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; + expect(textbox).not.toBeNull(); + expect(textbox!.getAttribute('contenteditable')).toBe('true'); + }); + }); +}); + +// ─── Security — XSS in displayName (CWE-79) ────────────────────────────────── + +describe('PersonMentionEditor — XSS resistance', () => { + it('renders a malicious displayName as text, not as HTML elements', async () => { + // A historical sidecar entry whose displayName contains an HTML payload + // that would execute if interpolated as raw HTML. Tiptap's renderHTML + // returns the @-prefixed string as the third tuple entry, which + // ProseMirror's DOMSerializer treats as a Text node — escaping it. + const maliciousMention: PersonMention = { + personId: '00000000-0000-0000-0000-000000000001', + displayName: '' + }; + + renderHost({ + value: '@', + mentionedPersons: [maliciousMention] + }); + + await vi.waitFor(() => { + const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null; + expect(textbox).not.toBeNull(); + // No element from the malicious payload should have appeared as a real + // DOM node. (Tiptap inserts its own ProseMirror-separator in empty + // paragraphs — that is internal markup and never carries user attrs; + // guard against the injection by checking the user-controlled attrs.) + expect(textbox!.querySelector('img[onerror]')).toBeNull(); + expect(textbox!.querySelector('img[src="x"]')).toBeNull(); + expect(textbox!.querySelector('script')).toBeNull(); + // The payload should appear as visible text content instead. + expect(textbox!.textContent ?? '').toContain(''); + }); + }); +}); + // ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── describe('PersonMentionEditor — touch target', () => { @@ -370,13 +370,11 @@ describe('PersonMentionEditor — touch target', () => { mockFetchWithPersons(); renderHost(); - const ta = getTextarea(); - ta.focus(); - ta.value = '@Aug'; - ta.selectionStart = 4; - ta.selectionEnd = 4; - ta.dispatchEvent(new Event('input', { bubbles: true })); - await flushDebounce(); + await userEvent.type(page.getByRole('textbox'), '@Aug'); + + await vi.waitFor(async () => { + await expect.element(page.getByRole('option').first()).toBeVisible(); + }); const option = document.querySelector('[role="option"]') as HTMLElement; expect(option).not.toBeNull(); diff --git a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte index e608d694..cdc31df0 100644 --- a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte @@ -9,10 +9,17 @@ type Props = { initialValue?: string; initialMentions?: PersonMention[]; placeholder?: string; + disabled?: boolean; onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void; }; -let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props(); +let { + initialValue = '', + initialMentions = [], + placeholder, + disabled = false, + onChange +}: Props = $props(); // initial* props seed mount-time state; reading them inside untrack signals // the intentional one-shot capture and silences state_referenced_locally. @@ -28,4 +35,5 @@ $effect(() => { bind:value={value} bind:mentionedPersons={mentionedPersons} placeholder={placeholder} + disabled={disabled} /> diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 982b43ff..417c3fbe 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -79,17 +79,6 @@ let leftBorderClass = $derived( saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : '' ); -// Single source of truth for the editor's textarea — stored on attach so -// we can read selection bounds for quote selection without re-querying the DOM. -let textareaEl: HTMLTextAreaElement | null = null; - -function captureTextarea(node: HTMLTextAreaElement) { - textareaEl = node; - return () => { - textareaEl = null; - }; -} - function emitChange() { onTextChange(localText, localMentions); } @@ -101,17 +90,6 @@ async function handleDelete() { }); if (confirmed) onDeleteClick(); } - -function handleTextareaMouseUp() { - if (!textareaEl) return; - const start = textareaEl.selectionStart; - const end = textareaEl.selectionEnd; - if (start !== end) { - selectedQuote = localText.substring(start, end); - } else { - selectedQuote = null; - } -}
- -
- localText, - (v) => { - localText = v; - emitChange(); - }} - bind:mentionedPersons={() => localMentions, - (next) => { - localMentions = next; - emitChange(); - }} - placeholder={m.transcription_block_placeholder()} - onfocus={onFocus} - captureTextarea={captureTextarea} - /> -
+ + localText, + (v) => { + localText = v; + emitChange(); + }} + bind:mentionedPersons={() => localMentions, + (next) => { + localMentions = next; + emitChange(); + }} + placeholder={m.transcription_block_placeholder()} + onfocus={onFocus} + onSelectionChange={(text) => (selectedQuote = text)} + /> {#if selectedQuote}

{m.transcription_block_quote_hint()}

diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index c05a8c88..85961e75 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -41,8 +41,7 @@ describe('TranscriptionBlock — rendering', () => { it('renders text in textarea', async () => { renderBlock(); - const textarea = page.getByRole('textbox'); - await expect.element(textarea).toHaveValue('Liebe Mutter,'); + await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument(); }); it('renders optional label when provided', async () => { @@ -226,14 +225,18 @@ describe('TranscriptionBlock — delete confirmation', () => { // ─── Quote selection ───────────────────────────────────────────────────────── describe('TranscriptionBlock — quote selection', () => { - it('shows quote hint after text is selected in textarea', async () => { + it('shows quote hint after text is selected in the editor', async () => { renderBlock({ text: 'Breslau, den 12. August' }); await page.getByRole('textbox').click(); - // Select text and fire mouseup via native DOM — locator.selectText/dispatchEvent not available - const el = document.querySelector('textarea') as HTMLTextAreaElement; - el.focus(); - el.setSelectionRange(0, el.value.length); - el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + // Select all text in the contenteditable via the native Selection API. + // Tiptap fires selectionUpdate which the block forwards as onSelectionChange. + const editorEl = document.querySelector('[role="textbox"]') as HTMLElement; + const range = document.createRange(); + range.selectNodeContents(editorEl); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + editorEl.dispatchEvent(new Event('input', { bubbles: true })); await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 3c80a1da..e57f212e 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -8,7 +8,6 @@ export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' | 'INVALID_PERSON_TYPE' - | 'PERSON_RENAME_CONFLICT' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -80,8 +79,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_alias_not_found(); case 'INVALID_PERSON_TYPE': return m.error_invalid_person_type(); - case 'PERSON_RENAME_CONFLICT': - return m.error_person_rename_conflict(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE': diff --git a/frontend/src/lib/utils/mentionSerializer.spec.ts b/frontend/src/lib/utils/mentionSerializer.spec.ts new file mode 100644 index 00000000..19b4c4a2 --- /dev/null +++ b/frontend/src/lib/utils/mentionSerializer.spec.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { deserialize, serialize } from './mentionSerializer'; +import type { PersonMention } from '$lib/types'; + +// ─── deserialize ───────────────────────────────────────────────────────────── + +describe('deserialize', () => { + it('returns a valid Tiptap doc for an empty string', () => { + const doc = deserialize('', []); + expect(doc.type).toBe('doc'); + expect(doc.content).toHaveLength(1); + expect(doc.content![0].type).toBe('paragraph'); + }); + + it('returns plain text node for text with no mentions', () => { + const doc = deserialize('Hallo Welt', []); + const para = doc.content![0]; + expect(para.content).toHaveLength(1); + expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' }); + }); + + it('places a mention node at the correct position in the paragraph', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('Hallo @Clara Welt', sidecar); + const nodes = doc.content![0].content!; + expect(nodes).toHaveLength(3); + expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' }); + expect(nodes[1]).toMatchObject({ + type: 'mention', + attrs: { displayName: 'Clara', personId: 'uuid-x' } + }); + expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' }); + }); + + it('prefers the longer displayName when two sidecar entries share a prefix', () => { + const sidecar: PersonMention[] = [ + { personId: 'uuid-short', displayName: 'Auguste' }, + { personId: 'uuid-long', displayName: 'Auguste Raddatz' } + ]; + const doc = deserialize('@Auguste Raddatz', sidecar); + const nodes = doc.content![0].content!; + expect(nodes).toHaveLength(1); + expect(nodes[0]).toMatchObject({ + type: 'mention', + attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' } + }); + }); + + it('splits multiple paragraphs on newline', () => { + const doc = deserialize('Zeile 1\nZeile 2', []); + expect(doc.content).toHaveLength(2); + expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' }); + expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' }); + }); +}); + +// ─── serialize ─────────────────────────────────────────────────────────────── + +describe('serialize', () => { + it('serializes plain text unchanged', () => { + const doc = deserialize('Hallo Welt', []); + const { text, mentionedPersons } = serialize(doc); + expect(text).toBe('Hallo Welt'); + expect(mentionedPersons).toEqual([]); + }); + + it('serializes mention node back to @displayName', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('Hallo @Clara Welt', sidecar); + const { text, mentionedPersons } = serialize(doc); + expect(text).toBe('Hallo @Clara Welt'); + expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]); + }); + + it('joins multi-paragraph doc back with newlines', () => { + const doc = deserialize('Zeile 1\nZeile 2', []); + const { text } = serialize(doc); + expect(text).toBe('Zeile 1\nZeile 2'); + }); + + it('de-duplicates repeated mentions in the sidecar', () => { + const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }]; + const doc = deserialize('@Clara und @Clara', sidecar); + const { mentionedPersons } = serialize(doc); + expect(mentionedPersons).toHaveLength(1); + expect(mentionedPersons[0].personId).toBe('uuid-x'); + }); +}); + +// ─── Round-trip invariant ───────────────────────────────────────────────────── + +describe('round-trip invariant', () => { + it('text is preserved exactly through deserialize → serialize', () => { + const cases = [ + ['', []], + ['Hallo Welt', []], + ['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]], + ['Zeile 1\nZeile 2', []], + ['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]] + ] as const; + + for (const [text, sidecar] of cases) { + const doc = deserialize(text, sidecar as PersonMention[]); + const { text: out } = serialize(doc); + expect(out, `round-trip failed for: "${text}"`).toBe(text); + } + }); +}); + +// ─── Backward compatibility (AC-6) ──────────────────────────────────────────── + +describe('backward compatibility', () => { + it('old-format full-name sidecar entry still round-trips correctly', () => { + // Before this issue, displayName stored the person's full DB name. + // renderTranscriptionBody already handles this — so does the serializer. + const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]; + const text = 'Brief von @Auguste Raddatz'; + const doc = deserialize(text, oldSidecar); + const { text: out, mentionedPersons } = serialize(doc); + expect(out).toBe(text); + expect(mentionedPersons).toEqual(oldSidecar); + }); +}); + +// ─── Security ───────────────────────────────────────────────────────────────── + +describe('security', () => { + it('displayName containing HTML special chars is preserved as a string, not injected', () => { + const sidecar: PersonMention[] = [ + { personId: 'uuid-x', displayName: '' } + ]; + const text = '@'; + const doc = deserialize(text, sidecar); + const { mentionedPersons } = serialize(doc); + // The displayName is stored verbatim — HTML escaping is the renderer's job + expect(mentionedPersons[0].displayName).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils/mentionSerializer.ts b/frontend/src/lib/utils/mentionSerializer.ts new file mode 100644 index 00000000..b6213f25 --- /dev/null +++ b/frontend/src/lib/utils/mentionSerializer.ts @@ -0,0 +1,113 @@ +import type { JSONContent } from '@tiptap/core'; +import type { PersonMention } from '$lib/types'; + +/** + * Converts stored block text + sidecar into a Tiptap ProseMirror document. + * + * The text is split by "\n" into paragraphs. Within each paragraph, sidecar + * entries are matched against "@displayName" tokens (longest first) and + * converted to mention nodes. Unmatched text becomes plain text nodes. + * + * Round-trip invariant: serialize(deserialize(text, sidecar)).text === text + */ +export function deserialize(text: string, sidecar: PersonMention[]): JSONContent { + const lines = text === '' ? [''] : text.split('\n'); + + // Sort sidecar by displayName length descending so longer names shadow + // shorter prefix matches (same heuristic as renderTranscriptionBody). + const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length); + + return { + type: 'doc', + content: lines.map((line) => ({ + type: 'paragraph', + content: parseLine(line, sorted) + })) + }; +} + +function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] { + if (text === '') return []; + + if (sidecar.length === 0) { + return [{ type: 'text', text }]; + } + + // Build a list of mention ranges: { start, end, mention } + const ranges: Array<{ start: number; end: number; mention: PersonMention }> = []; + + for (const mention of sidecar) { + const needle = `@${mention.displayName}`; + let idx = 0; + while (idx < text.length) { + const pos = text.indexOf(needle, idx); + if (pos === -1) break; + // Check that the range doesn't overlap an already-found range + const end = pos + needle.length; + const overlaps = ranges.some((r) => r.start < end && r.end > pos); + if (!overlaps) { + ranges.push({ start: pos, end, mention }); + } + idx = pos + 1; + } + } + + if (ranges.length === 0) { + return [{ type: 'text', text }]; + } + + // Sort by position + ranges.sort((a, b) => a.start - b.start); + + const nodes: JSONContent[] = []; + let cursor = 0; + + for (const { start, end, mention } of ranges) { + if (start > cursor) { + nodes.push({ type: 'text', text: text.slice(cursor, start) }); + } + nodes.push({ + type: 'mention', + attrs: { displayName: mention.displayName, personId: mention.personId } + }); + cursor = end; + } + + if (cursor < text.length) { + nodes.push({ type: 'text', text: text.slice(cursor) }); + } + + return nodes; +} + +/** + * Converts a Tiptap ProseMirror document back to stored block text + sidecar. + * + * Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName" + * and collected into mentionedPersons (de-duplicated by personId). + */ +export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } { + const paragraphs = doc.content ?? []; + const mentionedPersons: PersonMention[] = []; + const seenIds = new Set(); + const lines: string[] = []; + + for (const para of paragraphs) { + let line = ''; + for (const node of para.content ?? []) { + if (node.type === 'text') { + line += node.text ?? ''; + } else if (node.type === 'mention') { + const { displayName, personId } = node.attrs ?? {}; + line += `@${displayName}`; + if (!seenIds.has(personId)) { + seenIds.add(personId); + mentionedPersons.push({ personId, displayName }); + } + } + } + lines.push(line); + } + + return { text: lines.join('\n'), mentionedPersons }; +} diff --git a/frontend/src/lib/utils/personMention.spec.ts b/frontend/src/lib/utils/personMention.spec.ts deleted file mode 100644 index 7101c27c..00000000 --- a/frontend/src/lib/utils/personMention.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { detectPersonMention } from './personMention'; - -describe('detectPersonMention', () => { - it('returns null when text has no @', () => { - expect(detectPersonMention('hello world', 11)).toBeNull(); - }); - - it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => { - expect(detectPersonMention('user@example', 12)).toBeNull(); - }); - - it('returns query for @ at the very start of string', () => { - expect(detectPersonMention('@Aug', 4)).toBe('Aug'); - }); - - it('returns empty string immediately after @', () => { - expect(detectPersonMention('@', 1)).toBe(''); - }); - - it('returns single-word query', () => { - expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste'); - }); - - it('keeps the trigger active when the query has a trailing space', () => { - expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste '); - }); - - it('returns multi-word query (spaces allowed)', () => { - expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz'); - }); - - it('returns single-character query', () => { - expect(detectPersonMention('@M', 2)).toBe('M'); - }); - - it('returns null when the query crosses a newline', () => { - expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull(); - }); - - it('returns null when a second @ appears in the query (next mention starts)', () => { - expect(detectPersonMention('@Aug@bar', 8)).toBeNull(); - }); - - it('uses the most recent @ when separated by whitespace', () => { - // '@Aug @Bert' with cursor at end — the second @ is the trigger. - expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert'); - }); - - it('returns the query when the cursor sits exactly at a newline boundary', () => { - // '@Aug\nfoo' with cursor at index 4 — right at the newline before it - // is consumed. The query is still 'Aug' because nothing past the cursor - // counts. - expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug'); - }); - - it('returns null when cursor is before the @', () => { - expect(detectPersonMention('@Hans', 0)).toBeNull(); - }); - - it('uses the most recent @ in the text', () => { - // cursor is just after the second @ + a few chars - expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert'); - }); -}); diff --git a/frontend/src/lib/utils/personMention.ts b/frontend/src/lib/utils/personMention.ts deleted file mode 100644 index 7611c89f..00000000 --- a/frontend/src/lib/utils/personMention.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Given the current textarea value and cursor position, returns the - * @-person-mention query being typed (the text after the last triggering @), - * or null if no person-mention is active. - * - * Rules — distinct from comment-mentions in `mention.ts`: - * - @ must be at the start of the string or preceded by whitespace - * - The query may contain spaces (historical persons commonly have multi-word - * display names — "Auguste Raddatz", "Maria von Müller-Schultz") - * - The query stops at a newline or at a second @ (the next mention starts) - */ -export function detectPersonMention(text: string, cursorPos: number): string | null { - const before = text.slice(0, cursorPos); - const atIndex = before.lastIndexOf('@'); - if (atIndex === -1) return null; - - if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null; - - const query = before.slice(atIndex + 1); - if (query.includes('\n') || query.includes('@')) return null; - - return query; -} diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index e4d9cce0..f44f295c 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -416,6 +416,23 @@ } } +/* ─── Tiptap editor ─────────────────────────────────────────────────────────── */ + +/* Placeholder: shown on the inner contenteditable when the editor is empty + and unfocused. Uses the data-placeholder attribute set via editorProps. */ +.tiptap-editor-inner[data-placeholder]:not(:focus)::before { + content: attr(data-placeholder); + color: var(--c-ink-3); + pointer-events: none; + float: left; + height: 0; +} + +/* Remove default ProseMirror focus ring — the outer wrapper has its own. */ +.tiptap-editor-inner:focus { + outline: none; +} + @keyframes slide { 0% { transform: translateX(-100%); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0c70bf19..91d23d4b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -493,6 +493,181 @@ resolved "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz" integrity sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ== +"@tiptap/core@^3.22.5", "@tiptap/core@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz" + integrity sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ== + +"@tiptap/extension-blockquote@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz" + integrity sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ== + +"@tiptap/extension-bold@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz" + integrity sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg== + +"@tiptap/extension-bullet-list@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz" + integrity sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA== + +"@tiptap/extension-code-block@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz" + integrity sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag== + +"@tiptap/extension-code@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz" + integrity sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ== + +"@tiptap/extension-document@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz" + integrity sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw== + +"@tiptap/extension-dropcursor@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz" + integrity sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA== + +"@tiptap/extension-gapcursor@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz" + integrity sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg== + +"@tiptap/extension-hard-break@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz" + integrity sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg== + +"@tiptap/extension-heading@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz" + integrity sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw== + +"@tiptap/extension-horizontal-rule@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz" + integrity sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A== + +"@tiptap/extension-italic@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz" + integrity sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA== + +"@tiptap/extension-link@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz" + integrity sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ== + dependencies: + linkifyjs "^4.3.2" + +"@tiptap/extension-list-item@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz" + integrity sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A== + +"@tiptap/extension-list-keymap@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz" + integrity sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg== + +"@tiptap/extension-list@^3.22.5", "@tiptap/extension-list@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz" + integrity sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg== + +"@tiptap/extension-mention@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz" + integrity sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww== + +"@tiptap/extension-ordered-list@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz" + integrity sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ== + +"@tiptap/extension-paragraph@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz" + integrity sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g== + +"@tiptap/extension-strike@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz" + integrity sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww== + +"@tiptap/extension-text@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz" + integrity sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw== + +"@tiptap/extension-underline@^3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz" + integrity sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA== + +"@tiptap/extensions@^3.22.5", "@tiptap/extensions@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz" + integrity sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg== + +"@tiptap/pm@^3.22.5", "@tiptap/pm@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz" + integrity sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw== + dependencies: + prosemirror-changeset "^2.3.0" + prosemirror-commands "^1.6.2" + prosemirror-dropcursor "^1.8.1" + prosemirror-gapcursor "^1.3.2" + prosemirror-history "^1.4.1" + prosemirror-keymap "^1.2.2" + prosemirror-model "^1.24.1" + prosemirror-schema-list "^1.5.0" + prosemirror-state "^1.4.3" + prosemirror-tables "^1.6.4" + prosemirror-transform "^1.10.2" + prosemirror-view "^1.38.1" + +"@tiptap/starter-kit@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz" + integrity sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw== + dependencies: + "@tiptap/core" "^3.22.5" + "@tiptap/extension-blockquote" "^3.22.5" + "@tiptap/extension-bold" "^3.22.5" + "@tiptap/extension-bullet-list" "^3.22.5" + "@tiptap/extension-code" "^3.22.5" + "@tiptap/extension-code-block" "^3.22.5" + "@tiptap/extension-document" "^3.22.5" + "@tiptap/extension-dropcursor" "^3.22.5" + "@tiptap/extension-gapcursor" "^3.22.5" + "@tiptap/extension-hard-break" "^3.22.5" + "@tiptap/extension-heading" "^3.22.5" + "@tiptap/extension-horizontal-rule" "^3.22.5" + "@tiptap/extension-italic" "^3.22.5" + "@tiptap/extension-link" "^3.22.5" + "@tiptap/extension-list" "^3.22.5" + "@tiptap/extension-list-item" "^3.22.5" + "@tiptap/extension-list-keymap" "^3.22.5" + "@tiptap/extension-ordered-list" "^3.22.5" + "@tiptap/extension-paragraph" "^3.22.5" + "@tiptap/extension-strike" "^3.22.5" + "@tiptap/extension-text" "^3.22.5" + "@tiptap/extension-underline" "^3.22.5" + "@tiptap/extensions" "^3.22.5" + "@tiptap/pm" "^3.22.5" + +"@tiptap/suggestion@3.22.5": + version "3.22.5" + resolved "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz" + integrity sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA== + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz" @@ -1508,6 +1683,11 @@ lilconfig@^2.0.5: resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz" integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== +linkifyjs@^4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz" + integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA== + locate-character@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz" @@ -1645,6 +1825,11 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +orderedmap@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz" + integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" @@ -1800,6 +1985,111 @@ prettier@^3.0, prettier@^3.0.0, prettier@^3.6.2: resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz" integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== +prosemirror-changeset@^2.3.0: + version "2.4.1" + resolved "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz" + integrity sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw== + dependencies: + prosemirror-transform "^1.0.0" + +prosemirror-commands@^1.6.2: + version "1.7.1" + resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz" + integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.10.2" + +prosemirror-dropcursor@^1.8.1: + version "1.8.2" + resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz" + integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + prosemirror-view "^1.1.0" + +prosemirror-gapcursor@^1.3.2: + version "1.4.1" + resolved "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz" + integrity sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw== + dependencies: + prosemirror-keymap "^1.0.0" + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-view "^1.0.0" + +prosemirror-history@^1.4.1: + version "1.5.0" + resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz" + integrity sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg== + dependencies: + prosemirror-state "^1.2.2" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.31.0" + rope-sequence "^1.3.0" + +prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3: + version "1.2.3" + resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz" + integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw== + dependencies: + prosemirror-state "^1.0.0" + w3c-keyname "^2.2.0" + +prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.4: + version "1.25.4" + resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz" + integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA== + dependencies: + orderedmap "^2.0.0" + +prosemirror-schema-list@^1.5.0: + version "1.5.1" + resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz" + integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.7.3" + +prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4: + version "1.4.4" + resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz" + integrity sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.27.0" + +prosemirror-tables@^1.6.4: + version "1.8.5" + resolved "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz" + integrity sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw== + dependencies: + prosemirror-keymap "^1.2.3" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-transform "^1.10.5" + prosemirror-view "^1.41.4" + +prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.7.3: + version "1.12.0" + resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz" + integrity sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w== + dependencies: + prosemirror-model "^1.21.0" + +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4: + version "1.41.8" + resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz" + integrity sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA== + dependencies: + prosemirror-model "^1.20.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + punycode@^2.1.0: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" @@ -1863,6 +2153,11 @@ rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^2.68.0||^3.0.0||^4.0.0, rollup@^ "@rollup/rollup-win32-x64-msvc" "4.59.0" fsevents "~2.3.2" +rope-sequence@^1.3.0: + version "1.3.4" + resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz" + integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== + sade@^1.7.4: version "1.8.1" resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz" @@ -2178,6 +2473,11 @@ vitest@^4.0.0, vitest@^4.0.10, vitest@4.1.0: vite "^6.0.0 || ^7.0.0 || ^8.0.0-0" why-is-node-running "^2.3.0" +w3c-keyname@^2.2.0: + version "2.2.8" + resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz" + integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== + webpack-virtual-modules@^0.6.2: version "0.6.2" resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz" diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..bcb6238b --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "packageRules": [ + { + "matchPackagePatterns": ["^@tiptap/"], + "groupName": "tiptap", + "automerge": false + } + ] +}