package net.krazyweb.starmodmanager.data;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Task;
import javafx.concurrent.WorkerStateEvent;
import javafx.event.EventHandler;
import name.fraser.neil.plaintext.diff_match_patch;
import name.fraser.neil.plaintext.diff_match_patch.Diff;
import name.fraser.neil.plaintext.diff_match_patch.Patch;
import net.krazyweb.helpers.Archive;
import net.krazyweb.helpers.FileHelper;
import net.krazyweb.stardb.databases.AssetDatabase;
import net.krazyweb.starmodmanager.data.Mod.ModOrderComparator;
import net.krazyweb.starmodmanager.dialogue.MessageDialogue;
import net.krazyweb.starmodmanager.dialogue.ProgressDialogue;
import net.krazyweb.starmodmanager.dialogue.MessageDialogue.MessageType;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.ParameterizedMessage;
public class ModList implements ModListModelInterface {
private static class DumbBoolContainer {
private boolean value;
}
private static final Logger log = LogManager.getLogger(ModList.class);
private SettingsModelInterface settings;
private DatabaseModelInterface database;
private LocalizerModelInterface localizer;
private boolean locked;
private List<Mod> mods;
private Set<Observer> observers;
public ModList(final SettingsModelFactory settingsFactory, final DatabaseModelFactory databaseFactory, final LocalizerModelFactory localizerFactory) {
observers = new HashSet<>();
settings = settingsFactory.getInstance();
database = databaseFactory.getInstance();
localizer = localizerFactory.getInstance();
}
@Override
public Task<Void> getLoadTask() {
return new GetModListTask(this);
}
@Override
public Task<Void> getNewModsTask() {
return new GetNewModsTask(this);
}
@Override
public void addMods(final List<Path> files) {
final ProgressDialogue progress = new ProgressDialogue(localizer.getMessage("modlist.addingmods"));
final Set<String> currentMods = new HashSet<>();
for (Mod mod : mods) {
currentMods.add(mod.getInternalName());
}
final List<Mod> newMods = new ArrayList<>();
final DumbBoolContainer recoverableErrorOccurred = new DumbBoolContainer();
recoverableErrorOccurred.value = false;
final Task<Integer> addModsTask = new Task<Integer>() {
@Override
protected Integer call() throws Exception {
this.updateProgress(0, files.size());
Files.createDirectories(settings.getPropertyPath("modsdir"));
Set<Path> toRemove = new HashSet<>();
for (int i = 0; i < files.size(); i++) {
Path file = files.get(i);
this.updateMessage(localizer.getMessage("modlist.loadingmod") + file.getFileName());
Set<Mod> modsToAdd = Mod.load(file, mods.size(), new SettingsFactory(), new DatabaseFactory(), new LocalizerFactory());
if (modsToAdd == null || modsToAdd.isEmpty()) {
recoverableErrorOccurred.value = true;
continue;
}
for (Mod mod : modsToAdd) {
if (!currentMods.contains(mod.getInternalName())) {
mods.add(mod);
newMods.add(mod);
for (Path path : files) {
if (Files.isSameFile(settings.getPropertyPath("modsdir").resolve(mod.getArchiveName()), path)) {
toRemove.add(path);
log.debug("File is used by mod manager, will not delete: {} - {}", settings.getPropertyPath("modsdir").resolve(mod.getArchiveName()), path);
} else {
Path parent = path.getParent();
while (parent != null) {
if (parent.equals(settings.getPropertyPath("modsdir"))) {
break;
}
parent = parent.getParent();
}
if (parent == null) {
log.debug("File is not in the mod manager's mod directory, will not delete: {} - {}", settings.getPropertyPath("modsdir").resolve(mod.getArchiveName()), path);
toRemove.add(path);
}
}
}
} else {
//TODO Notify user of mod existence
log.debug("Mod already exists, skipping: {}", file);
Path parent = file.getParent();
while (parent != null) {
if (parent.equals(settings.getPropertyPath("modsdir"))) {
break;
}
parent = parent.getParent();
}
if (parent == null) {
log.debug("File is not in the mod manager's mod directory, will not delete: {} - {}", settings.getPropertyPath("modsdir").resolve(mod.getArchiveName()), file);
toRemove.add(file);
}
}
}
this.updateProgress(i, files.size());
}
files.removeAll(toRemove);
/*for (Path path : files) {
log.debug("Deleting file '{}' - File is unused.", path);
try {
FileHelper.deleteFile(path);
} catch (final IOException e) {
log.error("", e);
}
}*/
//TODO FIX ^^^
return 1;
}
};
addModsTask.setOnFailed(new EventHandler<WorkerStateEvent>() {
@Override
public void handle(WorkerStateEvent t) {
log.error("Error occurred while getting mods!", addModsTask.getException());
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.addmoderror"), localizer.getMessage("modlist.addmoderror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
progress.close();
}
});
addModsTask.setOnSucceeded(new EventHandler<WorkerStateEvent>() {
@Override
public void handle(WorkerStateEvent t) {
if (recoverableErrorOccurred.value) {
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlisttask.recoverableerror"), localizer.getMessage("modlisttask.recoverableerror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
progress.close();
for (Mod mod : newMods) {
notifyObservers(new Object[] { "modadded", mod });
}
}
});
progress.getProgressBar().bind(addModsTask.progressProperty(), files.size());
progress.getText().textProperty().bind(addModsTask.messageProperty());
progress.start();
Thread t = new Thread(addModsTask);
t.setName("Add Mods Thread");
t.setDaemon(true);
t.start();
}
@Override
public void deleteMod(final Mod mod) {
if (mod.isInstalled()) {
uninstallMod(mod);
}
try {
database.deleteMod(mod);
} catch (SQLException e) {
log.error("", e);
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.dbconnectionerror.deletemod"), localizer.getMessage("modlist.dbconnectionerror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
try {
FileHelper.deleteFile(Paths.get(settings.getPropertyString("modsdir") + File.separator + mod.getArchiveName())); //TODO Better Path manipulation
} catch (IOException e) {
log.error("", e);
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.fileerror.deletemod"), localizer.getMessage("modlist.fileerror.deletemod.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
removeMod(mod);
notifyObservers(new Object[] { "moddeleted", mod });
}
@Override
public Task<Void> getInstallModTask(final Mod mod) {
final Task<Void> installModsTask = new Task<Void>() {
@Override
protected Void call() throws Exception {
this.updateProgress(0, 1);
//Get every installed mod, including the one to be installed
List<Mod> installedMods = getInstalledMods();
installedMods.add(mod);
//Then separate out those with conflicts
List<Mod> conflictingMods = getConflictingMods(installedMods);
//No new conflicts were found, so just install the new mod normally
if (!conflictingMods.contains(mod)) {
Archive archive = new Archive(settings.getPropertyPath("modsdir").resolve(mod.getArchiveName()));
archive.extract();
archive.extractToFolder(settings.getPropertyPath("starboundpath").resolve("mods").resolve(mod.getInternalName()));
this.updateProgress(1, 1);
return null;
}
//Delete the folders of the other mods if they exist; new conflicts may exist with the new mod
for (Mod m : conflictingMods) {
FileHelper.deleteFile(settings.getPropertyPath("starboundpath").resolve("mods").resolve(m.getInternalName()));
}
Set<Path> modifiedFiles = conflictingFiles(conflictingMods);
Map<Mod, Archive> modArchives = new HashMap<>();
for (Mod m : installedMods) {
Archive archive = new Archive(settings.getPropertyPath("modsdir").resolve(m.getArchiveName()));
archive.extract();
modArchives.put(m, archive);
}
//Find all the non-conflicting files in each of those mods
//Copy the non-conflicting files to a their own mods' folders
FileHelper.deleteFile(settings.getPropertyPath("starboundpath").resolve("mods").resolve(settings.getPropertyPath("patchfolder")));
for (Mod m : conflictingMods) {
for (ModFile file : m.getFiles()) {
if (!modifiedFiles.contains(file.getPath())) {
log.debug("Path: {}", file.getPath());
modArchives.get(m).extractFileToFolder(file.getPath(), settings.getPropertyPath("starboundpath").resolve("mods").resolve(m.getInternalName()));
}
}
}
//Copy all non-JSON conflicting files into the patch folder
//Make sure to respect the load order here
Collections.reverse(conflictingMods);
log.debug("Load order for conflicting mods (reversed):");
for (Mod m : conflictingMods) {
log.debug(" [{}] - {}", m.getOrder(), m.getDisplayName());
}
for (Mod m : conflictingMods) {
for (ModFile file : m.getFiles()) {
if (modifiedFiles.contains(file.getPath()) && !file.isJson()) {
modArchives.get(m).extractFileToFolder(file.getPath(), settings.getPropertyPath("starboundpath").resolve("mods").resolve(settings.getPropertyPath("patchfolder")));
}
}
}
AssetDatabase db = AssetDatabase.open(settings.getPropertyPath("starboundpath").resolve("assets").resolve("packed.pak"));
//Get all JSON files and merge them, then save them
for (Path path : modifiedFiles) {
if (!FileHelper.isJSON(path)) {
continue;
}
String originalFile = null;
String outputFile = "";
if (db.getFileList().contains("/" + path.toString())) {
log.debug("Retrieving asset {} from database.", "/" + path.toString());
originalFile = new String(db.getAsset("/" + path.toString()));
} else {
boolean found = false;
for (Mod m : conflictingMods) {
if (found) {
break;
}
for (ModFile file : m.getFiles()) {
if (file.getPath().equals(path)) {
originalFile = new String(modArchives.get(m).getFile(path).getData());
found = true;
break;
}
}
}
}
diff_match_patch dpm = new diff_match_patch();
LinkedList<Patch> patchesToApply = new LinkedList<Patch>();
for (Mod m : conflictingMods) {
for (ModFile file : m.getFiles()) {
if (!file.getPath().toString().equals(path.toString())) {
continue;
}
String changedFile = new String(modArchives.get(m).getFile(file.getPath()).getData());
if (conflictingMods.indexOf(m) != conflictingMods.size() - 1) {
log.debug("Merging file for {} : {}", m.getDisplayName(), file.getPath());
LinkedList<Diff> diff = dpm.diff_main(originalFile, changedFile);
LinkedList<Patch> patches = dpm.patch_make(diff);
patchesToApply.addAll(patches);
} else {
log.debug("Merging file for 2 {}", m.getDisplayName());
outputFile = (String) dpm.patch_apply(patchesToApply, changedFile)[0];
}
}
}
Path outputPath = settings.getPropertyPath("starboundpath").resolve("mods").resolve(settings.getPropertyPath("patchfolder")).resolve(path);
Files.createDirectories(outputPath.getParent());
OutputStream output = Files.newOutputStream(outputPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
output.write(outputFile.getBytes());
output.close();
}
//Finally, save the mod manager .modinfo file to the mod patch folder
Path outputPath = settings.getPropertyPath("starboundpath").resolve("mods").resolve(settings.getPropertyPath("patchfolder")).resolve("ModManagerPatch.modinfo");
Files.createDirectories(outputPath.getParent());
byte[] modinfoData = IOUtils.toByteArray(ModList.class.getClassLoader().getResourceAsStream("ModManagerPatch.modinfo"));
OutputStream output = Files.newOutputStream(outputPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
output.write(modinfoData);
output.close();
this.updateProgress(1, 1);
return null;
}
};
installModsTask.setOnFailed(new EventHandler<WorkerStateEvent>() {
@Override
public void handle(final WorkerStateEvent t) {
log.error("Error occurred while installing mods!", installModsTask.getException());
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.installmoderror"), localizer.getMessage("modlist.installmoderror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
});
installModsTask.setOnSucceeded(new EventHandler<WorkerStateEvent>() {
@Override
public void handle(final WorkerStateEvent t) {
mod.setInstalled(true);
try {
database.updateMod(mod);
} catch (final SQLException e) {
log.error("", e);
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.dbconnectionerror.installmod"), localizer.getMessage("modlist.dbconnectionerror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
}
});
return installModsTask;
}
@Override
public void uninstallMod(final Mod mod) {
final Task<Integer> uninstallModsTask = new Task<Integer>() {
@Override
protected Integer call() throws Exception {
log.info("Uninstalling mod: {}", mod.getInternalName());
try {
log.debug("Deleting from: {}", settings.getPropertyPath("starboundpath").resolve("mods").resolve(mod.getInternalName()));
FileHelper.deleteFile(
settings.getPropertyPath("starboundpath").resolve("mods").resolve(mod.getInternalName())
);
} catch (IOException e) {
log.error(new ParameterizedMessage("Uninstalling Mod: {}", mod.getInternalName()), e);
}
return 1;
}
};
uninstallModsTask.setOnFailed(new EventHandler<WorkerStateEvent>() {
@Override
public void handle(final WorkerStateEvent t) {
log.error("Error occurred while uninstalling mods!", uninstallModsTask.getException());
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.uninstallmoderror"), localizer.getMessage("modlist.uninstallmoderror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
});
uninstallModsTask.setOnSucceeded(new EventHandler<WorkerStateEvent>() {
@Override
public void handle(final WorkerStateEvent t) {
mod.setInstalled(false);
if (getConflictingMods(getInstalledMods()).contains(mod)) {
List<Mod> conflictingMods = getConflictingMods(getInstalledMods());
conflictingMods.remove(mod);
Collections.reverse(conflictingMods);
reinstallConflictingMods(conflictingMods);
}
try {
database.updateMod(mod);
} catch (final SQLException e) {
log.error("", e);
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.dbconnectionerror.installmod"), localizer.getMessage("modlist.dbconnectionerror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
}
});
Thread t = new Thread(uninstallModsTask);
t.setName("Uninstall Mods Thread");
t.setDaemon(true);
t.start();
}
@Override
public void reinstallConflictingMods(final List<Mod> oldMods) {
List<Mod> oldConflictingMods = getConflictingMods(oldMods);
List<Mod> conflictingMods = getConflictingMods(getInstalledMods());
for (int i = 0; i < oldConflictingMods.size(); i++) {
if (oldConflictingMods.get(i) != conflictingMods.get(i)) {
Mod mod = conflictingMods.get(i);
uninstallMod(mod);
Task<Void> task = getInstallModTask(mod);
final ProgressDialogue lview = new ProgressDialogue(localizer.formatMessage("modview.install.title"));
lview.getProgressBar().bind(task.progressProperty(), 1.0);
lview.getText().setText(localizer.formatMessage("modview.install.dialogue", mod.getDisplayName()));
task.progressProperty().addListener(new ChangeListener<Number>() {
@Override
public void changed(final ObservableValue<? extends Number> observableValue, final Number oldValue, final Number newValue) {
if (newValue.doubleValue() >= 0.999) {
lview.close();
}
}
});
Thread thread = new Thread(task);
thread.setDaemon(true);
thread.setName("Install Mod Thread");
lview.start();
thread.start();
break;
}
}
}
@Override
public void hideMod(final Mod mod) {
mod.setHidden(true);
try {
database.updateMod(mod);
} catch (final SQLException e) {
log.error("", e);
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.dbconnectionerror.installmod"), localizer.getMessage("modlist.dbconnectionerror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
removeMod(mod);
notifyObservers(new Object[] { "modhidden", mod });
}
private void removeMod(final Mod mod) {
mods.remove(mod);
Collections.sort(mods, new ModOrderComparator());
for (Mod m : mods) {
m.setOrder(mods.indexOf(m));
}
}
@Override
public void moveMod(final Mod mod, final int amount) {
if (locked) {
log.trace("Mod list locked; cannot move mod: {}", mod.getInternalName());
return;
}
log.trace("Performing rotation, results:");
if (amount > 0) {
if (mods.indexOf(mod) - amount > 0) {
Collections.rotate(mods.subList(mods.indexOf(mod) - amount, mods.indexOf(mod) + 1), 1);
} else {
Collections.rotate(mods.subList(0, mods.indexOf(mod) + 1), 1);
}
} else {
if (mods.indexOf(mod) - amount + 1 <= mods.size()) {
Collections.rotate(mods.subList(mods.indexOf(mod), mods.indexOf(mod) - amount + 1), -1);
} else {
Collections.rotate(mods.subList(mods.indexOf(mod), mods.size()), -1);
}
}
for (Mod m : mods) {
m.setOrder(mods.indexOf(m));
log.trace(" [{}] {}", m.getOrder(), m.getInternalName());
try {
database.updateMod(m);
} catch (SQLException e) {
log.error("", e);
MessageDialogue dialogue = new MessageDialogue(localizer.getMessage("modlist.dbconnectionerror.installmod"), localizer.getMessage("modlist.dbconnectionerror.title"), MessageType.ERROR, new LocalizerFactory());
dialogue.getResult();
}
}
}
@Override
public List<Mod> getMods() {
List<Mod> modListCopy = new ArrayList<>(mods);
return modListCopy;
}
@Override
public int indexOf(final Mod mod) {
return mods.indexOf(mod);
}
@Override
public void setLocked(final boolean locked) {
this.locked = locked;
}
@Override
public boolean isLocked() {
return locked;
}
@Override
public void setModList(final List<Mod> list) {
Set<Mod> toRemove = new HashSet<>();
for (Mod m : list) {
if (m.isHidden()) {
toRemove.add(m);
}
}
list.removeAll(toRemove);
Collections.sort(list, new ModOrderComparator());
for (Mod m : list) {
m.setOrder(list.indexOf(m));
}
this.mods = list;
notifyObservers("modlistupdated");
}
@Override
public List<Mod> getInstalledMods() {
List<Mod> installedMods = new ArrayList<>();
for (Mod m : mods) {
if (m.isInstalled()) {
installedMods.add(m);
}
}
return installedMods;
}
private List<Mod> getConflictingMods(final List<Mod> modList) {
List<Mod> conflictingMods = new ArrayList<>();
for (Mod m1 : modList) {
for (Mod m2 : modList) {
if (m1 != m2 && m1.conflictsWith(m2)) {
if (!conflictingMods.contains(m1)) {
conflictingMods.add(m1);
}
if (!conflictingMods.contains(m2)) {
conflictingMods.add(m2);
}
}
}
}
Collections.sort(conflictingMods, new ModOrderComparator());
return conflictingMods;
}
private Set<Path> conflictingFiles(final List<Mod> conflictingMods) {
Map<Path, Integer> fileCounts = new HashMap<>();
//First, count all instances of modified files.
for (Mod m : conflictingMods) {
for (ModFile modFile : m.getFiles()) {
if (modFile.isAutoMerged() || modFile.isIgnored() || modFile.isModinfo()) {
continue;
}
if (fileCounts.containsKey(modFile.getPath())) {
fileCounts.put(modFile.getPath(), fileCounts.get(modFile.getPath()) + 1);
} else {
fileCounts.put(modFile.getPath(), 1);
}
}
}
Set<Path> modifiedFiles = new HashSet<>();
//All files with an instance count > 1 have conflicts
for (Path p : fileCounts.keySet()) {
if (fileCounts.get(p) > 1) {
modifiedFiles.add(p);
log.debug("Modified File: {}", p);
}
}
return modifiedFiles;
}
@Override
public void addObserver(final Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(final Observer observer) {
observers.remove(observer);
}
private final void notifyObservers(final Object message) {
for (final Observer o : observers) {
o.update(this, message);
}
}
}