diff --git a/CHANGELOG.md b/CHANGELOG.md index bddc024ffd70..8f3157a96926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Fixed +- Fixed issues with escaping keywords in "Keywords Editor" [#14780](https://github.com/JabRef/jabref/issues/14780) - We fixed an issue where resetting preference does not reset External File Type to default. [#15117](https://github.com/JabRef/jabref/issues/15117) - We fixed an issue where institutional authors in braces {Institutional Author} triggered a "Names are not in the standard format" warning. [#15157](https://github.com/JabRef/jabref/issues/15157) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java index cafd98f7ee2e..615bd1384cee 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditor.java @@ -2,7 +2,9 @@ import java.net.URL; import java.util.Comparator; +import java.util.List; import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; import javax.swing.undo.UndoManager; @@ -113,7 +115,7 @@ public KeywordsEditor(Field field, keywordTagsField.setMatcher((keyword, searchText) -> keyword.get().toLowerCase().startsWith(searchText.toLowerCase())); keywordTagsField.setComparator(Comparator.comparing(Keyword::get)); - keywordTagsField.setNewItemProducer(searchText -> KeywordsEditorViewModel.getStringConverter().fromString(searchText)); + keywordTagsField.setNewItemProducer(searchText -> viewModel.parseKeyword(searchText)); keywordTagsField.setShowSearchIcon(false); keywordTagsField.setOnMouseClicked(_ -> keywordTagsField.getEditor().requestFocus()); @@ -124,7 +126,11 @@ public KeywordsEditor(Field field, String keywordSeparator = String.valueOf(viewModel.getKeywordSeparator()); keywordTagsField.getEditor().setOnKeyReleased(event -> { if (event.getText().equals(keywordSeparator)) { - keywordTagsField.commit(); + String editorText = keywordTagsField.getEditor().getText(); + + if (isSeparatedKeyword(editorText, keywordSeparator)) { + keywordTagsField.commit(); + } event.consume(); } }); @@ -155,6 +161,22 @@ public KeywordsEditor(Field field, Bindings.bindContentBidirectional(keywordTagsField.getTags(), viewModel.keywordListProperty()); } + private boolean isSeparatedKeyword(String keywordString, String keywordSeparator) { + int separatorLastOccurrence = keywordString.lastIndexOf(keywordSeparator); + if (separatorLastOccurrence == -1) { + return false; + } + + int separatorFirstOccurrence = keywordString.lastIndexOf(keywordSeparator); + String substringWithSeparator = new StringBuilder(keywordString.substring(0, separatorFirstOccurrence)).reverse().toString(); + + AtomicBoolean isSeparatedKeyword = new AtomicBoolean(true); + substringWithSeparator.chars().takeWhile(symbol -> symbol == Keyword.DEFAULT_ESCAPE_SYMBOL) + .forEachOrdered(_ -> isSeparatedKeyword.set(!isSeparatedKeyword.get())); + + return isSeparatedKeyword.get(); + } + private Node createTag(Keyword keyword) { Label tagLabel = new Label(); tagLabel.setText(keywordTagsField.getConverter().toString(keyword)); @@ -172,7 +194,7 @@ private Node createTag(Keyword keyword) { tagLabel.setOnMouseClicked(event -> { if (event.getClickCount() == 2) { keywordTagsField.removeTags(keyword); - keywordTagsField.getEditor().setText(keyword.get()); + keywordTagsField.getEditor().setText(KeywordList.serialize(List.of(keyword), viewModel.getKeywordSeparator())); keywordTagsField.getEditor().positionCaret(keyword.get().length()); } }); diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java index 2a354f71be46..fa43952731dc 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModel.java @@ -18,6 +18,7 @@ import org.jabref.model.entry.KeywordList; import org.jabref.model.entry.field.Field; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -86,7 +87,7 @@ public List getSuggestions(String request) { .distinct() .collect(Collectors.toList()); - Keyword requestedKeyword = new Keyword(request); + Keyword requestedKeyword = parseKeyword(request); if (!suggestions.contains(requestedKeyword)) { suggestions.addFirst(requestedKeyword); } @@ -94,6 +95,11 @@ public List getSuggestions(String request) { return suggestions; } + @Nullable + public Keyword parseKeyword(String keyword) { + return KeywordList.parse(keyword, keywordSeparator).stream().findFirst().orElse(null); + } + public Character getKeywordSeparator() { return keywordSeparator; } diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModelTest.java index 18f6800def3e..226f18151253 100644 --- a/jabgui/src/test/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/KeywordsEditorViewModelTest.java @@ -1,14 +1,70 @@ package org.jabref.gui.fieldeditors; +import java.util.List; + +import javax.swing.undo.UndoManager; + +import org.jabref.gui.autocompleter.SuggestionProvider; +import org.jabref.logic.integrity.FieldCheckers; +import org.jabref.logic.preferences.CliPreferences; +import org.jabref.model.entry.BibEntryPreferences; import org.jabref.model.entry.Keyword; +import org.jabref.model.entry.KeywordList; +import org.jabref.model.entry.field.Field; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class KeywordsEditorViewModelTest { + private KeywordsEditorViewModel viewModel; + + @BeforeEach + void setUp() { + SuggestionProvider suggestionProvider = mock(SuggestionProvider.class); + CliPreferences cliPreferences = mock(CliPreferences.class); + + BibEntryPreferences bibEntryPreferences = mock(BibEntryPreferences.class); + + when(cliPreferences.getBibEntryPreferences()).thenReturn(bibEntryPreferences); + when(bibEntryPreferences.getKeywordSeparator()).thenReturn(','); + when(suggestionProvider.getPossibleSuggestions()).thenReturn(List.of("value", "key\\,\\\\", "parent > node > child", "father \\> inheritor")); + viewModel = new KeywordsEditorViewModel(mock(Field.class), suggestionProvider, mock(FieldCheckers.class), cliPreferences, mock(UndoManager.class)); + } + + @Test + void getSuggestionsWithEscapedSeparator() { + String request = "key"; + assertEquals(List.of(new Keyword(request), new Keyword("key\\,\\\\")), viewModel.getSuggestions(request)); + } + + @Test + void getSuggestionsWithEscapedHierarchicalDelimiter() { + String request = "father"; + assertEquals(List.of(new Keyword(request), new Keyword("father \\> inheritor")), viewModel.getSuggestions(request)); + } + + @Test + void parseKeywordWithHierarchicalKeywords() { + String hierarchichalString = "parent > node > child"; + Keyword parsedKeyword = KeywordList.parse(hierarchichalString, viewModel.getKeywordSeparator()).get(0); + + assertEquals(parsedKeyword, viewModel.parseKeyword(hierarchichalString)); + } + + @Test + void parseKeywordWithMultipleKeywords() { + String multipleKeysStr = "key1, key2"; + Keyword firstParsedKeyword = KeywordList.parse(multipleKeysStr, viewModel.getKeywordSeparator()).get(0); + + assertEquals(firstParsedKeyword, viewModel.parseKeyword(multipleKeysStr)); + } + @Test - void stringConverterWithHierarchicalKeywords() { + void stringConverterToStringWithHierarchicalKeywords() { String hierarchichalString = "parent > node > child"; Keyword keyword = Keyword.ofHierarchical(hierarchichalString); diff --git a/jablib/src/main/java/org/jabref/model/entry/Keyword.java b/jablib/src/main/java/org/jabref/model/entry/Keyword.java index 2021c53d13b9..9b7ed682b18a 100644 --- a/jablib/src/main/java/org/jabref/model/entry/Keyword.java +++ b/jablib/src/main/java/org/jabref/model/entry/Keyword.java @@ -15,6 +15,7 @@ public class Keyword extends ChainNode implements Comparable { public static Character DEFAULT_HIERARCHICAL_DELIMITER = '>'; + public static final Character DEFAULT_ESCAPE_SYMBOL = '\\'; private final String keyword; public Keyword(@NonNull String keyword) { diff --git a/jablib/src/main/java/org/jabref/model/entry/KeywordList.java b/jablib/src/main/java/org/jabref/model/entry/KeywordList.java index cb783bc3bfd3..d976cef92603 100644 --- a/jablib/src/main/java/org/jabref/model/entry/KeywordList.java +++ b/jablib/src/main/java/org/jabref/model/entry/KeywordList.java @@ -65,7 +65,7 @@ public static KeywordList parse(@NonNull String keywordString, @NonNull Characte if (isEscaping.get()) { currentToken.append(currentChar); isEscaping.set(false); - } else if (currentChar == '\\') { + } else if (currentChar == Keyword.DEFAULT_ESCAPE_SYMBOL) { isEscaping.set(true); } else if (currentChar == Keyword.DEFAULT_HIERARCHICAL_DELIMITER) { hierarchy.add(currentToken.toString().trim()); @@ -90,15 +90,16 @@ public static KeywordList parse(@NonNull String keywordString, @NonNull Characte public static String serialize(List keywords, Character delimiter) { String delimiterStr = delimiter.toString(); - String escapedDelimiter = "\\" + delimiterStr; + String escapeSequenceStr = Keyword.DEFAULT_ESCAPE_SYMBOL.toString(); + String escapedDelimiter = escapeSequenceStr + delimiterStr; String hierarchicalDelimiterStr = Keyword.DEFAULT_HIERARCHICAL_DELIMITER.toString(); - String escapedHierarchicalDelimiter = "\\" + hierarchicalDelimiterStr; + String escapedHierarchicalDelimiter = escapeSequenceStr + hierarchicalDelimiterStr; String hierarchicalSeparator = " " + hierarchicalDelimiterStr + " "; return keywords.stream() .map(keyword -> keyword.flatten().stream() .map(Keyword::get) - .map(nodeKeyword -> nodeKeyword.replace("\\", "\\\\")) + .map(nodeKeyword -> nodeKeyword.replace(escapeSequenceStr, escapeSequenceStr + escapeSequenceStr)) .map(nodeKeyword -> nodeKeyword.replace(delimiterStr, escapedDelimiter)) .map(nodeKeyword -> nodeKeyword.replace(hierarchicalDelimiterStr, escapedHierarchicalDelimiter)) .collect(Collectors.joining(hierarchicalSeparator)))