From b6b8845f5feb3dfce1d4cc3b6beb31c047c309f6 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Tue, 11 Nov 2025 21:27:32 +0100 Subject: [PATCH 01/19] Fix --- .../gui/fieldeditors/DateEditorViewModel.java | 51 ++- .../java/org/jabref/model/entry/Date.java | 305 ++++-------------- 2 files changed, 114 insertions(+), 242 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index 7e1c98b16f2..3ddae87c1eb 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -1,9 +1,11 @@ package org.jabref.gui.fieldeditors; import java.time.DateTimeException; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; +import java.util.Optional; import javax.swing.undo.UndoManager; @@ -14,7 +16,6 @@ import org.jabref.logic.util.strings.StringUtil; import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,8 +23,10 @@ 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; } @@ -32,7 +35,16 @@ public StringConverter getDateToStringConverter() { return new StringConverter<>() { @Override public String toString(TemporalAccessor date) { - if (date != null) { + String currentText = textProperty().get(); + if (currentText != null && !currentText.isEmpty()) { + Optional parsedDate = Date.parse(currentText); + if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { + // Text property contains a valid range, return it as-is + return currentText; + } + } + + if (date != null && date != RANGE_SENTINEL) { try { return dateFormatter.format(date); } catch (DateTimeException ex) { @@ -47,16 +59,43 @@ public String toString(TemporalAccessor date) { @Override public TemporalAccessor fromString(String string) { if (StringUtil.isNotBlank(string)) { + String sanitizedString = sanitizeIncompleteRange(string); + + Optional parsedDate = Date.parse(sanitizedString); + if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { + return RANGE_SENTINEL; + } + try { - return dateFormatter.parse(string); + return dateFormatter.parse(sanitizedString); } catch (DateTimeParseException exception) { - // We accept all kinds of dates (not just in the format specified) - return Date.parse(string).map(Date::toTemporalAccessor).orElse(null); + return parsedDate + .filter(date -> date.getEndDate().isEmpty()) + .map(Date::toTemporalAccessor) + .orElse(null); } } else { return null; } } + + private String sanitizeIncompleteRange(String dateString) { + String trimmed = dateString.trim(); + + // Remove the trailing slash (e.g., "2010/" → "2010") + if (trimmed.endsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { + LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed); + return trimmed.substring(0, trimmed.length() - 1).trim(); + } + + // Remove the leading slash (e.g., "/2010" → "2010") + if (trimmed.startsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { + LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed); + return trimmed.substring(1).trim(); + } + + return dateString; + } }; } } diff --git a/jablib/src/main/java/org/jabref/model/entry/Date.java b/jablib/src/main/java/org/jabref/model/entry/Date.java index 1c0a37d5996..5fd4129c6f8 100644 --- a/jablib/src/main/java/org/jabref/model/entry/Date.java +++ b/jablib/src/main/java/org/jabref/model/entry/Date.java @@ -27,39 +27,39 @@ public class Date { static { List formatStrings = Arrays.asList( - "uuuu-MM-dd'T'HH:mm[:ss][xxx][xx][X]", // covers 2018-10-03T07:24:14+03:00 - "uuuu-MM-dd'T'HH:m[:ss][xxx][xx][X]", // covers 2018-10-03T17:2 - "uuuu-MM-dd'T'H:mm[:ss][xxx][xx][X]", // covers 2018-10-03T7:24 - "uuuu-MM-dd'T'H:m[:ss][xxx][xx][X]", // covers 2018-10-03T7:7 - "uuuu-MM-dd'T'HH[:ss][xxx][xx][X]", // covers 2018-10-03T07 - "uuuu-MM-dd'T'H[:ss][xxx][xx][X]", // covers 2018-10-03T7 - "uuuu-M-d", // covers 2009-1-15 - "uuuu-M", // covers 2009-11 - "uuuu/M", // covers 2020/10 - "d-M-uuuu", // covers 15-1-2012 - "M-uuuu", // covers 1-2012 - "M/uuuu", // covers 9/2015 and 09/2015 - "M/uu", // covers 9/15 - "MMMM d, uuuu", // covers September 1, 2015 - "MMMM, uuuu", // covers September, 2015 - "MMMM uuuu", // covers September 2015 - "d.M.uuuu", // covers 15.1.2015 - "uuuu.M.d", // covers 2015.1.15 - "uuuu", // covers 2015 - "MMM, uuuu", // covers Jan, 2020 - "MMM. uuuu", // covers Oct. 2020 - "MMM uuuu", // covers Jan 2020 - "uuuu.MM.d", // covers 2015.10.15 - "d MMMM u/d MMMM u", // covers 20 January 2015/20 February 2015 - "d MMMM u", // covers 20 January 2015 + "uuuu-MM-dd'T'HH:mm[:ss][xxx][xx][X]", + "uuuu-MM-dd'T'HH:m[:ss][xxx][xx][X]", + "uuuu-MM-dd'T'H:mm[:ss][xxx][xx][X]", + "uuuu-MM-dd'T'H:m[:ss][xxx][xx][X]", + "uuuu-MM-dd'T'HH[:ss][xxx][xx][X]", + "uuuu-MM-dd'T'H[:ss][xxx][xx][X]", + "uuuu-M-d", + "uuuu-M", + "uuuu/M", + "d-M-uuuu", + "M-uuuu", + "M/uuuu", + "M/uu", + "MMMM d, uuuu", + "MMMM, uuuu", + "MMMM uuuu", + "d.M.uuuu", + "uuuu.M.d", + "uuuu", + "MMM, uuuu", + "MMM. uuuu", + "MMM uuuu", + "uuuu.MM.d", + "d MMMM u/d MMMM u", + "d MMMM u", "d MMMM u / d MMMM u", - "u'-'", // covers 2015- - "u'?'", // covers 2023? - "u G", // covers 1 BC and 1 AD - "uuuu G", // covers 0030 BC and 0005 AD - "u G/u G", // covers 30 BC/5 AD - "uuuu G/uuuu G", // covers 0030 BC/0005 AD - "uuuu-MM G/uuuu-MM G" // covers 0030-01 BC/0005-02 AD + "u'-'", + "u'?'", + "u G", + "uuuu G", + "u G/u G", + "uuuu G/uuuu G", + "uuuu-MM G/uuuu-MM G" ); SIMPLE_DATE_FORMATS = formatStrings.stream() @@ -69,17 +69,10 @@ public class Date { (builder, formatterBuilder) -> builder.append(formatterBuilder.toFormatter())) .toFormatter(Locale.US); - /* - * There is also {@link org.jabref.model.entry.Date#parse(java.lang.String)}. - * The regex of that method cannot be used as we parse single dates here and that method parses: - * i) date ranges - * ii) two dates separated by '/' - * Additionally, parse method requires the reviewed String to hold only a date. - */ - DATE_REGEX = "\\d{4}-\\d{1,2}-\\d{1,2}" + // covers YYYY-MM-DD, YYYY-M-DD, YYYY-MM-D, YYYY-M-D - "|\\d{4}\\.\\d{1,2}\\.\\d{1,2}|" + // covers YYYY.MM.DD, YYYY.M.DD, YYYY.MM.D, YYYY.M.D - "(January|February|March|April|May|June|July|August|September|" + - "October|November|December) \\d{1,2}, \\d{4}"; // covers Month DD, YYYY & Month D, YYYY + DATE_REGEX = "\\d{4}-\\d{1,2}-\\d{1,2}" + + "|\\d{4}\\.\\d{1,2}\\.\\d{1,2}|" + + "(January|February|March|April|May|June|July|August|September|" + + "October|November|December) \\d{1,2}, \\d{4}"; } private final TemporalAccessor date; @@ -100,16 +93,10 @@ public Date(int year) { public Date(TemporalAccessor date) { this.date = date; - endDate = null; - season = null; + this.endDate = null; + this.season = null; } - /** - * Creates a Date from date and endDate. - * - * @param date the start date - * @param endDate the start date - */ public Date(TemporalAccessor date, TemporalAccessor endDate) { this.date = date; this.endDate = endDate; @@ -122,12 +109,6 @@ public Date(TemporalAccessor date, Season season) { this.endDate = null; } - /** - * Creates a Date from date and endDate. - * - * @param dateString the string to extract the date information - * @throws DateTimeParseException if dataString is mal-formatted - */ public static Optional parse(@NonNull String dateString) { dateString = dateString.strip(); @@ -135,127 +116,18 @@ public static Optional parse(@NonNull String dateString) { return Optional.empty(); } - // if dateString has range format, treat as date range + // Parse date ranges if (dateString.matches( - "\\d{4}/\\d{4}|" + // uuuu/uuuu - "\\d{4}-\\d{2}/\\d{4}-\\d{2}|" + // uuuu-mm/uuuu-mm - "\\d{4}-\\d{2}-\\d{2}/\\d{4}-\\d{2}-\\d{2}|" + // uuuu-mm-dd/uuuu-mm-dd - "(?i)(January|February|March|April|May|June|July|August|September|October|November|December)" + - "( |\\-)(\\d{1,4})/(January|February|March|April|May|June|July|August|September|October|November" + - "|December)( |\\-)(\\d{1,4})(?i-)|" + // January 2015/January 2015 - "(?i)(\\d{1,2})( )(January|February|March|April|May|June|July|August|September|October|November|December)" + - "( |\\-)(\\d{1,4})/(\\d{1,2})( )" + - "(January|February|March|April|May|June|July|August|September|October|November|December)" + - "( |\\-)(\\d{1,4})(?i-)" // 20 January 2015/20 January 2015 - )) { + "\\d{4}/\\d{4}|" + + "\\d{4}-\\d{2}/\\d{4}-\\d{2}|" + + "\\d{4}-\\d{2}-\\d{2}/\\d{4}-\\d{2}-\\d{2}")) { try { String[] strDates = dateString.split("/"); TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(strDates[0].strip()); TemporalAccessor parsedEndDate = SIMPLE_DATE_FORMATS.parse(strDates[1].strip()); return Optional.of(new Date(parsedDate, parsedEndDate)); } catch (DateTimeParseException e) { - LOGGER.warn("Invalid Date format for range", e); - return Optional.empty(); - } - } else if (dateString.matches( - "\\d{4} / \\d{4}|" + // uuuu / uuuu - "\\d{4}-\\d{2} / \\d{4}-\\d{2}|" + // uuuu-mm / uuuu-mm - "\\d{4}-\\d{2}-\\d{2} / \\d{4}-\\d{2}-\\d{2}|" + // uuuu-mm-dd / uuuu-mm-dd - "(?i)(January|February|March|April|May|June|July|August|September|October|November|December)" + - "( |\\-)(\\d{1,4}) / (January|February|March|April|May|June|July|August|September|October|November" + - "|December)( |\\-)(\\d{1,4})(?i-)|" + // January 2015/January 2015 - "(?i)(\\d{1,2})( )(January|February|March|April|May|June|July|August|September|October|November|December)" + - "( |\\-)(\\d{1,4}) / (\\d{1,2})( )" + - "(January|February|March|April|May|June|July|August|September|October|November|December)" + - "( |\\-)(\\d{1,4})(?i-)" // 20 January 2015/20 January 2015 - )) { - try { - String[] strDates = dateString.split(" / "); - TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(strDates[0].strip()); - TemporalAccessor parsedEndDate = SIMPLE_DATE_FORMATS.parse(strDates[1].strip()); - return Optional.of(new Date(parsedDate, parsedEndDate)); - } catch (DateTimeParseException e) { - LOGGER.warn("Invalid Date format range", e); - return Optional.empty(); - } - } else if (dateString.matches( - "\\d{1,4} BC/\\d{1,4} AD|" + // 30 BC/5 AD and 0030 BC/0005 AD - "\\d{1,4} BC/\\d{1,4} BC|" + // 30 BC/10 BC and 0030 BC/0010 BC - "\\d{1,4} AD/\\d{1,4} AD|" + // 5 AD/10 AD and 0005 AD/0010 AD - "\\d{1,4}-\\d{1,2} BC/\\d{1,4}-\\d{1,2} AD|" + // 5 AD/10 AD and 0005 AD/0010 AD - "\\d{1,4}-\\d{1,2} BC/\\d{1,4}-\\d{1,2} BC|" + // 5 AD/10 AD and 0005 AD/0010 AD - "\\d{1,4}-\\d{1,2} AD/\\d{1,4}-\\d{1,2} AD" // 5 AD/10 AD and 0005 AD/0010 AD - )) { - try { - String[] strDates = dateString.split("/"); - TemporalAccessor parsedDate = parseDateWithEraIndicator(strDates[0]); - TemporalAccessor parsedEndDate = parseDateWithEraIndicator(strDates[1]); - return Optional.of(new Date(parsedDate, parsedEndDate)); - } catch (DateTimeParseException e) { - LOGGER.warn("Invalid Date format range", e); - return Optional.empty(); - } - } else if (dateString.matches( - "\\d{1,4} BC / \\d{1,4} AD|" + // 30 BC / 5 AD and 0030 BC / 0005 AD - "\\d{1,4} BC / \\d{1,4} BC|" + // 30 BC / 10 BC and 0030 BC / 0010 BC - "\\d{1,4} AD / \\d{1,4} AD|" + // 5 AD / 10 AD and 0005 AD / 0010 AD - "\\d{1,4}-\\d{1,2} BC / \\d{1,4}-\\d{1,2} AD|" + // 5 AD/10 AD and 0005 AD/0010 AD - "\\d{1,4}-\\d{1,2} BC / \\d{1,4}-\\d{1,2} BC|" + // 5 AD/10 AD and 0005 AD/0010 AD - "\\d{1,4}-\\d{1,2} AD / \\d{1,4}-\\d{1,2} AD" // 5 AD/10 AD and 0005 AD/0010 AD - )) { - try { - String[] strDates = dateString.split(" / "); - TemporalAccessor parsedDate = parseDateWithEraIndicator(strDates[0]); - TemporalAccessor parsedEndDate = parseDateWithEraIndicator(strDates[1]); - return Optional.of(new Date(parsedDate, parsedEndDate)); - } catch (DateTimeParseException e) { - LOGGER.warn("Invalid Date format range", e); - return Optional.empty(); - } - } - - // if dateString is single year - if (dateString.matches("\\d{4}-|\\d{4}\\?")) { - try { - String year = dateString.substring(0, dateString.length() - 1); - TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(year); - return Optional.of(new Date(parsedDate)); - } catch (DateTimeParseException e) { - LOGGER.debug("Invalid Date format", e); - return Optional.empty(); - } - } - - // handle the new date formats with era indicators - if (dateString.matches( - "\\d{1,4} BC|" + // covers 1 BC - "\\d{1,4} AD|" + // covers 1 BC - "\\d{1,4}-\\d{1,2} BC|" + // covers 0030-01 BC - "\\d{1,4}-\\d{1,2} AD" // covers 0005-01 AD - )) { - try { - // Parse the date with era indicator - TemporalAccessor date = parseDateWithEraIndicator(dateString); - return Optional.of(new Date(date)); - } catch (DateTimeParseException e) { - LOGGER.warn("Invalid Date format with era indicator", e); - return Optional.empty(); - } - } - // handle date whose month is represented as a season. - if (dateString.matches( - "^(\\d{1,4})-(\\d{1,2})$" // covers 2025-21 - )) { - try { - // parse the date with season - Optional optional = parseDateWithSeason(dateString); - if (optional.isPresent()) { - return optional; - } - // else, just pass - } catch (DateTimeParseException e) { - // neither month nor season. - LOGGER.debug("Invalid Date format", e); + LOGGER.warn("Invalid date format for range", e); return Optional.empty(); } } @@ -264,7 +136,7 @@ public static Optional parse(@NonNull String dateString) { TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(dateString); return Optional.of(new Date(parsedDate)); } catch (DateTimeParseException e) { - LOGGER.debug("Invalid Date format", e); + LOGGER.debug("Invalid date format", e); return Optional.empty(); } } @@ -287,7 +159,6 @@ public static Optional parse(Optional yearValue, } else { date = year.get(); } - return Optional.of(new Date(date)); } @@ -302,53 +173,21 @@ private static Optional convertToInt(String value) { } } - /** - * Create a date with a string with era indicator. - * - * @param dateString the string which contain era indicator to extract the date information - * @return the date information with TemporalAccessor type - */ - private static TemporalAccessor parseDateWithEraIndicator(String dateString) { - String yearString = dateString.strip().substring(0, dateString.length() - 2); - - String[] parts = yearString.split("-"); - int year = Integer.parseInt(parts[0].strip()); - - if (dateString.endsWith("BC")) { - year = 1 - year; - } - if (parts.length > 1) { - int month = Integer.parseInt(parts[1].strip()); - return YearMonth.of(year, month); + public String getNormalized() { + if (isRange()) { + String normalizedStartDate = NORMALIZED_DATE_FORMATTER.format(date); + String normalizedEndDate = NORMALIZED_DATE_FORMATTER.format(endDate); + return normalizedStartDate + "/" + normalizedEndDate; } - return Year.of(year); + return NORMALIZED_DATE_FORMATTER.format(date); } - /** - * Create a date whose month is represented as a season. - * - * @param dateString the string which contain season to extract the date information - * @return the date information with TemporalAccessor type - */ - private static Optional parseDateWithSeason(String dateString) { - String[] parts = dateString.split("-"); - int monthOrSeason = Integer.parseInt(parts[1].strip()); - // Is month, don't parse it here. - if (monthOrSeason >= 1 && monthOrSeason <= 12) { - return Optional.empty(); - } - // check month is season - Optional optional = Season.getSeasonByNumber(monthOrSeason); - if (optional.isPresent()) { - int year = Integer.parseInt(parts[0].strip()); - // use month as season - return Optional.of(new Date(Year.of(year), optional.get())); - } - throw new DateTimeParseException("Invalid Date format for season", dateString, parts[0].length()); + public Optional getEndDate() { + return Optional.ofNullable(endDate); } - public String getNormalized() { - return NORMALIZED_DATE_FORMATTER.format(date); + private boolean isRange() { + return endDate != null; } public Optional getYear() { @@ -389,25 +228,26 @@ public boolean equals(Object o) { } Date date1 = (Date) o; - return Objects.equals(getYear(), date1.getYear()) && - Objects.equals(getMonth(), date1.getMonth()) && - Objects.equals(getSeason(), date1.getSeason()) && - Objects.equals(getDay(), date1.getDay()) && - Objects.equals(get(ChronoField.HOUR_OF_DAY), date1.get(ChronoField.HOUR_OF_DAY)) && - Objects.equals(get(ChronoField.MINUTE_OF_HOUR), date1.get(ChronoField.MINUTE_OF_HOUR)) && - Objects.equals(get(ChronoField.SECOND_OF_DAY), date1.get(ChronoField.SECOND_OF_DAY)) && - Objects.equals(get(ChronoField.OFFSET_SECONDS), date1.get(ChronoField.OFFSET_SECONDS)); + return Objects.equals(getYear(), date1.getYear()) + && Objects.equals(getMonth(), date1.getMonth()) + && Objects.equals(getSeason(), date1.getSeason()) + && Objects.equals(getDay(), date1.getDay()) + && Objects.equals(get(ChronoField.HOUR_OF_DAY), date1.get(ChronoField.HOUR_OF_DAY)) + && Objects.equals(get(ChronoField.MINUTE_OF_HOUR), date1.get(ChronoField.MINUTE_OF_HOUR)) + && Objects.equals(get(ChronoField.SECOND_OF_DAY), date1.get(ChronoField.SECOND_OF_DAY)) + && Objects.equals(get(ChronoField.OFFSET_SECONDS), date1.get(ChronoField.OFFSET_SECONDS)); + } + + @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)); } @Override public String toString() { String formattedDate = date.toString(); - // If there is a season, then only the year and month fields will have values, - // and the month corresponds to the season. - // Here is no need to check the second, hour, and month, day fields. if (season != null) { - // The Date standard library does not have any API for handling seasons, - // so here is no compact form. return "Date{" + "date=" + formattedDate + ", " + "season=" + season.getName() + @@ -420,13 +260,6 @@ public String toString() { } else if (date.isSupported(ChronoField.MONTH_OF_YEAR) && date.isSupported(ChronoField.DAY_OF_MONTH)) { formattedDate = DateTimeFormatter.ISO_DATE.format(date); } - return "Date{" + - "date=" + formattedDate + - '}'; - } - - @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)); + return "Date{" + "date=" + formattedDate + '}'; } } From 43dfef263c57737164249ff69b4f6dcc680acb3e Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Tue, 11 Nov 2025 22:12:00 +0100 Subject: [PATCH 02/19] [PATCH] added support for date ranges for biblatex Date Fixes JabRef/jabref#8902 --- .../gui/fieldeditors/DateEditorViewModel.java | 58 ++-- .../java/org/jabref/model/entry/Date.java | 313 ++++++++++++++---- 2 files changed, 275 insertions(+), 96 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index 3ddae87c1eb..a6ec5f19083 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -1,11 +1,9 @@ package org.jabref.gui.fieldeditors; import java.time.DateTimeException; -import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; -import java.util.Optional; import javax.swing.undo.UndoManager; @@ -16,6 +14,8 @@ import org.jabref.logic.util.strings.StringUtil; import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; +import java.time.LocalDate; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,9 +24,7 @@ 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; } @@ -39,36 +37,56 @@ public String toString(TemporalAccessor date) { if (currentText != null && !currentText.isEmpty()) { Optional parsedDate = Date.parse(currentText); if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { - // Text property contains a valid range, return it as-is + return currentText; } } - if (date != null && date != RANGE_SENTINEL) { try { return dateFormatter.format(date); } catch (DateTimeException ex) { - LOGGER.error("Could not format date", ex); + LOGGER.debug("Cannot format date", ex); return ""; } - } else { - return ""; } + return ""; } + private String sanitizeIncompleteRange(String dateString) { + String trimmed = dateString.trim(); + + // Remove the trailing slash (e.g., "2010/" → "2010") + if (trimmed.endsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { + LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed); + return trimmed.substring(0, trimmed.length() - 1).trim(); + } + + // Remove the leading slash (e.g., "/2010" → "2010") + if (trimmed.startsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { + LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed); + return trimmed.substring(1).trim(); + } + return dateString; + } @Override public TemporalAccessor fromString(String string) { if (StringUtil.isNotBlank(string)) { + // ✅ NEW: Sanitize incomplete ranges (e.g., "2010/" → "2010") String sanitizedString = sanitizeIncompleteRange(string); + // Priority 1: Check if it's a date range Optional parsedDate = Date.parse(sanitizedString); if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { + // It's a range! Return sentinel to signal this is a special case + // The toString() method will retrieve the value from textProperty() return RANGE_SENTINEL; } + // Priority 2: Try strict format parsing try { return dateFormatter.parse(sanitizedString); } catch (DateTimeParseException exception) { + // Priority 3: Try flexible parsing (single dates only) return parsedDate .filter(date -> date.getEndDate().isEmpty()) .map(Date::toTemporalAccessor) @@ -78,24 +96,8 @@ public TemporalAccessor fromString(String string) { return null; } } - - private String sanitizeIncompleteRange(String dateString) { - String trimmed = dateString.trim(); - - // Remove the trailing slash (e.g., "2010/" → "2010") - if (trimmed.endsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { - LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed); - return trimmed.substring(0, trimmed.length() - 1).trim(); - } - - // Remove the leading slash (e.g., "/2010" → "2010") - if (trimmed.startsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { - LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed); - return trimmed.substring(1).trim(); - } - - return dateString; - } }; + } + } diff --git a/jablib/src/main/java/org/jabref/model/entry/Date.java b/jablib/src/main/java/org/jabref/model/entry/Date.java index 5fd4129c6f8..a71567100d8 100644 --- a/jablib/src/main/java/org/jabref/model/entry/Date.java +++ b/jablib/src/main/java/org/jabref/model/entry/Date.java @@ -27,39 +27,39 @@ public class Date { static { List formatStrings = Arrays.asList( - "uuuu-MM-dd'T'HH:mm[:ss][xxx][xx][X]", - "uuuu-MM-dd'T'HH:m[:ss][xxx][xx][X]", - "uuuu-MM-dd'T'H:mm[:ss][xxx][xx][X]", - "uuuu-MM-dd'T'H:m[:ss][xxx][xx][X]", - "uuuu-MM-dd'T'HH[:ss][xxx][xx][X]", - "uuuu-MM-dd'T'H[:ss][xxx][xx][X]", - "uuuu-M-d", - "uuuu-M", - "uuuu/M", - "d-M-uuuu", - "M-uuuu", - "M/uuuu", - "M/uu", - "MMMM d, uuuu", - "MMMM, uuuu", - "MMMM uuuu", - "d.M.uuuu", - "uuuu.M.d", - "uuuu", - "MMM, uuuu", - "MMM. uuuu", - "MMM uuuu", - "uuuu.MM.d", - "d MMMM u/d MMMM u", - "d MMMM u", + "uuuu-MM-dd'T'HH:mm[:ss][xxx][xx][X]", // covers 2018-10-03T07:24:14+03:00 + "uuuu-MM-dd'T'HH:m[:ss][xxx][xx][X]", // covers 2018-10-03T17:2 + "uuuu-MM-dd'T'H:mm[:ss][xxx][xx][X]", // covers 2018-10-03T7:24 + "uuuu-MM-dd'T'H:m[:ss][xxx][xx][X]", // covers 2018-10-03T7:7 + "uuuu-MM-dd'T'HH[:ss][xxx][xx][X]", // covers 2018-10-03T07 + "uuuu-MM-dd'T'H[:ss][xxx][xx][X]", // covers 2018-10-03T7 + "uuuu-M-d", // covers 2009-1-15 + "uuuu-M", // covers 2009-11 + "uuuu/M", // covers 2020/10 + "d-M-uuuu", // covers 15-1-2012 + "M-uuuu", // covers 1-2012 + "M/uuuu", // covers 9/2015 and 09/2015 + "M/uu", // covers 9/15 + "MMMM d, uuuu", // covers September 1, 2015 + "MMMM, uuuu", // covers September, 2015 + "MMMM uuuu", // covers September 2015 + "d.M.uuuu", // covers 15.1.2015 + "uuuu.M.d", // covers 2015.1.15 + "uuuu", // covers 2015 + "MMM, uuuu", // covers Jan, 2020 + "MMM. uuuu", // covers Oct. 2020 + "MMM uuuu", // covers Jan 2020 + "uuuu.MM.d", // covers 2015.10.15 + "d MMMM u/d MMMM u", // covers 20 January 2015/20 February 2015 + "d MMMM u", // covers 20 January 2015 "d MMMM u / d MMMM u", - "u'-'", - "u'?'", - "u G", - "uuuu G", - "u G/u G", - "uuuu G/uuuu G", - "uuuu-MM G/uuuu-MM G" + "u'-'", // covers 2015- + "u'?'", // covers 2023? + "u G", // covers 1 BC and 1 AD + "uuuu G", // covers 0030 BC and 0005 AD + "u G/u G", // covers 30 BC/5 AD + "uuuu G/uuuu G", // covers 0030 BC/0005 AD + "uuuu-MM G/uuuu-MM G" // covers 0030-01 BC/0005-02 AD ); SIMPLE_DATE_FORMATS = formatStrings.stream() @@ -69,10 +69,17 @@ public class Date { (builder, formatterBuilder) -> builder.append(formatterBuilder.toFormatter())) .toFormatter(Locale.US); - DATE_REGEX = "\\d{4}-\\d{1,2}-\\d{1,2}" - + "|\\d{4}\\.\\d{1,2}\\.\\d{1,2}|" - + "(January|February|March|April|May|June|July|August|September|" - + "October|November|December) \\d{1,2}, \\d{4}"; + /* + * There is also {@link org.jabref.model.entry.Date#parse(java.lang.String)}. + * The regex of that method cannot be used as we parse single dates here and that method parses: + * i) date ranges + * ii) two dates separated by '/' + * Additionally, parse method requires the reviewed String to hold only a date. + */ + DATE_REGEX = "\\d{4}-\\d{1,2}-\\d{1,2}" + // covers YYYY-MM-DD, YYYY-M-DD, YYYY-MM-D, YYYY-M-D + "|\\d{4}\\.\\d{1,2}\\.\\d{1,2}|" + // covers YYYY.MM.DD, YYYY.M.DD, YYYY.MM.D, YYYY.M.D + "(January|February|March|April|May|June|July|August|September|" + + "October|November|December) \\d{1,2}, \\d{4}"; // covers Month DD, YYYY & Month D, YYYY } private final TemporalAccessor date; @@ -93,10 +100,16 @@ public Date(int year) { public Date(TemporalAccessor date) { this.date = date; - this.endDate = null; - this.season = null; + endDate = null; + season = null; } + /** + * Creates a Date from date and endDate. + * + * @param date the start date + * @param endDate the start date + */ public Date(TemporalAccessor date, TemporalAccessor endDate) { this.date = date; this.endDate = endDate; @@ -109,6 +122,12 @@ public Date(TemporalAccessor date, Season season) { this.endDate = null; } + /** + * Creates a Date from date and endDate. + * + * @param dateString the string to extract the date information + * @throws DateTimeParseException if dataString is mal-formatted + */ public static Optional parse(@NonNull String dateString) { dateString = dateString.strip(); @@ -116,18 +135,127 @@ public static Optional parse(@NonNull String dateString) { return Optional.empty(); } - // Parse date ranges + // if dateString has range format, treat as date range if (dateString.matches( - "\\d{4}/\\d{4}|" - + "\\d{4}-\\d{2}/\\d{4}-\\d{2}|" - + "\\d{4}-\\d{2}-\\d{2}/\\d{4}-\\d{2}-\\d{2}")) { + "\\d{4}/\\d{4}|" + // uuuu/uuuu + "\\d{4}-\\d{2}/\\d{4}-\\d{2}|" + // uuuu-mm/uuuu-mm + "\\d{4}-\\d{2}-\\d{2}/\\d{4}-\\d{2}-\\d{2}|" + // uuuu-mm-dd/uuuu-mm-dd + "(?i)(January|February|March|April|May|June|July|August|September|October|November|December)" + + "( |\\-)(\\d{1,4})/(January|February|March|April|May|June|July|August|September|October|November" + + "|December)( |\\-)(\\d{1,4})(?i-)|" + // January 2015/January 2015 + "(?i)(\\d{1,2})( )(January|February|March|April|May|June|July|August|September|October|November|December)" + + "( |\\-)(\\d{1,4})/(\\d{1,2})( )" + + "(January|February|March|April|May|June|July|August|September|October|November|December)" + + "( |\\-)(\\d{1,4})(?i-)" // 20 January 2015/20 January 2015 + )) { try { String[] strDates = dateString.split("/"); TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(strDates[0].strip()); TemporalAccessor parsedEndDate = SIMPLE_DATE_FORMATS.parse(strDates[1].strip()); return Optional.of(new Date(parsedDate, parsedEndDate)); } catch (DateTimeParseException e) { - LOGGER.warn("Invalid date format for range", e); + LOGGER.warn("Invalid Date format for range", e); + return Optional.empty(); + } + } else if (dateString.matches( + "\\d{4} / \\d{4}|" + // uuuu / uuuu + "\\d{4}-\\d{2} / \\d{4}-\\d{2}|" + // uuuu-mm / uuuu-mm + "\\d{4}-\\d{2}-\\d{2} / \\d{4}-\\d{2}-\\d{2}|" + // uuuu-mm-dd / uuuu-mm-dd + "(?i)(January|February|March|April|May|June|July|August|September|October|November|December)" + + "( |\\-)(\\d{1,4}) / (January|February|March|April|May|June|July|August|September|October|November" + + "|December)( |\\-)(\\d{1,4})(?i-)|" + // January 2015/January 2015 + "(?i)(\\d{1,2})( )(January|February|March|April|May|June|July|August|September|October|November|December)" + + "( |\\-)(\\d{1,4}) / (\\d{1,2})( )" + + "(January|February|March|April|May|June|July|August|September|October|November|December)" + + "( |\\-)(\\d{1,4})(?i-)" // 20 January 2015/20 January 2015 + )) { + try { + String[] strDates = dateString.split(" / "); + TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(strDates[0].strip()); + TemporalAccessor parsedEndDate = SIMPLE_DATE_FORMATS.parse(strDates[1].strip()); + return Optional.of(new Date(parsedDate, parsedEndDate)); + } catch (DateTimeParseException e) { + LOGGER.warn("Invalid Date format range", e); + return Optional.empty(); + } + } else if (dateString.matches( + "\\d{1,4} BC/\\d{1,4} AD|" + // 30 BC/5 AD and 0030 BC/0005 AD + "\\d{1,4} BC/\\d{1,4} BC|" + // 30 BC/10 BC and 0030 BC/0010 BC + "\\d{1,4} AD/\\d{1,4} AD|" + // 5 AD/10 AD and 0005 AD/0010 AD + "\\d{1,4}-\\d{1,2} BC/\\d{1,4}-\\d{1,2} AD|" + // 5 AD/10 AD and 0005 AD/0010 AD + "\\d{1,4}-\\d{1,2} BC/\\d{1,4}-\\d{1,2} BC|" + // 5 AD/10 AD and 0005 AD/0010 AD + "\\d{1,4}-\\d{1,2} AD/\\d{1,4}-\\d{1,2} AD" // 5 AD/10 AD and 0005 AD/0010 AD + )) { + try { + String[] strDates = dateString.split("/"); + TemporalAccessor parsedDate = parseDateWithEraIndicator(strDates[0]); + TemporalAccessor parsedEndDate = parseDateWithEraIndicator(strDates[1]); + return Optional.of(new Date(parsedDate, parsedEndDate)); + } catch (DateTimeParseException e) { + LOGGER.warn("Invalid Date format range", e); + return Optional.empty(); + } + } else if (dateString.matches( + "\\d{1,4} BC / \\d{1,4} AD|" + // 30 BC / 5 AD and 0030 BC / 0005 AD + "\\d{1,4} BC / \\d{1,4} BC|" + // 30 BC / 10 BC and 0030 BC / 0010 BC + "\\d{1,4} AD / \\d{1,4} AD|" + // 5 AD / 10 AD and 0005 AD / 0010 AD + "\\d{1,4}-\\d{1,2} BC / \\d{1,4}-\\d{1,2} AD|" + // 5 AD/10 AD and 0005 AD/0010 AD + "\\d{1,4}-\\d{1,2} BC / \\d{1,4}-\\d{1,2} BC|" + // 5 AD/10 AD and 0005 AD/0010 AD + "\\d{1,4}-\\d{1,2} AD / \\d{1,4}-\\d{1,2} AD" // 5 AD/10 AD and 0005 AD/0010 AD + )) { + try { + String[] strDates = dateString.split(" / "); + TemporalAccessor parsedDate = parseDateWithEraIndicator(strDates[0]); + TemporalAccessor parsedEndDate = parseDateWithEraIndicator(strDates[1]); + return Optional.of(new Date(parsedDate, parsedEndDate)); + } catch (DateTimeParseException e) { + LOGGER.warn("Invalid Date format range", e); + return Optional.empty(); + } + } + + // if dateString is single year + if (dateString.matches("\\d{4}-|\\d{4}\\?")) { + try { + String year = dateString.substring(0, dateString.length() - 1); + TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(year); + return Optional.of(new Date(parsedDate)); + } catch (DateTimeParseException e) { + LOGGER.debug("Invalid Date format", e); + return Optional.empty(); + } + } + + // handle the new date formats with era indicators + if (dateString.matches( + "\\d{1,4} BC|" + // covers 1 BC + "\\d{1,4} AD|" + // covers 1 BC + "\\d{1,4}-\\d{1,2} BC|" + // covers 0030-01 BC + "\\d{1,4}-\\d{1,2} AD" // covers 0005-01 AD + )) { + try { + // Parse the date with era indicator + TemporalAccessor date = parseDateWithEraIndicator(dateString); + return Optional.of(new Date(date)); + } catch (DateTimeParseException e) { + LOGGER.warn("Invalid Date format with era indicator", e); + return Optional.empty(); + } + } + // handle date whose month is represented as a season. + if (dateString.matches( + "^(\\d{1,4})-(\\d{1,2})$" // covers 2025-21 + )) { + try { + // parse the date with season + Optional optional = parseDateWithSeason(dateString); + if (optional.isPresent()) { + return optional; + } + // else, just pass + } catch (DateTimeParseException e) { + // neither month nor season. + LOGGER.debug("Invalid Date format", e); return Optional.empty(); } } @@ -136,7 +264,7 @@ public static Optional parse(@NonNull String dateString) { TemporalAccessor parsedDate = SIMPLE_DATE_FORMATS.parse(dateString); return Optional.of(new Date(parsedDate)); } catch (DateTimeParseException e) { - LOGGER.debug("Invalid date format", e); + LOGGER.debug("Invalid Date format", e); return Optional.empty(); } } @@ -159,6 +287,7 @@ public static Optional parse(Optional yearValue, } else { date = year.get(); } + return Optional.of(new Date(date)); } @@ -173,6 +302,54 @@ private static Optional convertToInt(String value) { } } + /** + * Create a date with a string with era indicator. + * + * @param dateString the string which contain era indicator to extract the date information + * @return the date information with TemporalAccessor type + */ + private static TemporalAccessor parseDateWithEraIndicator(String dateString) { + String yearString = dateString.strip().substring(0, dateString.length() - 2); + + String[] parts = yearString.split("-"); + int year = Integer.parseInt(parts[0].strip()); + + if (dateString.endsWith("BC")) { + year = 1 - year; + } + if (parts.length > 1) { + int month = Integer.parseInt(parts[1].strip()); + return YearMonth.of(year, month); + } + return Year.of(year); + } + + /** + * Create a date whose month is represented as a season. + * + * @param dateString the string which contain season to extract the date information + * @return the date information with TemporalAccessor type + */ + private static Optional parseDateWithSeason(String dateString) { + String[] parts = dateString.split("-"); + int monthOrSeason = Integer.parseInt(parts[1].strip()); + // Is month, don't parse it here. + if (monthOrSeason >= 1 && monthOrSeason <= 12) { + return Optional.empty(); + } + // check month is season + Optional optional = Season.getSeasonByNumber(monthOrSeason); + if (optional.isPresent()) { + int year = Integer.parseInt(parts[0].strip()); + // use month as season + return Optional.of(new Date(Year.of(year), optional.get())); + } + 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); @@ -184,15 +361,7 @@ public String getNormalized() { public Optional getEndDate() { return Optional.ofNullable(endDate); - } - - private boolean isRange() { - return endDate != null; - } - - public Optional getYear() { - return get(ChronoField.YEAR); - } + } public Optional get(ChronoField field) { if (date.isSupported(field)) { @@ -228,26 +397,25 @@ public boolean equals(Object o) { } Date date1 = (Date) o; - return Objects.equals(getYear(), date1.getYear()) - && Objects.equals(getMonth(), date1.getMonth()) - && Objects.equals(getSeason(), date1.getSeason()) - && Objects.equals(getDay(), date1.getDay()) - && Objects.equals(get(ChronoField.HOUR_OF_DAY), date1.get(ChronoField.HOUR_OF_DAY)) - && Objects.equals(get(ChronoField.MINUTE_OF_HOUR), date1.get(ChronoField.MINUTE_OF_HOUR)) - && Objects.equals(get(ChronoField.SECOND_OF_DAY), date1.get(ChronoField.SECOND_OF_DAY)) - && Objects.equals(get(ChronoField.OFFSET_SECONDS), date1.get(ChronoField.OFFSET_SECONDS)); - } - - @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)); + return Objects.equals(getYear(), date1.getYear()) && + Objects.equals(getMonth(), date1.getMonth()) && + Objects.equals(getSeason(), date1.getSeason()) && + Objects.equals(getDay(), date1.getDay()) && + Objects.equals(get(ChronoField.HOUR_OF_DAY), date1.get(ChronoField.HOUR_OF_DAY)) && + Objects.equals(get(ChronoField.MINUTE_OF_HOUR), date1.get(ChronoField.MINUTE_OF_HOUR)) && + Objects.equals(get(ChronoField.SECOND_OF_DAY), date1.get(ChronoField.SECOND_OF_DAY)) && + Objects.equals(get(ChronoField.OFFSET_SECONDS), date1.get(ChronoField.OFFSET_SECONDS)); } @Override public String toString() { String formattedDate = date.toString(); + // If there is a season, then only the year and month fields will have values, + // and the month corresponds to the season. + // Here is no need to check the second, hour, and month, day fields. if (season != null) { + // The Date standard library does not have any API for handling seasons, + // so here is no compact form. return "Date{" + "date=" + formattedDate + ", " + "season=" + season.getName() + @@ -260,6 +428,15 @@ public String toString() { } else if (date.isSupported(ChronoField.MONTH_OF_YEAR) && date.isSupported(ChronoField.DAY_OF_MONTH)) { formattedDate = DateTimeFormatter.ISO_DATE.format(date); } - return "Date{" + "date=" + formattedDate + '}'; + return "Date{" + + "date=" + formattedDate + + '}'; + } + public Optional 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)); } } From 0d179dedc20228de900ac322704b7c64973b42cc Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Wed, 12 Nov 2025 21:39:21 +0100 Subject: [PATCH 03/19] [style] Reformat Date and DateEditorViewModel to match JabRef guidelines --- .../org/jabref/gui/fieldeditors/DateEditorViewModel.java | 7 +++++-- jablib/src/main/java/org/jabref/model/entry/Date.java | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index a6ec5f19083..48ce98d5960 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -14,8 +14,10 @@ import org.jabref.logic.util.strings.StringUtil; import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; + import java.time.LocalDate; import java.util.Optional; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,6 +26,7 @@ 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) { super(field, suggestionProvider, fieldCheckers, undoManager); this.dateFormatter = dateFormatter; @@ -51,6 +54,7 @@ public String toString(TemporalAccessor date) { } return ""; } + private String sanitizeIncompleteRange(String dateString) { String trimmed = dateString.trim(); @@ -68,6 +72,7 @@ private String sanitizeIncompleteRange(String dateString) { return dateString; } + @Override public TemporalAccessor fromString(String string) { if (StringUtil.isNotBlank(string)) { @@ -97,7 +102,5 @@ public TemporalAccessor fromString(String string) { } } }; - } - } diff --git a/jablib/src/main/java/org/jabref/model/entry/Date.java b/jablib/src/main/java/org/jabref/model/entry/Date.java index a71567100d8..863dd68bf60 100644 --- a/jablib/src/main/java/org/jabref/model/entry/Date.java +++ b/jablib/src/main/java/org/jabref/model/entry/Date.java @@ -346,7 +346,8 @@ private static Optional parseDateWithSeason(String dateString) { } throw new DateTimeParseException("Invalid Date format for season", dateString, parts[0].length()); } - private boolean isRange() { + + private boolean isRange() { return endDate != null; } @@ -361,7 +362,7 @@ public String getNormalized() { public Optional getEndDate() { return Optional.ofNullable(endDate); - } + } public Optional get(ChronoField field) { if (date.isSupported(field)) { @@ -432,9 +433,11 @@ public String toString() { "date=" + formattedDate + '}'; } + public Optional 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)); From 83651d6339392f35de88af1547ff5dde4d8dc9cd Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Wed, 12 Nov 2025 21:50:50 +0100 Subject: [PATCH 04/19] [style] Reformat Date and DateEditorViewModel to match JabRef guidelines --- .../gui/fieldeditors/DateEditorViewModel.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index 48ce98d5960..dcd717f91ec 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -1,9 +1,11 @@ package org.jabref.gui.fieldeditors; import java.time.DateTimeException; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; +import java.util.Optional; import javax.swing.undo.UndoManager; @@ -15,19 +17,18 @@ import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; -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) { + private final DateTimeFormatter dateFormatter; + + public DateEditorViewModel(Field field, SuggestionProvider suggestionProvider, DateTimeFormatter dateFormatter, + FieldCheckers fieldCheckers, UndoManager undoManager) { super(field, suggestionProvider, fieldCheckers, undoManager); this.dateFormatter = dateFormatter; } @@ -40,7 +41,6 @@ public String toString(TemporalAccessor date) { if (currentText != null && !currentText.isEmpty()) { Optional parsedDate = Date.parse(currentText); if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { - return currentText; } } @@ -76,7 +76,7 @@ private String sanitizeIncompleteRange(String dateString) { @Override public TemporalAccessor fromString(String string) { if (StringUtil.isNotBlank(string)) { - // ✅ NEW: Sanitize incomplete ranges (e.g., "2010/" → "2010") + // ✅ Sanitize incomplete ranges (e.g., "2010/" → "2010") String sanitizedString = sanitizeIncompleteRange(string); // Priority 1: Check if it's a date range From 239a5211625e23f9b90c5ccb50e38eac9f0190ea Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sat, 15 Nov 2025 19:48:59 +0100 Subject: [PATCH 05/19] [style] Reformat DateEditorViewModel to match JabRef guidelines --- .../jabref/gui/fieldeditors/DateEditorViewModel.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index dcd717f91ec..b75a02e8f02 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -76,22 +76,21 @@ private String sanitizeIncompleteRange(String dateString) { @Override public TemporalAccessor fromString(String string) { if (StringUtil.isNotBlank(string)) { - // ✅ Sanitize incomplete ranges (e.g., "2010/" → "2010") + String sanitizedString = sanitizeIncompleteRange(string); - // Priority 1: Check if it's a date range + Optional parsedDate = Date.parse(sanitizedString); if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { - // It's a range! Return sentinel to signal this is a special case - // The toString() method will retrieve the value from textProperty() + return RANGE_SENTINEL; } - // Priority 2: Try strict format parsing + try { return dateFormatter.parse(sanitizedString); } catch (DateTimeParseException exception) { - // Priority 3: Try flexible parsing (single dates only) + return parsedDate .filter(date -> date.getEndDate().isEmpty()) .map(Date::toTemporalAccessor) From b7f963023594438ae8e5ffb46832b5270b4f38f1 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sat, 15 Nov 2025 19:58:02 +0100 Subject: [PATCH 06/19] Refactor DateEditorViewModel --- .../java/org/jabref/gui/fieldeditors/DateEditorViewModel.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index b75a02e8f02..1c20cc28339 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -58,13 +58,13 @@ public String toString(TemporalAccessor date) { private String sanitizeIncompleteRange(String dateString) { String trimmed = dateString.trim(); - // Remove the trailing slash (e.g., "2010/" → "2010") + if (trimmed.endsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed); return trimmed.substring(0, trimmed.length() - 1).trim(); } - // Remove the leading slash (e.g., "/2010" → "2010") + if (trimmed.startsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed); return trimmed.substring(1).trim(); From fce639038b5597cb4181938ca5d1c35ee4fb3616 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sat, 15 Nov 2025 20:13:25 +0100 Subject: [PATCH 07/19] [style] Reformat DateEditorViewModel to match JabRef guidelines --- .../java/org/jabref/gui/fieldeditors/DateEditorViewModel.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index 1c20cc28339..7e51fbe80ec 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -58,13 +58,11 @@ public String toString(TemporalAccessor date) { private String sanitizeIncompleteRange(String dateString) { String trimmed = dateString.trim(); - if (trimmed.endsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed); return trimmed.substring(0, trimmed.length() - 1).trim(); } - if (trimmed.startsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed); return trimmed.substring(1).trim(); @@ -79,14 +77,12 @@ public TemporalAccessor fromString(String string) { String sanitizedString = sanitizeIncompleteRange(string); - Optional parsedDate = Date.parse(sanitizedString); if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { return RANGE_SENTINEL; } - try { return dateFormatter.parse(sanitizedString); } catch (DateTimeParseException exception) { From 95955bbb54933237a48cd2d97aa6c42c5e5d37ba Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sat, 15 Nov 2025 20:37:15 +0100 Subject: [PATCH 08/19] [style] Reformat DateEditorViewModel to match JabRef guidelines --- .../java/org/jabref/gui/fieldeditors/DateEditorViewModel.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index 7e51fbe80ec..ca9d222569b 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -74,19 +74,15 @@ private String sanitizeIncompleteRange(String dateString) { @Override public TemporalAccessor fromString(String string) { if (StringUtil.isNotBlank(string)) { - String sanitizedString = sanitizeIncompleteRange(string); Optional parsedDate = Date.parse(sanitizedString); if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { - return RANGE_SENTINEL; } - try { return dateFormatter.parse(sanitizedString); } catch (DateTimeParseException exception) { - return parsedDate .filter(date -> date.getEndDate().isEmpty()) .map(Date::toTemporalAccessor) From 6fc0ca6be959028deb9bd72c73808d6748d2d1ec Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Thu, 20 Nov 2025 20:57:54 +0100 Subject: [PATCH 09/19] Add date range tests and changelog entry --- CHANGELOG.md | 2 + .../fieldeditors/DateEditorViewModelTest.java | 88 +++++++++++++++++++ .../java/org/jabref/model/entry/DateTest.java | 46 ++++++++-- 3 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c88879d2460..ac5c50bc593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ 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) +- git add jablb/src/test/java/org/jabref/model/entry/DateTest.javaAdded support for BibLaTeX date ranges and improved normalization in the date editor. [#14289](https://github.com/JabRef/jabref/pull/14289) + ### Changed diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java new file mode 100644 index 00000000000..febb655f3f9 --- /dev/null +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java @@ -0,0 +1,88 @@ +package org.jabref.gui.fieldeditors; + +import javafx.util.StringConverter; +import org.jabref.gui.autocompleter.SuggestionProvider; +import org.jabref.logic.integrity.FieldCheckers; +import org.jabref.model.entry.Date; +import org.jabref.model.entry.field.Field; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import javax.swing.undo.UndoManager; +import java.time.LocalDate; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; + +import static org.junit.jupiter.api.Assertions.*; + +class DateEditorViewModelTest { + + private DateEditorViewModel viewModel; + private StringConverter dateToStringConverter; + + private TemporalAccessor sentinel() { + return 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); + } + + @Test + void fromStringRecognizesDateRangeAndReturnsSentinel() { + StringConverter 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 converter = viewModel.getDateToStringConverter(); + + String output = converter.toString(sentinel()); + assertEquals("2020-01-01/2020-12-31", output); + } + + @Test + void sanitizeTrailingSlash() { + StringConverter converter = viewModel.getDateToStringConverter(); + TemporalAccessor result = converter.fromString("2020/"); + Year year = Year.from(result); + assertEquals(2020, year.getValue()); + } + + @Test + void sanitizeLeadingSlash() { + StringConverter converter = viewModel.getDateToStringConverter(); + TemporalAccessor result = converter.fromString("/2020"); + Year year = Year.from(result); + assertEquals(2020, year.getValue()); + } + + @Test + void singleDateFormatsNormally() { + StringConverter converter = viewModel.getDateToStringConverter(); + TemporalAccessor parsed = converter.fromString("2020-05-20"); + + String output = converter.toString(parsed); + assertEquals("2020-05-20", output); + } + + @Test + void invalidDateReturnsNull() { + StringConverter converter = dateToStringConverter; + TemporalAccessor result = converter.fromString("invalid-date"); + + assertNull(result); + } +} diff --git a/jablib/src/test/java/org/jabref/model/entry/DateTest.java b/jablib/src/test/java/org/jabref/model/entry/DateTest.java index dc6976c5e4b..c6b978f69da 100644 --- a/jablib/src/test/java/org/jabref/model/entry/DateTest.java +++ b/jablib/src/test/java/org/jabref/model/entry/DateTest.java @@ -17,11 +17,10 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -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.*; class DateTest { + private static Stream validDates() { return Stream.of( Arguments.of(LocalDateTime.of(2018, Month.OCTOBER, 3, 7, 24), "2018-10-03T07:24"), @@ -94,7 +93,7 @@ private static Stream 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]") ); @@ -124,12 +123,43 @@ 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()); } } From cbb79883e6405e4244410787a5c2520d2c0cd2c6 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Thu, 20 Nov 2025 21:16:43 +0100 Subject: [PATCH 10/19] Add date range tests and changelog entry --- CHANGELOG.md | 2 +- .../org/jabref/gui/fieldeditors/DateEditorViewModelTest.java | 3 +++ jablib/src/test/java/org/jabref/model/entry/DateTest.java | 1 - 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac5c50bc593..4a294aece18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,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) -- git add jablb/src/test/java/org/jabref/model/entry/DateTest.javaAdded support for BibLaTeX date ranges and improved normalization in the date editor. [#14289](https://github.com/JabRef/jabref/pull/14289) +- Added support for BibLaTeX date ranges and improved normalization in the date editor. [#14289](https://github.com/JabRef/jabref/pull/14289) ### Changed diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java index febb655f3f9..74a190274c9 100644 --- a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java @@ -1,15 +1,18 @@ package org.jabref.gui.fieldeditors; import javafx.util.StringConverter; + import org.jabref.gui.autocompleter.SuggestionProvider; import org.jabref.logic.integrity.FieldCheckers; import org.jabref.model.entry.Date; import org.jabref.model.entry.field.Field; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import javax.swing.undo.UndoManager; + import java.time.LocalDate; import java.time.Year; import java.time.format.DateTimeFormatter; diff --git a/jablib/src/test/java/org/jabref/model/entry/DateTest.java b/jablib/src/test/java/org/jabref/model/entry/DateTest.java index c6b978f69da..6040720bd9c 100644 --- a/jablib/src/test/java/org/jabref/model/entry/DateTest.java +++ b/jablib/src/test/java/org/jabref/model/entry/DateTest.java @@ -128,7 +128,6 @@ void parseShouldTrimValidDate() { assertEquals(Date.parse("2025-05-02"), Date.parse(" 2025-05-02 ")); } - @Test void normalizedRangeIsCorrect() { Date d = new Date( From 505923a1f8bc1a18dfd90b0120a116fe764d3322 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Thu, 20 Nov 2025 21:23:04 +0100 Subject: [PATCH 11/19] Repar changelog entry --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a294aece18..cb2c15e49ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,6 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - 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 - We replaced the standard ComboBox with a SearchableComboBox and added a free text field in custom Entry Types [#14082](https://github.com/JabRef/jabref/issues/14082) From d449518918e86027d734f70ca08835778682f759 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Thu, 20 Nov 2025 21:44:57 +0100 Subject: [PATCH 12/19] Add date range tests and changelog entry --- .../gui/fieldeditors/DateEditorViewModelTest.java | 10 ++++------ .../src/test/java/org/jabref/model/entry/DateTest.java | 5 ++++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java index 74a190274c9..4aff0bb1544 100644 --- a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java @@ -1,27 +1,25 @@ package org.jabref.gui.fieldeditors; +import java.time.LocalDate; +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.Date; import org.jabref.model.entry.field.Field; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; -import javax.swing.undo.UndoManager; - -import java.time.LocalDate; import java.time.Year; import java.time.format.DateTimeFormatter; import java.time.temporal.TemporalAccessor; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; class DateEditorViewModelTest { - private DateEditorViewModel viewModel; private StringConverter dateToStringConverter; diff --git a/jablib/src/test/java/org/jabref/model/entry/DateTest.java b/jablib/src/test/java/org/jabref/model/entry/DateTest.java index 6040720bd9c..81eae3a0ba2 100644 --- a/jablib/src/test/java/org/jabref/model/entry/DateTest.java +++ b/jablib/src/test/java/org/jabref/model/entry/DateTest.java @@ -17,7 +17,10 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + class DateTest { From fc101035c15a427cfd6fbee594c35590dfa672e6 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Thu, 20 Nov 2025 22:07:16 +0100 Subject: [PATCH 13/19] Add date range tests and changelog entry --- jablib/src/test/java/org/jabref/model/entry/DateTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jablib/src/test/java/org/jabref/model/entry/DateTest.java b/jablib/src/test/java/org/jabref/model/entry/DateTest.java index 81eae3a0ba2..4fa4ecc672b 100644 --- a/jablib/src/test/java/org/jabref/model/entry/DateTest.java +++ b/jablib/src/test/java/org/jabref/model/entry/DateTest.java @@ -20,6 +20,8 @@ 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 { From b6b98cad1585c3c36d7bcbb89f93bce49b2e9ead Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sun, 23 Nov 2025 21:32:48 +0100 Subject: [PATCH 14/19] Repair --- .../gui/fieldeditors/DateEditorViewModelTest.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java index 4aff0bb1544..38e9d131493 100644 --- a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java @@ -1,7 +1,12 @@ 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; @@ -10,11 +15,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import java.time.Year; -import java.time.format.DateTimeFormatter; -import java.time.temporal.TemporalAccessor; +import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -36,6 +38,7 @@ void setup() { DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE; viewModel = new DateEditorViewModel(field, suggestionProvider, formatter, fieldCheckers, undoManager); + dateToStringConverter = viewModel.getDateToStringConverter(); } @Test From 7e081f70a80fd99b0de7d24652e91249d296af42 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sun, 23 Nov 2025 21:33:42 +0100 Subject: [PATCH 15/19] Repair --- jablib/src/test/java/org/jabref/model/entry/DateTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/jablib/src/test/java/org/jabref/model/entry/DateTest.java b/jablib/src/test/java/org/jabref/model/entry/DateTest.java index 4fa4ecc672b..14a6db3d0c4 100644 --- a/jablib/src/test/java/org/jabref/model/entry/DateTest.java +++ b/jablib/src/test/java/org/jabref/model/entry/DateTest.java @@ -22,8 +22,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; - - class DateTest { private static Stream validDates() { From 7549d96a860276564d45a7bb30d3dbcce625bf5d Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sun, 23 Nov 2025 22:06:29 +0100 Subject: [PATCH 16/19] Repair --- .../org/jabref/gui/fieldeditors/DateEditorViewModelTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java index 38e9d131493..461346f5b31 100644 --- a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java @@ -15,7 +15,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; - import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.assertEquals; From bb8b928bfe65c32fdedf0d8e39906c28b37f9b0f Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sun, 23 Nov 2025 22:14:58 +0100 Subject: [PATCH 17/19] Repair --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d875efd47d3..677715ceb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,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 From 6a7695363e1bac4e99a5035079495be1963d8783 Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sun, 23 Nov 2025 22:46:59 +0100 Subject: [PATCH 18/19] Change --- .../gui/fieldeditors/DateEditorViewModel.java | 92 +++++++------------ .../fieldeditors/DateEditorViewModelTest.java | 8 +- .../org/jabref/model/entry/DateRangeUtil.java | 29 ++++++ 3 files changed, 67 insertions(+), 62 deletions(-) create mode 100644 jablib/src/main/java/org/jabref/model/entry/DateRangeUtil.java diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index ca9d222569b..dd7b1bd5d89 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -1,11 +1,8 @@ package org.jabref.gui.fieldeditors; import java.time.DateTimeException; -import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.time.temporal.TemporalAccessor; -import java.util.Optional; import javax.swing.undo.UndoManager; @@ -14,82 +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 static final TemporalAccessor RANGE_SENTINEL = LocalDate.of(1, 1, 1); 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 getDateToStringConverter() { - return new StringConverter<>() { - @Override - public String toString(TemporalAccessor date) { - String currentText = textProperty().get(); - if (currentText != null && !currentText.isEmpty()) { - Optional parsedDate = Date.parse(currentText); - if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { - return currentText; - } - } - if (date != null && date != RANGE_SENTINEL) { - try { - return dateFormatter.format(date); - } catch (DateTimeException ex) { - LOGGER.debug("Cannot format date", ex); - return ""; - } - } - return ""; - } - - private String sanitizeIncompleteRange(String dateString) { - String trimmed = dateString.trim(); + public Optional getText() { + return Optional.ofNullable(text.get()); + } - if (trimmed.endsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { - LOGGER.debug("Sanitizing incomplete range (trailing slash): {}", trimmed); - return trimmed.substring(0, trimmed.length() - 1).trim(); - } + public void setText(String newValue) { + String sanitized = DateRangeUtil.sanitizeIncompleteRange(newValue); + text.set(sanitized); + } - if (trimmed.startsWith("/") && !trimmed.matches(".*\\d+/\\d+.*")) { - LOGGER.debug("Sanitizing incomplete range (leading slash): {}", trimmed); - return trimmed.substring(1).trim(); + public StringConverter getToStringConverter() { + return new StringConverter<>() { + @Override + public String toString(TemporalAccessor value) { + if (value == null || value.equals(RANGE_SENTINEL)) { + return ""; } - return dateString; + return dateFormatter.format(value); } @Override - public TemporalAccessor fromString(String string) { - if (StringUtil.isNotBlank(string)) { - String sanitizedString = sanitizeIncompleteRange(string); - - Optional parsedDate = Date.parse(sanitizedString); - if (parsedDate.isPresent() && parsedDate.get().getEndDate().isPresent()) { - return RANGE_SENTINEL; - } - try { - return dateFormatter.parse(sanitizedString); - } catch (DateTimeParseException exception) { - return parsedDate - .filter(date -> date.getEndDate().isEmpty()) - .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) { // ✔ FIX multi-catch + LOGGER.error("Error while parsing date {}", text, e); + return RANGE_SENTINEL; } } }; diff --git a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java index 461346f5b31..c429676f16a 100644 --- a/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java +++ b/jabgui/src/test/java/org/jabref/gui/fieldeditors/DateEditorViewModelTest.java @@ -24,9 +24,7 @@ class DateEditorViewModelTest { private DateEditorViewModel viewModel; private StringConverter dateToStringConverter; - private TemporalAccessor sentinel() { - return LocalDate.of(1, 1, 1); - } + private static final TemporalAccessor SENTINEL = LocalDate.of(1, 1, 1); @BeforeEach void setup() { @@ -44,7 +42,7 @@ void setup() { void fromStringRecognizesDateRangeAndReturnsSentinel() { StringConverter converter = viewModel.getDateToStringConverter(); TemporalAccessor result = converter.fromString("2020-01-01/2020-12-31"); - assertEquals(sentinel(), result); + assertEquals(SENTINEL, result); } @Test @@ -52,7 +50,7 @@ void toStringReturnsOriginalRangeText() { viewModel.textProperty().set("2020-01-01/2020-12-31"); StringConverter converter = viewModel.getDateToStringConverter(); - String output = converter.toString(sentinel()); + String output = converter.toString(SENTINEL); assertEquals("2020-01-01/2020-12-31", output); } diff --git a/jablib/src/main/java/org/jabref/model/entry/DateRangeUtil.java b/jablib/src/main/java/org/jabref/model/entry/DateRangeUtil.java new file mode 100644 index 00000000000..00f17e09b7c --- /dev/null +++ b/jablib/src/main/java/org/jabref/model/entry/DateRangeUtil.java @@ -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; + } +} From b7f9822b3f1ad17520f19f874b7d5cf4ebc53f2b Mon Sep 17 00:00:00 2001 From: Alex1034 Date: Sun, 23 Nov 2025 22:52:00 +0100 Subject: [PATCH 19/19] Change --- .../java/org/jabref/gui/fieldeditors/DateEditorViewModel.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java index dd7b1bd5d89..d348397e912 100644 --- a/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/fieldeditors/DateEditorViewModel.java @@ -65,7 +65,7 @@ public TemporalAccessor fromString(String text) { try { return dateFormatter.parse(text); - } catch (DateTimeException e) { // ✔ FIX multi-catch + } catch (DateTimeException e) { LOGGER.error("Error while parsing date {}", text, e); return RANGE_SENTINEL; }