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();
+				}
+			}
+		}
+		
+	}
+}