package org.jabref.gui.fieldeditors;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ListProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import org.jabref.Globals;
import org.jabref.gui.DialogService;
import org.jabref.gui.externalfiles.DownloadExternalFile;
import org.jabref.gui.externalfiles.FileDownloadTask;
import org.jabref.gui.externalfiletype.ExternalFileType;
import org.jabref.gui.externalfiletype.ExternalFileTypes;
import org.jabref.gui.externalfiletype.UnknownExternalFileType;
import org.jabref.gui.util.BackgroundTask;
import org.jabref.gui.util.BindingsHelper;
import org.jabref.gui.util.FileDialogConfiguration;
import org.jabref.gui.util.TaskExecutor;
import org.jabref.logic.importer.FulltextFetchers;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.net.URLDownload;
import org.jabref.logic.util.OS;
import org.jabref.logic.util.io.FileFinder;
import org.jabref.logic.util.io.FileFinders;
import org.jabref.logic.util.io.FileUtil;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.FileFieldParser;
import org.jabref.model.entry.FileFieldWriter;
import org.jabref.model.entry.LinkedFile;
import org.jabref.model.util.FileHelper;
import org.jabref.preferences.JabRefPreferences;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class LinkedFilesEditorViewModel extends AbstractEditorViewModel {
private static final Log LOGGER = LogFactory.getLog(LinkedFilesEditorViewModel.class);
private ListProperty<LinkedFileViewModel> files = new SimpleListProperty<>(FXCollections.observableArrayList(LinkedFileViewModel::getObservables));
private BooleanProperty fulltextLookupInProgress = new SimpleBooleanProperty(false);
private DialogService dialogService;
private BibDatabaseContext databaseContext;
private TaskExecutor taskExecutor;
public LinkedFilesEditorViewModel(DialogService dialogService, BibDatabaseContext databaseContext, TaskExecutor taskExecutor) {
this.dialogService = dialogService;
this.databaseContext = databaseContext;
this.taskExecutor = taskExecutor;
BindingsHelper.bindContentBidirectional(
files,
text,
LinkedFilesEditorViewModel::getStringRepresentation,
this::parseToFileViewModel
);
}
private static String getStringRepresentation(List<LinkedFileViewModel> files) {
// Only serialize linked files, not the ones that are automatically found
List<LinkedFile> filesToSerialize = files.stream()
.filter(file -> !file.isAutomaticallyFound())
.map(LinkedFileViewModel::getFile)
.collect(Collectors.toList());
return FileFieldWriter.getStringRepresentation(filesToSerialize);
}
/**
* Creates an instance of {@link LinkedFile} based on the given file.
* We try to guess the file type and relativize the path against the given file directories.
*
* TODO: Move this method to {@link LinkedFile} as soon as {@link ExternalFileType} lives in model.
*/
private static LinkedFile fromFile(Path file, List<Path> fileDirectories) {
String fileExtension = FileHelper.getFileExtension(file).orElse("");
ExternalFileType suggestedFileType = ExternalFileTypes.getInstance()
.getExternalFileTypeByExt(fileExtension).orElse(new UnknownExternalFileType(fileExtension));
Path relativePath = FileUtil.shortenFileName(file, fileDirectories);
return new LinkedFile("", relativePath.toString(), suggestedFileType.getName());
}
public boolean isFulltextLookupInProgress() {
return fulltextLookupInProgress.get();
}
public BooleanProperty fulltextLookupInProgressProperty() {
return fulltextLookupInProgress;
}
private List<LinkedFileViewModel> parseToFileViewModel(String stringValue) {
return FileFieldParser.parse(stringValue).stream()
.map(LinkedFileViewModel::new)
.collect(Collectors.toList());
}
public ObservableList<LinkedFileViewModel> getFiles() {
return files.get();
}
public ListProperty<LinkedFileViewModel> filesProperty() {
return files;
}
public void addNewFile() {
Path workingDirectory = databaseContext.getFirstExistingFileDir(Globals.prefs.getFileDirectoryPreferences())
.orElse(Paths.get(Globals.prefs.get(JabRefPreferences.WORKING_DIRECTORY)));
FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder()
.withInitialDirectory(workingDirectory)
.build();
List<Path> fileDirectories = databaseContext.getFileDirectoriesAsPaths(Globals.prefs.getFileDirectoryPreferences());
dialogService.showFileOpenDialog(fileDialogConfiguration).ifPresent(
newFile -> {
LinkedFile newLinkedFile = fromFile(newFile, fileDirectories);
files.add(new LinkedFileViewModel(newLinkedFile));
}
);
}
@Override
public void bindToEntry(String fieldName, BibEntry entry) {
super.bindToEntry(fieldName, entry);
if (entry != null) {
BackgroundTask<List<LinkedFileViewModel>> findAssociatedNotLinkedFiles = BackgroundTask
.wrap(() -> findAssociatedNotLinkedFiles(entry))
.onSuccess(newFiles -> files.addAll(newFiles));
taskExecutor.execute(findAssociatedNotLinkedFiles);
}
}
/**
* Find files that are probably associated to the given entry but not yet linked.
*/
private List<LinkedFileViewModel> findAssociatedNotLinkedFiles(BibEntry entry) {
final List<Path> dirs = databaseContext.getFileDirectoriesAsPaths(Globals.prefs.getFileDirectoryPreferences());
final List<String> extensions = ExternalFileTypes.getInstance().getExternalFileTypeSelection().stream().map(ExternalFileType::getExtension).collect(Collectors.toList());
// Run the search operation:
FileFinder fileFinder = FileFinders.constructFromConfiguration(Globals.prefs.getAutoLinkPreferences());
List<Path> newFiles = fileFinder.findAssociatedFiles(entry, dirs, extensions);
List<LinkedFileViewModel> result = new ArrayList<>();
for (Path newFile : newFiles) {
boolean alreadyLinked = files.get().stream()
.map(file -> file.findIn(dirs))
.anyMatch(file -> file.isPresent() && file.get().equals(newFile));
if (!alreadyLinked) {
LinkedFileViewModel newLinkedFile = new LinkedFileViewModel(fromFile(newFile, dirs));
newLinkedFile.markAsAutomaticallyFound();
result.add(newLinkedFile);
}
}
return result;
}
public void fetchFulltext() {
if (entry.isPresent()) {
FulltextFetchers fetcher = new FulltextFetchers(Globals.prefs.getImportFormatPreferences());
BackgroundTask
.wrap(() -> fetcher.findFullTextPDF(entry.get()))
.onRunning(() -> fulltextLookupInProgress.setValue(true))
.onFinished(() -> fulltextLookupInProgress.setValue(false))
.onSuccess(url -> {
if (url.isPresent()) {
addFromURL(url.get());
} else {
dialogService.notify(Localization.lang("Full text document download failed"));
}
})
.executeWith(taskExecutor);
}
}
public void addFromURL() {
Optional<String> urlText = dialogService.showInputDialogAndWait(
Localization.lang("Download file"), Localization.lang("Enter URL to download"));
if (urlText.isPresent()) {
try {
URL url = new URL(urlText.get());
addFromURL(url);
} catch (MalformedURLException exception) {
dialogService.showErrorDialogAndWait(
Localization.lang("Invalid URL"),
exception
);
}
}
}
private void addFromURL(URL url) {
URLDownload urlDownload = new URLDownload(url);
Optional<ExternalFileType> suggestedType = inferFileType(urlDownload);
String suggestedTypeName = suggestedType.map(ExternalFileType::getName).orElse("");
List<Path> fileDirectories = databaseContext.getFileDirectoriesAsPaths(Globals.prefs.getFileDirectoryPreferences());
Path destination = constructSuggestedPath(suggestedType, fileDirectories);
LinkedFileViewModel temporaryDownloadFile = new LinkedFileViewModel(new LinkedFile("", url, suggestedTypeName));
files.add(temporaryDownloadFile);
FileDownloadTask downloadTask = new FileDownloadTask(url, destination);
temporaryDownloadFile.downloadProgressProperty().bind(downloadTask.progressProperty());
downloadTask.setOnSucceeded(event -> {
files.remove(temporaryDownloadFile);
LinkedFile newLinkedFile = fromFile(destination, fileDirectories);
files.add(new LinkedFileViewModel(newLinkedFile));
});
downloadTask.setOnFailed(event ->
dialogService.showErrorDialogAndWait("", downloadTask.getException()));
taskExecutor.execute(downloadTask);
}
private Optional<ExternalFileType> inferFileType(URLDownload urlDownload) {
Optional<ExternalFileType> suggestedType = inferFileTypeFromMimeType(urlDownload);
// If we did not find a file type from the MIME type, try based on extension:
if (!suggestedType.isPresent()) {
suggestedType = inferFileTypeFromURL(urlDownload.getSource().toExternalForm());
}
return suggestedType;
}
private Path constructSuggestedPath(Optional<ExternalFileType> suggestedType, List<Path> fileDirectories) {
String suffix = suggestedType.map(ExternalFileType::getExtension).orElse("");
String suggestedName = getSuggestedFileName(suffix);
Path directory;
if (fileDirectories.isEmpty()) {
directory = null;
} else {
directory = fileDirectories.get(0);
}
final Path suggestDir = directory == null ? Paths.get(System.getProperty("user.home")) : directory;
return suggestDir.resolve(suggestedName);
}
private Optional<ExternalFileType> inferFileTypeFromMimeType(URLDownload urlDownload) {
try {
// TODO: what if this takes long time?
String mimeType = urlDownload.getMimeType(); // Read MIME type
if (mimeType != null) {
LOGGER.debug("MIME Type suggested: " + mimeType);
return ExternalFileTypes.getInstance().getExternalFileTypeByMimeType(mimeType);
} else {
return Optional.empty();
}
} catch (IOException ex) {
LOGGER.debug("Error while inferring MIME type for URL " + urlDownload.getSource(), ex);
return Optional.empty();
}
}
private Optional<ExternalFileType> inferFileTypeFromURL(String url) {
String extension = DownloadExternalFile.getSuffix(url);
if (extension != null) {
return ExternalFileTypes.getInstance().getExternalFileTypeByExt(extension);
} else {
return Optional.empty();
}
}
private String getSuggestedFileName(String suffix) {
String plannedName = FileUtil.createFileNameFromPattern(databaseContext.getDatabase(), entry.get(),
Globals.prefs.get(JabRefPreferences.IMPORT_FILENAMEPATTERN),
Globals.prefs.getLayoutFormatterPreferences(Globals.journalAbbreviationLoader));
if (!suffix.isEmpty()) {
plannedName += "." + suffix;
}
/*
* [ 1548875 ] download pdf produces unsupported filename
*
* http://sourceforge.net/tracker/index.php?func=detail&aid=1548875&group_id=92314&atid=600306
* FIXME: rework this! just allow alphanumeric stuff or so?
* https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx#naming_conventions
* http://superuser.com/questions/358855/what-characters-are-safe-in-cross-platform-file-names-for-linux-windows-and-os
* https://support.apple.com/en-us/HT202808
*/
if (OS.WINDOWS) {
plannedName = plannedName.replaceAll("\\?|\\*|\\<|\\>|\\||\\\"|\\:|\\.$|\\[|\\]", "");
} else if (OS.OS_X) {
plannedName = plannedName.replace(":", "");
}
return plannedName;
}
}