diff --git a/CHANGELOG.md b/CHANGELOG.md index 48482f765e9..ae20f389310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - We added a search bar for filtering keyboard shortcuts. [#11686](https://github.com/JabRef/jabref/issues/11686) - We added new modifiers `camel_case`, `camel_case_n`, `short_title`, and `very_short_title` for the [citation key generator](https://docs.jabref.org/setup/citationkeypatterns). [#11367](https://github.com/JabRef/jabref/issues/11367) - By double clicking on a local citation in the Citation Relations Tab you can now jump the linked entry. [#11955](https://github.com/JabRef/jabref/pull/11955) +- Added batch fetching of bibliographic data for multiple entries in the "Lookup" menu. [koppor#372](https://github.com/koppor/jabref/issues/372) - We use the menu icon for background tasks as a progress indicator to visualise an import's progress when dragging and dropping several PDF files into the main table. [#12072](https://github.com/JabRef/jabref/pull/12072) - The PDF content importer now supports importing title from upto the second page of the PDF. [#12139](https://github.com/JabRef/jabref/issues/12139) diff --git a/src/main/java/org/jabref/gui/actions/StandardActions.java b/src/main/java/org/jabref/gui/actions/StandardActions.java index df789a51733..2e98d027bcd 100644 --- a/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -37,6 +37,7 @@ public enum StandardActions implements Action { OPEN_URL(Localization.lang("Open URL or DOI"), IconTheme.JabRefIcons.WWW, KeyBinding.OPEN_URL_OR_DOI), SEARCH_SHORTSCIENCE(Localization.lang("Search ShortScience")), MERGE_WITH_FETCHED_ENTRY(Localization.lang("Get bibliographic data from %0", "DOI/ISBN/...")), + BATCH_MERGE_WITH_FETCHED_ENTRY(Localization.lang("Get bibliographic data from %0 (fully automated)", "DOI/ISBN/...")), ATTACH_FILE(Localization.lang("Attach file"), IconTheme.JabRefIcons.ATTACH_FILE), ATTACH_FILE_FROM_URL(Localization.lang("Attach file from URL"), IconTheme.JabRefIcons.DOWNLOAD_FILE), PRIORITY(Localization.lang("Priority"), IconTheme.JabRefIcons.PRIORITY), diff --git a/src/main/java/org/jabref/gui/frame/MainMenu.java b/src/main/java/org/jabref/gui/frame/MainMenu.java index 4a90a556172..26dc4fa2cb8 100644 --- a/src/main/java/org/jabref/gui/frame/MainMenu.java +++ b/src/main/java/org/jabref/gui/frame/MainMenu.java @@ -51,7 +51,9 @@ import org.jabref.gui.linkedfile.RedownloadMissingFilesAction; import org.jabref.gui.maintable.NewLibraryFromPdfActionOffline; import org.jabref.gui.maintable.NewLibraryFromPdfActionOnline; +import org.jabref.gui.mergeentries.BatchEntryMergeWithFetchedDataAction; import org.jabref.gui.mergeentries.MergeEntriesAction; +import org.jabref.gui.mergeentries.MergeWithFetchedEntryAction; import org.jabref.gui.plaincitationparser.PlainCitationParserAction; import org.jabref.gui.preferences.GuiPreferences; import org.jabref.gui.preferences.ShowPreferencesAction; @@ -273,7 +275,19 @@ private void createMenu() { new SeparatorMenuItem(), - factory.createMenuItem(StandardActions.FIND_UNLINKED_FILES, new FindUnlinkedFilesAction(dialogService, stateManager)) + factory.createMenuItem(StandardActions.FIND_UNLINKED_FILES, new FindUnlinkedFilesAction(dialogService, stateManager)), + + new SeparatorMenuItem(), + + factory.createMenuItem( + StandardActions.MERGE_WITH_FETCHED_ENTRY, + new MergeWithFetchedEntryAction(dialogService, stateManager, taskExecutor, preferences, undoManager)), + + new SeparatorMenuItem(), + + factory.createMenuItem( + StandardActions.BATCH_MERGE_WITH_FETCHED_ENTRY, + new BatchEntryMergeWithFetchedDataAction(stateManager, undoManager, preferences, dialogService, taskExecutor)) ); final MenuItem pushToApplicationMenuItem = factory.createMenuItem(pushToApplicationCommand.getAction(), pushToApplicationCommand); diff --git a/src/main/java/org/jabref/gui/maintable/RightClickMenu.java b/src/main/java/org/jabref/gui/maintable/RightClickMenu.java index 66430e8770e..dbbfc79f8af 100644 --- a/src/main/java/org/jabref/gui/maintable/RightClickMenu.java +++ b/src/main/java/org/jabref/gui/maintable/RightClickMenu.java @@ -21,9 +21,7 @@ import org.jabref.gui.keyboard.KeyBindingRepository; import org.jabref.gui.linkedfile.AttachFileAction; import org.jabref.gui.linkedfile.AttachFileFromURLAction; -import org.jabref.gui.menus.ChangeEntryTypeMenu; import org.jabref.gui.mergeentries.MergeEntriesAction; -import org.jabref.gui.mergeentries.MergeWithFetchedEntryAction; import org.jabref.gui.preferences.GuiPreferences; import org.jabref.gui.preview.CopyCitationAction; import org.jabref.gui.preview.PreviewPreferences; @@ -87,12 +85,7 @@ public static ContextMenu create(BibEntryTableViewModel entry, extractFileReferencesOffline, factory.createMenuItem(StandardActions.OPEN_URL, new OpenUrlAction(dialogService, stateManager, preferences)), - factory.createMenuItem(StandardActions.SEARCH_SHORTSCIENCE, new SearchShortScienceAction(dialogService, stateManager, preferences)), - - new SeparatorMenuItem(), - - new ChangeEntryTypeMenu(libraryTab.getSelectedEntries(), libraryTab.getBibDatabaseContext(), undoManager, entryTypesManager).asSubMenu(), - factory.createMenuItem(StandardActions.MERGE_WITH_FETCHED_ENTRY, new MergeWithFetchedEntryAction(dialogService, stateManager, taskExecutor, preferences, undoManager)) + factory.createMenuItem(StandardActions.SEARCH_SHORTSCIENCE, new SearchShortScienceAction(dialogService, stateManager, preferences)) ); EasyBind.subscribe(preferences.getGrobidPreferences().grobidEnabledProperty(), enabled -> { diff --git a/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeTask.java b/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeTask.java new file mode 100644 index 00000000000..8e3007683fc --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeTask.java @@ -0,0 +1,153 @@ +package org.jabref.gui.mergeentries; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import javax.swing.undo.UndoManager; + +import org.jabref.gui.undo.NamedCompound; +import org.jabref.logic.importer.fetcher.MergingIdBasedFetcher; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.NotificationService; +import org.jabref.model.entry.BibEntry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A background task that handles fetching and merging of bibliography entries. + * This implementation provides improved concurrency handling, better Optional usage, + * and more robust error handling. + */ +public class BatchEntryMergeTask extends BackgroundTask> { + + private static final Logger LOGGER = LoggerFactory.getLogger(BatchEntryMergeTask.class); + private final MergeContext context; + private final NamedCompound compoundEdit; + private final AtomicInteger processedEntries; + private final AtomicInteger successfulUpdates; + + public BatchEntryMergeTask(MergeContext context) { + this.context = context; + this.compoundEdit = new NamedCompound(Localization.lang("Merge entries")); + this.processedEntries = new AtomicInteger(0); + this.successfulUpdates = new AtomicInteger(0); + + configureTask(); + } + + private void configureTask() { + setTitle(Localization.lang("Fetching and merging entry(s)")); + withInitialMessage(Localization.lang("Starting merge operation...")); + showToUser(true); + } + + @Override + public List call() throws Exception { + try { + List updatedEntries = processMergeEntries(); + finalizeOperation(updatedEntries); + LOGGER.debug("Merge operation completed. Processed: {}, Successfully updated: {}", + processedEntries.get(), successfulUpdates.get()); + notifySuccess(successfulUpdates.get()); + return updatedEntries; + } catch (Exception e) { + LOGGER.error("Critical error during merge operation", e); + notifyError(e); + throw e; + } + } + + private List processMergeEntries() { + return context.entries().stream() + .takeWhile(_ -> !isCancelled()) + .map(this::processSingleEntryWithProgress) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } + + private Optional processSingleEntryWithProgress(BibEntry entry) { + updateProgress(processedEntries.incrementAndGet(), context.entries().size()); + updateMessage(Localization.lang("Processing entry %0 of %1", + processedEntries.get(), + context.entries().size())); + return processSingleEntry(entry); + } + + private Optional processSingleEntry(BibEntry entry) { + try { + LOGGER.debug("Processing entry: {}", entry); + return context.fetcher().fetchEntry(entry) + .filter(MergingIdBasedFetcher.FetcherResult::hasChanges) + .flatMap(result -> { + boolean changesApplied = applyMerge(entry, result); + if (changesApplied) { + successfulUpdates.incrementAndGet(); + return entry.getCitationKey(); + } + return Optional.empty(); + }); + } catch (Exception e) { + handleEntryProcessingError(entry, e); + return Optional.empty(); + } + } + + private boolean applyMerge(BibEntry entry, MergingIdBasedFetcher.FetcherResult result) { + synchronized (compoundEdit) { + try { + return MergeEntriesHelper.mergeEntries(result.mergedEntry(), entry, compoundEdit); + } catch (Exception e) { + LOGGER.error("Error during merge operation for entry: {}", entry, e); + return false; + } + } + } + + private void handleEntryProcessingError(BibEntry entry, Exception e) { + String citationKey = entry.getCitationKey().orElse("unknown"); + String message = Localization.lang("Error processing entry", citationKey, e.getMessage()); + LOGGER.error(message, e); + context.notificationService().notify(message); + } + + private void finalizeOperation(List updatedEntries) { + if (!updatedEntries.isEmpty()) { + synchronized (compoundEdit) { + compoundEdit.end(); + context.undoManager.addEdit(compoundEdit); + } + } + } + + private void notifySuccess(int updateCount) { + String message = updateCount == 0 + ? Localization.lang("No updates found.") + : Localization.lang("Batch update successful. %0 entry(s) updated.", updateCount); + context.notificationService().notify(message); + } + + private void notifyError(Exception e) { + context.notificationService().notify( + Localization.lang("Merge operation failed: %0", e.getMessage())); + } + + /** + * Record containing all the context needed for the merge operation. + * Implements defensive copying to ensure immutability. + */ + public record MergeContext( + List entries, + MergingIdBasedFetcher fetcher, + UndoManager undoManager, + NotificationService notificationService + ) { + public MergeContext { + entries = List.copyOf(entries); // Defensive copy + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeWithFetchedDataAction.java b/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeWithFetchedDataAction.java new file mode 100644 index 00000000000..1ddbf5e0048 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/BatchEntryMergeWithFetchedDataAction.java @@ -0,0 +1,67 @@ +package org.jabref.gui.mergeentries; + +import java.util.List; + +import javax.swing.undo.UndoManager; + +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.ActionHelper; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.importer.fetcher.MergingIdBasedFetcher; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.NotificationService; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; + +/** + * Handles batch merging of bibliography entries with fetched data. + */ +public class BatchEntryMergeWithFetchedDataAction extends SimpleCommand { + + private final StateManager stateManager; + private final UndoManager undoManager; + private final GuiPreferences preferences; + private final NotificationService notificationService; + private final TaskExecutor taskExecutor; + + public BatchEntryMergeWithFetchedDataAction(StateManager stateManager, + UndoManager undoManager, + GuiPreferences preferences, + NotificationService notificationService, + TaskExecutor taskExecutor) { + this.stateManager = stateManager; + this.undoManager = undoManager; + this.preferences = preferences; + this.notificationService = notificationService; + this.taskExecutor = taskExecutor; + + this.executable.bind(ActionHelper.needsDatabase(stateManager)); + } + + @Override + public void execute() { + if (stateManager.getActiveDatabase().isEmpty()) { + return; + } + + List entries = stateManager.getActiveDatabase() + .map(BibDatabaseContext::getEntries) + .orElse(List.of()); + + if (entries.isEmpty()) { + notificationService.notify(Localization.lang("No entries available for merging")); + return; + } + + MergingIdBasedFetcher fetcher = new MergingIdBasedFetcher(preferences.getImportFormatPreferences()); + BatchEntryMergeTask mergeTask = new BatchEntryMergeTask( + new BatchEntryMergeTask.MergeContext(entries, + fetcher, + undoManager, + notificationService)); + + mergeTask.executeWith(taskExecutor); + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesHelper.java b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesHelper.java new file mode 100644 index 00000000000..990429fd69e --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesHelper.java @@ -0,0 +1,108 @@ +package org.jabref.gui.mergeentries; + +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; + +import org.jabref.gui.undo.NamedCompound; +import org.jabref.gui.undo.UndoableChangeType; +import org.jabref.gui.undo.UndoableFieldChange; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.FieldFactory; +import org.jabref.model.entry.types.EntryType; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Helper class for merging bibliography entries with undo support. + * Source entry data is merged into the library entry, with longer field values preferred + * and obsolete fields removed. + */ +public final class MergeEntriesHelper { + + private static final Logger LOGGER = LoggerFactory.getLogger(MergeEntriesHelper.class); + + private MergeEntriesHelper() { + } + + /** + * Merges two BibEntry objects with undo support. + * + * @param entryFromFetcher The entry containing new information (source, from the fetcher) + * @param entryFromLibrary The entry to be updated (target, from the library) + * @param undoManager Compound edit to collect undo information + */ + public static boolean mergeEntries(BibEntry entryFromFetcher, BibEntry entryFromLibrary, NamedCompound undoManager) { + LOGGER.debug("Entry from fetcher: {}", entryFromFetcher); + LOGGER.debug("Entry from library: {}", entryFromLibrary); + + boolean typeChanged = mergeEntryType(entryFromFetcher, entryFromLibrary, undoManager); + boolean fieldsChanged = mergeFields(entryFromFetcher, entryFromLibrary, undoManager); + boolean fieldsRemoved = removeFieldsNotPresentInFetcher(entryFromFetcher, entryFromLibrary, undoManager); + + return typeChanged || fieldsChanged || fieldsRemoved; + } + + private static boolean mergeEntryType(BibEntry entryFromFetcher, BibEntry entryFromLibrary, NamedCompound undoManager) { + EntryType fetcherType = entryFromFetcher.getType(); + EntryType libraryType = entryFromLibrary.getType(); + + if (!libraryType.equals(fetcherType)) { + LOGGER.debug("Updating type {} -> {}", libraryType, fetcherType); + entryFromLibrary.setType(fetcherType); + undoManager.addEdit(new UndoableChangeType(entryFromLibrary, libraryType, fetcherType)); + return true; + } + return false; + } + + private static boolean mergeFields(BibEntry entryFromFetcher, BibEntry entryFromLibrary, NamedCompound undoManager) { + Set allFields = new LinkedHashSet<>(); + allFields.addAll(entryFromFetcher.getFields()); + allFields.addAll(entryFromLibrary.getFields()); + + boolean anyFieldsChanged = false; + + for (Field field : allFields) { + Optional fetcherValue = entryFromFetcher.getField(field); + Optional libraryValue = entryFromLibrary.getField(field); + + if (fetcherValue.isPresent() && shouldUpdateField(fetcherValue.get(), libraryValue)) { + LOGGER.debug("Updating field {}: {} -> {}", field, libraryValue.orElse(null), fetcherValue.get()); + entryFromLibrary.setField(field, fetcherValue.get()); + undoManager.addEdit(new UndoableFieldChange(entryFromLibrary, field, libraryValue.orElse(null), fetcherValue.get())); + anyFieldsChanged = true; + } + } + return anyFieldsChanged; + } + + private static boolean removeFieldsNotPresentInFetcher(BibEntry entryFromFetcher, BibEntry entryFromLibrary, NamedCompound undoManager) { + Set obsoleteFields = new LinkedHashSet<>(entryFromLibrary.getFields()); + obsoleteFields.removeAll(entryFromFetcher.getFields()); + + boolean anyFieldsRemoved = false; + + for (Field field : obsoleteFields) { + if (FieldFactory.isInternalField(field)) { + continue; + } + + Optional value = entryFromLibrary.getField(field); + if (value.isPresent()) { + LOGGER.debug("Removing obsolete field {} with value {}", field, value.get()); + entryFromLibrary.clearField(field); + undoManager.addEdit(new UndoableFieldChange(entryFromLibrary, field, value.get(), null)); + anyFieldsRemoved = true; + } + } + return anyFieldsRemoved; + } + + private static boolean shouldUpdateField(String fetcherValue, Optional libraryValue) { + return libraryValue.map(value -> fetcherValue.length() > value.length()) + .orElse(true); + } +} diff --git a/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java b/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java new file mode 100644 index 00000000000..fc0e1ca3955 --- /dev/null +++ b/src/main/java/org/jabref/logic/importer/fetcher/MergingIdBasedFetcher.java @@ -0,0 +1,138 @@ +package org.jabref.logic.importer.fetcher; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jabref.logic.importer.FetcherException; +import org.jabref.logic.importer.IdBasedFetcher; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.WebFetchers; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.strings.StringUtil; + +import org.mariadb.jdbc.internal.logging.Logger; +import org.mariadb.jdbc.internal.logging.LoggerFactory; + +/** + * Fetches and merges bibliographic information from external sources into existing BibEntry objects. + * Supports multiple identifier types (DOI, ISBN, Eprint) and attempts fetching in a defined order + * until successful. + * The merging only adds new fields from the fetched entry and does not modify existing fields + * in the library entry. + */ +public class MergingIdBasedFetcher { + private static final Logger LOGGER = LoggerFactory.getLogger(MergingIdBasedFetcher.class); + private static final List SUPPORTED_FIELDS = + List.of(StandardField.DOI, StandardField.ISBN, StandardField.EPRINT); + private final ImportFormatPreferences importFormatPreferences; + + public MergingIdBasedFetcher(ImportFormatPreferences importFormatPreferences) { + this.importFormatPreferences = importFormatPreferences; + } + + public record FetcherResult( + BibEntry entryFromLibrary, + BibEntry mergedEntry, + boolean hasChanges, + Set updatedFields + ) { + public FetcherResult { + updatedFields = Set.copyOf(updatedFields); + } + } + + public Optional fetchEntry(BibEntry entryFromLibrary) { + if (entryFromLibrary == null) { + return Optional.empty(); + } + + logEntryProcessing(entryFromLibrary); + return findFirstValidFetch(entryFromLibrary); + } + + private void logEntryProcessing(BibEntry entry) { + LOGGER.debug("Processing library entry: {}", + entry.getCitationKey().orElse("[no key]")); + + SUPPORTED_FIELDS.forEach(field -> + entry.getField(field).ifPresent(value -> + LOGGER.debug("Entry has {} identifier: {}", field, value))); + } + + private Optional findFirstValidFetch(BibEntry entry) { + return SUPPORTED_FIELDS.stream() + .map(field -> fetchWithField(entry, field)) + .flatMap(Optional::stream) + .findFirst(); + } + + private Optional fetchWithField(BibEntry entry, Field field) { + return entry.getField(field) + .flatMap(identifier -> fetchWithIdentifier(field, identifier, entry)); + } + + private Optional fetchWithIdentifier(Field field, String identifier, + BibEntry entryFromLibrary) { + return WebFetchers.getIdBasedFetcherForField(field, importFormatPreferences) + .flatMap(fetcher -> executeFetch(fetcher, field, identifier, entryFromLibrary)); + } + + private Optional executeFetch(IdBasedFetcher fetcher, Field field, + String identifier, BibEntry entryFromLibrary) { + try { + LOGGER.debug("Fetching with {}: {}", + fetcher.getClass().getSimpleName(), identifier); + return fetcher.performSearchById(identifier) + .map(fetchedEntry -> mergeBibEntries(entryFromLibrary, fetchedEntry)); + } catch (FetcherException e) { + LOGGER.error("Fetch failed for {} with identifier: {}", + field, identifier, e); + return Optional.empty(); + } + } + + private FetcherResult mergeBibEntries(BibEntry entryFromLibrary, + BibEntry fetchedEntry) { + BibEntry mergedEntry = new BibEntry(entryFromLibrary.getType()); + + entryFromLibrary.getFields().forEach(field -> + entryFromLibrary.getField(field) + .ifPresent(value -> mergedEntry.setField(field, value))); + + Set updatedFields = updateFieldsFromSource(fetchedEntry, mergedEntry); + + return new FetcherResult(entryFromLibrary, mergedEntry, + !updatedFields.isEmpty(), updatedFields); + } + + private Set updateFieldsFromSource(BibEntry sourceEntry, + BibEntry targetEntry) { + return sourceEntry.getFields().stream() + .filter(field -> shouldUpdateField(field, sourceEntry, targetEntry)) + .peek(field -> updateField(field, sourceEntry, targetEntry)) + .collect(Collectors.toSet()); + } + + private boolean shouldUpdateField(Field field, BibEntry sourceEntry, BibEntry targetEntry) { + String sourceValue = sourceEntry.getField(field) + .map(StringUtil::normalize) + .orElse(""); + String targetValue = targetEntry.getField(field) + .map(StringUtil::normalize) + .orElse(""); + return !sourceValue.equals(targetValue); + } + + private void updateField(Field field, BibEntry sourceEntry, + BibEntry targetEntry) { + sourceEntry.getField(field).ifPresent(value -> { + targetEntry.setField(field, value); + LOGGER.debug("Updated field {}: '{}'", field, value); + }); + } +} + diff --git a/src/main/java/org/jabref/model/strings/StringUtil.java b/src/main/java/org/jabref/model/strings/StringUtil.java index 2237526cd3e..6cec1b9322e 100644 --- a/src/main/java/org/jabref/model/strings/StringUtil.java +++ b/src/main/java/org/jabref/model/strings/StringUtil.java @@ -25,6 +25,12 @@ public class StringUtil { // contains all possible line breaks, not omitting any break such as "\\n" private static final Pattern LINE_BREAKS = Pattern.compile("\\r\\n|\\r|\\n"); private static final Pattern BRACED_TITLE_CAPITAL_PATTERN = Pattern.compile("\\{[A-Z]+\\}"); + private static final Pattern NORMALIZE_PATTERN = Pattern.compile( + "\\s+|" + // multiple whitespace + "\\s*-+\\s*|" + // hyphens with surrounding spaces + "\\s*,\\s*|" + // commas with surrounding spaces + "\\s*;\\s*|" + // semicolons with surrounding spaces + "\\s*:\\s*"); // colons with surrounding spaces private static final UnicodeToReadableCharMap UNICODE_CHAR_MAP = new UnicodeToReadableCharMap(); public static String booleanToBinaryString(boolean expression) { @@ -755,4 +761,43 @@ public static String removeStringAtTheEnd(String string, String stringToBeRemove public static boolean endsWithIgnoreCase(String string, String suffix) { return StringUtils.endsWithIgnoreCase(string, suffix); } + + /** + * Normalizes a string by standardizing whitespace and punctuation. This includes: + * - Trimming outer whitespace + * - Converting multiple whitespace characters to a single space + * - Converting line breaks to spaces + * - Standardizing formatting around punctuation (hyphens, commas, semicolons, colons) + * + * @param value The string to normalize + * @return The normalized string, or empty string if input is null + */ + public static String normalize(String value) { + if (value == null) { + return ""; + } + + String withoutLineBreaks = LINE_BREAKS.matcher(value).replaceAll(" "); + + String trimmed = withoutLineBreaks.trim(); + return NORMALIZE_PATTERN.matcher(trimmed).replaceAll(match -> { + String matchStr = match.group(); + if (matchStr.matches("\\s+")) { + return " "; + } + if (matchStr.matches("\\s*-+\\s*")) { + return "-"; + } + if (matchStr.matches("\\s*,\\s*")) { + return ", "; + } + if (matchStr.matches("\\s*;\\s*")) { + return "; "; + } + if (matchStr.matches("\\s*:\\s*")) { + return ": "; + } + return matchStr; + }); + } } diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index 32bc777fcc6..95e541d2534 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -1335,7 +1335,6 @@ Help\ on\ Name\ Formatting=Help on Name Formatting Add\ new\ file\ type=Add new file type Original\ entry=Original entry -No\ information\ added=No information added Select\ at\ least\ one\ entry\ to\ manage\ keywords.=Select at least one entry to manage keywords. OpenDocument\ text=OpenDocument text OpenDocument\ spreadsheet=OpenDocument spreadsheet @@ -1691,8 +1690,18 @@ Get\ bibliographic\ data\ from\ %0=Get bibliographic data from %0 No\ %0\ found=No %0 found Entry\ from\ %0=Entry from %0 Merge\ entry\ with\ %0\ information=Merge entry with %0 information +Get\ bibliographic\ data\ from\ %0\ (fully\ automated)=Get bibliographic data from %0 (fully automated) +Batch\ update\ successful.\ %0\ entry(s)\ updated.=Batch update successful. %0 entry(s) updated. +Error\ processing\ entry=Error processing entry +Merge\ operation\ failed\:\ %0=Merge operation failed: %0 +No\ entries\ available\ for\ merging=No entries available for merging +Starting\ merge\ operation...=Starting merge operation... +Processing\ entry\ %0\ of\ %1=Processing entry %0 of %1 +Fetching\ and\ merging\ entry(s)=Fetching and merging entry(s) +No\ updates\ found.=No updates found. +Fetching\ information\ using\ %0=Fetching information using %0 +No\ information\ added=No information added Updated\ entry\ with\ info\ from\ %0=Updated entry with info from %0 - Add\ new\ list=Add new list Open\ existing\ list=Open existing list Remove\ list=Remove list @@ -2507,7 +2516,6 @@ Error\ downloading=Error downloading No\ data\ was\ found\ for\ the\ identifier=No data was found for the identifier Server\ not\ available=Server not available -Fetching\ information\ using\ %0=Fetching information using %0 Look\ up\ identifier=Look up identifier Bibliographic\ data\ not\ found.\ Cause\ is\ likely\ the\ client\ side.\ Please\ check\ connection\ and\ identifier\ for\ correctness.=Bibliographic data not found. Cause is likely the client side. Please check connection and identifier for correctness. Bibliographic\ data\ not\ found.\ Cause\ is\ likely\ the\ server\ side.\ Please\ try\ again\ later.=Bibliographic data not found. Cause is likely the server side. Please try again later.