diff --git a/pandalib-core/pom.xml b/pandalib-core/pom.xml
index 52daa81..54a5a31 100644
--- a/pandalib-core/pom.xml
+++ b/pandalib-core/pom.xml
@@ -36,5 +36,12 @@
pandalib-chat
${project.version}
+
+
+
+ ch.eitchnet
+ cron
+ 1.6.2
+
diff --git a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupCleaner.java b/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/BackupCleaner.java
similarity index 96%
rename from pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupCleaner.java
rename to pandalib-core/src/main/java/fr/pandacube/lib/core/backup/BackupCleaner.java
index b02b3f7..3504a77 100644
--- a/pandalib-paper/src/main/java/fr/pandacube/lib/paper/modules/backup/BackupCleaner.java
+++ b/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/BackupCleaner.java
@@ -1,8 +1,8 @@
-package fr.pandacube.lib.paper.modules.backup;
+package fr.pandacube.lib.core.backup;
import fr.pandacube.lib.chat.Chat;
import fr.pandacube.lib.util.Log;
-import org.bukkit.ChatColor;
+import net.md_5.bungee.api.ChatColor;
import java.io.File;
import java.time.LocalDateTime;
@@ -89,7 +89,7 @@ public abstract class BackupCleaner implements UnaryOperator backupQueue = new ArrayList<>();
+
+ /* package */ final AtomicReference runningBackup = new AtomicReference<>();
+
+ private final Timer schedulerTimer = new Timer();
+
+ public BackupManager(File backupDirectory) {
+ this.backupDirectory = backupDirectory;
+ persist = new Persist(this);
+
+
+ long nextMinute = ZonedDateTime.now().plusMinutes(1).withSecond(0).withNano(0)
+ .toInstant().toEpochMilli();
+ schedulerTimer.scheduleAtFixedRate(this, new Date(nextMinute), 60_000);
+ }
+
+
+ protected void addProcess(BackupProcess process) {
+ process.displayNextSchedule();
+ backupQueue.add(process);
+ }
+
+
+ public File getBackupDirectory() {
+ return backupDirectory;
+ }
+
+ public synchronized void run() {
+ BackupProcess tmp;
+ if ((tmp = runningBackup.get()) != null) {
+ tmp.logProgress();
+ }
+ else {
+ backupQueue.sort(null);
+ for (BackupProcess process : backupQueue) {
+ if (System.currentTimeMillis() >= process.getNext() && process.couldRunNow()) {
+ process.run();
+ return;
+ }
+ }
+ }
+ }
+
+
+
+ public synchronized void onDisable() {
+
+ schedulerTimer.cancel();
+
+ if (runningBackup.get() != null) {
+ Log.warning("[Backup] Waiting after the end of a backup...");
+ BackupProcess tmp;
+ while ((tmp = runningBackup.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 (runningBackup.get() == null)
+ break;
+ Thread.sleep(500);
+ }
+ } catch (Throwable e) { // could occur because of synchronization errors/interruption/...
+ break;
+ }
+ }
+ }
+
+ persist.save();
+ }
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/BackupProcess.java b/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/BackupProcess.java
new file mode 100644
index 0000000..b1bb865
--- /dev/null
+++ b/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/BackupProcess.java
@@ -0,0 +1,270 @@
+package fr.pandacube.lib.core.backup;
+
+import fc.cron.CronExpression;
+import fr.pandacube.lib.util.FileUtils;
+import fr.pandacube.lib.util.Log;
+import net.md_5.bungee.api.ChatColor;
+
+import java.io.File;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiPredicate;
+
+public abstract class BackupProcess implements Comparable, Runnable {
+ private final BackupManager backupManager;
+
+ public final String identifier;
+
+
+ protected ZipCompressor compressor = null;
+
+
+ private boolean enabled = true;
+ private String scheduling = "0 2 * * *"; // cron format, here is everyday at 2am
+ private BackupCleaner backupCleaner = null;
+ private List ignoreList = new ArrayList<>();
+
+
+ protected BackupProcess(BackupManager bm, final String n) {
+ backupManager = bm;
+ identifier = n;
+ }
+
+ public BackupManager getBackupManager() {
+ return backupManager;
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ protected String getDisplayName() {
+ return getIdentifier();
+ }
+
+
+
+ @Override
+ public int compareTo(final BackupProcess process) {
+ return Long.compare(getNext(), process.getNext());
+ }
+
+
+
+
+
+ public abstract BiPredicate getFilenameFilter();
+
+ public abstract File getSourceDir();
+
+ protected abstract File getTargetDir();
+
+ protected abstract void onBackupStart();
+
+ protected abstract void onBackupEnd(boolean success);
+
+
+
+
+
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getScheduling() {
+ return scheduling;
+ }
+
+ public void setScheduling(String scheduling) {
+ this.scheduling = scheduling;
+ }
+
+ public BackupCleaner getBackupCleaner() {
+ return backupCleaner;
+ }
+
+ public void setBackupCleaner(BackupCleaner backupCleaner) {
+ this.backupCleaner = backupCleaner;
+ }
+
+
+
+
+
+
+
+
+
+ @Override
+ public void run() {
+ getBackupManager().runningBackup.set(this);
+
+ try {
+ BiPredicate filter = getFilenameFilter();
+ File sourceDir = getSourceDir();
+
+ if (!sourceDir.exists()) {
+ Log.warning("[Backup] Unable to compress " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + ": source directory " + sourceDir + " doesn’t exist");
+ return;
+ }
+
+ File targetDir = getTargetDir();
+ File target = new File(targetDir, getDateFileName() + ".zip");
+
+ onBackupStart();
+
+ new Thread(() -> {
+ Log.info("[Backup] Starting for " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + " ...");
+
+ compressor = new ZipCompressor(sourceDir, target, 9, filter);
+
+ boolean success = false;
+ try {
+ compressor.compress();
+
+ success = true;
+
+ Log.info("[Backup] Finished for " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET);
+
+ try {
+ BackupCleaner cleaner = getBackupCleaner();
+ if (cleaner != null)
+ cleaner.cleanupArchives(targetDir, getDisplayName());
+ } catch (Exception e) {
+ Log.severe(e);
+ }
+ }
+ catch (final Exception e) {
+ Log.severe("[Backup] Failed: " + sourceDir + " -> " + target, e);
+
+ FileUtils.delete(target);
+ if (target.exists())
+ Log.warning("unable to delete: " + target);
+ } finally {
+
+ getBackupManager().runningBackup.set(null);
+
+ onBackupEnd(success);
+
+ displayNextSchedule();
+
+ }
+ }, "Backup Thread " + identifier).start();
+ } catch (Throwable t) {
+ getBackupManager().runningBackup.set(null);
+ throw t;
+ }
+
+ }
+
+
+
+
+
+
+
+
+
+
+ public abstract void displayNextSchedule();
+
+
+ public static final DateTimeFormatter dateFileNameFormatter = new DateTimeFormatterBuilder()
+ .appendValue(ChronoField.YEAR, 4)
+ .appendValue(ChronoField.MONTH_OF_YEAR, 2)
+ .appendValue(ChronoField.DAY_OF_MONTH, 2)
+ .appendLiteral('-')
+ .appendValue(ChronoField.HOUR_OF_DAY, 2)
+ .appendValue(ChronoField.MINUTE_OF_HOUR, 2)
+ .appendValue(ChronoField.SECOND_OF_MINUTE, 2)
+ .toFormatter();
+
+
+ private String getDateFileName() {
+ return dateFileNameFormatter.format(ZonedDateTime.now());
+ }
+
+
+ public void logProgress() {
+ if (compressor == null)
+ return;
+ Log.info("[Backup] " + ChatColor.GRAY + getDisplayName() + ChatColor.RESET + ": " + compressor.getState().getLegacyText());
+ }
+
+
+
+
+
+
+ public boolean couldRunNow() {
+ if (!isEnabled())
+ return false;
+ if (!isDirty())
+ return false;
+ if (getNext() > System.currentTimeMillis())
+ return false;
+ return true;
+ }
+
+
+
+
+ public long getNext() {
+ if (!hasNextScheduled())
+ return Long.MAX_VALUE;
+ return getNextCompress(backupManager.persist.isDirtySince(identifier));
+ }
+
+ public boolean hasNextScheduled() {
+ return isEnabled() && isDirty();
+ }
+
+ public boolean isDirty() {
+ return backupManager.persist.isDirty(identifier);
+ }
+
+ public void setDirtySinceNow() {
+ backupManager.persist.setDirtySinceNow(identifier);
+ }
+
+ public void setNotDirty() {
+ backupManager.persist.setNotDirty(identifier);
+ }
+
+
+
+
+
+ /**
+ * 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
+ */
+ public long getNextCompress(long dirtySince) {
+ if (dirtySince == -1)
+ return 0;
+
+ CronExpression parsedScheduling;
+ try {
+ parsedScheduling = new CronExpression(getScheduling(), false);
+ } catch (IllegalArgumentException e) {
+ Log.severe("Invalid backup scheduling configuration '" + getScheduling() + "'.", e);
+ return 0;
+ }
+
+ return parsedScheduling.nextTimeAfter(ZonedDateTime.ofInstant(Instant.ofEpochMilli(dirtySince), ZoneId.systemDefault()))
+ .toInstant()
+ .toEpochMilli();
+ }
+}
diff --git a/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/Persist.java b/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/Persist.java
new file mode 100644
index 0000000..1fed96d
--- /dev/null
+++ b/pandalib-core/src/main/java/fr/pandacube/lib/core/backup/Persist.java
@@ -0,0 +1,89 @@
+package fr.pandacube.lib.core.backup;
+
+import com.google.gson.JsonParseException;
+import com.google.gson.reflect.TypeToken;
+import fr.pandacube.lib.core.json.Json;
+import fr.pandacube.lib.util.Log;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class Persist {
+ protected final BackupManager backupManager;
+
+ private Map dirtySince = new HashMap<>();
+
+ private final File file;
+
+ // private final Set dirtyWorldsSave = new HashSet<>();
+
+ public Persist(BackupManager bm) {
+ backupManager = bm;
+ file = new File(bm.getBackupDirectory(), "source-dirty-since.yml");
+ load();
+ }
+
+ public void reload() {
+ load();
+ }
+
+ protected void load() {
+ boolean loaded = false;
+ try {
+ dirtySince = Json.gson.fromJson(new FileReader(file), new TypeToken