diff --git a/pandalib-paper/pom.xml b/pandalib-paper/pom.xml
index f458ca5..c18faff 100644
--- a/pandalib-paper/pom.xml
+++ b/pandalib-paper/pom.xml
@@ -70,6 +70,13 @@
paper-mojangapi
${paper.version}-SNAPSHOT
+
+
+
+ ch.eitchnet
+ cron
+ 1.6.2
+
\ No newline at end of file
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/PandaLibPaper.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/PandaLibPaper.java
index bca175a..7a47efc 100644
--- a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/PandaLibPaper.java
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/PandaLibPaper.java
@@ -1,5 +1,6 @@
package fr.pandacube.lib.paper;
+import fr.pandacube.lib.paper.modules.PerformanceAnalysisManager;
import org.bukkit.plugin.Plugin;
public class PandaLibPaper {
@@ -8,6 +9,8 @@ public class PandaLibPaper {
public static void init(Plugin plugin) {
PandaLibPaper.plugin = plugin;
+
+ PerformanceAnalysisManager.getInstance(); // initialize
}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupCleaner.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupCleaner.java
new file mode 100644
index 0000000..4d42ccb
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupCleaner.java
@@ -0,0 +1,101 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import java.io.File;
+import java.time.LocalDateTime;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+import fr.pandacube.lib.util.Log;
+
+public abstract class BackupCleaner implements UnaryOperator> {
+
+ public static BackupCleaner KEEPING_N_LAST(int n) {
+ return new BackupCleaner() {
+ @Override
+ public TreeSet apply(TreeSet archives) {
+ return archives.descendingSet().stream()
+ .limit(n)
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+ };
+ }
+
+
+ public static BackupCleaner KEEPING_1_EVERY_N_MONTH(int n) {
+ return new BackupCleaner() {
+ @Override
+ public TreeSet apply(TreeSet localDateTimes) {
+ return localDateTimes.stream()
+ .collect(Collectors.groupingBy(
+ ldt -> {
+ return ldt.getYear() * 4 + ldt.getMonthValue() / 2;
+ },
+ TreeMap::new,
+ Collectors.minBy(LocalDateTime::compareTo))
+ )
+ .values()
+ .stream()
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toCollection(TreeSet::new));
+ }
+ };
+ }
+
+
+
+
+ public BackupCleaner merge(BackupCleaner other) {
+ BackupCleaner self = this;
+ return new BackupCleaner() {
+ @Override
+ public TreeSet apply(TreeSet archives) {
+ TreeSet merged = new TreeSet<>();
+ merged.addAll(self.apply(archives));
+ merged.addAll(other.apply(archives));
+ return merged;
+ }
+ };
+ }
+
+
+
+
+ public void cleanupArchives(File archiveDir) {
+ String[] files = archiveDir.list();
+
+ Log.info("[Backup] Cleaning up backup directory " + archiveDir + "...");
+
+ TreeMap datedFiles = new TreeMap<>();
+
+ for (String filename : files) {
+ File file = new File(archiveDir, filename);
+ if (!filename.matches("\\d{8}-\\d{6}\\.zip")) {
+ Log.warning("[Backup] Invalid file in backup directory: " + file);
+ continue;
+ }
+
+ String dateTimeStr = filename.substring(0, filename.length() - 4);
+ LocalDateTime ldt = LocalDateTime.parse(dateTimeStr, CompressProcess.dateFileNameFormatter);
+
+ datedFiles.put(ldt, file);
+ }
+
+ TreeSet keptFiles = apply(new TreeSet<>(datedFiles.keySet()));
+
+ for (Entry datedFile : datedFiles.entrySet()) {
+ if (keptFiles.contains(datedFile.getKey()))
+ continue;
+ // datedFile.getValue().delete(); // TODO check if the filtering is ok before actually removing files
+ Log.info("[Backup] Removed expired backup file " + datedFile.getValue());
+ }
+
+ Log.info("[Backup] Backup directory " + archiveDir + " cleaned.");
+ }
+
+
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupConfig.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupConfig.java
new file mode 100644
index 0000000..5e61cd0
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupConfig.java
@@ -0,0 +1,15 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class BackupConfig {
+ public boolean worldBackupEnabled = true;
+ public boolean workdirBackupEnabled = true;
+ public String scheduling = "0 2 * * 1"; // cron format, here is everyday at 2am
+ public File backupDirectory = null;
+ public BackupCleaner worldBackupCleaner = BackupCleaner.KEEPING_1_EVERY_N_MONTH(3).merge(BackupCleaner.KEEPING_N_LAST(5));
+ public BackupCleaner workdirBackupCleaner = BackupCleaner.KEEPING_1_EVERY_N_MONTH(3).merge(BackupCleaner.KEEPING_N_LAST(5));
+ public List workdirIgnoreList = new ArrayList<>();
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupManager.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupManager.java
new file mode 100644
index 0000000..37724b9
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupManager.java
@@ -0,0 +1,196 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import fc.cron.CronExpression;
+import fr.pandacube.lib.paper.PandaLibPaper;
+import fr.pandacube.lib.util.Log;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerChangedWorldEvent;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.event.world.WorldLoadEvent;
+import org.bukkit.event.world.WorldSaveEvent;
+
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class BackupManager implements Runnable, Listener {
+
+
+
+ Persist persist;
+
+ private final List compressQueue = new ArrayList<>();
+
+ private final Set compressWorlds = new HashSet<>();
+
+ /* package */ AtomicReference compressRunning = new AtomicReference<>();
+
+ private final Set dirtyForSave = new HashSet<>();
+
+ BackupConfig config;
+
+ public BackupManager(BackupConfig config) {
+ setConfig(config);
+ persist = new Persist(this);
+
+
+ for (final World world : Bukkit.getWorlds()) {
+ initCompressProcess(Type.WORLDS, world.getName());
+ }
+
+ initCompressProcess(Type.WORKDIR, null);
+
+ Bukkit.getServer().getScheduler().runTaskTimer(PandaLibPaper.getPlugin(), this, (60 - Calendar.getInstance().get(Calendar.SECOND)) * 20L, 60 * 20L);
+
+ Bukkit.getServer().getPluginManager().registerEvents(this, PandaLibPaper.getPlugin());
+
+ }
+
+ public void setConfig(BackupConfig config) {
+ this.config = config;
+ }
+
+
+ public void onDisable() {
+
+ if (compressRunning.get() != null) {
+ Log.warning("[Backup] Waiting after the end of a backup...");
+ CompressProcess tmp;
+ while ((tmp = compressRunning.get()) != null) {
+ try {
+ tmp.logProgress();
+ // wait 5 seconds between each progress log
+ // but check if the process has ended each .5 seconds
+ for (int i = 0; i < 10; i++) {
+ if (compressRunning.get() == null)
+ break;
+ Thread.sleep(500);
+ }
+ } catch (Throwable e) { // could occur because of synchronization errors/interruption/...
+ break;
+ }
+ }
+ }
+
+ // save dirty status of worlds
+ for (String wName : dirtyForSave) {
+ World w = Bukkit.getWorld(wName);
+ if (w != null)
+ persist.updateDirtyStatusAfterSave(w);
+ }
+
+ persist.save();
+ }
+
+ private void initCompressProcess(final Type type, final String worldName) {
+ if (!type.backupEnabled(config))
+ return;
+ if (type == Type.WORLDS) {
+ if (compressWorlds.contains(worldName))
+ return;
+ compressWorlds.add(worldName);
+ }
+ CompressProcess process = type == Type.WORLDS ? new CompressWorldProcess(this, worldName) : new CompressWorkdirProcess(this);
+ process.displayDirtynessStatus();
+ compressQueue.add(process);
+ }
+
+ @Override
+ public void run() {
+ CompressProcess tmp;
+ if ((tmp = compressRunning.get()) != null) {
+ tmp.logProgress();
+ }
+ else {
+ compressQueue.sort(null);
+ for (CompressProcess process : compressQueue) {
+ if (System.currentTimeMillis() >= process.getNext() && process.couldRunNow()) {
+ process.run();
+ return;
+ }
+ }
+ }
+ }
+
+
+
+
+
+ /**
+ * get the timestamp (in ms) of when the next compress will run, depending on since when the files to compress are dirty.
+ * @param dirtySince the timestamp in ms since the files are dirty
+ * @return the timestamp in ms when the next compress of the files should be run, or 0 if it is not yet scheduled
+ */
+ /* package */ long getNextCompress(long dirtySince) {
+ if (dirtySince == -1)
+ return 0;
+
+ CronExpression parsedScheduling;
+ try {
+ parsedScheduling = new CronExpression(config.scheduling, false);
+ } catch (IllegalArgumentException e) {
+ Log.severe("Invalid backup scheduling configuration '" + config.scheduling + "'.", e);
+ return 0;
+ }
+
+ ZonedDateTime ldt = parsedScheduling.nextTimeAfter(ZonedDateTime.from(Instant.ofEpochMilli(dirtySince)));
+ // Log.info("Compress config: " + compressConfig + " - interval: " + interval);
+
+ return ldt.toInstant().toEpochMilli();
+ }
+
+
+
+
+
+
+
+
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onWorldLoad(WorldLoadEvent event) {
+ initCompressProcess(Type.WORLDS, event.getWorld().getName());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onWorldSave(WorldSaveEvent event) {
+ if (event.getWorld().getLoadedChunks().length > 0
+ || dirtyForSave.contains(event.getWorld().getName())) {
+ persist.updateDirtyStatusAfterSave(event.getWorld());
+ dirtyForSave.remove(event.getWorld().getName());
+ }
+ }
+
+
+
+
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onPlayerChangeWorldEvent(PlayerChangedWorldEvent event) {
+ dirtyForSave.add(event.getFrom().getName());
+ dirtyForSave.add(event.getPlayer().getWorld().getName());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onPlayerJoin(PlayerJoinEvent event) {
+ dirtyForSave.add(event.getPlayer().getWorld().getName());
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR)
+ public void onPlayerQuit(PlayerQuitEvent event) {
+ dirtyForSave.add(event.getPlayer().getWorld().getName());
+ }
+
+
+
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressProcess.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressProcess.java
new file mode 100644
index 0000000..250b4b7
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressProcess.java
@@ -0,0 +1,194 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import fr.pandacube.lib.chat.Chat;
+import fr.pandacube.lib.paper.PandaLibPaper;
+import fr.pandacube.lib.paper.modules.PerformanceAnalysisManager;
+import fr.pandacube.lib.paper.util.AutoUpdatedBossBar;
+import fr.pandacube.lib.util.FileUtils;
+import fr.pandacube.lib.util.Log;
+import net.kyori.adventure.bossbar.BossBar;
+import net.kyori.adventure.bossbar.BossBar.Color;
+import net.kyori.adventure.bossbar.BossBar.Overlay;
+import org.bukkit.Bukkit;
+import org.bukkit.ChatColor;
+
+import java.io.File;
+import java.text.DateFormat;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.function.BiPredicate;
+
+public abstract class CompressProcess implements Comparable, Runnable {
+ protected final BackupManager backupManager;
+ public final Type type;
+ public final String name;
+
+
+
+ private ZipCompressor compressor = null;
+
+ protected CompressProcess(BackupManager bm, final Type t, final String n) {
+ backupManager = bm;
+ type = t;
+ name = n;
+ }
+
+ @Override
+ public int compareTo(final CompressProcess process) {
+ return Long.compare(getNext(), process.getNext());
+ }
+
+
+ public abstract BiPredicate getFilenameFilter();
+
+ public abstract File getSourceDir();
+
+ protected abstract void onCompressStart();
+
+ protected abstract void onCompressEnd(boolean success);
+
+ protected abstract File getTargetDir();
+
+
+ @Override
+ public void run() {
+ backupManager.compressRunning.set(this);
+
+ BiPredicate filter = getFilenameFilter();
+ File sourceDir = getSourceDir();
+
+ if (!sourceDir.exists()) {
+ Log.warning(String.format("%% unable to compress %s (check path: %s)", name, sourceDir.getPath()));
+ backupManager.compressRunning.set(null);
+ return;
+ }
+
+ File targetDir = getTargetDir();
+ File target = new File(targetDir, getDateFileName() + ".zip");
+
+
+ BossBar bossBar = BossBar.bossBar(Chat.text("Archivage"), 0, Color.YELLOW, Overlay.NOTCHED_20);
+ AutoUpdatedBossBar auBossBar = new AutoUpdatedBossBar(bossBar, (bar) -> {
+ bar.setTitle(Chat.infoText("Archivage ")
+ .thenData(type + "\\" + name)
+ .thenText(" : ")
+ .then(compressor == null
+ ? Chat.text("Démarrage...")
+ : compressor.getState()
+ )
+ );
+ bar.setProgress(compressor == null ? 0 : compressor.getProgress());
+ });
+ auBossBar.scheduleUpdateTimeSyncThreadAsync(100, 100);
+
+ onCompressStart();
+
+ Bukkit.getScheduler().runTaskAsynchronously(PandaLibPaper.getPlugin(), () -> {
+ Log.info("[Backup] starting for " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " ...");
+
+ compressor = new ZipCompressor(sourceDir, target, 9, filter);
+
+ PerformanceAnalysisManager.getInstance().addBossBar(bossBar);
+
+ boolean success = false;
+ try {
+ compressor.compress();
+
+ success = true;
+
+ Log.info("[Backup] finished for " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET);
+
+ backupManager.persist.updateDirtyStatusAfterCompress(type, name);
+
+ displayDirtynessStatus();
+ }
+ catch (final Exception e) {
+ Log.severe("[Backup] Failed: " + sourceDir + " -> " + target, e);
+
+ FileUtils.delete(target);
+ if (target.exists())
+ Log.warning("unable to delete: " + target);
+ } finally {
+
+ backupManager.compressRunning.set(null);
+ boolean successF = success;
+ Bukkit.getScheduler().runTask(PandaLibPaper.getPlugin(), () -> onCompressEnd(successF));
+
+ try {
+ Thread.sleep(2000);
+ } catch(InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ PerformanceAnalysisManager.getInstance().removeBossBar(bossBar);
+ }
+ });
+ }
+
+
+
+ public void displayDirtynessStatus() {
+ if (hasNextScheduled() && type == Type.WORLDS) {
+ Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " is dirty. Next backup on "
+ + DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
+ }
+ else if (hasNextScheduled()) {
+ Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " next backup on "
+ + DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG).format(new Date(getNext())));
+ }
+ else {
+ Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + " is clean. Next backup not scheduled.");
+ }
+ }
+
+
+ static DateTimeFormatter dateFileNameFormatter = new DateTimeFormatterBuilder()
+ .append(DateTimeFormatter.BASIC_ISO_DATE)
+ .appendLiteral('-')
+ .appendValue(ChronoField.HOUR_OF_DAY, 2) // there is no DateTimeFormatter.BASIC_ISO_TIME
+ .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+ .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+ .toFormatter();
+
+
+ private String getDateFileName() {
+ Calendar calendar = Calendar.getInstance();
+ return dateFileNameFormatter.format(calendar.toInstant());
+ }
+
+
+ public void logProgress() {
+ if (compressor == null)
+ return;
+ Log.info("[Backup] " + ChatColor.GRAY + type + "\\" + name + ChatColor.RESET + ": " + compressor.getState().getLegacyText());
+ }
+
+
+
+ public boolean couldRunNow() {
+ if (!type.backupEnabled(backupManager.config))
+ return false;
+ if (!backupManager.persist.isDirty(type, name))
+ return false;
+ if (getNext() > System.currentTimeMillis())
+ return false;
+ return true;
+ }
+
+
+
+
+ public long getNext() {
+ if (!hasNextScheduled())
+ return Long.MAX_VALUE;
+ return backupManager.getNextCompress(backupManager.persist.isDirtySince(type, name));
+ }
+
+ public boolean hasNextScheduled() {
+ return backupManager.persist.isDirty(type, name);
+ }
+
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressWorkdirProcess.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressWorkdirProcess.java
new file mode 100644
index 0000000..9252f65
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressWorkdirProcess.java
@@ -0,0 +1,71 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import java.io.File;
+import java.util.List;
+import java.util.function.BiPredicate;
+
+public class CompressWorkdirProcess extends CompressProcess {
+
+ protected CompressWorkdirProcess(BackupManager bm) {
+ super(bm, Type.WORKDIR, Type.WORKDIR.toString());
+ }
+
+
+ public BiPredicate getFilenameFilter() {
+ List ignoreList = backupManager.config.workdirIgnoreList;
+ if (ignoreList == null)
+ return null;
+ return new SourceFileFilter();
+ }
+
+
+
+ private class SourceFileFilter implements BiPredicate {
+
+
+ @Override
+ public boolean test(File file, String path) {
+ if (globalExcluded(file, path))
+ return false;
+ for (String exclude : backupManager.config.workdirIgnoreList) {
+ if (exclude.startsWith("/")) { // relative to source of workdir
+ if (path.matches(exclude.substring(1)))
+ return false;
+ }
+ else {
+ String name = path.substring(path.lastIndexOf("/") + 1);
+ if (name.matches(exclude))
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public boolean globalExcluded(File file, String path) {
+ if (file.isDirectory() && new File(file, "level.dat").exists())
+ return true;
+ if (new File(getSourceDir(), "logs").equals(file))
+ return true;
+ return false;
+ }
+
+ }
+
+
+
+ @Override
+ public File getSourceDir() {
+ return new File(".");
+ }
+
+ @Override
+ protected void onCompressStart() { }
+
+ @Override
+ protected void onCompressEnd(boolean success) { }
+
+ @Override
+ protected File getTargetDir() {
+ return new File(backupManager.config.backupDirectory, "workdir");
+ }
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressWorldProcess.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressWorldProcess.java
new file mode 100644
index 0000000..2f3a4e6
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/CompressWorldProcess.java
@@ -0,0 +1,54 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import fr.pandacube.lib.paper.util.WorldUtil;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+
+import java.io.File;
+import java.util.function.BiPredicate;
+
+public class CompressWorldProcess extends CompressProcess {
+
+ private boolean autoSave = true;
+
+ protected CompressWorldProcess(BackupManager bm, final String n) {
+ super(bm, Type.WORLDS, n);
+ }
+
+ private World getWorld() {
+ return Bukkit.getWorld(name);
+ }
+
+
+ public BiPredicate getFilenameFilter() {
+ return (f, s) -> true;
+ }
+
+
+ @Override
+ public File getSourceDir() {
+ return WorldUtil.worldDir(name);
+ }
+
+ @Override
+ protected void onCompressStart() {
+ World w = getWorld();
+ if (w == null)
+ return;
+ autoSave = w.isAutoSave();
+ w.setAutoSave(false);
+ }
+
+ @Override
+ protected void onCompressEnd(boolean success) {
+ World w = getWorld();
+ if (w == null)
+ return;
+ w.setAutoSave(autoSave);
+ }
+
+ @Override
+ protected File getTargetDir() {
+ return new File(backupManager.config.backupDirectory, type.toString() + "/" + name);
+ }
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/Persist.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/Persist.java
new file mode 100644
index 0000000..c3addda
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/Persist.java
@@ -0,0 +1,133 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.text.DateFormat;
+import java.util.Date;
+
+import org.bukkit.World;
+import org.bukkit.configuration.InvalidConfigurationException;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.yaml.snakeyaml.error.YAMLException;
+
+import fr.pandacube.lib.paper.PandaLibPaper;
+import fr.pandacube.lib.util.Log;
+
+public class Persist extends YamlConfiguration {
+ protected final BackupManager backupManager;
+
+ private final File file;
+
+ // private final Set dirtyWorldsSave = new HashSet<>();
+
+ public Persist(BackupManager bm) {
+ file = new File(PandaLibPaper.getPlugin().getDataFolder(), "backup_persist.yml");
+ backupManager = bm;
+ load();
+ }
+
+ public void reload() {
+ load();
+ }
+
+ protected void load() {
+ boolean loaded = false;
+ try {
+ load(file);
+ loaded = true;
+ }
+ catch (final FileNotFoundException ignored) { }
+ catch (final IOException e) {
+ Log.severe("cannot load " + file, e);
+ }
+ catch (final InvalidConfigurationException e) {
+ if (e.getCause() instanceof YAMLException) Log.severe("Config file " + file + " isn't valid!", e);
+ else if (e.getCause() == null || e.getCause() instanceof ClassCastException) Log.severe("Config file " + file + " isn't valid!");
+ else Log.severe("cannot load " + file, e);
+ }
+
+ if (!loaded) {
+ options().copyDefaults(true);
+ save();
+ }
+ }
+
+ public void save() {
+ try {
+ save(file);
+ }
+ catch (final IOException e) {
+ Log.severe("could not save " + file, e);
+ }
+ }
+
+
+
+
+
+ /**
+ * Make the specified world dirty for compress. Also makes the specified world clean for saving if nobody is connected there.
+ */
+ public void updateDirtyStatusAfterSave(final World world) {
+ if (world == null)
+ return;
+ if (!isDirty(Type.WORLDS, world.getName())) { // don't set dirty if it is already
+ setDirtySinceNow(Type.WORLDS, world.getName());
+ Log.info("[Backup] " + Type.WORLDS + "\\" + world.getName() + " was saved and is now dirty. Next backup on "
+ + DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG)
+ .format(new Date(backupManager.getNextCompress(System.currentTimeMillis())))
+ );
+ }
+ }
+
+ /**
+ * Update the dirty status after the specified compress process is done.
+ * @param t the type of process
+ * @param n the name of the process (for instance, the name of the world)
+ */
+ public void updateDirtyStatusAfterCompress(Type t, String n) {
+ if (t == Type.WORLDS)
+ setNotDirty(t, n);
+ else
+ setDirtySinceNow(t, n);
+ }
+
+
+ private void setDirtySinceNow(Type t, String n) {
+ set(t + "." + n + ".dirty_since", System.currentTimeMillis());
+ save();
+ }
+
+ private void setNotDirty(Type t, String n) {
+ if (t == Type.WORKDIR)
+ return; // WORKDIR are always considered dirty
+ set(t + "." + n + ".dirty_since", -1);
+ save();
+ }
+
+
+ public boolean isDirty(Type t, String n) {
+ if (t == Type.WORKDIR)
+ return true;
+ return isDirtySince(t, n) != -1;
+ }
+
+ public long isDirtySince(Type t, String n) {
+ if (!contains(t + "." + n + ".dirty_since"))
+ setDirtySinceNow(t, n);
+ return getLong(t + "." + n + ".dirty_since");
+ }
+
+ /*
+ *
+ * type: // (worlds|others)
+ * name: // (root|plugin|)
+ * dirty_since: (true|false)
+ *
+ */
+
+
+
+
+}
\ No newline at end of file
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/Type.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/Type.java
new file mode 100644
index 0000000..10dbd37
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/Type.java
@@ -0,0 +1,19 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+public enum Type {
+ WORLDS,
+ WORKDIR;
+
+ @Override
+ public String toString() {
+ return name().toLowerCase();
+ }
+
+ public boolean backupEnabled(BackupConfig cfg) {
+ return switch (this) {
+ case WORLDS -> cfg.worldBackupEnabled;
+ case WORKDIR -> cfg.workdirBackupEnabled;
+ };
+ }
+
+}
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/ZipCompressor.java b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/ZipCompressor.java
new file mode 100644
index 0000000..405d079
--- /dev/null
+++ b/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/ZipCompressor.java
@@ -0,0 +1,194 @@
+package fr.pandacube.lib.paper.modules.backup;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiPredicate;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import fr.pandacube.lib.chat.Chat;
+import fr.pandacube.lib.util.MemoryUtil;
+import fr.pandacube.lib.util.TimeUtil;
+
+/**
+ * Handles the creation of a zip file that will have the content of a provided folder.
+ */
+public class ZipCompressor {
+ private static final int BUFFER_SIZE = 16 * 1024;
+
+
+
+ private final File srcDir, destFile;
+ private final int compressionLevel;
+ private final BiPredicate filter;
+
+ private final List entriesToCompress;
+ private ZipOutputStream zipOutStream;
+
+ private final Object stateLock = new Object();
+ private final long inputByteSize;
+ private long startTime;
+ private long elapsedByte = 0;
+ private Exception exception = null;
+ private boolean started = false;
+ private boolean finished = false;
+
+ /**
+ * Creates a new zip compressor.
+ * @param s the source directory.
+ * @param d the destination file.
+ * @param c the compression level, used in {@link ZipOutputStream#setLevel(int)} .
+ * @param f a filter that returns true for the files to include in the zip file, false to exclude.
+ */
+ public ZipCompressor(File s, File d, int c, BiPredicate f) {
+ srcDir = s;
+ destFile = d;
+ compressionLevel = c;
+ filter = f;
+
+ entriesToCompress = new ArrayList<>();
+ inputByteSize = addEntry("");
+
+ }
+
+ /**
+ * Returns a displayable representation of the running compression.
+ * @return a displayable representation of the running compression.
+ */
+ public Chat getState() {
+ synchronized (stateLock) {
+ if (!started) {
+ return Chat.text("Démarrage...");
+ }
+ else if (!finished && exception == null) {
+ float progress = getProgress();
+ long elapsedTime = System.nanoTime() - startTime;
+ long remainingTime = (long)(elapsedTime / progress) - elapsedTime;
+ return Chat.chat()
+ .infoColor()
+ .thenData(Math.round(progress*100*10)/10 + "% ")
+ .thenText("(")
+ .thenData(MemoryUtil.humanReadableSize(elapsedByte) + "/" + MemoryUtil.humanReadableSize(inputByteSize))
+ .thenText(") - Temps restant estimé : ")
+ .thenData(TimeUtil.durationToString(remainingTime / 1000000));
+ }
+ else if (exception != null) {
+ return Chat.failureText("Erreur lors de l'archivage (voir console pour les détails)");
+ }
+ else { // finished
+ return Chat.successText("Terminé !");
+ }
+ }
+ }
+
+ /**
+ * Gets the progress of the running compression.
+ * @return the progress of the running compression 0 when it starts and 1 when it finishes.
+ */
+ public float getProgress() {
+ if (!started)
+ return 0;
+ if (finished)
+ return 1;
+ return elapsedByte / (float) inputByteSize;
+ }
+
+ /**
+ * Run the compression on the current thread, and returns after the end of the compression.
+ * Should be run asynchronously (not on Server Thread).
+ * @throws Exception if an error occurs during compression.
+ */
+ public void compress() throws Exception {
+ destFile.getParentFile().mkdirs();
+
+ try(ZipOutputStream zipStream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(destFile), BUFFER_SIZE))) {
+ zipOutStream = zipStream;
+ zipOutStream.setLevel(compressionLevel);
+
+ synchronized (stateLock) {
+ startTime = System.nanoTime();
+ started = true;
+ }
+
+ for (Entry entry : entriesToCompress) {
+ entry.zip();
+ }
+
+ synchronized (stateLock) {
+ finished = true;
+ }
+ } catch (Exception e) {
+ synchronized (stateLock) {
+ exception = e;
+ }
+ throw e;
+ }
+ }
+
+
+ private long addEntry(String currentEntry) {
+ final File currentFile = new File(srcDir, currentEntry);
+ if (!currentFile.exists())
+ return 0;
+ if (currentFile.isDirectory()) {
+ if (!currentEntry.isEmpty()) { // it's not the zip root directory
+ currentEntry += "/";
+ entriesToCompress.add(new Entry(currentFile, currentEntry));
+ }
+
+ long sum = 0;
+ for (String child : currentFile.list()) {
+ String childEntry = currentEntry + child;
+ if (filter.test(new File(currentFile, child), childEntry))
+ sum += addEntry(childEntry);
+ }
+
+ return sum;
+ }
+ else { // is a file
+ entriesToCompress.add(new Entry(currentFile, currentEntry));
+ return currentFile.length();
+ }
+ }
+
+ private class Entry {
+ File file;
+ String entry;
+ Entry(File f, String e) {
+ file = f;
+ entry = e;
+ }
+
+ void zip() throws IOException {
+ ZipEntry zipEntry = new ZipEntry(entry);
+ BasicFileAttributes attributes = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
+ if (attributes.isDirectory()) {
+ zipOutStream.putNextEntry(zipEntry);
+ zipOutStream.closeEntry();
+ }
+ else {
+
+ zipEntry.setTime(attributes.lastModifiedTime().toMillis());
+ zipOutStream.putNextEntry(zipEntry);
+
+ try {
+ Files.copy(file.toPath(), zipOutStream);
+ }
+ finally {
+ zipOutStream.closeEntry();
+ }
+
+ synchronized (stateLock) {
+ elapsedByte += attributes.size();
+ }
+ }
+ }
+
+ }
+}