diff --git a/CHANGELOG.md b/CHANGELOG.md index c2feaad4519..ba2cb70ee35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - We added the option to change the Git username and PAT in Network Preferences. [#14509](https://github.com/JabRef/jabref/pull/14509) - When parsing a plain text citation, we added support for recognizing and extracting arXiv identifiers. [#14455](https://github.com/JabRef/jabref/pull/14455) - We introduced a new "Search Engine URL Template" setting in Preferences to allow users to customize their search engine URL templates [#12268](https://github.com/JabRef/jabref/issues/12268) +- We added pseudonymization of groups [#14117](https://github.com/JabRef/jabref/issues/14117) ### Changed diff --git a/jablib/src/main/java/org/jabref/logic/pseudonymization/Pseudonymization.java b/jablib/src/main/java/org/jabref/logic/pseudonymization/Pseudonymization.java index abdedcc84d7..5af09ac27ff 100644 --- a/jablib/src/main/java/org/jabref/logic/pseudonymization/Pseudonymization.java +++ b/jablib/src/main/java/org/jabref/logic/pseudonymization/Pseudonymization.java @@ -5,11 +5,16 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.groups.AbstractGroup; +import org.jabref.model.groups.GroupTreeNode; +import org.jabref.model.metadata.MetaData; import org.jspecify.annotations.NullMarked; @@ -31,6 +36,8 @@ public Result pseudonymizeLibrary(BibDatabaseContext bibDatabaseContext) { Map> fieldToValueToIdMap = new HashMap<>(); List newEntries = pseudonymizeEntries(bibDatabaseContext, fieldToValueToIdMap); + Optional newGroups = pseudonymizeGroups(bibDatabaseContext, fieldToValueToIdMap); + Map valueMapping = new HashMap<>(); fieldToValueToIdMap.forEach((field, stringToIntMap) -> stringToIntMap.forEach((value, id) -> valueMapping.put(field.getName().toLowerCase(Locale.ROOT) + "-" + id, value))); @@ -38,6 +45,7 @@ public Result pseudonymizeLibrary(BibDatabaseContext bibDatabaseContext) { BibDatabase bibDatabase = new BibDatabase(newEntries); BibDatabaseContext result = new BibDatabaseContext(bibDatabase); result.setMode(bibDatabaseContext.getMode()); + newGroups.ifPresent(result.getMetaData()::setGroups); return new Result(result, valueMapping); } @@ -63,4 +71,44 @@ private static List pseudonymizeEntries(BibDatabaseContext bibDatabase } return newEntries; } + + /** + * Pseudonymizes the root group and all subgroups. + * If no groups exist, returns empty. + */ + private static Optional pseudonymizeGroups(BibDatabaseContext bibDatabaseContext, Map> fieldToValueToIdMap) { + MetaData metadata = bibDatabaseContext.getMetaData(); + Optional groupsOpt = metadata.getGroups(); + + if (groupsOpt.isEmpty()) { + return Optional.empty(); + } + + GroupTreeNode originalRoot = groupsOpt.get(); + Map groupValueMap = fieldToValueToIdMap.computeIfAbsent(StandardField.GROUPS, _ -> new HashMap<>()); + + GroupTreeNode newRoot = pseudonymizeGroupNode(originalRoot, groupValueMap); + return Optional.of(newRoot); + } + + /** + * Recursively rewrites a group node and its children. + * Each original group receives a generated ID, resulting in: original -> "groups-n" + */ + private static GroupTreeNode pseudonymizeGroupNode(GroupTreeNode node, Map valueToIdMap) { + AbstractGroup originalGroup = node.getGroup(); + AbstractGroup groupCopy = originalGroup.deepCopy(); + + String originalName = node.getName(); + int id = valueToIdMap.computeIfAbsent(originalName, _ -> valueToIdMap.size() + 1); + groupCopy.nameProperty().setValue(StandardField.GROUPS.getName() + "-" + id); + + GroupTreeNode newNode = new GroupTreeNode(groupCopy); + for (GroupTreeNode child : node.getChildren()) { + GroupTreeNode childCopy = pseudonymizeGroupNode(child, valueToIdMap); + newNode.addChild(childCopy); + } + + return newNode; + } } diff --git a/jablib/src/test/java/org/jabref/logic/pseudonymization/PseudonymizationTest.java b/jablib/src/test/java/org/jabref/logic/pseudonymization/PseudonymizationTest.java index 38443d4c19e..bf725a090da 100644 --- a/jablib/src/test/java/org/jabref/logic/pseudonymization/PseudonymizationTest.java +++ b/jablib/src/test/java/org/jabref/logic/pseudonymization/PseudonymizationTest.java @@ -21,6 +21,10 @@ import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.entry.field.StandardField; +import org.jabref.model.groups.AllEntriesGroup; +import org.jabref.model.groups.ExplicitGroup; +import org.jabref.model.groups.GroupHierarchyType; +import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.metadata.SaveOrder; import org.jabref.model.util.DummyFileUpdateMonitor; @@ -30,6 +34,7 @@ import org.mockito.Answers; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -129,4 +134,69 @@ void pseudonymizeLibraryFile(@TempDir Path tempDir) throws URISyntaxException, I assertTrue(Files.exists(target)); } + + @Test + void pseudonymizeGroups() { + // given + GroupTreeNode root = new GroupTreeNode(new AllEntriesGroup("Root")); + GroupTreeNode used = root.addSubgroup(new ExplicitGroup("Used", GroupHierarchyType.INDEPENDENT, ',')); + used.addSubgroup(new ExplicitGroup("Sub", GroupHierarchyType.INDEPENDENT, ',')); + + BibDatabaseContext databaseContext = new BibDatabaseContext(new BibDatabase()); + databaseContext.getMetaData().setGroups(root); + + Pseudonymization pseudonymization = new Pseudonymization(); + + // when + Pseudonymization.Result result = pseudonymization.pseudonymizeLibrary(databaseContext); + GroupTreeNode newRoot = result.bibDatabaseContext().getMetaData().getGroups().orElseThrow(); + + // then + assertEquals("groups-1", newRoot.getName()); + assertTrue(newRoot.getFirstChild().isPresent()); + + GroupTreeNode newUsed = newRoot.getFirstChild().orElseThrow(); + assertEquals("groups-2", newUsed.getName()); + assertTrue(newUsed.getFirstChild().isPresent()); + + GroupTreeNode newSub = newUsed.getFirstChild().orElseThrow(); + assertEquals("groups-3", newSub.getName()); + + Map mapping = result.valueMapping(); + assertEquals("Root", mapping.get("groups-1")); + assertEquals("Used", mapping.get("groups-2")); + assertEquals("Sub", mapping.get("groups-3")); + } + + @Test + void pseudonymizeEntriesWithGroup() { + // given + BibDatabaseContext databaseContext = new BibDatabaseContext(new BibDatabase(List.of( + new BibEntry("first").withField(StandardField.GROUPS, "MyGroup"), + new BibEntry("second").withField(StandardField.GROUPS, "MyGroup"), + new BibEntry("third").withField(StandardField.GROUPS, "OtherGroup") + ))); + + Pseudonymization pseudonymization = new Pseudonymization(); + + // when + Pseudonymization.Result result = pseudonymization.pseudonymizeLibrary(databaseContext); + + // then + List entries = result.bibDatabaseContext().getEntries(); + assertEquals(3, entries.size()); + + String myGroup1 = entries.getFirst().getField(StandardField.GROUPS).orElseThrow(); + String myGroup2 = entries.get(1).getField(StandardField.GROUPS).orElseThrow(); + String otherGroup = entries.get(2).getField(StandardField.GROUPS).orElseThrow(); + + assertEquals(myGroup1, myGroup2); + assertTrue(myGroup1.startsWith("groups-")); + assertTrue(otherGroup.startsWith("groups-")); + assertNotEquals(myGroup1, otherGroup); + + Map mapping = result.valueMapping(); + assertEquals("MyGroup", mapping.get(myGroup1)); + assertEquals("OtherGroup", mapping.get(otherGroup)); + } } diff --git a/jablib/src/test/resources/org/jabref/logic/pseudonymization/Chocolate-pseudnomyized.bib b/jablib/src/test/resources/org/jabref/logic/pseudonymization/Chocolate-pseudnomyized.bib index b7598ce584d..4a6949b4966 100644 --- a/jablib/src/test/resources/org/jabref/logic/pseudonymization/Chocolate-pseudnomyized.bib +++ b/jablib/src/test/resources/org/jabref/logic/pseudonymization/Chocolate-pseudnomyized.bib @@ -211,3 +211,14 @@ @Article{citationkey-15 } @Comment{jabref-meta: databaseType:biblatex;} + +@Comment{jabref-meta: grouping: +0 AllEntriesGroup:; +1 SearchGroup:groups-3\;0\;groups !=~ .+\;0\;1\;1\;\;\;\;; +1 SearchGroup:groups-4\;0\;file !=~ .+\;0\;1\;1\;\;\;\;; +1 StaticGroup:groups-5\;0\;1\;\;\;\;; +1 SearchGroup:groups-6\;0\;groups !=~ .+ and readstatus !=~ .+\;0\;1\;1\;\;\;\;; +1 KeywordGroup:groups-7\;0\;readstatus\;skimmed\;0\;0\;1\;\;\;\;; +1 KeywordGroup:groups-8\;0\;readstatus\;read\;0\;0\;1\;\;\;\;; +1 StaticGroup:groups-1\;0\;1\;\;\;\;; +}