Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d9c7c5c
Rename sidecar and PDF together to the configured filename pattern
koppor Jul 14, 2026
6a5dc20
Link CHANGELOG entry to its pull request
koppor Jul 14, 2026
c5876ce
Align lambda ternary to the IntelliJ code style
koppor Jul 14, 2026
b1a3c3b
Merge branch 'directory-groups' into directory-pattern-renames
koppor Jul 17, 2026
c549614
Update write-back requirement marker and apply OpenRewrite cleanup
koppor Jul 17, 2026
b0e3eab
Ignore Markdown companions in automatic file linking
koppor Jul 17, 2026
367f726
Align stream chain to the IntelliJ code style
koppor Jul 17, 2026
a43d608
Remove unused Predicate import
koppor Jul 17, 2026
c7cf755
Enable reveal and terminal for directory libraries
koppor Jul 17, 2026
5532d73
Merge directory-groups into directory-pattern-renames
koppor Sep 3, 2026
c6c147f
Remove duplicated CHANGELOG entries left by the resync merge
koppor Sep 3, 2026
37221ee
Merge directory-groups into directory-pattern-renames, hardening the …
koppor Sep 3, 2026
f2fd7fa
Merge branch 'directory-groups' into directory-pattern-renames
koppor Sep 3, 2026
63fdc44
Auto-link skips only Markdown sidecars, not name partners
koppor Sep 3, 2026
d0b8105
Apply IntelliJ formatter after upstream resync
koppor Sep 6, 2026
b103f68
Merge remote-tracking branch 'origin/directory-groups' into directory…
koppor Sep 7, 2026
c16ba72
Merge directory-groups into directory-pattern-renames
koppor Sep 7, 2026
3fb5ac7
Renumber the directory-as-library ADR to 0072
koppor Sep 7, 2026
f2a209a
Merge branch 'directory-groups' into directory-pattern-renames
koppor Sep 7, 2026
0b6a341
Merge directory-groups (with origin/main) into directory-pattern-renames
koppor Sep 12, 2026
da7d6e6
Merge directory-groups into directory-pattern-renames
koppor Sep 13, 2026
b5ac72c
Merge branch 'directory-groups' into directory-pattern-renames
koppor Sep 13, 2026
42f424b
Merge branch 'directory-groups' into directory-pattern-renames
koppor Sep 13, 2026
a7622fa
Merge branch 'directory-groups' into directory-pattern-renames
koppor Sep 23, 2026
982bb0d
Move directory-library CHANGELOG entries out of the released section
koppor Sep 23, 2026
1adcbc9
Merge branch 'directory-groups' into directory-pattern-renames
koppor Sep 23, 2026
66e6fb7
Use unnamed variables where the value is unused
koppor Sep 23, 2026
File filter

Filter by extension

Filter by extension

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

### Added

- 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)
- Directory libraries now stay in sync with external file changes: creating, editing, deleting, or renaming `.yml`/`.md`/`.pdf` files in the opened folder updates the open library live, and renames keep the affected entries (selection and undo history survive). [#738](https://github.com/JabRef/jabref-koppor/pull/738)
Expand All @@ -19,6 +20,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv

### Changed

- Automatic file linking no longer links the Markdown sidecars of a directory library (Markdown files with a Hayagriva frontmatter); other Markdown files are still linked. [#741](https://github.com/JabRef/jabref-koppor/pull/741)
- We changed the Hayagriva YAML export to keep data JabRef cannot edit (short titles, person aliases, additional identifiers) when re-exporting an imported file, to write comments, and to derive `date` from the BibTeX year/month fields. [#736](https://github.com/JabRef/jabref-koppor/pull/736)

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions docs/requirements/directory-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,16 @@ 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`

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
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.

Needs: impl

<!-- markdownlint-disable-file MD022 -->
4 changes: 3 additions & 1 deletion docs/requirements/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ As a consequence, the file is copied.
Needs: impl

## Auto-link broken linked file
`req~logic.externalfiles.file-transfer.auto-link~1`
`req~logic.externalfiles.file-transfer.auto-link~2`

After a file is linked to an entry, the user might move the file to another directory without JabRef, leading to broken linked file.

The function `Quality -> Automatically set file links` can help user to auto-link the moved files based on the broken file name, or the entry citation key.

A Markdown sidecar of a directory library (a Markdown file opening with a Hayagriva frontmatter block) is an entry's source, not an attachment, and is never auto-linked. Any other Markdown file is still linked.

Needs: impl, utest

## Searching unlinked files must not block result scrolling
Expand Down
7 changes: 7 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/actions/ActionHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ public static BooleanExpression needsSavedLocalDatabase(StateManager stateManage
return BooleanExpression.booleanExpression(binding);
}

/// Like [#needsSavedLocalDatabase], but also accepts a directory library: both have a
/// location on disk to reveal in the file explorer or open a terminal in.
public static BooleanExpression needsDatabaseOnDisk(StateManager stateManager) {
EasyBinding<Boolean> binding = EasyBind.map(stateManager.activeDatabaseProperty(), context -> context.filter(c -> c.getPathOnDisk().isPresent()).isPresent());
return BooleanExpression.booleanExpression(binding);
}

public static BooleanExpression needsSharedDatabase(StateManager stateManager) {
EasyBinding<Boolean> binding = EasyBind.map(stateManager.activeDatabaseProperty(), context -> context.filter(c -> c.getLocation() == DatabaseLocation.SHARED).isPresent());
return BooleanExpression.booleanExpression(binding);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,13 @@ public static void openFolderAndSelectFile(Path fileLink,
executeCommand(command, absolutePath, dialogService);
}

/// Opens a new console starting on the given file location
/// Opens a new console: in the given directory itself, or, given a file, in the file's
/// parent directory.
///
/// @param file Location the console should be opened at.
public static void openConsole(Path file, GuiPreferences preferences, DialogService dialogService) throws IOException {
if (file == null) {
return;
}

String absolutePath = file.toAbsolutePath().getParent().toString();
/// @param fileOrDirectory Location the console should be opened at.
public static void openConsole(Path fileOrDirectory, GuiPreferences preferences, DialogService dialogService) throws IOException {
Path absolute = fileOrDirectory.toAbsolutePath();
String absolutePath = Files.isDirectory(absolute) ? absolute.toString() : absolute.getParent().toString();

boolean useCustomTerminal = preferences.getExternalApplicationsPreferences().useCustomTerminal();
if (!useCustomTerminal) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.jabref.gui.frame.ExternalApplicationsPreferences;
import org.jabref.logic.FilePreferences;
import org.jabref.logic.bibtex.FileFieldWriter;
import org.jabref.logic.directorylibrary.MarkdownSidecar;
import org.jabref.logic.util.io.AutoLinkPreferences;
import org.jabref.logic.util.io.FileFinder;
import org.jabref.logic.util.io.FileFinders;
Expand Down Expand Up @@ -57,6 +58,8 @@ public List<IOException> getFileExceptions() {

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

private final MarkdownSidecar markdownSidecar = new MarkdownSidecar();

private final List<Path> directories;
private final AutoLinkPreferences autoLinkPreferences;
private final ExternalApplicationsPreferences externalApplicationsPreferences;
Expand All @@ -78,7 +81,7 @@ private AutoSetFileLinksUtil(List<Path> directories, ExternalApplicationsPrefere
this.brokenLinkedFileNameBasedFileFinder = FileFinders.constructBrokenLinkedFileNameBasedFileFinder();
}

/// [impl->req~logic.externalfiles.file-transfer.auto-link~1]
/// [impl->req~logic.externalfiles.file-transfer.auto-link~2]
public LinkFilesResult linkAssociatedFiles(List<BibEntry> entries, BiConsumer<List<LinkedFile>, BibEntry> onAddLinkedFile) {
LinkFilesResult result = new LinkFilesResult();

Expand Down Expand Up @@ -239,11 +242,27 @@ public Collection<LinkedFile> findAssociatedNotLinkedFilesWithFinder(
// Only keep associated files that are not linked
return associatedFiles
.stream()
.filter(associatedFile -> !isMarkdownSidecar(associatedFile))
.filter(associatedFile -> !isFileAlreadyLinked(associatedFile, linkedFiles))
.map(this::buildLinkedFileFromPath)
.toList();
}

/// A Markdown sidecar of a directory library (Hayagriva frontmatter, see [MarkdownSidecar])
/// is an entry's source, not an attachment, so it must not be auto-linked. Any other
/// Markdown file is still linked.
private boolean isMarkdownSidecar(Path file) {
if (!MarkdownSidecar.hasMarkdownExtension(file)) {
return false;
}
try {
return markdownSidecar.looksLikeSidecar(file);
} catch (IOException e) {
LOGGER.debug("Could not probe {} for a sidecar frontmatter", file, e);
return false;
}
}

private boolean isBrokenLinkedFile(LinkedFile file) {
return file.findIn(directories).isEmpty();
}
Expand Down
7 changes: 4 additions & 3 deletions jabgui/src/main/java/org/jabref/gui/frame/JabRefFrame.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.jabref.gui.actions.ActionHelper.needsSavedLocalDatabase;
import static org.jabref.gui.actions.ActionHelper.needsDatabaseOnDisk;

/// Represents the inner frame of the JabRef window
public class JabRefFrame extends BorderPane implements LibraryTabContainer, UiMessageHandler {
Expand Down Expand Up @@ -886,12 +886,13 @@ public OpenDatabaseFolder(DialogService dialogService, StateManager stateManager
this.dialogService = dialogService;
this.preferences = preferences;
this.databaseContext = databaseContext;
this.executable.bind(needsSavedLocalDatabase(stateManager));
this.executable.bind(needsDatabaseOnDisk(stateManager));
}

@Override
public void execute() {
Optional.of(databaseContext.get()).flatMap(BibDatabaseContext::getDatabasePath).ifPresent(path -> {
// For a directory library, reveal the library's root directory itself
Optional.of(databaseContext.get()).flatMap(BibDatabaseContext::getPathOnDisk).ifPresent(path -> {
try {
NativeDesktop.openFolderAndSelectFile(path, preferences.getExternalApplicationsPreferences(), dialogService);
} catch (IOException e) {
Expand Down
19 changes: 11 additions & 8 deletions jabgui/src/main/java/org/jabref/gui/frame/OpenConsoleAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public OpenConsoleAction(Supplier<BibDatabaseContext> databaseContext, StateMana
this.preferences = preferences;
this.dialogService = dialogService;

this.executable.bind(ActionHelper.needsSavedLocalDatabase(stateManager));
this.executable.bind(ActionHelper.needsDatabaseOnDisk(stateManager));
}

/// Using this constructor will result in executing the command on the active database.
Expand All @@ -43,12 +43,15 @@ public OpenConsoleAction(StateManager stateManager, GuiPreferences preferences,

@Override
public void execute() {
Optional.ofNullable(databaseContext.get()).or(stateManager::getActiveDatabase).flatMap(BibDatabaseContext::getDatabasePath).ifPresent(path -> {
try {
NativeDesktop.openConsole(path, preferences, dialogService);
} catch (IOException e) {
LOGGER.info("Could not open console", e);
}
});
Optional.ofNullable(databaseContext.get())
.or(stateManager::getActiveDatabase)
.flatMap(BibDatabaseContext::getPathOnDisk)
.ifPresent(path -> {
try {
NativeDesktop.openConsole(path, preferences, dialogService);
} catch (IOException e) {
LOGGER.info("Could not open console", e);
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.function.Function;

import javafx.event.Event;
import javafx.event.EventHandler;
Expand All @@ -27,7 +29,9 @@
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.DirectoryMonitor;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.logic.util.io.FileUtil;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.BibEntryTypesManager;
import org.jabref.model.util.FileUpdateMonitor;

Expand Down Expand Up @@ -146,8 +150,10 @@ 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());
DirectoryLibrarySynchronizer synchronizer = new DirectoryLibrarySynchronizer(
databaseContext, scanResult.catalog(), pdfEntryFactory, this::disposeFile,
databaseContext, scanResult.catalog(), pdfEntryFactory, this::disposeFile, fileNameGenerator,
UiTaskExecutor::runInJavaFXThread);
databaseContext.attachDirectorySynchronizer(synchronizer);
synchronizer.startWatching(Injector.instantiateModelOrService(DirectoryMonitor.class));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
Expand All @@ -21,7 +22,6 @@
import org.jabref.model.entry.types.StandardEntryType;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
Expand Down Expand Up @@ -71,6 +71,47 @@ void findAssociatedNotLinkedFilesSuccess() throws IOException {
assertEquals(expected, actual);
}

/// [utest->req~logic.externalfiles.file-transfer.auto-link~2]
@Test
void plainMarkdownNextToPdfIsLinkedToo() throws IOException {
Files.createFile(path.getParent().resolve("CiteKey.md"));
when(databaseContext.getFileDirectories(any())).thenReturn(List.of(path.getParent()));
AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs);
Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry);
assertEquals(List.of(new LinkedFile("", Path.of("CiteKey.md"), "Markdown"), new LinkedFile("", Path.of("CiteKey.pdf"), "PDF")),
actual.stream().sorted(Comparator.comparing(LinkedFile::getLink)).toList());
}

/// [utest->req~logic.externalfiles.file-transfer.auto-link~2]
@Test
void plainMarkdownFileIsLinked(@TempDir Path tempDir) throws IOException {
Files.createFile(tempDir.resolve("CiteKey.md"));
when(databaseContext.getFileDirectories(any())).thenReturn(List.of(tempDir));
AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs);
Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry);
assertEquals(List.of(new LinkedFile("", Path.of("CiteKey.md"), "Markdown")), actual);
}

/// [utest->req~logic.externalfiles.file-transfer.auto-link~2]
@Test
void markdownSidecarIsNotLinked(@TempDir Path tempDir) throws IOException {
Files.writeString(tempDir.resolve("CiteKey.md"), """
---
CiteKey:
type: article
title: A Test Article
---

# Notes

Some notes.
""");
when(databaseContext.getFileDirectories(any())).thenReturn(List.of(tempDir));
AutoSetFileLinksUtil util = new AutoSetFileLinksUtil(databaseContext, externalApplicationsPreferences, filePreferences, autoLinkPrefs);
Collection<LinkedFile> actual = util.findAssociatedNotLinkedFiles(entry);
assertEquals(List.of(), actual);
}

@Test
void findAssociatedNotLinkedFilesForEmptySearchDir() throws IOException {
when(databaseContext.getFileDirectories(any())).thenReturn(List.of());
Expand Down Expand Up @@ -181,17 +222,14 @@ void findAllAssociatedNotLinkedFilesAndNotRepeated(@TempDir Path tempDir) throws
assertEquals(expected, Set.copyOf(matchedFiles));
}

/// [utest->req~logic.externalfiles.file-transfer.auto-link~1]
/// [utest->req~logic.externalfiles.file-transfer.auto-link~2]
@Nested
@DisplayName("linkAssociatedFiles")
class linkAssociatedFiles {

@Nested
@DisplayName("byCitationKeyOnly")
class byCitationKeyOnly {

@Nested
@DisplayName("configuredCitationKeyDependencyWithStart")
class configuredCitationKeyDependencyWithStart {

/// ```
Expand Down Expand Up @@ -355,7 +393,6 @@ void autoLinkByCitationKeyStartMatchingTwoFilesAtSubFolderAndOneMatchABrokenLink
}

@Nested
@DisplayName("configuredCitationKeyDependencyWithExact")
class configuredCitationKeyDependencyWithExact {

/// ```
Expand Down Expand Up @@ -462,7 +499,6 @@ void autoLinkByCitationKeyExactAtSubFolder(@TempDir Path root) throws Exception
}

@Nested
@DisplayName("byBrokenLinkedFileNameOnly")
class byBrokenLinkedFileNameOnly {
/// ```
/// CK: WeDoNotCare
Expand Down Expand Up @@ -744,7 +780,6 @@ void noAutoLinkCopyFileFromSubfolderToSubfolder(@TempDir Path root) throws Excep
}

@Nested
@DisplayName("byCitationKeyAndBrokenLinkedFileName")
class byCitationKeyAndBrokenLinkedFileName {

/// ```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,22 @@ void newActionGetsCurrentDatabase() {
OpenConsoleAction action = new OpenConsoleAction(stateManager, preferences, null);
action.execute();
verify(stateManager, times(1)).getActiveDatabase();
verify(current, times(1)).getDatabasePath();
verify(current, times(1)).getPathOnDisk();
}

@Test
void newActionGetsSuppliedDatabase() {
OpenConsoleAction action = new OpenConsoleAction(() -> other, stateManager, preferences, null);
action.execute();
verify(stateManager, never()).getActiveDatabase();
verify(other, times(1)).getDatabasePath();
verify(other, times(1)).getPathOnDisk();
}

@Test
void actionDefaultsToCurrentDatabase() {
OpenConsoleAction action = new OpenConsoleAction(() -> null, stateManager, preferences, null);
action.execute();
verify(stateManager, times(1)).getActiveDatabase();
verify(current, times(1)).getDatabasePath();
verify(current, times(1)).getPathOnDisk();
}
}
Loading
Loading