Skip to content

Add auto-renaming of linked files on entry data change #13295

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
### Added

- We introduced a settings parameters to manage citations' relations local storage time-to-live with a default value set to 30 days. [#11189](https://github.com/JabRef/jabref/issues/11189)
- We introduced an option in Preferences under (under Linked files -> Linked file name conventions) to automatically rename linked files when an entry data changes. [#11316](https://github.com/JabRef/jabref/issues/11316)

### Changed

Expand Down
4 changes: 4 additions & 0 deletions jabgui/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ dependencies {
testImplementation("io.github.classgraph:classgraph:4.8.179")
testImplementation("org.testfx:testfx-core:4.0.16-alpha")
testImplementation("org.testfx:testfx-junit5:4.0.16-alpha")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.12.2")
testImplementation("org.junit.jupiter:junit-jupiter:5.12.2")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.12.2")
testImplementation("org.junit.platform:junit-platform-launcher:1.12.2")

testImplementation("org.mockito:mockito-core:5.18.0") {
exclude(group = "net.bytebuddy", module = "byte-buddy")
Expand Down
2 changes: 2 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/LibraryTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import org.jabref.gui.collab.DatabaseChangeMonitor;
import org.jabref.gui.dialogs.AutosaveUiManager;
import org.jabref.gui.exporter.SaveDatabaseAction;
import org.jabref.gui.externalfiles.AutoRenameFileOnEntryChange;
import org.jabref.gui.externalfiles.ImportHandler;
import org.jabref.gui.fieldeditors.LinkedFileViewModel;
import org.jabref.gui.importer.actions.OpenDatabaseAction;
Expand Down Expand Up @@ -203,6 +204,7 @@ private void initializeComponentsAndListeners(boolean isDummyContext) {

bibDatabaseContext.getDatabase().registerListener(this);
bibDatabaseContext.getMetaData().registerListener(this);
new AutoRenameFileOnEntryChange(bibDatabaseContext, preferences);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The instantiation of AutoRenameFileOnEntryChange should be moved to org.jabref.logic package as it involves non-GUI operations, adhering to the layered architecture principle.


this.selectedGroupsProperty = new SimpleListProperty<>(stateManager.getSelectedGroups(bibDatabaseContext));
this.tableModel = new MainTableDataModel(getBibDatabaseContext(), preferences, taskExecutor, getIndexManager(), selectedGroupsProperty(), searchQueryProperty, resultSizeProperty());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package org.jabref.gui.externalfiles;

import java.util.HashSet;
import java.util.Set;

import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.FilePreferences;
import org.jabref.logic.citationkeypattern.CitationKeyGenerator;
import org.jabref.logic.cleanup.RenamePdfCleanup;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.event.FieldChangedEvent;

import com.google.common.eventbus.Subscribe;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.jabref.logic.citationkeypattern.BracketedPattern.expandBrackets;

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

private final GuiPreferences preferences;
private final BibDatabaseContext bibDatabaseContext;
private final RenamePdfCleanup renamePdfCleanup;

public AutoRenameFileOnEntryChange(BibDatabaseContext bibDatabaseContext, GuiPreferences preferences) {
this.bibDatabaseContext = bibDatabaseContext;
this.preferences = preferences;
bibDatabaseContext.getDatabase().registerListener(this);
renamePdfCleanup = new RenamePdfCleanup(false, () -> bibDatabaseContext, preferences.getFilePreferences());
}

@Subscribe
public void listen(FieldChangedEvent event) {
FilePreferences filePreferences = preferences.getFilePreferences();

if (!filePreferences.shouldAutoRenameFilesOnChange()
|| filePreferences.getFileNamePattern().isEmpty()
|| filePreferences.getFileNamePattern() == null
|| !relatesToFilePattern(filePreferences.getFileNamePattern(), event)) {
return;
}

BibEntry entry = event.getBibEntry();
if (entry.getFiles().isEmpty()) {
return;
}
new CitationKeyGenerator(bibDatabaseContext, preferences.getCitationKeyPatternPreferences()).generateAndSetKey(entry);
renamePdfCleanup.cleanup(entry);

LOGGER.info("Field changed for entry {}: {}", entry.getCitationKey().orElse("defaultCitationKey"), event.getField().getName());
}

private boolean relatesToFilePattern(String fileNamePattern, FieldChangedEvent event) {
Set<String> extractedFields = new HashSet<>();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 'new HashSet<>()' is not optimal. Consider using 'Set.of()' for better readability and performance as per modern Java practices.


expandBrackets(fileNamePattern, bracketContent -> {
extractedFields.add(bracketContent);
return bracketContent;
});

return extractedFields.contains("bibtexkey")
|| extractedFields.contains(event.getField().getName());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class LinkedFilesTab extends AbstractPreferenceTabView<LinkedFilesTabView
@FXML private TextField autolinkRegexKey;

@FXML private CheckBox fulltextIndex;
@FXML private CheckBox autoRenameFilesOnChange;

@FXML private ComboBox<String> fileNamePattern;
@FXML private TextField fileDirectoryPattern;
Expand Down Expand Up @@ -73,6 +74,7 @@ public void initialize() {
autolinkRegexKey.textProperty().bindBidirectional(viewModel.autolinkRegexKeyProperty());
autolinkRegexKey.disableProperty().bind(autolinkUseRegex.selectedProperty().not());
fulltextIndex.selectedProperty().bindBidirectional(viewModel.fulltextIndexProperty());
autoRenameFilesOnChange.selectedProperty().bindBidirectional(viewModel.autoRenameFilesOnChangeProperty());
fileNamePattern.valueProperty().bindBidirectional(viewModel.fileNamePatternProperty());
fileNamePattern.itemsProperty().bind(viewModel.defaultFileNamePatternsProperty());
fileDirectoryPattern.textProperty().bindBidirectional(viewModel.fileDirectoryPatternProperty());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class LinkedFilesTabViewModel implements PreferenceTabViewModel {
private final ListProperty<String> defaultFileNamePatternsProperty =
new SimpleListProperty<>(FXCollections.observableArrayList(FilePreferences.DEFAULT_FILENAME_PATTERNS));
private final BooleanProperty fulltextIndex = new SimpleBooleanProperty();
private final BooleanProperty autoRenameFilesOnChangeProperty = new SimpleBooleanProperty();
private final StringProperty fileNamePatternProperty = new SimpleStringProperty();
private final StringProperty fileDirectoryPatternProperty = new SimpleStringProperty();
private final BooleanProperty confirmLinkedFileDeleteProperty = new SimpleBooleanProperty();
Expand Down Expand Up @@ -82,6 +83,7 @@ public void setValues() {
useMainFileDirectoryProperty.setValue(!filePreferences.shouldStoreFilesRelativeToBibFile());
useBibLocationAsPrimaryProperty.setValue(filePreferences.shouldStoreFilesRelativeToBibFile());
fulltextIndex.setValue(filePreferences.shouldFulltextIndexLinkedFiles());
autoRenameFilesOnChangeProperty.setValue(filePreferences.shouldAutoRenameFilesOnChange());
fileNamePatternProperty.setValue(filePreferences.getFileNamePattern());
fileDirectoryPatternProperty.setValue(filePreferences.getFileDirectoryPattern());
confirmLinkedFileDeleteProperty.setValue(filePreferences.confirmDeleteLinkedFile());
Expand All @@ -104,6 +106,7 @@ public void storeSettings() {
// External files preferences / Attached files preferences / File preferences
filePreferences.setMainFileDirectory(mainFileDirectoryProperty.getValue());
filePreferences.setStoreFilesRelativeToBibFile(useBibLocationAsPrimaryProperty.getValue());
filePreferences.setAutoRenameFilesOnChange(autoRenameFilesOnChangeProperty.getValue());
filePreferences.setFileNamePattern(fileNamePatternProperty.getValue());
filePreferences.setFileDirectoryPattern(fileDirectoryPatternProperty.getValue());
filePreferences.setFulltextIndexLinkedFiles(fulltextIndex.getValue());
Expand Down Expand Up @@ -179,6 +182,10 @@ public ListProperty<String> defaultFileNamePatternsProperty() {
return defaultFileNamePatternsProperty;
}

public BooleanProperty autoRenameFilesOnChangeProperty() {
return autoRenameFilesOnChangeProperty;
}

public StringProperty fileNamePatternProperty() {
return fileNamePatternProperty;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
<CheckBox fx:id="fulltextIndex" text="%Automatically index all linked files for fulltext search"/>

<Label styleClass="sectionHeader" text="%Linked file name conventions"/>
<CheckBox fx:id="autoRenameFilesOnChange" text="%Auto rename files if entry changes"/>
<GridPane hgap="4.0" vgap="4.0">
<columnConstraints>
<ColumnConstraints hgrow="SOMETIMES" percentWidth="30.0"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package org.jabref.gui.externalfiles;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;

import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.logic.FilePreferences;
import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences;
import org.jabref.logic.citationkeypattern.GlobalCitationKeyPatterns;
import org.jabref.model.database.BibDatabase;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.LinkedFile;
import org.jabref.model.entry.field.StandardField;
import org.jabref.model.entry.types.StandardEntryType;
import org.jabref.model.metadata.MetaData;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static org.jabref.logic.citationkeypattern.CitationKeyGenerator.DEFAULT_UNWANTED_CHARACTERS;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class AutoRenameFileOnEntryChangeTest {
private FilePreferences filePreferences;
private BibEntry entry;
private Path tempDir;

@BeforeEach
void setUp(@TempDir Path tempDir) {
this.tempDir = tempDir;
MetaData metaData = new MetaData();
metaData.setLibrarySpecificFileDirectory(tempDir.toString());
BibDatabaseContext bibDatabaseContext = new BibDatabaseContext(new BibDatabase(), metaData);
GlobalCitationKeyPatterns keyPattern = GlobalCitationKeyPatterns.fromPattern("[auth][year]");
GuiPreferences guiPreferences = mock(GuiPreferences.class);
filePreferences = mock(FilePreferences.class);
CitationKeyPatternPreferences patternPreferences = new CitationKeyPatternPreferences(
false,
true,
false,
CitationKeyPatternPreferences.KeySuffix.SECOND_WITH_A,
"",
"",
DEFAULT_UNWANTED_CHARACTERS,
keyPattern,
"",
',');

when(guiPreferences.getCitationKeyPatternPreferences()).thenReturn(patternPreferences);
when(guiPreferences.getFilePreferences()).thenReturn(filePreferences);
when(filePreferences.shouldStoreFilesRelativeToBibFile()).thenReturn(true);
when(filePreferences.getFileNamePattern()).thenReturn("[bibtexkey]");

entry = new BibEntry(StandardEntryType.Article).withCitationKey("oldKey2081")
.withField(StandardField.AUTHOR, "oldKey")
.withField(StandardField.YEAR, "2081");

bibDatabaseContext.getDatabase().insertEntry(entry);
new AutoRenameFileOnEntryChange(bibDatabaseContext, guiPreferences);
}

@Test
void noFileRenameByDefault() throws IOException {
Files.createFile(tempDir.resolve("oldKey2081.pdf"));
entry.setFiles(List.of(new LinkedFile("", "oldKey2081.pdf", "PDF")));
entry.setField(StandardField.AUTHOR, "newKey");

assertEquals("oldKey2081.pdf", entry.getFiles().getFirst().getLink());
assertTrue(Files.exists(tempDir.resolve("oldKey2081.pdf")));
}

@Test
void noFileRenameOnEmptyFilePattern() throws IOException {
Files.createFile(tempDir.resolve("oldKey2081.pdf"));
entry.setFiles(List.of(new LinkedFile("", "oldKey2081.pdf", "PDF")));
when(filePreferences.getFileNamePattern()).thenReturn("");
when(filePreferences.shouldAutoRenameFilesOnChange()).thenReturn(true);
entry.setField(StandardField.AUTHOR, "newKey");

assertEquals("oldKey2081.pdf", entry.getFiles().getFirst().getLink());
assertTrue(Files.exists(tempDir.resolve("oldKey2081.pdf")));
}

@Test
void singleFileRenameOnEntryChange() throws IOException {
Files.createFile(tempDir.resolve("oldKey2081.pdf"));
entry.setFiles(List.of(new LinkedFile("", "oldKey2081.pdf", "PDF")));
when(filePreferences.shouldAutoRenameFilesOnChange()).thenReturn(true);

// change author only
entry.setField(StandardField.AUTHOR, "newKey");
assertEquals("newKey2081.pdf", entry.getFiles().getFirst().getLink());
assertTrue(Files.exists(tempDir.resolve("newKey2081.pdf")));

// change year only
entry.setField(StandardField.YEAR, "2082");
assertEquals("newKey2082.pdf", entry.getFiles().getFirst().getLink());
assertTrue(Files.exists(tempDir.resolve("newKey2082.pdf")));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion checks for a boolean condition instead of asserting the expected content of the object. Use assertEquals to compare expected and actual values.

}

@Test
void multipleFilesRenameOnEntryChange() throws IOException {
// create multiple entries
List<String> fileNames = Arrays.asList(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modern Java data structures should be used. Instead of Arrays.asList, consider using List.of for better performance and readability.

"oldKey2081.pdf",
"oldKey2081.jpg",
"oldKey2081.csv",
"oldKey2081.doc",
"oldKey2081.docx"
);

for (String fileName : fileNames) {
Path filePath = tempDir.resolve(fileName);
Files.createFile(filePath);
}

LinkedFile pdfLinkedFile = new LinkedFile("", "oldKey2081.pdf", "PDF");
LinkedFile jpgLinkedFile = new LinkedFile("", "oldKey2081.jpg", "JPG");
LinkedFile csvLinkedFile = new LinkedFile("", "oldKey2081.csv", "CSV");
LinkedFile docLinkedFile = new LinkedFile("", "oldKey2081.doc", "DOC");
LinkedFile docxLinkedFile = new LinkedFile("", "oldKey2081.docx", "DOCX");

entry.setFiles(List.of(pdfLinkedFile, jpgLinkedFile, csvLinkedFile, docLinkedFile, docxLinkedFile));
when(filePreferences.shouldAutoRenameFilesOnChange()).thenReturn(true);

// Change author only
entry.setField(StandardField.AUTHOR, "newKey");
assertTrue(Files.exists(tempDir.resolve("newKey2081.pdf")));
assertTrue(Files.exists(tempDir.resolve("newKey2081.jpg")));
assertTrue(Files.exists(tempDir.resolve("newKey2081.csv")));
assertTrue(Files.exists(tempDir.resolve("newKey2081.doc")));
assertTrue(Files.exists(tempDir.resolve("newKey2081.docx")));

// change year only
entry.setField(StandardField.YEAR, "2082");
assertTrue(Files.exists(tempDir.resolve("newKey2082.pdf")));
assertTrue(Files.exists(tempDir.resolve("newKey2082.jpg")));
assertTrue(Files.exists(tempDir.resolve("newKey2082.csv")));
assertTrue(Files.exists(tempDir.resolve("newKey2082.doc")));
assertTrue(Files.exists(tempDir.resolve("newKey2082.docx")));
}
}
15 changes: 15 additions & 0 deletions jablib/src/main/java/org/jabref/logic/FilePreferences.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class FilePreferences {
private final StringProperty userAndHost = new SimpleStringProperty();
private final SimpleStringProperty mainFileDirectory = new SimpleStringProperty();
private final BooleanProperty storeFilesRelativeToBibFile = new SimpleBooleanProperty();
private final BooleanProperty autoRenameFilesOnChange = new SimpleBooleanProperty();
private final StringProperty fileNamePattern = new SimpleStringProperty();
private final StringProperty fileDirectoryPattern = new SimpleStringProperty();
private final BooleanProperty downloadLinkedFiles = new SimpleBooleanProperty();
Expand All @@ -39,6 +40,7 @@ public class FilePreferences {
public FilePreferences(String userAndHost,
String mainFileDirectory,
boolean storeFilesRelativeToBibFile,
boolean autoRenameFilesOnChange,
String fileNamePattern,
String fileDirectoryPattern,
boolean downloadLinkedFiles,
Expand All @@ -55,6 +57,7 @@ public FilePreferences(String userAndHost,
this.userAndHost.setValue(userAndHost);
this.mainFileDirectory.setValue(mainFileDirectory);
this.storeFilesRelativeToBibFile.setValue(storeFilesRelativeToBibFile);
this.autoRenameFilesOnChange.setValue(autoRenameFilesOnChange);
this.fileNamePattern.setValue(fileNamePattern);
this.fileDirectoryPattern.setValue(fileDirectoryPattern);
this.downloadLinkedFiles.setValue(downloadLinkedFiles);
Expand Down Expand Up @@ -106,6 +109,18 @@ public void setStoreFilesRelativeToBibFile(boolean shouldStoreFilesRelativeToBib
this.storeFilesRelativeToBibFile.set(shouldStoreFilesRelativeToBibFile);
}

public boolean shouldAutoRenameFilesOnChange() {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method should return an Optional instead of a primitive boolean to avoid returning null and to align with modern Java practices.

return autoRenameFilesOnChange.get();
}

public BooleanProperty autoRenameFilesOnChangeProperty() {
return autoRenameFilesOnChange;
}

public void setAutoRenameFilesOnChange(boolean autoRenameFilesOnChange) {
this.autoRenameFilesOnChange.set(autoRenameFilesOnChange);
}

public String getFileNamePattern() {
return fileNamePattern.get();
}
Expand Down
Loading
Loading