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