Skip to content
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 @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv

### Added

- We added a library-specific override of the "Auto rename files if entry changes" preference to the library properties. [#769](https://github.com/JabRef/jabref-koppor/pull/769)
- In directory libraries, a sidecar and its PDF are now renamed together to the configured filename pattern (Preferences > Linked files) whenever the entry is edited — e.g. changing the citation key renames both files. [#741](https://github.com/JabRef/jabref-koppor/pull/741)
- The groups panel of a directory library now mirrors the folder structure: each subdirectory appears as a group containing the entries whose files live there (updated live as files change). [#740](https://github.com/JabRef/jabref-koppor/pull/740)
- Directory libraries now save into their sidecar files: edits are written back automatically (debounced until typing pauses; Ctrl+S forces the write and no longer creates a `.bib`), the first edit of a PDF-only entry creates a Markdown sidecar (`X.md` with the Hayagriva data as frontmatter and the comment fields as notes body), renaming a citation key renames the YAML key, and deleting an entry removes it from its file (the file is trashed once empty, the PDF stays). Hand-written content that JabRef does not understand survives rewrites. [#739](https://github.com/JabRef/jabref-koppor/pull/739)
Expand Down
10 changes: 6 additions & 4 deletions docs/requirements/directory-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@ entries or subgroups can be added to them, they cannot be dragged or edited.
Needs: impl

## The sidecar and its PDF follow the configured filename pattern
`req~directory-library.pattern-rename~1`
`req~directory-library.pattern-rename~2`

When write-back touches a single-entry sidecar, the sidecar and its equally named PDF are
renamed together to the base name the configured filename pattern (Linked files preferences)
generates for the entry, keeping the pair in sync. Multi-entry files have no single generating
When write-back touches a single-entry sidecar and auto-renaming is enabled, the sidecar and
its equally named PDF are renamed together to the base name the configured filename pattern
(Linked files preferences) generates for the entry, keeping the pair in sync. Auto-renaming
follows the global "Auto rename files if entry changes" preference unless the library
properties override it (General tab; the same override applies to `.bib` libraries). Multi-entry files have no single generating
entry and keep their name; occupied target names and pattern failures leave the current name
untouched. Entry file links and the catalog follow the rename; the watcher does not re-import
the renamed files.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.jabref.gui.collab.metedatachange;

import java.util.Optional;

import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.VBox;
Expand Down Expand Up @@ -72,14 +74,23 @@ private ScrollPane createDefaultDiffScrollPane(MetaDataDiff.Difference diff) {
VBox diffContainer = new VBox(12);

// Show both original and new values
diffContainer.getChildren().add(new Label(diff.originalObject().toString()));
diffContainer.getChildren().add(new Label(diff.newObject().toString()));
diffContainer.getChildren().add(new Label(formatValue(diff, diff.originalObject())));
diffContainer.getChildren().add(new Label(formatValue(diff, diff.newObject())));

ScrollPane scrollPane = new ScrollPane(diffContainer);
scrollPane.setFitToWidth(true);
return scrollPane;
}

private static String formatValue(MetaDataDiff.Difference diff, Object value) {
if (diff.differenceType() == MetaDataDiff.DifferenceType.AUTO_RENAME_FILES_ON_CHANGE
&& value instanceof Optional<?> override) {
return override.map(rename -> Boolean.TRUE.equals(rename) ? Localization.lang("Yes") : Localization.lang("No"))
.orElse(Localization.lang("Use global preference"));
}
return value.toString();
}

private String getDifferenceString(MetaDataDiff.DifferenceType changeType) {
return switch (changeType) {
case PROTECTED ->
Expand All @@ -106,6 +117,8 @@ private String getDifferenceString(MetaDataDiff.DifferenceType changeType) {
Localization.lang("Library-specific file directory");
case CONTENT_SELECTOR ->
Localization.lang("Content selectors");
case AUTO_RENAME_FILES_ON_CHANGE ->
Localization.lang("Auto rename files if entry changes");
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import org.jabref.logic.FilePreferences;
import org.jabref.logic.cleanup.RenamePdfCleanup;
import org.jabref.logic.shared.DatabaseLocation;
import org.jabref.logic.util.strings.StringUtil;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.event.FieldChangedEvent;
Expand All @@ -14,19 +16,27 @@
public class AutoRenameFileOnEntryChange {
private static final Logger LOGGER = LoggerFactory.getLogger(AutoRenameFileOnEntryChange.class);

private final BibDatabaseContext bibDatabaseContext;
private final FilePreferences filePreferences;
private final RenamePdfCleanup renamePdfCleanup;

public AutoRenameFileOnEntryChange(BibDatabaseContext bibDatabaseContext, FilePreferences filePreferences) {
this.bibDatabaseContext = bibDatabaseContext;
this.filePreferences = filePreferences;
renamePdfCleanup = new RenamePdfCleanup(false, () -> bibDatabaseContext, filePreferences);
}

/// The library properties override the global preference when set (`MetaData#getAutoRenameFilesOnChange`);
/// without a filename pattern there is nothing to rename to.
public static boolean isEnabled(BibDatabaseContext bibDatabaseContext, FilePreferences filePreferences) {
return !StringUtil.isBlank(filePreferences.getFileNamePattern())
&& bibDatabaseContext.getMetaData().getAutoRenameFilesOnChange().orElseGet(filePreferences::shouldAutoRenameFilesOnChange);
}

@Subscribe
public void listen(FieldChangedEvent event) {
if (!filePreferences.shouldAutoRenameFilesOnChange()
|| filePreferences.getFileNamePattern().isEmpty()
|| filePreferences.getFileNamePattern() == null) {
// A directory library renames its sidecar/PDF pairs in the write-back (see SidecarWriteBack)
if (bibDatabaseContext.getLocation() == DatabaseLocation.DIRECTORY || !isEnabled(bibDatabaseContext, filePreferences)) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.jabref.gui.clipboard.ClipBoardManager;
import org.jabref.gui.desktop.os.NativeDesktop;
import org.jabref.gui.exporter.SaveDatabaseAction;
import org.jabref.gui.externalfiles.AutoRenameFileOnEntryChange;
import org.jabref.gui.git.GitConflictResolverDialog;
import org.jabref.gui.git.GuiGitConflictResolverStrategy;
import org.jabref.gui.preferences.GuiPreferences;
Expand Down Expand Up @@ -160,8 +161,12 @@ private void showLibraryTab(DirectoryLibraryScanner.ScanResult scanResult, PdfEn
libraryTab.updateTabTitle(false);

BibDatabaseContext databaseContext = scanResult.databaseContext();
Function<BibEntry, Optional<String>> fileNameGenerator = entry -> FileUtil.createFileNameFromPattern(
databaseContext.getDatabase(), entry, preferences.getFilePreferences().getFileNamePattern());
// Evaluated per write, so a changed preference (global or library) takes effect immediately
// [impl->req~directory-library.pattern-rename~2]
Function<BibEntry, Optional<String>> fileNameGenerator = entry ->
AutoRenameFileOnEntryChange.isEnabled(databaseContext, preferences.getFilePreferences())
? FileUtil.createFileNameFromPattern(databaseContext.getDatabase(), entry, preferences.getFilePreferences().getFileNamePattern())
: Optional.empty();
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment on lines +167 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

9. Long names split folder file pairs 🐞 Bug ≡ Correctness

The directory-library fileNameGenerator returns the raw pattern basename while
AutoRenameFileOnEntryChange sends the same entry through LinkedFileHandler, which truncates the
PDF filename to 255 characters. When an enabled library override processes a sufficiently long
generated name, the PDF moves to the truncated basename before SidecarWriteBack looks for it under
the old basename, so only the sidecar receives the untruncated name and the pair is no longer
associated.
Agent Prompt
## Issue description
Directory libraries can pass an untruncated pattern result to sidecar write-back while the ordinary linked-file renamer truncates the corresponding PDF filename. Long generated names can therefore split an equally named PDF and sidecar pair.

## Fix Focus Areas
- jabgui/src/main/java/org/jabref/gui/importer/actions/OpenDirectoryLibraryAction.java[166-169]
- jablib/src/main/java/org/jabref/logic/directorylibrary/SidecarWriteBack.java[191-203]
- jablib/src/main/java/org/jabref/logic/externalfiles/LinkedFileHandler.java[245-255]

## Recommended Fix
Centralize sanitization and length limiting for generated pair basenames, accounting for both PDF and sidecar extensions, and use the same resulting basename in the ordinary linked-file renamer and directory sidecar write-back. Add a directory-library test with a generated name exceeding the filesystem limit and verify that the PDF, sidecar, catalog, and entry link all retain one matching basename.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Generated with Claude Code
Fixed: the entry-change renamer now skips folder libraries, so only the write-back renames the pair (one basename, no overlap).

Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
GuiGitConflictResolverStrategy conflictResolver = new GuiGitConflictResolverStrategy(
new GitConflictResolverDialog(dialogService, preferences, stateManager));
DirectoryLibrarySynchronizer synchronizer = new DirectoryLibrarySynchronizer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.UnaryOperator;

import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
Expand Down Expand Up @@ -37,6 +39,7 @@ public class GeneralPropertiesView extends AbstractPropertiesTabView<GeneralProp
@FXML private TextField userSpecificFileDirectory;
@FXML private TextField latexFileDirectory;
@FXML private TextField keywordSeparator;
@FXML private ComboBox<Optional<Boolean>> autoRenameFilesOnChange;
@FXML private Button libSpecificFileDirSwitchId;
@FXML private Button userSpecificFileDirSwitchId;
@FXML private Button laTexSpecificFileDirSwitchId;
Expand Down Expand Up @@ -101,6 +104,14 @@ public void initialize() {
change -> change.getControlNewText().length() <= 1 ? change : null;
keywordSeparator.setTextFormatter(new TextFormatter<>(singleCharacterFilter));

boolean globalAutoRename = preferences.getFilePreferences().shouldAutoRenameFilesOnChange();
new ViewModelListCellFactory<Optional<Boolean>>()
.withText(choice -> choice.map(GeneralPropertiesView::yesOrNo)
.orElse(Localization.lang("Use global preference (%0)", yesOrNo(globalAutoRename))))
.install(autoRenameFilesOnChange);
autoRenameFilesOnChange.setItems(FXCollections.observableArrayList(Optional.empty(), Optional.of(true), Optional.of(false)));
autoRenameFilesOnChange.valueProperty().bindBidirectional(viewModel.autoRenameFilesOnChangeProperty());

userSpecificFileDirectoryTooltip.setText(Localization.lang("User-specific file directory: %0", preferences.getFilePreferences().getUserAndHost()));
userSpecificFileDirectory.setTooltip(userSpecificFileDirectoryTooltip);

Expand Down Expand Up @@ -179,4 +190,8 @@ void userSpecificFileDirPathSwitch() {
void laTexSpecificFileDirPathSwitch() {
viewModel.togglePath(viewModel.laTexFileDirectoryProperty());
}

private static String yesOrNo(boolean value) {
return value ? Localization.lang("Yes") : Localization.lang("No");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public class GeneralPropertiesViewModel implements PropertiesTabViewModel {
private final StringProperty userSpecificFileDirectoryProperty = new SimpleStringProperty("");
private final StringProperty laTexFileDirectoryProperty = new SimpleStringProperty("");
private final StringProperty keywordSeparatorProperty = new SimpleStringProperty("");
private final ObjectProperty<Optional<Boolean>> autoRenameFilesOnChangeProperty = new SimpleObjectProperty<>(Optional.empty());

private final Validator librarySpecificFileDirectoryValidator;
private final Validator userSpecificFileDirectoryValidator;
Expand Down Expand Up @@ -91,6 +92,7 @@ public void setValues(MetaData metaData) {
userSpecificFileDirectoryProperty.setValue(metaData.getUserFileDirectory(preferences.getFilePreferences().getUserAndHost()).orElse("").trim());
laTexFileDirectoryProperty.setValue(metaData.getLatexFileDirectory(preferences.getFilePreferences().getUserAndHost()).map(Path::toString).orElse(""));
keywordSeparatorProperty.setValue(metaData.getKeywordSeparator().map(Object::toString).orElse(""));
autoRenameFilesOnChangeProperty.setValue(metaData.getAutoRenameFilesOnChange());
}

@Override
Expand Down Expand Up @@ -120,6 +122,7 @@ public void storeSettings(MetaData metaData) {
}

storeKeywordSeparator(metaData);
autoRenameFilesOnChangeProperty.getValue().ifPresentOrElse(metaData::setAutoRenameFilesOnChange, metaData::clearAutoRenameFilesOnChange);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Shared libraries keep stale rename rules 🐞 Bug ≡ Correctness

storeSettings clears the optional override, but MetaDataSerializer then omits its key while
shared metadata persistence only upserts emitted keys and remote parsing never clears an absent key.
When a user switches back to the global preference in a shared library, other clients can
indefinitely retain the previous override and continue renaming or not renaming linked files against
the current setting.
Agent Prompt
## Issue description
Clearing `autoRenameFilesOnChange` omits it from serialized metadata, but shared SQL persistence does not delete omitted ordinary keys and parsing into an existing `MetaData` does not clear an absent override. Other shared-library clients therefore retain the previous library-specific rename behavior.

## Fix Focus Areas
- jabgui/src/main/java/org/jabref/gui/libraryproperties/general/GeneralPropertiesViewModel.java[125-125]
- jablib/src/main/java/org/jabref/logic/shared/DBMSProcessor.java[624-637]
- jablib/src/main/java/org/jabref/logic/importer/util/MetaDataParser.java[164-171]

## Recommended Fix
Treat absence of this optional key as an explicit clear throughout shared synchronization: delete the stored SQL metadata row and notify clients when a complete serialized snapshot omits it, and clear the existing `MetaData` value before or while applying a fetched snapshot that lacks it. Add a shared-database synchronization test covering a transition from an explicit override to the global setting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 Generated with Claude Code
Not done: pre-existing shared-DB limitation for every clearable key (keyword separator, file directory, git flags), not specific to this override. Separate PR if wanted.

}

/// The separator and the group definitions the migration rewrites are both metadata, so the
Expand Down Expand Up @@ -225,6 +228,10 @@ public StringProperty keywordSeparatorProperty() {
return this.keywordSeparatorProperty;
}

public ObjectProperty<Optional<Boolean>> autoRenameFilesOnChangeProperty() {
return this.autoRenameFilesOnChangeProperty;
}

private Path getBrowseDirectory(String configuredDir) {
Optional<Path> libPath = this.databaseContext.getDatabasePath();
Path workingDir = preferences.getFilePreferences().getWorkingDirectory();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@
GridPane.columnIndex="0" GridPane.rowIndex="7"/>
<TextField fx:id="keywordSeparator" prefWidth="50.0" maxWidth="50.0"
GridPane.columnIndex="1" GridPane.rowIndex="7"/>
<Label text="%Auto rename files if entry changes"
GridPane.columnIndex="0" GridPane.rowIndex="8"/>
<ComboBox fx:id="autoRenameFilesOnChange" prefWidth="150.0"
GridPane.columnIndex="1" GridPane.hgrow="ALWAYS" GridPane.rowIndex="8"/>

</GridPane>
</fx:root>
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

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

Expand All @@ -42,7 +43,7 @@ void setUp(@TempDir Path tempDir) {
this.tempDir = tempDir;
MetaData metaData = new MetaData();
metaData.setLibrarySpecificFileDirectory(tempDir.toString());
BibDatabaseContext bibDatabaseContext = new BibDatabaseContext(new BibDatabase(), metaData);
bibDatabaseContext = new BibDatabaseContext(new BibDatabase(), metaData);
GlobalCitationKeyPatterns keyPattern = GlobalCitationKeyPatterns.fromPattern("[auth][year]");
GuiPreferences guiPreferences = mock(GuiPreferences.class);
filePreferences = mock(FilePreferences.class);
Expand Down Expand Up @@ -110,6 +111,45 @@ void noFileRenameOnEmptyFilePattern() throws IOException {
assertFileExists(tempDir.resolve("oldKey2081.pdf"));
}

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

entry.setField(StandardField.AUTHOR, "newKey");

assertEquals("newKey2081.pdf", entry.getFiles().getFirst().getLink());
assertFileExists(tempDir.resolve("newKey2081.pdf"));
}

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

entry.setField(StandardField.AUTHOR, "newKey");

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

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

entry.setField(StandardField.AUTHOR, "newKey");

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

@Test
void singleFileRenameOnEntryChange() throws IOException {
Files.createFile(tempDir.resolve("oldKey2081.pdf"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

public class MetaDataDiff {
public enum DifferenceType {
AUTO_RENAME_FILES_ON_CHANGE,
CONTENT_SELECTOR,
DEFAULT_KEY_PATTERN,
ENCODING,
Expand Down Expand Up @@ -112,6 +113,7 @@ public List<Difference> getDifferences(GlobalCitationKeyPatterns globalCitationK
addToListIfDiff(changes, DifferenceType.MODE, originalMetaData.getMode(), newMetaData.getMode());
addToListIfDiff(changes, DifferenceType.LIBRARY_SPECIFIC_FILE_DIRECTORY, originalMetaData.getLibrarySpecificFileDirectory(), newMetaData.getLibrarySpecificFileDirectory());
addToListIfDiff(changes, DifferenceType.CONTENT_SELECTOR, originalMetaData.getContentSelectors(), newMetaData.getContentSelectors());
addToListIfDiff(changes, DifferenceType.AUTO_RENAME_FILES_ON_CHANGE, originalMetaData.getAutoRenameFilesOnChange(), newMetaData.getAutoRenameFilesOnChange());
return changes;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.jabref.model.entry.BibEntry;
import org.jabref.model.groups.DirectoryStructureGroup;
import org.jabref.model.groups.GroupTreeNode;
import org.jabref.model.metadata.MetaData;

import org.jspecify.annotations.NullMarked;
import org.slf4j.Logger;
Expand Down Expand Up @@ -120,9 +121,12 @@ void doInitialize() {
writeScheduler.accept(mirror);
return;
}
// The mirror's metadata is the only place user-defined groups of a directory
// library survive a restart — the sidecars carry entries, not library metadata
readBibContext(mirror).ifPresent(this::adoptUserGroups);
// The mirror's metadata is the only place library settings and user-defined groups of
// a directory library survive a restart — the sidecars carry entries, not library metadata
readBibContext(mirror).ifPresent(remote -> {
adoptLibrarySettings(remote);
adoptUserGroups(remote);
});
try {
if (Files.exists(baseFile(root)) && Files.mismatch(mirror, baseFile(root)) == -1L) {
return;
Expand Down Expand Up @@ -180,6 +184,22 @@ private void merge(BibDatabaseContext remote) {
markDirty();
}

/// Restores the library properties that the sidecars do not carry. The keyword separator
/// comes first: explicit group memberships in the sidecars are separated by it.
private void adoptLibrarySettings(BibDatabaseContext remote) {
MetaData remoteMetaData = remote.getMetaData();
Optional<Character> keywordSeparator = remoteMetaData.getKeywordSeparator();
Optional<Boolean> autoRename = remoteMetaData.getAutoRenameFilesOnChange();
if (keywordSeparator.isEmpty() && autoRename.isEmpty()) {
return;
}
modelUpdateMarshaller.accept(() -> {
MetaData metaData = databaseContext.getMetaData();
keywordSeparator.ifPresent(metaData::setKeywordSeparator);
autoRename.ifPresent(metaData::setAutoRenameFilesOnChange);
});
}

/// Restores user-defined groups from the mirror's metadata into the freshly scanned
/// context (whose tree only holds the automatic directory-structure group). The
/// serialized directory-structure group itself is skipped — the scanner installs it with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
/// The debounce, retry, and reporting of pending writes live in [PendingWrites]; this class
/// only knows how to write one file. Callers hold the synchronizer's monitor.
// [impl->req~directory-library.write-back~2]
// [impl->req~directory-library.pattern-rename~1]
// [impl->req~directory-library.pattern-rename~2]
@NullMarked
class SidecarWriteBack {

Expand Down
Loading
Loading