Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b6b8845
Fix
Alex1034 Nov 11, 2025
43dfef2
[PATCH] added support for date ranges for biblatex Date
Alex1034 Nov 11, 2025
0d179de
[style] Reformat Date and DateEditorViewModel to match JabRef guidelines
Alex1034 Nov 12, 2025
83651d6
[style] Reformat Date and DateEditorViewModel to match JabRef guidelines
Alex1034 Nov 12, 2025
ee996be
Merge branch 'main' into feature/date-range
Alex1034 Nov 12, 2025
239a521
[style] Reformat DateEditorViewModel to match JabRef guidelines
Alex1034 Nov 15, 2025
b7f9630
Refactor DateEditorViewModel
Alex1034 Nov 15, 2025
d8682f2
[style] Reformat DateEditorViewModel to match JabRef guidelines
Alex1034 Nov 15, 2025
fce6390
[style] Reformat DateEditorViewModel to match JabRef guidelines
Alex1034 Nov 15, 2025
95955bb
[style] Reformat DateEditorViewModel to match JabRef guidelines
Alex1034 Nov 15, 2025
d2a2435
Merge branch 'main' into feature/date-range
Alex1034 Nov 19, 2025
6fc0ca6
Add date range tests and changelog entry
Alex1034 Nov 20, 2025
cbb7988
Add date range tests and changelog entry
Alex1034 Nov 20, 2025
505923a
Repar changelog entry
Alex1034 Nov 20, 2025
bd5d007
Merge branch 'main' into feature/date-range
Alex1034 Nov 20, 2025
d449518
Add date range tests and changelog entry
Alex1034 Nov 20, 2025
fc10103
Add date range tests and changelog entry
Alex1034 Nov 20, 2025
ecf0922
Merge branch 'main' into feature/date-range
Alex1034 Nov 23, 2025
b6b98ca
Repair
Alex1034 Nov 23, 2025
7e081f7
Repair
Alex1034 Nov 23, 2025
5ea1f56
Repair
Alex1034 Nov 23, 2025
7549d96
Repair
Alex1034 Nov 23, 2025
bb8b928
Repair
Alex1034 Nov 23, 2025
6a76953
Change
Alex1034 Nov 23, 2025
b7f9822
Change
Alex1034 Nov 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added the possibility to configure the email provided to unpaywall. [#14340](https://github.com/JabRef/jabref/pull/14340)
- We added a "Regenerate" button for the AI chat allowing the user to make the language model reformulate its response to the previous prompt. [#12191](https://github.com/JabRef/jabref/issues/12191)
- We added support for transliteration of fields to English and automatic transliteration of generated citation key. [#11377](https://github.com/JabRef/jabref/issues/11377)
- Added support for BibLaTeX date ranges and improved normalization in the date editor. [#14289](https://github.com/JabRef/jabref/pull/14289)

### Changed

Expand All @@ -39,6 +40,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We fixed the checkbox in merge dialog "Treat duplicates the same way" to make it functional. [#14224](https://github.com/JabRef/jabref/pull/14224)
- Correct fallback window height (786 → 768) in JabRefGUI. [#14295](https://github.com/JabRef/jabref/pull/14295)
- We fixed an issue where keybindings could not be edited and saved. [#14237](https://github.com/JabRef/jabref/issues/14237)
- We fixed issues with BibLaTeX date range handling and improved the DateEditorViewModel tests. [#8902](https://github.com/JabRef/jabref/issues/8902)

### Removed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import java.time.DateTimeException;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.TemporalAccessor;

import javax.swing.undo.UndoManager;
Expand All @@ -12,49 +11,63 @@
import org.jabref.gui.autocompleter.SuggestionProvider;
import org.jabref.logic.integrity.FieldCheckers;
import org.jabref.logic.util.strings.StringUtil;
import org.jabref.model.entry.Date;
import org.jabref.model.entry.field.Field;

import org.jabref.model.entry.DateRangeUtil;

import java.time.LocalDate;
import java.util.Optional;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DateEditorViewModel extends AbstractEditorViewModel {

private static final Logger LOGGER = LoggerFactory.getLogger(DateEditorViewModel.class);

private final DateTimeFormatter dateFormatter;
private static final TemporalAccessor RANGE_SENTINEL = LocalDate.of(1, 1, 1);

public DateEditorViewModel(Field field, SuggestionProvider<?> suggestionProvider, DateTimeFormatter dateFormatter, FieldCheckers fieldCheckers, UndoManager undoManager) {
public DateEditorViewModel(Field field,
SuggestionProvider<?> suggestionProvider,
DateTimeFormatter dateFormatter,
FieldCheckers fieldCheckers,
UndoManager undoManager) {
super(field, suggestionProvider, fieldCheckers, undoManager);
this.dateFormatter = dateFormatter;
}

public StringConverter<TemporalAccessor> getDateToStringConverter() {
public Optional<String> getText() {
return Optional.ofNullable(text.get());
}

public void setText(String newValue) {
String sanitized = DateRangeUtil.sanitizeIncompleteRange(newValue);
text.set(sanitized);
}

public StringConverter<TemporalAccessor> getToStringConverter() {
return new StringConverter<>() {
@Override
public String toString(TemporalAccessor date) {
if (date != null) {
try {
return dateFormatter.format(date);
} catch (DateTimeException ex) {
LOGGER.error("Could not format date", ex);
return "";
}
} else {
public String toString(TemporalAccessor value) {
if (value == null || value.equals(RANGE_SENTINEL)) {
return "";
}

return dateFormatter.format(value);
}

@Override
public TemporalAccessor fromString(String string) {
if (StringUtil.isNotBlank(string)) {
try {
return dateFormatter.parse(string);
} catch (DateTimeParseException exception) {
// We accept all kinds of dates (not just in the format specified)
return Date.parse(string).map(Date::toTemporalAccessor).orElse(null);
}
} else {
return null;
public TemporalAccessor fromString(String text) {
if (StringUtil.isBlank(text)) {
return RANGE_SENTINEL;
}

try {
return dateFormatter.parse(text);
} catch (DateTimeException e) {
LOGGER.error("Error while parsing date {}", text, e);
return RANGE_SENTINEL;
}
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package org.jabref.gui.fieldeditors;

import java.time.LocalDate;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;

import javax.swing.undo.UndoManager;

import javafx.util.StringConverter;

import org.jabref.gui.autocompleter.SuggestionProvider;
import org.jabref.logic.integrity.FieldCheckers;
import org.jabref.model.entry.field.Field;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

class DateEditorViewModelTest {
private DateEditorViewModel viewModel;
private StringConverter<TemporalAccessor> dateToStringConverter;

private static final TemporalAccessor SENTINEL = LocalDate.of(1, 1, 1);

@BeforeEach
void setup() {
Field field = Mockito.mock(Field.class);
SuggestionProvider<?> suggestionProvider = Mockito.mock(SuggestionProvider.class);
FieldCheckers fieldCheckers = Mockito.mock(FieldCheckers.class);
UndoManager undoManager = new UndoManager();
DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE;

viewModel = new DateEditorViewModel(field, suggestionProvider, formatter, fieldCheckers, undoManager);
dateToStringConverter = viewModel.getDateToStringConverter();
}

@Test
void fromStringRecognizesDateRangeAndReturnsSentinel() {
StringConverter<TemporalAccessor> converter = viewModel.getDateToStringConverter();
TemporalAccessor result = converter.fromString("2020-01-01/2020-12-31");
assertEquals(SENTINEL, result);
}

@Test
void toStringReturnsOriginalRangeText() {
viewModel.textProperty().set("2020-01-01/2020-12-31");
StringConverter<TemporalAccessor> converter = viewModel.getDateToStringConverter();

String output = converter.toString(SENTINEL);
assertEquals("2020-01-01/2020-12-31", output);
}

@Test
void sanitizeTrailingSlash() {
StringConverter<TemporalAccessor> converter = viewModel.getDateToStringConverter();
TemporalAccessor result = converter.fromString("2020/");
Year year = Year.from(result);
assertEquals(2020, year.getValue());
}

@Test
void sanitizeLeadingSlash() {
StringConverter<TemporalAccessor> converter = viewModel.getDateToStringConverter();
TemporalAccessor result = converter.fromString("/2020");
Year year = Year.from(result);
assertEquals(2020, year.getValue());
}

@Test
void singleDateFormatsNormally() {
StringConverter<TemporalAccessor> converter = viewModel.getDateToStringConverter();
TemporalAccessor parsed = converter.fromString("2020-05-20");

String output = converter.toString(parsed);
assertEquals("2020-05-20", output);
}

@Test
void invalidDateReturnsNull() {
StringConverter<TemporalAccessor> converter = dateToStringConverter;
TemporalAccessor result = converter.fromString("invalid-date");

assertNull(result);
}
}
17 changes: 15 additions & 2 deletions jablib/src/main/java/org/jabref/model/entry/Date.java
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,21 @@ private static Optional<Date> parseDateWithSeason(String dateString) {
throw new DateTimeParseException("Invalid Date format for season", dateString, parts[0].length());
}

private boolean isRange() {
return endDate != null;
}

public String getNormalized() {
if (isRange()) {
String normalizedStartDate = NORMALIZED_DATE_FORMATTER.format(date);
String normalizedEndDate = NORMALIZED_DATE_FORMATTER.format(endDate);
return normalizedStartDate + "/" + normalizedEndDate;
}
return NORMALIZED_DATE_FORMATTER.format(date);
}

public Optional<Integer> getYear() {
return get(ChronoField.YEAR);
public Optional<TemporalAccessor> getEndDate() {
return Optional.ofNullable(endDate);
}

public Optional<Integer> get(ChronoField field) {
Expand Down Expand Up @@ -425,6 +434,10 @@ public String toString() {
'}';
}

public Optional<Integer> getYear() {
return get(ChronoField.YEAR);
}

@Override
public int hashCode() {
return Objects.hash(getYear(), getMonth(), getSeason(), getDay(), get(ChronoField.HOUR_OF_DAY), get(ChronoField.MINUTE_OF_HOUR), get(ChronoField.OFFSET_SECONDS));
Expand Down
29 changes: 29 additions & 0 deletions jablib/src/main/java/org/jabref/model/entry/DateRangeUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package org.jabref.model.entry;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


public class DateRangeUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(DateRangeUtil.class);

public static String sanitizeIncompleteRange(String dateString) {
if (dateString == null) {
return null;
}

String trimmed = dateString.trim();

if (trimmed.endsWith("/") && trimmed.matches(".+\\d{4}/")) {
LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed);
return trimmed.substring(0, trimmed.length() - 1).trim();
}

if (trimmed.startsWith("/") && trimmed.matches("/\\d{4}.+")) {
LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed);
return trimmed.substring(1).trim();
}

return dateString;
}
}
42 changes: 37 additions & 5 deletions jablib/src/test/java/org/jabref/model/entry/DateTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

class DateTest {

private static Stream<Arguments> validDates() {
return Stream.of(
Arguments.of(LocalDateTime.of(2018, Month.OCTOBER, 3, 7, 24), "2018-10-03T07:24"),
Expand Down Expand Up @@ -94,7 +96,7 @@ private static Stream<Arguments> invalidCornerCases() {
return Stream.of(
Arguments.of("", "input value not empty"),
Arguments.of("32-06-2014", "day of month exists [1]"),
Arguments.of("00-06-2014", "day of month exists [2]"),
Arguments.of("00-06-2014", "day of month exists[2]"),
Arguments.of("30-13-2014", "month exists [1]"),
Arguments.of("30-00-2014", "month exists [2]")
);
Expand Down Expand Up @@ -124,12 +126,42 @@ void parseDateNull() {
assertThrows(NullPointerException.class, () -> Date.parse(null));
}

// Date.parse() has been updated to defensively strip surrounding whitespace from input strings.
@Test
void parseShouldTrimValidDate() {
assertEquals(
Date.parse("2025-05-02"),
Date.parse(" 2025-05-02 ")
assertEquals(Date.parse("2025-05-02"), Date.parse(" 2025-05-02 "));
}

@Test
void normalizedRangeIsCorrect() {
Date d = new Date(
LocalDate.of(2020, 1, 1),
LocalDate.of(2020, 2, 1)
);

assertEquals("2020-01-01/2020-02-01", d.getNormalized());
}

@Test
void normalizedSingleDateIsCorrect() {
Date d = new Date(LocalDate.of(2020, 1, 1));

assertEquals("2020-01-01", d.getNormalized());
}

@Test
void endDatePresentForRange() {
Date d = new Date(
LocalDate.of(2020, 1, 1),
LocalDate.of(2020, 12, 31)
);

assertTrue(d.getEndDate().isPresent());
}

@Test
void endDateEmptyForSingleDate() {
Date d = new Date(LocalDate.of(2020, 1, 1));

assertTrue(d.getEndDate().isEmpty());
}
}
Loading