diff --git a/docs/requirements/filed.md b/docs/requirements/filed.md new file mode 100644 index 00000000000..e3679f7325a --- /dev/null +++ b/docs/requirements/filed.md @@ -0,0 +1,21 @@ +# File Transfer Between Bib Entries + +## File is reachable and should not be copied +`req~logic.externalfiles.file-transfer.reachable-no-copy~1` +When a linked file is reachable from the target context, the system must adjust the relative path in the target entry but must not copy the file again. + +Needs: impl + +## File is not reachable, but the path is the same +`req~logic.externalfiles.file-transfer.not-reachable-same-path~1` +When a linked file is not reachable from the target context, and the relative path in both source and target entry is the same, the file must be copied to the target context. + +Needs: impl + +## File is not reachable, and a different path is used +`req~logic.externalfiles.file-transfer.not-reachable-different-path~1` +When a linked file is not reachable from the target context, and the relative path differs between source and target entries, the file must be copied and the directory structure must be created to preserve the relative link. + +Needs: impl + + diff --git a/jabgui/src/main/java/org/jabref/gui/ClipBoardManager.java b/jabgui/src/main/java/org/jabref/gui/ClipBoardManager.java index d9c8617b526..a0eb25f4642 100644 --- a/jabgui/src/main/java/org/jabref/gui/ClipBoardManager.java +++ b/jabgui/src/main/java/org/jabref/gui/ClipBoardManager.java @@ -7,6 +7,7 @@ import java.awt.datatransfer.UnsupportedFlavorException; import java.io.IOException; import java.util.List; +import java.util.Optional; import javafx.application.Platform; import javafx.scene.control.TextInputControl; @@ -19,6 +20,7 @@ import org.jabref.logic.bibtex.BibEntryWriter; import org.jabref.logic.bibtex.FieldWriter; import org.jabref.logic.preferences.CliPreferences; +import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.database.BibDatabaseMode; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibEntryTypesManager; @@ -35,6 +37,8 @@ public class ClipBoardManager { private static final Logger LOGGER = LoggerFactory.getLogger(ClipBoardManager.class); + private BibDatabaseContext sourceDatabaseContext; + private static Clipboard clipboard; private static java.awt.datatransfer.Clipboard primary; @@ -117,6 +121,10 @@ public static String getContentsPrimary() { return getContents(); } + public Optional getSourceBibDatabaseContext() { + return Optional.ofNullable(sourceDatabaseContext); + } + /** * Puts content onto the system clipboard. * @@ -166,6 +174,12 @@ public void setContent(List entries, BibEntryTypesManager entryTypesMa setContent(builder.toString()); } + public void setSourceBibDatabaseContext(BibDatabaseContext context) { + if (context != null) { + sourceDatabaseContext = context; + } + } + private String serializeEntries(List entries, BibEntryTypesManager entryTypesManager) throws IOException { CliPreferences preferences = Injector.instantiateModelOrService(CliPreferences.class); // BibEntry is not Java serializable. Thus, we need to do the serialization manually diff --git a/jabgui/src/main/java/org/jabref/gui/LibraryTab.java b/jabgui/src/main/java/org/jabref/gui/LibraryTab.java index d7223197d16..578e2927e0d 100644 --- a/jabgui/src/main/java/org/jabref/gui/LibraryTab.java +++ b/jabgui/src/main/java/org/jabref/gui/LibraryTab.java @@ -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.EntryImportHandlerTracker; import org.jabref.gui.externalfiles.ImportHandler; import org.jabref.gui.fieldeditors.LinkedFileViewModel; import org.jabref.gui.importer.actions.OpenDatabaseAction; @@ -60,6 +61,7 @@ import org.jabref.gui.util.UiTaskExecutor; import org.jabref.logic.ai.AiService; import org.jabref.logic.citationstyle.CitationStyleCache; +import org.jabref.logic.externalfiles.LinkedFileTransferHelper; import org.jabref.logic.importer.FetcherClientException; import org.jabref.logic.importer.FetcherException; import org.jabref.logic.importer.FetcherServerException; @@ -816,21 +818,53 @@ private int doCopyEntry(List selectedEntries) { } } - public void pasteEntry() { - List entriesToAdd; - String content = ClipBoardManager.getContents(); - entriesToAdd = importHandler.handleBibTeXData(content); - if (entriesToAdd.isEmpty()) { - entriesToAdd = handleNonBibTeXStringData(content); - } - if (entriesToAdd.isEmpty()) { - return; - } + public void pasteEntry() { + String content = ClipBoardManager.getContents(); + List entriesToAdd = importHandler.handleBibTeXData(content); + if (entriesToAdd.isEmpty()) { + entriesToAdd = handleNonBibTeXStringData(content); + } + if (entriesToAdd.isEmpty()) { + return; + } + copyEntriesWithFeedback(entriesToAdd); + } - importHandler.importEntriesWithDuplicateCheck(bibDatabaseContext, entriesToAdd); + private void copyEntriesWithFeedback(List entriesToAdd) { + final List finalEntriesToAdd = entriesToAdd; + + EntryImportHandlerTracker tracker = new EntryImportHandlerTracker(finalEntriesToAdd.size()); + + tracker.setOnFinish(() -> { + int importedCount = tracker.getImportedCount(); + int skippedCount = tracker.getSkippedCount(); + + String targetName = bibDatabaseContext.getDatabasePath() + .map(path -> path.getFileName().toString()) + .orElse(Localization.lang("target library")); + + if (importedCount == finalEntriesToAdd.size()) { + dialogService.notify(Localization.lang("Pasted %0 entry(s) to %1", + String.valueOf(importedCount), targetName)); + } else if (importedCount == 0) { + dialogService.notify(Localization.lang("No entry was pasted to %0", targetName)); + } else { + dialogService.notify(Localization.lang("Pasted %0 entry(s) to %1. %2 were skipped", + String.valueOf(importedCount), targetName, String.valueOf(skippedCount))); + } + }); + + importHandler.importEntriesWithDuplicateCheck(bibDatabaseContext, finalEntriesToAdd, tracker); + + if (clipBoardManager.getSourceBibDatabaseContext().isPresent()) { + tracker.setOnFinish(() -> LinkedFileTransferHelper + .adjustLinkedFilesForTarget(clipBoardManager.getSourceBibDatabaseContext().get(), + bibDatabaseContext, preferences.getFilePreferences())); } + } + - private List handleNonBibTeXStringData(String data) { + private List handleNonBibTeXStringData(String data) { try { return this.importHandler.handleStringData(data); } catch (FetcherException exception) { diff --git a/jabgui/src/main/java/org/jabref/gui/edit/CopyTo.java b/jabgui/src/main/java/org/jabref/gui/edit/CopyTo.java index 2164994660c..4603b5611a1 100644 --- a/jabgui/src/main/java/org/jabref/gui/edit/CopyTo.java +++ b/jabgui/src/main/java/org/jabref/gui/edit/CopyTo.java @@ -10,6 +10,8 @@ import org.jabref.gui.actions.SimpleCommand; import org.jabref.gui.externalfiles.EntryImportHandlerTracker; import org.jabref.gui.externalfiles.ImportHandler; +import org.jabref.logic.FilePreferences; +import org.jabref.logic.externalfiles.LinkedFileTransferHelper; import org.jabref.logic.l10n.Localization; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -25,6 +27,7 @@ public class CopyTo extends SimpleCommand { private final DialogService dialogService; private final StateManager stateManager; private final CopyToPreferences copyToPreferences; + private final FilePreferences filePreferences; private final ImportHandler importHandler; private final BibDatabaseContext sourceDatabaseContext; private final BibDatabaseContext targetDatabaseContext; @@ -32,12 +35,14 @@ public class CopyTo extends SimpleCommand { public CopyTo(DialogService dialogService, StateManager stateManager, CopyToPreferences copyToPreferences, + FilePreferences filePreferences, ImportHandler importHandler, BibDatabaseContext sourceDatabaseContext, BibDatabaseContext targetDatabaseContext) { this.dialogService = dialogService; this.stateManager = stateManager; this.copyToPreferences = copyToPreferences; + this.filePreferences = filePreferences; this.importHandler = importHandler; this.sourceDatabaseContext = sourceDatabaseContext; this.targetDatabaseContext = targetDatabaseContext; @@ -106,6 +111,9 @@ private void copyEntriesWithFeedback(List entriesToAdd, BibDatabaseCon }); importHandler.importEntriesWithDuplicateCheck(targetDatabaseContext, entriesToAdd, tracker); + tracker.setOnFinish(() -> LinkedFileTransferHelper + .adjustLinkedFilesForTarget(sourceDatabaseContext, targetDatabaseContext, filePreferences) + ); } public Optional getCrossRefEntry(BibEntry bibEntryToCheck, BibDatabaseContext sourceDatabaseContext) { diff --git a/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java b/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java index aa7ef91c847..778a8dd5a52 100644 --- a/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java +++ b/jabgui/src/main/java/org/jabref/gui/externalfiles/ImportHandler.java @@ -251,7 +251,7 @@ public void importEntryWithDuplicateCheck(BibDatabaseContext bibDatabaseContext, } private void importEntryWithDuplicateCheck(BibDatabaseContext bibDatabaseContext, BibEntry entry, DuplicateResolverDialog.DuplicateResolverResult decision, EntryImportHandlerTracker tracker) { - BibEntry entryToInsert = cleanUpEntry(bibDatabaseContext, entry); + BibEntry entryToInsert = (BibEntry) cleanUpEntry(bibDatabaseContext, entry).clone(); BackgroundTask.wrap(() -> findDuplicate(bibDatabaseContext, entryToInsert)) .onFailure(e -> { diff --git a/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java b/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java index ba4d8ab2119..6c7c3e25d7c 100644 --- a/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java +++ b/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java @@ -73,7 +73,7 @@ public static ContextMenu create(BibEntryTableViewModel entry, contextMenu.getItems().addAll( factory.createMenuItem(StandardActions.COPY, new EditAction(StandardActions.COPY, () -> libraryTab, stateManager, undoManager)), - createCopySubMenu(factory, dialogService, stateManager, preferences, clipBoardManager, abbreviationRepository, taskExecutor), + createCopySubMenu(factory, dialogService, stateManager, preferences, libraryTab, clipBoardManager, abbreviationRepository, taskExecutor), createCopyToMenu(factory, dialogService, stateManager, preferences, libraryTab, importHandler), factory.createMenuItem(StandardActions.PASTE, new EditAction(StandardActions.PASTE, () -> libraryTab, stateManager, undoManager)), factory.createMenuItem(StandardActions.CUT, new EditAction(StandardActions.CUT, () -> libraryTab, stateManager, undoManager)), @@ -157,7 +157,8 @@ private static Menu createCopyToMenu(ActionFactory factory, copyToMenu.getItems().addAll( factory.createCustomMenuItem( StandardActions.COPY_TO, - new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), importHandler, sourceDatabaseContext, bibDatabaseContext), + new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + preferences.getFilePreferences(), importHandler, sourceDatabaseContext, bibDatabaseContext), destinationDatabaseName ) ); @@ -171,11 +172,14 @@ private static Menu createCopySubMenu(ActionFactory factory, DialogService dialogService, StateManager stateManager, GuiPreferences preferences, + LibraryTab libraryTab, ClipBoardManager clipBoardManager, JournalAbbreviationRepository abbreviationRepository, TaskExecutor taskExecutor) { Menu copySpecialMenu = factory.createMenu(StandardActions.COPY_MORE); + clipBoardManager.setSourceBibDatabaseContext(libraryTab.getBibDatabaseContext()); + copySpecialMenu.getItems().addAll( factory.createMenuItem(StandardActions.COPY_TITLE, new CopyMoreAction(StandardActions.COPY_TITLE, dialogService, stateManager, clipBoardManager, preferences, abbreviationRepository)), factory.createMenuItem(StandardActions.COPY_KEY, new CopyMoreAction(StandardActions.COPY_KEY, dialogService, stateManager, clipBoardManager, preferences, abbreviationRepository)), diff --git a/jabgui/src/test/java/org/jabref/gui/edit/CopyToTest.java b/jabgui/src/test/java/org/jabref/gui/edit/CopyToTest.java index 876791c1afb..183bd0e0a9a 100644 --- a/jabgui/src/test/java/org/jabref/gui/edit/CopyToTest.java +++ b/jabgui/src/test/java/org/jabref/gui/edit/CopyToTest.java @@ -84,7 +84,7 @@ void executeCopyEntriesWithoutCrossRef() { selectedEntries.add(entry); when(stateManager.getSelectedEntries()).thenReturn((ObservableList) selectedEntries); - copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), preferences.getFilePreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); ArgumentCaptor trackerCaptor = ArgumentCaptor.forClass(EntryImportHandlerTracker.class); @@ -98,7 +98,8 @@ void executeCopyEntriesWithCrossRef() { selectedEntries.add(entryWithCrossRef); when(stateManager.getSelectedEntries()).thenReturn((ObservableList) selectedEntries); - copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); + copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + preferences.getFilePreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); ArgumentCaptor trackerCaptor = ArgumentCaptor.forClass(EntryImportHandlerTracker.class); copyTo.copyEntriesWithCrossRef(selectedEntries, targetDatabaseContext); @@ -111,7 +112,8 @@ void executeCopyEntriesWithCrossRef() { @Test void executeGetCrossRefEntry() { - copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); + copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + preferences.getFilePreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); BibEntry result = copyTo.getCrossRefEntry(entryWithCrossRef, sourceDatabaseContext).orElse(null); @@ -125,7 +127,8 @@ void executeExecuteWithoutCrossRef() { when(stateManager.getSelectedEntries()).thenReturn((ObservableList) selectedEntries); when(preferences.getCopyToPreferences().getShouldAskForIncludingCrossReferences()).thenReturn(false); - copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); + copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + preferences.getFilePreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); ArgumentCaptor trackerCaptor = ArgumentCaptor.forClass(EntryImportHandlerTracker.class); copyTo.execute(); @@ -140,7 +143,8 @@ void executeExecuteWithCrossRefAndUserIncludes() { when(preferences.getCopyToPreferences().getShouldAskForIncludingCrossReferences()).thenReturn(true); when(dialogService.showConfirmationDialogWithOptOutAndWait(anyString(), anyString(), anyString(), anyString(), anyString(), any())).thenReturn(true); - copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); + copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + preferences.getFilePreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); ArgumentCaptor trackerCaptor = ArgumentCaptor.forClass(EntryImportHandlerTracker.class); copyTo.execute(); @@ -158,7 +162,8 @@ void executeWithCrossRefAndUserExcludes() { when(preferences.getCopyToPreferences().getShouldAskForIncludingCrossReferences()).thenReturn(true); when(dialogService.showConfirmationDialogWithOptOutAndWait(anyString(), anyString(), anyString(), anyString(), anyString(), any())).thenReturn(false); - copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); + copyTo = new CopyTo(dialogService, stateManager, preferences.getCopyToPreferences(), + preferences.getFilePreferences(), importHandler, sourceDatabaseContext, targetDatabaseContext); ArgumentCaptor trackerCaptor = ArgumentCaptor.forClass(EntryImportHandlerTracker.class); copyTo.execute(); diff --git a/jablib/src/main/java/module-info.java b/jablib/src/main/java/module-info.java index 07bd3b6884e..8d9a0bc0a46 100644 --- a/jablib/src/main/java/module-info.java +++ b/jablib/src/main/java/module-info.java @@ -107,17 +107,14 @@ exports org.jabref.logic.pseudonymization; exports org.jabref.logic.citation.repository; - requires java.base; - - requires javafx.base; + requires javafx.base; requires javafx.graphics; // because of javafx.scene.paint.Color requires afterburner.fx; requires com.tobiasdiez.easybind; // for java.awt.geom.Rectangle2D required by org.jabref.logic.pdf.TextExtractor - requires java.desktop; - // SQL + // SQL requires java.sql; requires java.sql.rowset; @@ -249,6 +246,6 @@ requires mslinks; requires org.antlr.antlr4.runtime; requires org.libreoffice.uno; - requires org.jetbrains.annotations; - // endregion + requires org.jetbrains.annotations; + // endregion } diff --git a/jablib/src/main/java/org/jabref/logic/externalfiles/LinkedFileTransferHelper.java b/jablib/src/main/java/org/jabref/logic/externalfiles/LinkedFileTransferHelper.java new file mode 100644 index 00000000000..ed26d8c26a2 --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/externalfiles/LinkedFileTransferHelper.java @@ -0,0 +1,207 @@ +package org.jabref.logic.externalfiles; + +import org.jabref.logic.FilePreferences; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public class LinkedFileTransferHelper { + + private record FileCopyContext( + BibDatabaseContext sourceContext, + BibDatabaseContext targetContext, + FilePreferences filePreferences + ) {} + + private static final Logger LOGGER = LoggerFactory.getLogger(LinkedFileTransferHelper.class); + + /** + * Adjusts linked files when copying entries from source to target context. + * Files that are not reachable from the target context will be copied. + * Files in the target context whose relative paths differ from the source will have their paths adjusted + * + * @param sourceContext The source database context where files are currently located + * @param targetContext The target database context where files should be accessible + * @param filePreferences File preferences for both contexts + */ + public static Set adjustLinkedFilesForTarget( + BibDatabaseContext sourceContext, + BibDatabaseContext targetContext, + FilePreferences filePreferences + ) { + + Set modifiedEntries = new HashSet<>(); + + FileCopyContext context = new FileCopyContext(sourceContext, targetContext, filePreferences); + + for (BibEntry entry : targetContext.getEntries()) { + boolean entryChanged = false; + List linkedFiles = new ArrayList<>(); + + for (LinkedFile linkedFile : entry.getFiles()) { + if (linkedFile == null || linkedFile.getLink().isEmpty()) { + linkedFiles.add(linkedFile); + continue; + } + + Optional sourcePathOpt = linkedFile.findIn(sourceContext, filePreferences); + Optional targetPrimaryOpt = getPrimaryPath(targetContext, filePreferences); + + if (sourcePathOpt.isEmpty() || targetPrimaryOpt.isEmpty()) { + linkedFiles.add(linkedFile); + continue; + } + + Path relative; + if (sourcePathOpt.get().startsWith(targetPrimaryOpt.get())) { + relative = targetPrimaryOpt.get().relativize(sourcePathOpt.get()); + } else { + relative = Path.of("..").resolve(sourcePathOpt.get().getFileName()); + } + + if (isReachableFromPrimaryDirectory(relative)) { + entryChanged = isPathAdjusted(linkedFile, relative, linkedFiles, entryChanged); + } else { + entryChanged = isFileCopied(context, linkedFile, linkedFiles, entryChanged); + } + } + if (entryChanged) { + entry.setFiles(linkedFiles); + modifiedEntries.add(entry); + } + } + return modifiedEntries; + } + + /** + * Gets the primary directory path for the given context. + * This is a utility method extracted from the original implementation. + * + * @param context The database context + * @param filePreferences File preferences for the context + * @return Optional containing the primary directory path, or empty if none found + */ + public static Optional getPrimaryPath(BibDatabaseContext context, FilePreferences filePreferences) { + List directories = context.getFileDirectories(filePreferences); + if (directories.isEmpty()) { + return Optional.empty(); + } + return Optional.of(directories.getFirst()); + } + + /** + * Determines if the given relative path is reachable from the primary directory. + * A path is considered reachable if it does not start with ".." (i.e., does not traverse up the directory tree) + * and is not absolute. + * + * @param relativePath the path to check, relative to the primary directory + * @return true if the path is reachable from the primary directory, false otherwise + */ + public static boolean isReachableFromPrimaryDirectory(Path relativePath) { + try { + return !relativePath.startsWith("..") && !relativePath.isAbsolute(); + } catch (IllegalArgumentException e) { + return false; + } + } + + private static boolean isPathAdjusted(LinkedFile linkedFile, Path relative, List linkedFiles, boolean entryChanged) { + boolean pathUpdated = adjustPathForReachableFile( + linkedFile, relative + ); + if (pathUpdated) { + entryChanged = true; + } + linkedFiles.add(linkedFile); + return entryChanged; + } + + private static boolean isFileCopied(FileCopyContext context, LinkedFile linkedFile, List linkedFiles, boolean entryChanged) { + boolean fileCopied = copyFileToTargetContext( + linkedFile, context + ); + if (fileCopied) { + Optional newPath = linkedFile.findIn(context.targetContext(), context.filePreferences()); + newPath.ifPresent(path -> linkedFile.setLink( + FileUtil.relativize(path, context.targetContext(), context.filePreferences()).toString()) + ); + entryChanged = true; + } + linkedFiles.add(linkedFile); + return entryChanged; + } + + /** + * Adjusts the path of a file that is already reachable from the target context. + * + * @return true if the path was updated, false otherwise + */ + private static boolean adjustPathForReachableFile( + LinkedFile linkedFile, + Path relativePath + ) { + try { + String newLink = relativePath.toString(); + String currentLink = linkedFile.getLink(); + + if (!currentLink.equals(newLink)) { + linkedFile.setLink(newLink); + LOGGER.debug("Adjusted path for reachable file: {} -> {}", currentLink, newLink); + return true; + } + } catch (Exception e) { + LOGGER.warn("Failed to adjust path for file {}: {}", linkedFile.getLink(), e.getMessage()); + } + return false; + } + + /** + * Copies a file linked in a `LinkedFile` from the source context to the target context. + * Locates the source file using the source context and file preferences, then copies it to the + * corresponding path in the target context's primary directory, preserving the relative link. + * + * @return true if the file was successfully copied, false otherwise + */ + private static boolean copyFileToTargetContext( + LinkedFile linkedFile, + FileCopyContext context + ) { + Optional sourcePathOpt = linkedFile.findIn(context.sourceContext(), context.filePreferences()); + Optional targetDirOpt = getPrimaryPath(context.targetContext(), context.filePreferences()); + + if (sourcePathOpt.isEmpty() || targetDirOpt.isEmpty()) { + LOGGER.warn("Could not find source file {} for copy", linkedFile.getLink()); + return false; + } + + Path sourcePath = sourcePathOpt.get(); + Path relativeLinkPath = Path.of(linkedFile.getLink()); + Path fullTargetPath = targetDirOpt.get().resolve(relativeLinkPath); + + try { + Files.createDirectories(fullTargetPath.getParent()); + if (Files.exists(fullTargetPath)) { + LOGGER.warn("Target file {} already exists – not overwriting", fullTargetPath); + return false; + } + Files.copy(sourcePath, fullTargetPath); + LOGGER.info("Copied file from {} to {}", sourcePath, fullTargetPath); + return true; + } catch (IOException e) { + LOGGER.error("Failed to copy file from {} to {}: {}", sourcePath, fullTargetPath, e.getMessage()); + return false; + } + } +} diff --git a/jablib/src/test/java/org/jabref/logic/externalfiles/LinkedFileTransferHelperTest.java b/jablib/src/test/java/org/jabref/logic/externalfiles/LinkedFileTransferHelperTest.java new file mode 100644 index 00000000000..84748b9cc70 --- /dev/null +++ b/jablib/src/test/java/org/jabref/logic/externalfiles/LinkedFileTransferHelperTest.java @@ -0,0 +1,170 @@ +package org.jabref.logic.externalfiles; + +import org.jabref.logic.FilePreferences; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LinkedFileTransferHelperTest { + private BibDatabaseContext sourceContext; + private BibDatabaseContext targetContext; + private Path sourceDir; + private Path targetDir; + private Path testFile; + private BibEntry sourceEntry; + private BibEntry targetEntry; + private FilePreferences filePreferences = mock(FilePreferences.class); + + @Nested + class WhenFileIsReachable { + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + sourceDir = tempDir.resolve("source/target"); + targetDir = tempDir.resolve("source"); + + when(filePreferences.shouldStoreFilesRelativeToBibFile()).thenReturn(true); + + Files.createDirectories(sourceDir); + Files.createDirectories(targetDir); + + testFile = sourceDir.resolve("sourcefiles/test.pdf"); + Files.createDirectories(testFile.getParent()); + Files.createFile(testFile); + + sourceContext = new BibDatabaseContext(new BibDatabase()); + sourceContext.setDatabasePath(sourceDir.resolve("personal.bib")); + targetContext = new BibDatabaseContext(new BibDatabase()); + targetContext.setDatabasePath(targetDir.resolve("papers.bib")); + + sourceEntry = new BibEntry(); + LinkedFile linkedFile = new LinkedFile("Test", "sourcefiles/test.pdf", "PDF"); + + sourceEntry.setFiles(List.of(linkedFile)); + targetEntry = (BibEntry) sourceEntry.clone(); + targetEntry.setFiles(List.of(linkedFile)); + + sourceContext.getDatabase().insertEntry(sourceEntry); + targetContext.getDatabase().insertEntry(targetEntry); + } + + @Test + void pathDiffers_ShouldAdjustPath() { + var returnedEntries = LinkedFileTransferHelper.adjustLinkedFilesForTarget(sourceContext, targetContext, + filePreferences); + + assertEquals(1, returnedEntries.size()); + assertEquals("sourcefiles/test.pdf", sourceContext.getEntries().getFirst().getFiles().getFirst().getLink()); + assertEquals("target/sourcefiles/test.pdf", + targetContext.getEntries().getFirst().getFiles().getFirst().getLink()); + Path expectedFile = targetDir.resolve("test.pdf"); + assertEquals(true, !Files.exists(expectedFile)); + } + } + + @Nested + class WhenFileIsNotReachable { + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + sourceDir = tempDir.resolve("target/targetfiles"); + targetDir = tempDir.resolve("source/sourcefiles"); + + when(filePreferences.shouldStoreFilesRelativeToBibFile()).thenReturn(true); + + Files.createDirectories(sourceDir); + Files.createDirectories(targetDir); + + testFile = sourceDir.resolve("test.pdf"); + Files.createDirectories(testFile.getParent()); + Files.createFile(testFile); + + sourceContext = new BibDatabaseContext(new BibDatabase()); + sourceContext.setDatabasePath(sourceDir.resolve("personal.bib")); + targetContext = new BibDatabaseContext(new BibDatabase()); + targetContext.setDatabasePath(targetDir.resolve("papers.bib")); + + sourceEntry = new BibEntry(); + LinkedFile linkedFile = new LinkedFile("Test", "test.pdf", "PDF"); + + sourceEntry.setFiles(List.of(linkedFile)); + targetEntry = (BibEntry) sourceEntry.clone(); + targetEntry.setFiles(List.of(linkedFile)); + + sourceContext.getDatabase().insertEntry(sourceEntry); + targetContext.getDatabase().insertEntry(targetEntry); + } + + @Test + void fileNotReachable_ShouldCopyFile() { + var returnedEntries = LinkedFileTransferHelper.adjustLinkedFilesForTarget(sourceContext, targetContext, + filePreferences); + + assertEquals(1, returnedEntries.size()); + assertEquals("test.pdf", sourceContext.getEntries().getFirst().getFiles().getFirst().getLink()); + assertEquals("test.pdf", + targetContext.getEntries().getFirst().getFiles().getFirst().getLink()); + Path expectedFile = targetDir.resolve("test.pdf"); + assertEquals(true, Files.exists(expectedFile)); + } + } + + @Nested + class WhenFileIsNotReachableAndPathsDiffer { + + @BeforeEach + void setup(@TempDir Path tempDir) throws Exception { + sourceDir = tempDir.resolve("source"); + targetDir = tempDir.resolve("target/targetfiles"); + + when(filePreferences.shouldStoreFilesRelativeToBibFile()).thenReturn(true); + + Files.createDirectories(sourceDir); + Files.createDirectories(targetDir); + + testFile = sourceDir.resolve("sourcefiles/test.pdf"); + Files.createDirectories(testFile.getParent()); + Files.createFile(testFile); + + sourceContext = new BibDatabaseContext(new BibDatabase()); + sourceContext.setDatabasePath(sourceDir.resolve("personal.bib")); + targetContext = new BibDatabaseContext(new BibDatabase()); + targetContext.setDatabasePath(targetDir.resolve("papers.bib")); + + sourceEntry = new BibEntry(); + LinkedFile linkedFile = new LinkedFile("Test", "sourcefiles/test.pdf", "PDF"); + + sourceEntry.setFiles(List.of(linkedFile)); + targetEntry = (BibEntry) sourceEntry.clone(); + targetEntry.setFiles(List.of(linkedFile)); + + sourceContext.getDatabase().insertEntry(sourceEntry); + targetContext.getDatabase().insertEntry(targetEntry); + } + + @Test + void fileNotReachableAndPathsDiffer_ShouldCopyFileAndCreateDirectory() { + var returnedEntries = LinkedFileTransferHelper.adjustLinkedFilesForTarget(sourceContext, targetContext, + filePreferences); + + assertEquals(1, returnedEntries.size()); + assertEquals("sourcefiles/test.pdf", sourceContext.getEntries().getFirst().getFiles().getFirst().getLink()); + assertEquals("sourcefiles/test.pdf", + targetContext.getEntries().getFirst().getFiles().getFirst().getLink()); + Path expectedFile = targetDir.resolve("sourcefiles/test.pdf"); + assertEquals(true, Files.exists(expectedFile)); + } + } +}