From b66c9996e531c5ababc3f021e135894ec3adf7a7 Mon Sep 17 00:00:00 2001 From: Sheng Wang Date: Tue, 14 Oct 2025 20:00:07 +1100 Subject: [PATCH 01/20] groups: add model classes: AutomaticDateGroup + DateGroup + some basic tests currently only group based on year not months --- .../model/groups/AutomaticDateGroup.java | 56 +++++++++++++ .../org/jabref/model/groups/DateGroup.java | 53 +++++++++++++ .../model/groups/AutomaticDateGroupTest.java | 78 +++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java create mode 100644 jablib/src/main/java/org/jabref/model/groups/DateGroup.java create mode 100644 jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java diff --git a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java new file mode 100644 index 00000000000..cd1d23bbe88 --- /dev/null +++ b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java @@ -0,0 +1,56 @@ +package org.jabref.model.groups; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +public class AutomaticDateGroup extends AutomaticGroup{ + + private final Field field; + + public AutomaticDateGroup(String name, GroupHierarchyType context, Field field) { + super(name, context); + + this.field = field; + } + + @Override + public Set createSubgroups(BibEntry entry) { + var out = new LinkedHashSet(); + DateGroup.extractYear(field, entry).ifPresent(y->{ + String year = String.format("%04d", y); + DateGroup child = new DateGroup(year, GroupHierarchyType.INDEPENDENT, field, year); + out.add(new GroupTreeNode(child)); + }); + return out; + } + + @Override + public AbstractGroup deepCopy() { + return new AutomaticDateGroup(this.name.getValue(), this.context, this.field); + + } + + @Override + public int hashCode() { + return Objects.hash(field); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AutomaticDateGroup that = (AutomaticDateGroup) o; + return Objects.equals(field, that.field); + } + + +} diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java new file mode 100644 index 00000000000..2de160ab370 --- /dev/null +++ b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java @@ -0,0 +1,53 @@ +package org.jabref.model.groups; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.jabref.model.entry.Author; +import org.jabref.model.entry.AuthorList; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.Date; +import org.jabref.model.entry.field.Field; +import org.jabref.model.strings.LatexToUnicodeAdapter; + +/** + * Matches based on a latex free last name in a specified field. The field is parsed as an author list and the last names are resolved of latex. + */ +public class DateGroup extends AbstractGroup { + + private final Field field; + String date; + + public DateGroup(String groupName, GroupHierarchyType context, Field searchField, String date) { + super(groupName, context); + field=searchField; + this.date = date; + } + + + + static Optional extractYear(Field field, BibEntry bibEntry) { + return bibEntry.getField(field) + .flatMap(Date::parse) + .flatMap(Date::getYear); + } + + @Override + public boolean contains(BibEntry entry) { + return extractYear(this.field, entry) + .map(y -> String.format("%04d", y).equals(date)) + .orElse(false); + } + + @Override + public AbstractGroup deepCopy() { + return new DateGroup(getName(), getHierarchicalContext(), this.field, this.date); + } + + @Override + public boolean isDynamic() { + return true; + } +} diff --git a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java new file mode 100644 index 00000000000..1a545182799 --- /dev/null +++ b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java @@ -0,0 +1,78 @@ +package org.jabref.model.groups; +import javafx.collections.FXCollections; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.groups.*; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AutomaticDateGroupTest { + + @Test + void createsYearBucketFromDateField() { + BibEntry e = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + AutomaticDateGroup byYear = new AutomaticDateGroup("By Year", GroupHierarchyType.INCLUDING, StandardField.DATE); + + var children = byYear.createSubgroups(e); + assertEquals(1, children.size()); + GroupTreeNode node = children.iterator().next(); + assertEquals("2024", node.getName()); + assertTrue(node.getGroup().contains(e)); + } + + @Test + void createsYearBucketFromYearField() { + BibEntry e = new BibEntry().withField(StandardField.YEAR, "2023"); + AutomaticDateGroup byYear = new AutomaticDateGroup("By Year", GroupHierarchyType.INCLUDING, StandardField.YEAR); + + var children = byYear.createSubgroups(e); + assertEquals(1, children.size()); + assertEquals("2023", children.iterator().next().getName()); + } + + @Test + void mergesSameYearAcrossEntries() { + BibEntry e1 = new BibEntry().withField(StandardField.YEAR, "2023"); + BibEntry e2 = new BibEntry().withField(StandardField.DATE, "2023-05"); + + AutomaticDateGroup byYear = new AutomaticDateGroup("By Year", GroupHierarchyType.INCLUDING, StandardField.DATE); + var merged = byYear.createSubgroups(FXCollections.observableArrayList(List.of(e1, e2))); + + // Only one "2023" node after merge (relies on DateGroup.equals/hashCode) + assertEquals(1, merged.size()); + assertEquals("2023", merged.getFirst().getName()); + } + + + + @Test + void automaticDateGroupBuildsBucketAndFindsMatches() { + // Parent automatic group using DATE field + AutomaticDateGroup byYear = new AutomaticDateGroup("By Year", GroupHierarchyType.INCLUDING, StandardField.DATE); + + BibEntry e1 = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + BibEntry e2 = new BibEntry().withField(StandardField.DATE, "2024-01-02"); + BibEntry e3 = new BibEntry().withField(StandardField.DATE, "2023-12-31"); + + var entries = FXCollections.observableArrayList(List.of(e1, e2, e3)); + + // Build subgroups (merged by equals/hashCode of DateGroup) + var nodes = byYear.createSubgroups(entries); + + // Find the "2024" bucket + GroupTreeNode bucket2024 = nodes.stream() + .filter(n -> "2024".equals(n.getName())) + .findFirst() + .orElseThrow(); + + // It should match e1 and e2, but not e3 + var matches = bucket2024.findMatches(List.of(e1, e2, e3)); + assertEquals(2, matches.size()); + assertTrue(matches.contains(e1)); + assertTrue(matches.contains(e2)); + assertFalse(matches.contains(e3)); + } +} From 470cb2ec4e417f30ca029eff471634866fe63a6f Mon Sep 17 00:00:00 2001 From: Sheng Wang Date: Thu, 16 Oct 2025 19:11:34 +1100 Subject: [PATCH 02/20] model(groups): adding granularity to to model so it can now group by year following month and day, changing AutomaticDateGroup/DateGroup+ DateGranularity + tests (year/month/day) --- .../model/groups/AutomaticDateGroup.java | 29 +++++-- .../jabref/model/groups/DateGranularity.java | 7 ++ .../org/jabref/model/groups/DateGroup.java | 56 ++++++++++++- .../model/groups/AutomaticDateGroupTest.java | 80 +++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 jablib/src/main/java/org/jabref/model/groups/DateGranularity.java diff --git a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java index cd1d23bbe88..c439559078b 100644 --- a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java @@ -11,20 +11,39 @@ public class AutomaticDateGroup extends AutomaticGroup{ private final Field field; + private final DateGranularity granularity; public AutomaticDateGroup(String name, GroupHierarchyType context, Field field) { super(name, context); this.field = field; + granularity = DateGranularity.YEAR; + } + public AutomaticDateGroup(String name, GroupHierarchyType context, Field field, DateGranularity granularity) { + super(name, context); + this.field = field; + this.granularity = granularity; } @Override public Set createSubgroups(BibEntry entry) { var out = new LinkedHashSet(); - DateGroup.extractYear(field, entry).ifPresent(y->{ - String year = String.format("%04d", y); - DateGroup child = new DateGroup(year, GroupHierarchyType.INDEPENDENT, field, year); - out.add(new GroupTreeNode(child)); + + DateGroup.extractDate(field, entry).ifPresent(d -> { + switch (granularity) { + case YEAR -> { + DateGroup.extractYear(field, entry).ifPresent(y -> { + String key = String.format("%04d", y); + out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); + }); + } + case MONTH -> DateGroup.getDateKey(d, "YYYY-MM").ifPresent(key -> { + out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); + }); + case FULL_DATE -> DateGroup.getDateKey(d, "YYYY-MM-DD").ifPresent(key -> { + out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); + }); + } }); return out; } @@ -49,7 +68,7 @@ public boolean equals(Object o) { return false; } AutomaticDateGroup that = (AutomaticDateGroup) o; - return Objects.equals(field, that.field); + return Objects.equals(field, that.field) && granularity == that.granularity; } diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java b/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java new file mode 100644 index 00000000000..288fa9a8a16 --- /dev/null +++ b/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java @@ -0,0 +1,7 @@ +package org.jabref.model.groups; + +public enum DateGranularity { + YEAR, + MONTH, + FULL_DATE +} \ No newline at end of file diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java index 2de160ab370..bfb7fc4215d 100644 --- a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java @@ -10,6 +10,7 @@ import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; import org.jabref.model.strings.LatexToUnicodeAdapter; /** @@ -18,7 +19,7 @@ public class DateGroup extends AbstractGroup { private final Field field; - String date; + String date; // bucket key: "YYYY" or "YYYY-MM" or "YYYY-MM-DD" public DateGroup(String groupName, GroupHierarchyType context, Field searchField, String date) { super(groupName, context); @@ -34,11 +35,58 @@ static Optional extractYear(Field field, BibEntry bibEntry) { .flatMap(Date::getYear); } + static Optional extractDate(Field field, BibEntry entry) { + boolean isCore = + (field == StandardField.DATE) + || (field == StandardField.YEAR) + || (field == StandardField.MONTH) + || (field == StandardField.DAY); + + if (isCore) { + // use alias resolution so DATE <-> YEAR/MONTH/DAY works either way + return entry.getFieldOrAlias(StandardField.DATE).flatMap(Date::parse); + } else { + // e.g. urldate or any custom date-like field + return entry.getField(field).flatMap(Date::parse); + } + } + + static Optional getDateKey(Date d, String dateKeyFormat) { + int numOfdashes = (int) dateKeyFormat.chars().filter(ch -> ch == '-').count(); + // normalize parts + Optional y = d.getYear(); + return switch (numOfdashes) { + case 0 -> y.map(val -> String.format("%04d", val)); // "YYYY" + case 1 -> { // "YYYY-MM" + if (d.getYear().isPresent() && d.getMonth().isPresent()) { + String out = String.format("%04d-%02d", d.getYear().get(), d.getMonth().get().getNumber()); + yield Optional.of(out); + } else { + yield Optional.empty(); + } + } + case 2 -> { // "YYYY-MM-DD" + if (d.getYear().isPresent() && d.getMonth().isPresent() && d.getDay().isPresent()) { + String out = String.format("%04d-%02d-%02d", + d.getYear().get(), + d.getMonth().get().getNumber(), + d.getDay().get()); + yield Optional.of(out); + } else { + yield Optional.empty(); + } + } + default -> Optional.empty(); + }; + } + + @Override public boolean contains(BibEntry entry) { - return extractYear(this.field, entry) - .map(y -> String.format("%04d", y).equals(date)) - .orElse(false); + return extractDate(this.field, entry) + .flatMap(d -> getDateKey(d, this.date)) + .map(key -> key.equals(this.date)) + .orElse(false); } @Override diff --git a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java index 1a545182799..966e4633d19 100644 --- a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java +++ b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java @@ -4,6 +4,7 @@ import org.jabref.model.entry.field.StandardField; import org.jabref.model.groups.*; import org.junit.jupiter.api.Test; +import org.jabref.model.entry.Month; import java.util.List; @@ -75,4 +76,83 @@ void automaticDateGroupBuildsBucketAndFindsMatches() { assertTrue(matches.contains(e2)); assertFalse(matches.contains(e3)); } + + + + // MONTH granularity from DATE field (YYYY-MM-DD -> bucket "YYYY-MM") + @Test + void createsMonthBucketFromDateField() { + BibEntry e = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + AutomaticDateGroup byMonth = new AutomaticDateGroup("By Month", GroupHierarchyType.INCLUDING, + StandardField.DATE, DateGranularity.MONTH); + + var children = byMonth.createSubgroups(e); + assertEquals(1, children.size()); + GroupTreeNode node = children.iterator().next(); + assertEquals("2024-10", node.getName()); + assertTrue(node.getGroup().contains(e)); + } + + @Test + void mergesSameMonthAcrossEntries() { + BibEntry e1 = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + BibEntry e2 = new BibEntry().withField(StandardField.DATE, "2024-10"); + BibEntry e3 = new BibEntry().withField(StandardField.DATE, "2024-11-01"); + + AutomaticDateGroup byMonth = new AutomaticDateGroup("By Month", GroupHierarchyType.INCLUDING, + StandardField.DATE, DateGranularity.MONTH); + + var merged = byMonth.createSubgroups(FXCollections.observableArrayList(List.of(e1, e2, e3))); + assertEquals(2, merged.size()); // 2024-10 and 2024-11 present + + // Find the 2024-10 bucket and assert matches + GroupTreeNode bucket = merged.stream() + .filter(n -> "2024-10".equals(n.getName())) + .findFirst() + .orElseThrow(); + var matches = bucket.findMatches(List.of(e1, e2, e3)); + assertEquals(2, matches.size()); + assertTrue(matches.contains(e1)); + assertTrue(matches.contains(e2)); + assertFalse(matches.contains(e3)); + } + + // FULL_DATE granularity creates day bucket when full date is present + @Test + void createsFullDateBucketFromDateField() { + BibEntry e = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + AutomaticDateGroup byDay = new AutomaticDateGroup("By Day", GroupHierarchyType.INCLUDING, + StandardField.DATE, DateGranularity.FULL_DATE); + + var children = byDay.createSubgroups(e); + assertEquals(1, children.size()); + GroupTreeNode node = children.iterator().next(); + assertEquals("2024-10-14", node.getName()); + assertTrue(node.getGroup().contains(e)); + } + + // FULL_DATE bucket matches only exact date + @Test + void fullDateBucketMatchesOnlyExactDate() { + BibEntry e1 = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + BibEntry e2 = new BibEntry().withField(StandardField.DATE, "2024-10-03"); + BibEntry e3 = new BibEntry().withField(StandardField.DATE, "2024-10-14"); + + AutomaticDateGroup byDay = new AutomaticDateGroup("By Day", GroupHierarchyType.INCLUDING, + StandardField.DATE, DateGranularity.FULL_DATE); + + var nodes = byDay.createSubgroups(FXCollections.observableArrayList(List.of(e1, e2, e3))); + // after merge we should have a node "2024-10-14" and maybe one "2024-10-03" + GroupTreeNode bucket = nodes.stream() + .filter(n -> "2024-10-14".equals(n.getName())) + .findFirst() + .orElseThrow(); + + var matches = bucket.findMatches(List.of(e1, e2, e3)); + assertEquals(2, matches.size()); // e1 and e3 + assertTrue(matches.contains(e1)); + assertTrue(matches.contains(e3)); + assertFalse(matches.contains(e2)); + } + } From c80b3af02edabe1f1d0c0e94ec912fb956883b7d Mon Sep 17 00:00:00 2001 From: Sheng Wang Date: Fri, 17 Oct 2025 02:38:33 +1100 Subject: [PATCH 03/20] Fix formatting/checkstyle, apply OpenRewrite, add CHANGELOG --- CHANGELOG.md | 1 + jablib/src/main/abbrv.jabref.org | 2 +- .../model/groups/AutomaticDateGroup.java | 11 +++---- .../jabref/model/groups/DateGranularity.java | 2 +- .../org/jabref/model/groups/DateGroup.java | 32 ++++++++----------- jablib/src/main/resources/csl-locales | 2 +- jablib/src/main/resources/csl-styles | 2 +- .../model/groups/AutomaticDateGroupTest.java | 18 +++++------ 8 files changed, 31 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f56da295a..466a93c1ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- We added automatic date-based groups that create year/month/day subgroups from an entry’s date fields. [#10822](https://github.com/JabRef/jabref/issues/10822) - We made the "Configure API key" option in the Web Search preferences tab searchable via preferences search. [#13929](https://github.com/JabRef/jabref/issues/13929) - We added the integrity check to the jabkit cli application. [#13848](https://github.com/JabRef/jabref/issues/13848) - We added support for Cygwin-file paths on a Windows Operating System. [#13274](https://github.com/JabRef/jabref/issues/13274) diff --git a/jablib/src/main/abbrv.jabref.org b/jablib/src/main/abbrv.jabref.org index 176c06c4727..fc3ef9ee1ff 160000 --- a/jablib/src/main/abbrv.jabref.org +++ b/jablib/src/main/abbrv.jabref.org @@ -1 +1 @@ -Subproject commit 176c06c4727fa1005117ead24e8d5b9051e4f3ab +Subproject commit fc3ef9ee1ff8f6770971164963361f9d31549800 diff --git a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java index c439559078b..408801a81a5 100644 --- a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java @@ -3,15 +3,14 @@ import java.util.LinkedHashSet; import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.field.Field; -public class AutomaticDateGroup extends AutomaticGroup{ +public class AutomaticDateGroup extends AutomaticGroup { - private final Field field; private final DateGranularity granularity; + private final Field field; public AutomaticDateGroup(String name, GroupHierarchyType context, Field field) { super(name, context); @@ -19,6 +18,7 @@ public AutomaticDateGroup(String name, GroupHierarchyType context, Field field) this.field = field; granularity = DateGranularity.YEAR; } + public AutomaticDateGroup(String name, GroupHierarchyType context, Field field, DateGranularity granularity) { super(name, context); this.field = field; @@ -33,7 +33,7 @@ public Set createSubgroups(BibEntry entry) { switch (granularity) { case YEAR -> { DateGroup.extractYear(field, entry).ifPresent(y -> { - String key = String.format("%04d", y); + String key = "%04d".formatted(y); out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); }); } @@ -51,7 +51,6 @@ public Set createSubgroups(BibEntry entry) { @Override public AbstractGroup deepCopy() { return new AutomaticDateGroup(this.name.getValue(), this.context, this.field); - } @Override @@ -70,6 +69,4 @@ public boolean equals(Object o) { AutomaticDateGroup that = (AutomaticDateGroup) o; return Objects.equals(field, that.field) && granularity == that.granularity; } - - } diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java b/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java index 288fa9a8a16..07b32f40484 100644 --- a/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java +++ b/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java @@ -4,4 +4,4 @@ public enum DateGranularity { YEAR, MONTH, FULL_DATE -} \ No newline at end of file +} diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java index bfb7fc4215d..204c276abed 100644 --- a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java @@ -1,34 +1,23 @@ package org.jabref.model.groups; -import java.util.Collection; -import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; -import org.jabref.model.entry.Author; -import org.jabref.model.entry.AuthorList; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; import org.jabref.model.entry.field.StandardField; -import org.jabref.model.strings.LatexToUnicodeAdapter; -/** - * Matches based on a latex free last name in a specified field. The field is parsed as an author list and the last names are resolved of latex. - */ public class DateGroup extends AbstractGroup { - private final Field field; String date; // bucket key: "YYYY" or "YYYY-MM" or "YYYY-MM-DD" + private final Field field; public DateGroup(String groupName, GroupHierarchyType context, Field searchField, String date) { super(groupName, context); - field=searchField; + field = searchField; this.date = date; } - - static Optional extractYear(Field field, BibEntry bibEntry) { return bibEntry.getField(field) .flatMap(Date::parse) @@ -51,15 +40,23 @@ static Optional extractDate(Field field, BibEntry entry) { } } + /** + * Returns a date group key from {@code d}. + * Format is inferred from {@code dateKeyFormat} by dash count: 0→YYYY, 1→YYYY-MM, 2→YYYY-MM-DD. + * If required parts are missing, returns {@link java.util.Optional#empty()}. + * + * @param d the parsed date + * @param dateKeyFormat sample format used only for its number of dashes + * @return optional key string in the requested granularity + */ static Optional getDateKey(Date d, String dateKeyFormat) { int numOfdashes = (int) dateKeyFormat.chars().filter(ch -> ch == '-').count(); - // normalize parts Optional y = d.getYear(); return switch (numOfdashes) { - case 0 -> y.map(val -> String.format("%04d", val)); // "YYYY" + case 0 -> y.map(val -> "%04d".formatted(val)); // "YYYY" case 1 -> { // "YYYY-MM" if (d.getYear().isPresent() && d.getMonth().isPresent()) { - String out = String.format("%04d-%02d", d.getYear().get(), d.getMonth().get().getNumber()); + String out = "%04d-%02d".formatted(d.getYear().get(), d.getMonth().get().getNumber()); yield Optional.of(out); } else { yield Optional.empty(); @@ -67,7 +64,7 @@ static Optional getDateKey(Date d, String dateKeyFormat) { } case 2 -> { // "YYYY-MM-DD" if (d.getYear().isPresent() && d.getMonth().isPresent() && d.getDay().isPresent()) { - String out = String.format("%04d-%02d-%02d", + String out = "%04d-%02d-%02d".formatted( d.getYear().get(), d.getMonth().get().getNumber(), d.getDay().get()); @@ -80,7 +77,6 @@ static Optional getDateKey(Date d, String dateKeyFormat) { }; } - @Override public boolean contains(BibEntry entry) { return extractDate(this.field, entry) diff --git a/jablib/src/main/resources/csl-locales b/jablib/src/main/resources/csl-locales index fbb76f61297..8c149db3008 160000 --- a/jablib/src/main/resources/csl-locales +++ b/jablib/src/main/resources/csl-locales @@ -1 +1 @@ -Subproject commit fbb76f6129728a234c4e42ba598c7bbbedd73301 +Subproject commit 8c149db30089aa45956d3373dbb0b269f7bd104f diff --git a/jablib/src/main/resources/csl-styles b/jablib/src/main/resources/csl-styles index 72350250645..b61592ea58b 160000 --- a/jablib/src/main/resources/csl-styles +++ b/jablib/src/main/resources/csl-styles @@ -1 +1 @@ -Subproject commit 723502506457f6b3a1b202c2debb8b8cf085098a +Subproject commit b61592ea58b94d790fa36708048153989840552c diff --git a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java index 966e4633d19..3165c8a6ae4 100644 --- a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java +++ b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java @@ -1,14 +1,17 @@ package org.jabref.model.groups; + +import java.util.List; + import javafx.collections.FXCollections; + import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.field.StandardField; -import org.jabref.model.groups.*; -import org.junit.jupiter.api.Test; -import org.jabref.model.entry.Month; -import java.util.List; +import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class AutomaticDateGroupTest { @@ -47,8 +50,6 @@ void mergesSameYearAcrossEntries() { assertEquals("2023", merged.getFirst().getName()); } - - @Test void automaticDateGroupBuildsBucketAndFindsMatches() { // Parent automatic group using DATE field @@ -77,8 +78,6 @@ void automaticDateGroupBuildsBucketAndFindsMatches() { assertFalse(matches.contains(e3)); } - - // MONTH granularity from DATE field (YYYY-MM-DD -> bucket "YYYY-MM") @Test void createsMonthBucketFromDateField() { @@ -154,5 +153,4 @@ void fullDateBucketMatchesOnlyExactDate() { assertTrue(matches.contains(e3)); assertFalse(matches.contains(e2)); } - } From 968232f70b8eaecddc2c4f817c770a7334719cb5 Mon Sep 17 00:00:00 2001 From: Sheng Wang Date: Fri, 17 Oct 2025 04:18:17 +1100 Subject: [PATCH 04/20] Match OpenRewrite/formatting expected by CI --- .../jabref/model/groups/AutomaticDateGroup.java | 16 +++++++++------- .../java/org/jabref/model/groups/DateGroup.java | 12 +++++++----- .../model/groups/AutomaticDateGroupTest.java | 6 +++--- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java index 408801a81a5..86cbc438cb3 100644 --- a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java @@ -37,15 +37,17 @@ public Set createSubgroups(BibEntry entry) { out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); }); } - case MONTH -> DateGroup.getDateKey(d, "YYYY-MM").ifPresent(key -> { - out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); - }); - case FULL_DATE -> DateGroup.getDateKey(d, "YYYY-MM-DD").ifPresent(key -> { - out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); - }); + case MONTH -> + DateGroup.getDateKey(d, "YYYY-MM").ifPresent(key -> { + out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); + }); + case FULL_DATE -> + DateGroup.getDateKey(d, "YYYY-MM-DD").ifPresent(key -> { + out.add(new GroupTreeNode(new DateGroup(key, GroupHierarchyType.INDEPENDENT, field, key))); + }); } }); - return out; + return out; } @Override diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java index 204c276abed..524c9ad7eac 100644 --- a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java @@ -27,9 +27,9 @@ static Optional extractYear(Field field, BibEntry bibEntry) { static Optional extractDate(Field field, BibEntry entry) { boolean isCore = (field == StandardField.DATE) - || (field == StandardField.YEAR) - || (field == StandardField.MONTH) - || (field == StandardField.DAY); + || (field == StandardField.YEAR) + || (field == StandardField.MONTH) + || (field == StandardField.DAY); if (isCore) { // use alias resolution so DATE <-> YEAR/MONTH/DAY works either way @@ -53,7 +53,8 @@ static Optional getDateKey(Date d, String dateKeyFormat) { int numOfdashes = (int) dateKeyFormat.chars().filter(ch -> ch == '-').count(); Optional y = d.getYear(); return switch (numOfdashes) { - case 0 -> y.map(val -> "%04d".formatted(val)); // "YYYY" + case 0 -> + y.map(val -> "%04d".formatted(val)); // "YYYY" case 1 -> { // "YYYY-MM" if (d.getYear().isPresent() && d.getMonth().isPresent()) { String out = "%04d-%02d".formatted(d.getYear().get(), d.getMonth().get().getNumber()); @@ -73,7 +74,8 @@ static Optional getDateKey(Date d, String dateKeyFormat) { yield Optional.empty(); } } - default -> Optional.empty(); + default -> + Optional.empty(); }; } diff --git a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java index 3165c8a6ae4..91820fc6659 100644 --- a/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java +++ b/jablib/src/test/java/org/jabref/model/groups/AutomaticDateGroupTest.java @@ -106,9 +106,9 @@ void mergesSameMonthAcrossEntries() { // Find the 2024-10 bucket and assert matches GroupTreeNode bucket = merged.stream() - .filter(n -> "2024-10".equals(n.getName())) - .findFirst() - .orElseThrow(); + .filter(n -> "2024-10".equals(n.getName())) + .findFirst() + .orElseThrow(); var matches = bucket.findMatches(List.of(e1, e2, e3)); assertEquals(2, matches.size()); assertTrue(matches.contains(e1)); From b1d2d7d7b3c3260735bd2a72aa01bb1e901bd6c8 Mon Sep 17 00:00:00 2001 From: Xu Date: Fri, 17 Oct 2025 13:27:42 +1100 Subject: [PATCH 05/20] Add serialization support for AutomaticDateGroup - Add AUTOMATIC_DATE_GROUP_ID constant to MetadataSerializationConfiguration - Implement serializeAutomaticDateGroup() in GroupSerializer * Serializes field name * Serializes granularity (YEAR/MONTH/FULL_DATE) - Add AutomaticDateGroup case in serialization switch - Add getField() and getGranularity() methods to AutomaticDateGroup - Fix deepCopy() and hashCode() to include granularity Serialization format: AutomaticDateGroup:name;context;field;granularity;... --- .../logic/exporter/GroupSerializer.java | 15 ++++++++++++ .../MetadataSerializationConfiguration.java | 5 ++++ .../model/groups/AutomaticDateGroup.java | 24 +++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java b/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java index 5dbd0c1746f..854190b371d 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java @@ -7,6 +7,7 @@ import org.jabref.logic.util.io.FileUtil; import org.jabref.model.groups.AbstractGroup; import org.jabref.model.groups.AllEntriesGroup; +import org.jabref.model.groups.AutomaticDateGroup; import org.jabref.model.groups.AutomaticGroup; import org.jabref.model.groups.AutomaticKeywordGroup; import org.jabref.model.groups.AutomaticPersonsGroup; @@ -141,6 +142,8 @@ private String serializeGroup(AbstractGroup group) { serializeAutomaticKeywordGroup(keywordGroup); case AutomaticPersonsGroup personsGroup -> serializeAutomaticPersonsGroup(personsGroup); + case AutomaticDateGroup dateGroup -> + serializeAutomaticDateGroup(dateGroup); case TexGroup texGroup -> serializeTexGroup(texGroup); case null -> @@ -175,6 +178,18 @@ private String serializeAutomaticPersonsGroup(AutomaticPersonsGroup group) { return sb.toString(); } + private String serializeAutomaticDateGroup(AutomaticDateGroup group) { + StringBuilder sb = new StringBuilder(); + sb.append(MetadataSerializationConfiguration.AUTOMATIC_DATE_GROUP_ID); + appendAutomaticGroupDetails(sb, group); + sb.append(StringUtil.quote(group.getField().getName(), MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR, MetadataSerializationConfiguration.GROUP_QUOTE_CHAR)); + sb.append(MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR); + sb.append(StringUtil.quote(group.getGranularity().name(), MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR, MetadataSerializationConfiguration.GROUP_QUOTE_CHAR)); + sb.append(MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR); + appendGroupDetails(sb, group); + return sb.toString(); + } + private void appendAutomaticGroupDetails(StringBuilder builder, AutomaticGroup group) { builder.append(StringUtil.quote(group.getName(), MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR, MetadataSerializationConfiguration.GROUP_QUOTE_CHAR)); builder.append(MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR); diff --git a/jablib/src/main/java/org/jabref/logic/util/MetadataSerializationConfiguration.java b/jablib/src/main/java/org/jabref/logic/util/MetadataSerializationConfiguration.java index 81505b53649..887a6428d7f 100644 --- a/jablib/src/main/java/org/jabref/logic/util/MetadataSerializationConfiguration.java +++ b/jablib/src/main/java/org/jabref/logic/util/MetadataSerializationConfiguration.java @@ -74,6 +74,11 @@ public class MetadataSerializationConfiguration { */ public static final String TEX_GROUP_ID = "TexGroup:"; + /** + * Identifier for AutomaticDateGroup. + */ + public static final String AUTOMATIC_DATE_GROUP_ID = "AutomaticDateGroup:"; + private MetadataSerializationConfiguration() { } } diff --git a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java index 86cbc438cb3..d8b259b5bb4 100644 --- a/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/AutomaticDateGroup.java @@ -25,6 +25,26 @@ public AutomaticDateGroup(String name, GroupHierarchyType context, Field field, this.granularity = granularity; } + /** + * Gets the field used for date extraction. + * Required by GroupSerializer for serialization. + * + * @return The field (e.g., StandardField.DATE or StandardField.YEAR) + */ + public Field getField() { + return field; + } + + /** + * Gets the granularity level for date grouping. + * Required by GroupSerializer for serialization. + * + * @return The granularity (YEAR, MONTH, or FULL_DATE) + */ + public DateGranularity getGranularity() { + return granularity; + } + @Override public Set createSubgroups(BibEntry entry) { var out = new LinkedHashSet(); @@ -52,12 +72,12 @@ public Set createSubgroups(BibEntry entry) { @Override public AbstractGroup deepCopy() { - return new AutomaticDateGroup(this.name.getValue(), this.context, this.field); + return new AutomaticDateGroup(this.name.getValue(), this.context, this.field, this.granularity); } @Override public int hashCode() { - return Objects.hash(field); + return Objects.hash(field, granularity); } @Override From e42864068b1a497296a61447e7962dd68cccf62a Mon Sep 17 00:00:00 2001 From: Xu Date: Fri, 17 Oct 2025 15:12:25 +1100 Subject: [PATCH 06/20] Add deserialization support for AutomaticDateGroup - Add AutomaticDateGroup and DateGranularity imports to GroupsParser - Implement automaticDateGroupFromString() method * Parse name, context, field from serialized string * Parse and convert granularity string to DateGranularity enum * Create AutomaticDateGroup with all parameters * Restore group details (color, icon, description) - Add condition check in fromString() to handle AutomaticDateGroup This enables AutomaticDateGroup to be loaded from .bib files, completing the save/load cycle with serialization. --- .../logic/importer/util/GroupsParser.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/jablib/src/main/java/org/jabref/logic/importer/util/GroupsParser.java b/jablib/src/main/java/org/jabref/logic/importer/util/GroupsParser.java index 1cd522b2393..6327753b1be 100644 --- a/jablib/src/main/java/org/jabref/logic/importer/util/GroupsParser.java +++ b/jablib/src/main/java/org/jabref/logic/importer/util/GroupsParser.java @@ -16,8 +16,10 @@ import org.jabref.model.entry.field.Field; import org.jabref.model.entry.field.FieldFactory; import org.jabref.model.groups.AbstractGroup; +import org.jabref.model.groups.AutomaticDateGroup; import org.jabref.model.groups.AutomaticKeywordGroup; import org.jabref.model.groups.AutomaticPersonsGroup; +import org.jabref.model.groups.DateGranularity; import org.jabref.model.groups.ExplicitGroup; import org.jabref.model.groups.GroupHierarchyType; import org.jabref.model.groups.GroupTreeNode; @@ -118,6 +120,9 @@ public static AbstractGroup fromString(String s, Character keywordSeparator, Fil if (s.startsWith(MetadataSerializationConfiguration.AUTOMATIC_KEYWORD_GROUP_ID)) { return automaticKeywordGroupFromString(s); } + if (s.startsWith(MetadataSerializationConfiguration.AUTOMATIC_DATE_GROUP_ID)) { + return automaticDateGroupFromString(s); + } if (s.startsWith(MetadataSerializationConfiguration.TEX_GROUP_ID)) { return texGroupFromString(s, fileMonitor, metaData, userAndHost); } @@ -165,6 +170,23 @@ private static AbstractGroup automaticPersonsGroupFromString(String string) { return newGroup; } + private static AbstractGroup automaticDateGroupFromString(String string) { + if (!string.startsWith(MetadataSerializationConfiguration.AUTOMATIC_DATE_GROUP_ID)) { + throw new IllegalArgumentException("AutomaticDateGroup cannot be created from \"" + string + "\"."); + } + QuotedStringTokenizer tok = new QuotedStringTokenizer(string.substring(MetadataSerializationConfiguration.AUTOMATIC_DATE_GROUP_ID + .length()), MetadataSerializationConfiguration.GROUP_UNIT_SEPARATOR, MetadataSerializationConfiguration.GROUP_QUOTE_CHAR); + + String name = StringUtil.unquote(tok.nextToken(), MetadataSerializationConfiguration.GROUP_QUOTE_CHAR); + GroupHierarchyType context = GroupHierarchyType.getByNumberOrDefault(Integer.parseInt(tok.nextToken())); + Field field = FieldFactory.parseField(StringUtil.unquote(tok.nextToken(), MetadataSerializationConfiguration.GROUP_QUOTE_CHAR)); + String granularityString = StringUtil.unquote(tok.nextToken(), MetadataSerializationConfiguration.GROUP_QUOTE_CHAR); + DateGranularity granularity = DateGranularity.valueOf(granularityString); + AutomaticDateGroup newGroup = new AutomaticDateGroup(name, context, field, granularity); + addGroupDetails(tok, newGroup); + return newGroup; + } + private static AbstractGroup automaticKeywordGroupFromString(String string) { if (!string.startsWith(MetadataSerializationConfiguration.AUTOMATIC_KEYWORD_GROUP_ID)) { throw new IllegalArgumentException("KeywordGroup cannot be created from \"" + string + "\"."); From 0d48242b63636474e357e041cb57b4feeda80a75 Mon Sep 17 00:00:00 2001 From: Xu Date: Fri, 17 Oct 2025 17:37:15 +1100 Subject: [PATCH 07/20] Add comprehensive tests for AutomaticDateGroup serialization Serialization tests (GroupSerializerTest.java): - Test YEAR granularity serialization - Test MONTH granularity serialization - Test serialization with color, icon, and description - Verify format: AutomaticDateGroup:name;context;field;granularity;... Deserialization tests (GroupsParserTest.java): - Test parsing YEAR granularity - Test parsing MONTH granularity - Test parsing FULL_DATE granularity - Test parsing with color, icon, and description - Verify correct object reconstruction from string Total: 7 new test cases covering all granularity types and edge cases. --- .../logic/exporter/GroupSerializerTest.java | 26 +++++++++++++++ .../logic/importer/util/GroupsParserTest.java | 33 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/jablib/src/test/java/org/jabref/logic/exporter/GroupSerializerTest.java b/jablib/src/test/java/org/jabref/logic/exporter/GroupSerializerTest.java index d84daf16afd..e32f587e60f 100644 --- a/jablib/src/test/java/org/jabref/logic/exporter/GroupSerializerTest.java +++ b/jablib/src/test/java/org/jabref/logic/exporter/GroupSerializerTest.java @@ -12,9 +12,11 @@ import org.jabref.model.database.BibDatabase; import org.jabref.model.entry.field.StandardField; import org.jabref.model.groups.AllEntriesGroup; +import org.jabref.model.groups.AutomaticDateGroup; import org.jabref.model.groups.AutomaticGroup; import org.jabref.model.groups.AutomaticKeywordGroup; import org.jabref.model.groups.AutomaticPersonsGroup; +import org.jabref.model.groups.DateGranularity; import org.jabref.model.groups.ExplicitGroup; import org.jabref.model.groups.GroupHierarchyType; import org.jabref.model.groups.GroupTreeNode; @@ -127,6 +129,30 @@ void serializeSingleAutomaticPersonGroup() { assertEquals(List.of("0 AutomaticPersonsGroup:myAutomaticGroup;0;author;1;;;;"), serialization); } + @Test + void serializeSingleAutomaticDateGroupWithYearGranularity() { + AutomaticDateGroup group = new AutomaticDateGroup("By Year", GroupHierarchyType.INDEPENDENT, StandardField.DATE, DateGranularity.YEAR); + List serialization = groupSerializer.serializeTree(GroupTreeNode.fromGroup(group)); + assertEquals(List.of("0 AutomaticDateGroup:By Year;0;date;YEAR;1;;;;"), serialization); + } + + @Test + void serializeSingleAutomaticDateGroupWithMonthGranularity() { + AutomaticDateGroup group = new AutomaticDateGroup("By Month", GroupHierarchyType.INCLUDING, StandardField.DATE, DateGranularity.MONTH); + List serialization = groupSerializer.serializeTree(GroupTreeNode.fromGroup(group)); + assertEquals(List.of("0 AutomaticDateGroup:By Month;2;date;MONTH;1;;;;"), serialization); + } + + @Test + void serializeSingleAutomaticDateGroupWithColorAndIcon() { + AutomaticDateGroup group = new AutomaticDateGroup("Publications", GroupHierarchyType.INDEPENDENT, StandardField.YEAR, DateGranularity.YEAR); + group.setColor(Color.BLUE.toString()); + group.setIconName("calendar"); + group.setDescription("Group by publication year"); + List serialization = groupSerializer.serializeTree(GroupTreeNode.fromGroup(group)); + assertEquals(List.of("0 AutomaticDateGroup:Publications;0;year;YEAR;1;0x0000ffff;calendar;Group by publication year;"), serialization); + } + @Test void serializeSingleTexGroup() throws IOException { TexGroup group = TexGroup.create("myTexGroup", GroupHierarchyType.INDEPENDENT, Path.of("path", "To", "File"), new DefaultAuxParser(new BibDatabase()), new MetaData(), ""); diff --git a/jablib/src/test/java/org/jabref/logic/importer/util/GroupsParserTest.java b/jablib/src/test/java/org/jabref/logic/importer/util/GroupsParserTest.java index 2daaf300208..76572f39cdc 100644 --- a/jablib/src/test/java/org/jabref/logic/importer/util/GroupsParserTest.java +++ b/jablib/src/test/java/org/jabref/logic/importer/util/GroupsParserTest.java @@ -13,9 +13,11 @@ import org.jabref.model.database.BibDatabase; import org.jabref.model.entry.field.StandardField; import org.jabref.model.groups.AbstractGroup; +import org.jabref.model.groups.AutomaticDateGroup; import org.jabref.model.groups.AutomaticGroup; import org.jabref.model.groups.AutomaticKeywordGroup; import org.jabref.model.groups.AutomaticPersonsGroup; +import org.jabref.model.groups.DateGranularity; import org.jabref.model.groups.ExplicitGroup; import org.jabref.model.groups.GroupHierarchyType; import org.jabref.model.groups.GroupTreeNode; @@ -141,4 +143,35 @@ void fromStringParsesSearchGroup() throws ParseException { AbstractGroup parsed = GroupsParser.fromString("SearchGroup:Data;2;project=data|number|quant*;0;1;1;;;;;", ',', fileMonitor, metaData, "userAndHost"); assertEquals(expected, parsed); } + + @Test + void fromStringParsesAutomaticDateGroupWithYearGranularity() throws ParseException { + AutomaticDateGroup expected = new AutomaticDateGroup("By Year", GroupHierarchyType.INDEPENDENT, StandardField.DATE, DateGranularity.YEAR); + AbstractGroup parsed = GroupsParser.fromString("AutomaticDateGroup:By Year;0;date;YEAR;1;;;;", ',', fileMonitor, metaData, "userAndHost"); + assertEquals(expected, parsed); + } + + @Test + void fromStringParsesAutomaticDateGroupWithMonthGranularity() throws ParseException { + AutomaticDateGroup expected = new AutomaticDateGroup("By Month", GroupHierarchyType.INCLUDING, StandardField.YEAR, DateGranularity.MONTH); + AbstractGroup parsed = GroupsParser.fromString("AutomaticDateGroup:By Month;2;year;MONTH;1;;;;", ',', fileMonitor, metaData, "userAndHost"); + assertEquals(expected, parsed); + } + + @Test + void fromStringParsesAutomaticDateGroupWithFullDateGranularity() throws ParseException { + AutomaticDateGroup expected = new AutomaticDateGroup("By Date", GroupHierarchyType.REFINING, StandardField.DATE, DateGranularity.FULL_DATE); + AbstractGroup parsed = GroupsParser.fromString("AutomaticDateGroup:By Date;1;date;FULL_DATE;1;;;;", ',', fileMonitor, metaData, "userAndHost"); + assertEquals(expected, parsed); + } + + @Test + void fromStringParsesAutomaticDateGroupWithColorAndIcon() throws ParseException { + AutomaticDateGroup expected = new AutomaticDateGroup("Publications", GroupHierarchyType.INDEPENDENT, StandardField.YEAR, DateGranularity.YEAR); + expected.setColor(Color.BLUE.toString()); + expected.setIconName("calendar"); + expected.setDescription("Group by publication year"); + AbstractGroup parsed = GroupsParser.fromString("AutomaticDateGroup:Publications;0;year;YEAR;1;0x0000ffff;calendar;Group by publication year;", ',', fileMonitor, metaData, "userAndHost"); + assertEquals(expected, parsed); + } } From 3e211e8fb8dec3f5ce6152481dd85de91d9272df Mon Sep 17 00:00:00 2001 From: Xu Date: Fri, 17 Oct 2025 23:17:28 +1100 Subject: [PATCH 08/20] Fix: Add missing DateGranularity import to GroupSerializer --- .../src/main/java/org/jabref/logic/exporter/GroupSerializer.java | 1 + 1 file changed, 1 insertion(+) diff --git a/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java b/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java index 854190b371d..37404504552 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java @@ -11,6 +11,7 @@ import org.jabref.model.groups.AutomaticGroup; import org.jabref.model.groups.AutomaticKeywordGroup; import org.jabref.model.groups.AutomaticPersonsGroup; +import org.jabref.model.groups.DateGranularity; import org.jabref.model.groups.ExplicitGroup; import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.groups.KeywordGroup; From e44970d91edc87eb6408fb1c81563729a73731f4 Mon Sep 17 00:00:00 2001 From: Xu Date: Fri, 17 Oct 2025 23:39:05 +1100 Subject: [PATCH 09/20] Fix: Remove unused DateGranularity import from GroupSerializer Checkstyle reported DateGranularity as an unused import because we only use it through method return type inference (getGranularity().name()). The import is not needed since we don't declare any variables of this type. --- .../src/main/java/org/jabref/logic/exporter/GroupSerializer.java | 1 - 1 file changed, 1 deletion(-) diff --git a/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java b/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java index 37404504552..854190b371d 100644 --- a/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java +++ b/jablib/src/main/java/org/jabref/logic/exporter/GroupSerializer.java @@ -11,7 +11,6 @@ import org.jabref.model.groups.AutomaticGroup; import org.jabref.model.groups.AutomaticKeywordGroup; import org.jabref.model.groups.AutomaticPersonsGroup; -import org.jabref.model.groups.DateGranularity; import org.jabref.model.groups.ExplicitGroup; import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.groups.KeywordGroup; From 40aafb67ca8aaac3dce3e005c929d8a7af64c40f Mon Sep 17 00:00:00 2001 From: Xu Date: Sat, 18 Oct 2025 19:02:10 +1100 Subject: [PATCH 10/20] Add JavaDoc comments to DateGranularity and DateGroup - Document DateGranularity enum values - Add class-level documentation for DateGroup - Ensures these files are detected as changed files in CI for JBang testing --- .../main/java/org/jabref/model/groups/DateGranularity.java | 6 ++++++ jablib/src/main/java/org/jabref/model/groups/DateGroup.java | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java b/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java index 07b32f40484..4a8c45af1f1 100644 --- a/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java +++ b/jablib/src/main/java/org/jabref/model/groups/DateGranularity.java @@ -1,7 +1,13 @@ package org.jabref.model.groups; +/** + * Defines the granularity level for date-based automatic grouping. + */ public enum DateGranularity { + /** Group by year only (e.g., 2024) */ YEAR, + /** Group by year and month (e.g., 2024-01) */ MONTH, + /** Group by full date (e.g., 2024-01-15) */ FULL_DATE } diff --git a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java index 524c9ad7eac..ec764d5ec43 100644 --- a/jablib/src/main/java/org/jabref/model/groups/DateGroup.java +++ b/jablib/src/main/java/org/jabref/model/groups/DateGroup.java @@ -7,6 +7,10 @@ import org.jabref.model.entry.field.Field; import org.jabref.model.entry.field.StandardField; +/** + * Group that matches entries based on their date/year field value. + * Supports grouping by YEAR, MONTH, or FULL_DATE granularity. + */ public class DateGroup extends AbstractGroup { String date; // bucket key: "YYYY" or "YYYY-MM" or "YYYY-MM-DD" From 5d182073251129852cce4b15c5a68abef9de7daf Mon Sep 17 00:00:00 2001 From: Xingyu Date: Sun, 19 Oct 2025 01:26:11 +1100 Subject: [PATCH 11/20] Add UI components for automatic year groups --- .../org/jabref/gui/groups/GroupDialogView.java | 7 +++++++ .../org/jabref/gui/groups/GroupDialog.fxml | 18 ++++++++++++++++++ .../main/resources/l10n/JabRef_en.properties | 12 ++++++++++++ 3 files changed, 37 insertions(+) diff --git a/jabgui/src/main/java/org/jabref/gui/groups/GroupDialogView.java b/jabgui/src/main/java/org/jabref/gui/groups/GroupDialogView.java index 536d4b29265..d18a6196e4e 100644 --- a/jabgui/src/main/java/org/jabref/gui/groups/GroupDialogView.java +++ b/jabgui/src/main/java/org/jabref/gui/groups/GroupDialogView.java @@ -43,7 +43,9 @@ import org.jabref.logic.help.HelpFile; import org.jabref.logic.l10n.Localization; import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.field.Field; import org.jabref.model.groups.AbstractGroup; +import org.jabref.model.groups.DateGranularity; import org.jabref.model.groups.GroupHierarchyType; import org.jabref.model.groups.GroupTreeNode; import org.jabref.model.search.SearchFlags; @@ -101,6 +103,11 @@ public class GroupDialogView extends BaseDialog { @FXML private TextField texGroupFilePath; + @FXML private RadioButton dateRadioButton; + @FXML private ComboBox dateGroupFieldCombo; + @FXML private ComboBox dateGroupOptionCombo; + @FXML private CheckBox dateGroupIncludeEmpty; + private final EnumMap hierarchyText = new EnumMap<>(GroupHierarchyType.class); private final EnumMap hierarchyToolTip = new EnumMap<>(GroupHierarchyType.class); diff --git a/jabgui/src/main/resources/org/jabref/gui/groups/GroupDialog.fxml b/jabgui/src/main/resources/org/jabref/gui/groups/GroupDialog.fxml index 324ed9ca48a..ea905b0cbc1 100644 --- a/jabgui/src/main/resources/org/jabref/gui/groups/GroupDialog.fxml +++ b/jabgui/src/main/resources/org/jabref/gui/groups/GroupDialog.fxml @@ -99,6 +99,12 @@ + + + + + @@ -183,6 +189,18 @@