diff --git a/README.md b/README.md index a3fbf00..f26a5a9 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ _✨ MC备份 / 回档模组 ✨_ `/qb` 或 `/quickbackupmulti`均可触发mod ## 特性 -- [ ] 定时备份 +- [x] 定时备份 - [x] 无限槽位 - [x] Hash对比并仅备份差异文件 - [ ] 个性化设置 diff --git a/build.gradle b/build.gradle index f314381..bde07ea 100644 --- a/build.gradle +++ b/build.gradle @@ -41,11 +41,15 @@ subprojects { minecraft "net.minecraft:minecraft:$rootProject.minecraft_version" mappings loom.officialMojangMappings() + // incremental storage implementation("io.github.skydynamic:incremental-storage-lib:$rootProject.incremental_storage_lib_version") implementation("org.jetbrains.exposed:exposed-core:0.57.0") implementation("org.jetbrains.exposed:exposed-jdbc:0.57.0") implementation("com.h2database:h2:2.2.224") + // https://mvnrepository.com/artifact/org.quartz-scheduler/quartz + implementation("org.quartz-scheduler:quartz:2.5.0") + // lombok compileOnly("org.projectlombok:lombok:1.18.30") annotationProcessor("org.projectlombok:lombok:1.18.30") diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ModContainer.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ModContainer.java index c9b19bd..98c51f7 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ModContainer.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ModContainer.java @@ -1,12 +1,16 @@ package io.github.skydynamic.quickbakcupmulti; import com.mojang.brigadier.CommandDispatcher; +import io.github.skydynamic.quickbakcupmulti.schedule.IModSchedule; import io.github.skydynamic.quickbakcupmulti.utils.permission.PermissionManager; import lombok.Getter; import lombok.Setter; import net.minecraft.commands.CommandSourceStack; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; @Setter @Getter @@ -19,6 +23,8 @@ public class ModContainer { private boolean isRestoringBackup; private String currentSelectionBackup; + private List schedules = new ArrayList<>(); + public ModContainer( CommandDispatcher dispatcher ) { @@ -26,4 +32,8 @@ public ModContainer( } public ModContainer() {} + + public Optional getSchedule(String name) { + return schedules.stream().filter(it -> it.getName().equals(name)).findFirst(); + } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/QuickbakcupmultiReforged.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/QuickbakcupmultiReforged.java index a90a236..ca50261 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/QuickbakcupmultiReforged.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/QuickbakcupmultiReforged.java @@ -4,6 +4,7 @@ import io.github.skydynamic.increment.storage.lib.utils.StorageManager; import io.github.skydynamic.quickbakcupmulti.command.ModCommand; import io.github.skydynamic.quickbakcupmulti.config.ModConfig; +import io.github.skydynamic.quickbakcupmulti.schedule.quartz.DisableQuartzInfoLogger; import io.github.skydynamic.quickbakcupmulti.translate.Translate; import io.github.skydynamic.quickbakcupmulti.utils.permission.PermissionManager; import lombok.Getter; @@ -12,6 +13,7 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.text.SimpleDateFormat; public final class QuickbakcupmultiReforged { public static final String MOD_ID = "quickbakcupmulti_reforged"; @@ -45,10 +47,18 @@ public static void init(ModContainer container) { if (!storagePath.exists()) { storagePath.mkdirs(); } + + // Disable Quartz Info Logger + DisableQuartzInfoLogger.disable(); } public static void registerCommand() { if (modContainer.getDispatcher() == null) return; ModCommand.register(modContainer.getDispatcher()); } + + public static String formatTimestamp(long timestamp) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + return sdf.format(timestamp); + } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ServerManager.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ServerManager.java index cd9bd6e..ca72361 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ServerManager.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/ServerManager.java @@ -1,6 +1,10 @@ package io.github.skydynamic.quickbakcupmulti; +import net.minecraft.commands.CommandSourceStack; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +import java.util.List; public class ServerManager { private final MinecraftServer server; @@ -18,4 +22,12 @@ public void startServer() { public void stopServer() { this.server.halt(false); } + + public List getPlayers() { + return this.server.getPlayerList().getPlayers(); + } + + public CommandSourceStack getCommandSource() { + return this.server.createCommandSourceStack(); + } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/command/MakeCommand.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/command/MakeCommand.java index eb006b7..5d89fb4 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/command/MakeCommand.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/command/MakeCommand.java @@ -4,6 +4,7 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder; import io.github.skydynamic.quickbakcupmulti.DatabaseCache; import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.schedule.ScheduleManager; import io.github.skydynamic.quickbakcupmulti.utils.BackupManager; import io.github.skydynamic.quickbakcupmulti.utils.permission.PermissionManager; import io.github.skydynamic.quickbakcupmulti.utils.permission.PermissionType; @@ -32,6 +33,10 @@ public void run() { if (QuickbakcupmultiReforged.getModConfig().isCacheDatabase()) { DatabaseCache.updateStorageInfoCaches(); } + + if (QuickbakcupmultiReforged.getModConfig().getScheduleBackupConfig().isResetTimerOnBackup() && ScheduleManager.resetTimer("scheduleBackup")) { + QuickbakcupmultiReforged.logger.info("Reset timer for scheduleBackup"); + } QuickbakcupmultiReforged.logger.info("Make Backup thread close => {}ms", System.currentTimeMillis() - l); } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/DatabaseConfig.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/DatabaseConfig.java new file mode 100644 index 0000000..145ff5e --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/DatabaseConfig.java @@ -0,0 +1,10 @@ +package io.github.skydynamic.quickbakcupmulti.config; + +import lombok.Getter; + +@SuppressWarnings("FieldMayBeFinal") +@Getter +public class DatabaseConfig { + private ScheduleConfig backup = new ScheduleConfig(); + +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ModConfig.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ModConfig.java index 8b7d0cf..f3d3bec 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ModConfig.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ModConfig.java @@ -5,6 +5,7 @@ import io.github.skydynamic.increment.storage.lib.manager.IConfig; import lombok.Getter; import lombok.Setter; +import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,7 +19,11 @@ public class ModConfig implements IConfig { private static final Logger logger = LoggerFactory.getLogger("Qbm-Config"); - private static final Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + private static final Gson gson = new GsonBuilder() + .setPrettyPrinting() + .disableHtmlEscaping() + .serializeNulls() + .create(); @Setter @Getter private ConfigStorage config = new ConfigStorage(); @@ -101,8 +106,20 @@ public boolean isCacheDatabase() { return config.cacheDatabase; } + public ScheduleBackupConfig getScheduleBackupConfig() { + return config.scheduleBackup; + } + + public PruneScheduleConfig getPruneScheduleConfig() { + return config.prune; + } + + public DatabaseConfig getDatabaseConfig() { + return config.database; + } + @Override - public String getStoragePath() { + public @NotNull String getStoragePath() { return config.storagePath; } @@ -127,12 +144,27 @@ public static class ConfigStorage { private String storagePath = "./QuickBackupMulti"; private boolean cacheDatabase = false; + private ScheduleBackupConfig scheduleBackup = new ScheduleBackupConfig(); + + private PruneScheduleConfig prune = new PruneScheduleConfig(); + + private DatabaseConfig database = new DatabaseConfig(); + @Override public String toString() { - return "ConfigStorage [checkUpdate=" + checkUpdate + ", ignoredFiles=" + ignoredFiles - + ", ignoredFolders=" + ignoredFolders + ", lang=" + lang - + ", autoRestartMode=" + autoRestartMode + ", storagePath=" + storagePath - + ", cacheDatabase=" + cacheDatabase + "]"; + return "ConfigStorage{" + + "checkUpdate=" + checkUpdate + + ", ignoredFiles=" + ignoredFiles + + ", ignoredFolders=" + ignoredFolders + + ", lang='" + lang + '\'' + + ", maxScheduleBackup=" + maxScheduleBackup + + ", autoRestartMode=" + autoRestartMode + + ", storagePath='" + storagePath + '\'' + + ", cacheDatabase=" + cacheDatabase + + ", scheduleBackup=" + scheduleBackup + + ", prune=" + prune + + ", database=" + database + + '}'; } } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/PbsConfig.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/PbsConfig.java new file mode 100644 index 0000000..ac92fbc --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/PbsConfig.java @@ -0,0 +1,17 @@ +package io.github.skydynamic.quickbakcupmulti.config; + +import lombok.Getter; + +@SuppressWarnings("FieldMayBeFinal") +@Getter +public class PbsConfig { + private boolean enabled = false; + private Integer maxAmount = 10; + private String maxLifeTime = "0s"; + private int last = -1; + private int hour = 0; + private int day = 0; + private int week = 0; + private int month = 1; + private int year = 0; +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/PruneScheduleConfig.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/PruneScheduleConfig.java new file mode 100644 index 0000000..29d9625 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/PruneScheduleConfig.java @@ -0,0 +1,10 @@ +package io.github.skydynamic.quickbakcupmulti.config; + +import lombok.Getter; + +@SuppressWarnings("FieldMayBeFinal") +@Getter +public class PruneScheduleConfig extends ScheduleConfig { + private String timezoneOverride = null; + private PbsConfig regularBackup = new PbsConfig(); +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ScheduleBackupConfig.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ScheduleBackupConfig.java new file mode 100644 index 0000000..d8d7cb0 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ScheduleBackupConfig.java @@ -0,0 +1,14 @@ +package io.github.skydynamic.quickbakcupmulti.config; + +import lombok.Getter; + +import java.util.List; + +@SuppressWarnings("FieldMayBeFinal") +@Getter +public class ScheduleBackupConfig extends ScheduleConfig { + private boolean resetTimerOnBackup = true; + private boolean requireOnlinePlayers = false; + private boolean requireOnlinePlayersIgnoreCarpetFakePlayer = true; + private List requireOnlinePlayersBlacklist = List.of(); +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ScheduleConfig.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ScheduleConfig.java new file mode 100644 index 0000000..d4cf0d7 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/config/ScheduleConfig.java @@ -0,0 +1,8 @@ +package io.github.skydynamic.quickbakcupmulti.config; + +public class ScheduleConfig { + public boolean enabled; + public Integer interval = 7200; + public String crontab = null; + public String jitter = "1m"; +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnLoadedWorldHandler.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnLoadedWorldHandler.java new file mode 100644 index 0000000..abe69f5 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnLoadedWorldHandler.java @@ -0,0 +1,49 @@ +package io.github.skydynamic.quickbakcupmulti.event; + +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.config.PruneScheduleConfig; +import io.github.skydynamic.quickbakcupmulti.config.ScheduleBackupConfig; +import io.github.skydynamic.quickbakcupmulti.config.ScheduleConfig; +import io.github.skydynamic.quickbakcupmulti.schedule.ScheduleManager; +import io.github.skydynamic.quickbakcupmulti.schedule.runnables.DefaultDatabaseBackupRunnable; +import io.github.skydynamic.quickbakcupmulti.schedule.runnables.DefaultPruneRunnable; +import io.github.skydynamic.quickbakcupmulti.schedule.runnables.DefaultScheduleBackupRunnable; + +public class OnLoadedWorldHandler { + public static void handler() { + // Database Schedule backup + ScheduleConfig databaseBackupConfig = QuickbakcupmultiReforged.getModConfig().getDatabaseConfig().getBackup(); + if (databaseBackupConfig.enabled) { + Runnable databaseBackupRunnable = new DefaultDatabaseBackupRunnable(); + if (databaseBackupConfig.interval != null) { + ScheduleManager.registerSchedule("databaseSchedule", databaseBackupConfig.interval, databaseBackupConfig.jitter, databaseBackupRunnable); + } else if (databaseBackupConfig.crontab != null) { + ScheduleManager.registerSchedule("databaseSchedule", databaseBackupConfig.crontab, databaseBackupConfig.jitter, databaseBackupRunnable); + } + } + + // Schedule backup + ScheduleBackupConfig scheduleBackupConfig = QuickbakcupmultiReforged.getModConfig().getScheduleBackupConfig(); + if (scheduleBackupConfig.enabled) { + Runnable scheduleBackupRunnable = new DefaultScheduleBackupRunnable(); + if (scheduleBackupConfig.interval != null) { + ScheduleManager.registerSchedule("scheduleBackup", scheduleBackupConfig.interval, scheduleBackupConfig.jitter, scheduleBackupRunnable); + } else if (scheduleBackupConfig.crontab != null) { + ScheduleManager.registerSchedule("scheduleBackup", scheduleBackupConfig.crontab, scheduleBackupConfig.jitter, scheduleBackupRunnable); + } + } + + // Prune schedule + PruneScheduleConfig pruneScheduleConfig = QuickbakcupmultiReforged.getModConfig().getPruneScheduleConfig(); + if (pruneScheduleConfig.enabled) { + Runnable pruneRunnable = new DefaultPruneRunnable(); + if (pruneScheduleConfig.interval != null) { + ScheduleManager.registerSchedule("pruneSchedule", pruneScheduleConfig.interval, pruneScheduleConfig.jitter, pruneRunnable); + } else if (pruneScheduleConfig.crontab != null) { + ScheduleManager.registerSchedule("pruneSchedule", pruneScheduleConfig.crontab, pruneScheduleConfig.jitter, pruneRunnable); + } + } + + ScheduleManager.startAllSchedule(); + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnServerStoppedHandler.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnServerStoppedHandler.java index 6131b3f..31e4159 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnServerStoppedHandler.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/event/OnServerStoppedHandler.java @@ -1,6 +1,7 @@ package io.github.skydynamic.quickbakcupmulti.event; import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.schedule.ScheduleManager; import io.github.skydynamic.quickbakcupmulti.utils.BackupManager; public class OnServerStoppedHandler { @@ -16,6 +17,7 @@ public static void handle() { } } else { QuickbakcupmultiReforged.getDatabase().closeDatabase(); + ScheduleManager.clearAllSchedule(); } } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/MixinMinecraftServer.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/MixinMinecraftServer.java index 8bc0874..8e9a34e 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/MixinMinecraftServer.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/MixinMinecraftServer.java @@ -6,6 +6,7 @@ import io.github.skydynamic.quickbakcupmulti.DatabaseCache; import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; import io.github.skydynamic.quickbakcupmulti.database.DatabaseManager; +import io.github.skydynamic.quickbakcupmulti.event.OnLoadedWorldHandler; import net.minecraft.server.MinecraftServer; import net.minecraft.server.Services; import net.minecraft.server.WorldStem; @@ -53,5 +54,7 @@ private void onLoadLevel(CallbackInfoReturnable cir) { if (QuickbakcupmultiReforged.getModConfig().isCacheDatabase()) { DatabaseCache.updateStorageInfoCaches(); } + + OnLoadedWorldHandler.handler(); } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/client/MixinClientPacketListener.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/client/MixinClientPacketListener.java index 17935e1..5d9ba4f 100644 --- a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/client/MixinClientPacketListener.java +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/mixin/client/MixinClientPacketListener.java @@ -20,9 +20,9 @@ public class MixinClientPacketListener { private void showWarning(ClientboundLoginPacket packet, CallbackInfo ci) { LocalPlayer player = Minecraft.getInstance().player; if (player != null) { - player.sendSystemMessage( + player.displayClientMessage( Component.literal("QuickbackupmultiReforged mod is not supporting clients now!") - .withStyle(ChatFormatting.RED) + .withStyle(ChatFormatting.RED), false ); } } diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/CronUtils.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/CronUtils.java new file mode 100644 index 0000000..0a215b7 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/CronUtils.java @@ -0,0 +1,76 @@ +package io.github.skydynamic.quickbakcupmulti.schedule; + +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.utils.DurationUtils; +import org.quartz.*; + +import java.text.ParseException; +import java.util.Date; + +public class CronUtils { + public enum ScheduleMode { + INTERVAL(Integer.class), + CRONTAB(String.class); + + private final Class type; + + ScheduleMode(Class type) { + this.type = type; + } + } + + public static Trigger buildTrigger(String name, ScheduleMode mode, T value, String jitter) { + IllegalArgumentException buildException = new IllegalArgumentException("Schedule mode %s requires value of type %s, but got %s (value: %s)" + .formatted(mode.name(), mode.type.getName(), value.getClass().getName(), value)); + int jitterSeconds = DurationUtils.parseAndRandom(jitter); + switch (mode) { + case INTERVAL -> { + if (value instanceof Integer v) { + return TriggerBuilder.newTrigger() + .withIdentity(name) + .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(v).repeatForever()) + .startAt(new Date(System.currentTimeMillis() + v * 1000L + jitterSeconds * 1000L)) + .usingJobData("jitter", jitterSeconds) + .build(); + } else { + QuickbakcupmultiReforged.logger.error("Failed to build schedule trigger", buildException); + } + } + case CRONTAB -> { + if (value instanceof String v) { + if (!cronIsValid(v)) { + QuickbakcupmultiReforged.logger.error("Failed to build schedule trigger, CronExpression {} is invalid", v); + return null; + } + return TriggerBuilder.newTrigger() + .withIdentity(name) + .withSchedule(CronScheduleBuilder.cronSchedule(v)) + .startAt(new Date(getNextExecutionTime(v).getTime() + jitterSeconds * 1000L)) + .usingJobData("jitter", jitterSeconds) + .build(); + } else { + QuickbakcupmultiReforged.logger.error("Failed to build schedule trigger", buildException); + } + } + } + return null; + } + + public static Date getNextExecutionTime(String cronExpress) { + try { + CronExpression cronExpression = new CronExpression(cronExpress); + return cronExpression.getNextValidTimeAfter(new Date()); + } catch (ParseException e) { + return new Date(); + } + } + + public static boolean cronIsValid(String cronExpression) { + try { + new CronExpression(cronExpression); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/IModSchedule.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/IModSchedule.java new file mode 100644 index 0000000..f7a54c8 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/IModSchedule.java @@ -0,0 +1,16 @@ +package io.github.skydynamic.quickbakcupmulti.schedule; + +public interface IModSchedule { + String getName(); + + boolean startSchedule(); + void stopSchedule(); + + boolean isRunning(); + + long getNextExecuteTime(); + + boolean resetTimer(); + + IModSchedule setExcutor(Runnable executor); +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/ScheduleManager.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/ScheduleManager.java new file mode 100644 index 0000000..f4af7a2 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/ScheduleManager.java @@ -0,0 +1,58 @@ +package io.github.skydynamic.quickbakcupmulti.schedule; + +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.schedule.impl.ModSchedule; + +public class ScheduleManager { + private static void registerSchedule(ModSchedule schedule) { + QuickbakcupmultiReforged.getModContainer().getSchedules().add(schedule); + QuickbakcupmultiReforged.logger.info("Register schedule: {}", schedule.getName()); + } + + public static void registerSchedule(String name, String crontab, String jitter, Runnable executor) { + ModSchedule schedule = new ModSchedule(name, crontab, jitter).setExcutor(executor); + registerSchedule(schedule); + } + + public static void registerSchedule(String name, int interval, String jitter, Runnable executor) { + ModSchedule schedule = new ModSchedule(name, interval, jitter).setExcutor(executor); + registerSchedule(schedule); + } + + public static void startAllSchedule() { + for (IModSchedule schedule : QuickbakcupmultiReforged.getModContainer().getSchedules()) { + if (!schedule.startSchedule()) { + QuickbakcupmultiReforged.logger.warn("Failed to start schedule: {}", schedule.getName()); + } else { + QuickbakcupmultiReforged.logger.info("Start schedule: {}, next execute time: {}", + schedule.getName(), + QuickbakcupmultiReforged.formatTimestamp(schedule.getNextExecuteTime()) + ); + } + } + } + + public static void stopAllSchedule() { + for (IModSchedule schedule : QuickbakcupmultiReforged.getModContainer().getSchedules()) { + if (schedule.isRunning()) { + schedule.stopSchedule(); + QuickbakcupmultiReforged.logger.info("Stop schedule: {}", schedule.getName()); + } + } + } + + public static void clearAllSchedule() { + stopAllSchedule(); + QuickbakcupmultiReforged.getModContainer().getSchedules().clear(); + } + + public static boolean resetTimer(String name) { + for (IModSchedule schedule : QuickbakcupmultiReforged.getModContainer().getSchedules()) { + if (schedule.getName().equals(name) && schedule.resetTimer()) { + QuickbakcupmultiReforged.logger.info("Reset timer: {}", name); + return true; + } + } + return false; + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/impl/ModSchedule.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/impl/ModSchedule.java new file mode 100644 index 0000000..b23c918 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/impl/ModSchedule.java @@ -0,0 +1,135 @@ +package io.github.skydynamic.quickbakcupmulti.schedule.impl; + +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.schedule.CronUtils; +import io.github.skydynamic.quickbakcupmulti.schedule.IModSchedule; +import io.github.skydynamic.quickbakcupmulti.schedule.quartz.ModJobFactory; +import org.quartz.*; +import org.quartz.impl.StdSchedulerFactory; + +import static io.github.skydynamic.quickbakcupmulti.schedule.CronUtils.buildTrigger; + +public class ModSchedule implements IModSchedule, Job { + private String identity; + + private String crontab; + private Integer interval; + private String jitter; + + private Runnable executor; + + protected JobDetail jobDetail; + protected Trigger trigger; + protected Scheduler scheduler; + + // Quartz + @SuppressWarnings("unused") + public ModSchedule() { + } + + public ModSchedule(String identity, Integer interval, String jitter) { + this.identity = identity; + this.interval = interval; + this.jitter = jitter; + } + + public ModSchedule(String identity, String crontab, String jitter) { + this.identity = identity; + this.crontab = crontab; + this.jitter = jitter; + } + + @Override + public String getName() { + return identity; + } + + @Override + public boolean startSchedule() { + jobDetail = JobBuilder.newJob(this.getClass()).withIdentity(identity).build(); + StdSchedulerFactory sf = new StdSchedulerFactory(); + + if (crontab != null && !crontab.isEmpty() && interval == null) { + trigger = buildTrigger(identity, CronUtils.ScheduleMode.CRONTAB, crontab, jitter); + } else if (interval != null && interval > 0 && crontab == null) { + trigger = buildTrigger(identity, CronUtils.ScheduleMode.INTERVAL, interval, jitter); + } else { + return false; + } + + if (trigger == null) { + return false; + } + + try { + scheduler = sf.getScheduler(); + scheduler.scheduleJob(jobDetail, trigger); + scheduler.setJobFactory(new ModJobFactory(this)); + scheduler.start(); + return true; + } catch (SchedulerException e) { + QuickbakcupmultiReforged.logger.error("Failed to get scheduler", e); + return false; + } + } + + @Override + public void stopSchedule() { + try { + scheduler.shutdown(true); + } catch (SchedulerException e) { + QuickbakcupmultiReforged.logger.error("Failed to stop scheduler", e); + } + } + + @Override + public ModSchedule setExcutor(Runnable executor) { + this.executor = executor; + return this; + } + + @Override + public boolean isRunning() { + try { + return scheduler.isStarted(); + } catch (SchedulerException e) { + return false; + } + } + + @Override + public long getNextExecuteTime() { + JobDataMap dataMap = trigger.getJobDataMap(); + int jitter = dataMap.getInt("jitter"); + return trigger.getNextFireTime().getTime() + jitter * 1000L; + } + + @Override + public boolean resetTimer() { + if (scheduler != null) { + stopSchedule(); + return startSchedule(); + } + return false; + } + + @Override + public void execute(JobExecutionContext jobExecutionContext) { + JobDataMap dataMap = jobExecutionContext.getMergedJobDataMap(); + int jitter = dataMap.getInt("jitter"); + + try { + Thread.sleep(jitter * 1000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + QuickbakcupmultiReforged.logger.info("Schedule {} execute in {}", identity, QuickbakcupmultiReforged.formatTimestamp(System.currentTimeMillis())); + executor.run(); + QuickbakcupmultiReforged.logger.info( + "Schedule {} execute done, next execute time: {}", + identity, + QuickbakcupmultiReforged.formatTimestamp(jobExecutionContext.getTrigger().getNextFireTime().getTime() + jitter * 1000L) + ); + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/quartz/DisableQuartzInfoLogger.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/quartz/DisableQuartzInfoLogger.java new file mode 100644 index 0000000..92f6aad --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/quartz/DisableQuartzInfoLogger.java @@ -0,0 +1,10 @@ +package io.github.skydynamic.quickbakcupmulti.schedule.quartz; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.core.config.Configurator; + +public class DisableQuartzInfoLogger { + public static void disable() { + Configurator.setLevel("org.quartz", Level.ERROR); + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/quartz/ModJobFactory.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/quartz/ModJobFactory.java new file mode 100644 index 0000000..a242081 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/quartz/ModJobFactory.java @@ -0,0 +1,19 @@ +package io.github.skydynamic.quickbakcupmulti.schedule.quartz; + +import org.quartz.Job; +import org.quartz.Scheduler; +import org.quartz.simpl.SimpleJobFactory; +import org.quartz.spi.TriggerFiredBundle; + +public class ModJobFactory extends SimpleJobFactory { + private final Job jobInstance; + + public ModJobFactory(Job jobInstance) { + this.jobInstance = jobInstance; + } + + @Override + public Job newJob(TriggerFiredBundle bundle, Scheduler scheduler){ + return jobInstance; + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultDatabaseBackupRunnable.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultDatabaseBackupRunnable.java new file mode 100644 index 0000000..a8b8156 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultDatabaseBackupRunnable.java @@ -0,0 +1,30 @@ +package io.github.skydynamic.quickbakcupmulti.schedule.runnables; + +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; + +public class DefaultDatabaseBackupRunnable implements Runnable { + @Override + public void run() { + Path storagePath = Path.of(QuickbakcupmultiReforged.getModConfig().getStoragePath()); + File databaseFile = storagePath.resolve("QuickBakcupMulti.mv.db").toFile(); + File databaseBackupPath = storagePath.resolve("databaseBackup").toFile(); + if (!databaseBackupPath.exists()) { + databaseBackupPath.mkdirs(); + } + + if (databaseFile.exists()) { + try { + FileUtils.copyFile(databaseFile, FileUtils.getFile(databaseBackupPath, "database-" + System.currentTimeMillis() + ".mv.db")); + } catch (IOException e) { + QuickbakcupmultiReforged.logger.error("Database Backup Failed", e); + } + } else { + QuickbakcupmultiReforged.logger.error("Database File Not Found"); + } + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultPruneRunnable.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultPruneRunnable.java new file mode 100644 index 0000000..11f7d17 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultPruneRunnable.java @@ -0,0 +1,91 @@ +package io.github.skydynamic.quickbakcupmulti.schedule.runnables; + +import io.github.skydynamic.increment.storage.lib.database.StorageInfo; +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.config.PbsConfig; +import io.github.skydynamic.quickbakcupmulti.config.PruneScheduleConfig; +import io.github.skydynamic.quickbakcupmulti.utils.BackupManager; +import io.github.skydynamic.quickbakcupmulti.utils.DurationUtils; +import net.minecraft.commands.CommandSourceStack; + +import java.time.ZoneId; +import java.util.*; + +public class DefaultPruneRunnable implements Runnable { + @Override + public void run() { + PruneScheduleConfig pruneScheduleConfig = QuickbakcupmultiReforged.getModConfig().getPruneScheduleConfig(); + CommandSourceStack commandSourceStack = QuickbakcupmultiReforged.getServerManager().getCommandSource(); + + List backupList = QuickbakcupmultiReforged.getDatabase().getAllStorageInfo(); + List toDelete = filterBackupWithPbs(pruneScheduleConfig.getRegularBackup(), backupList, pruneScheduleConfig.getTimezoneOverride()); + toDelete.forEach(backup -> BackupManager.deleteBackup(commandSourceStack, backup.getName())); + + QuickbakcupmultiReforged.logger.info("Prune backup: {}", toDelete.size()); + } + + private List filterBackupWithPbs(PbsConfig pbsConfig, List backupList, String timezoneOverride) { + if (pbsConfig == null || !pbsConfig.isEnabled() || backupList == null || backupList.isEmpty()) { + return new ArrayList<>(); + } + + ZoneId zoneId = timezoneOverride != null ? ZoneId.of(timezoneOverride) : ZoneId.systemDefault(); + + List filteredList = new ArrayList<>(backupList); + filteredList.sort(Comparator.comparingLong(StorageInfo::getTimestamp)); + + List toDelete = new ArrayList<>(); + + Map timeUnits = Map.of( + "hour", pbsConfig.getHour(), + "day", pbsConfig.getDay(), + "week", pbsConfig.getWeek(), + "month", pbsConfig.getMonth(), + "year", pbsConfig.getYear() + ); + + for (Map.Entry entry : timeUnits.entrySet()) { + String unit = entry.getKey(); + int count = entry.getValue(); + if (count <= 0) continue; + + Map latestPerUnit = new HashMap<>(); + for (StorageInfo backup : filteredList) { + String key = DurationUtils.formatByUnit(backup.getTimestamp(), unit, zoneId); + if (!latestPerUnit.containsKey(key)) { + latestPerUnit.put(key, backup); + } else if (backup.getTimestamp() > latestPerUnit.get(key).getTimestamp()) { + toDelete.add(latestPerUnit.get(key)); + latestPerUnit.put(key, backup); + } else { + toDelete.add(backup); + } + } + filteredList.removeAll(latestPerUnit.values()); + } + + if (pbsConfig.getLast() > 0 && filteredList.size() > pbsConfig.getLast()) { + int keepCount = pbsConfig.getLast(); + List toKeep = filteredList.subList(filteredList.size() - keepCount, filteredList.size()); + Set toKeepSet = new HashSet<>(toKeep); + toDelete.addAll(filteredList.stream().filter(b -> !toKeepSet.contains(b)).toList()); + } + + if (pbsConfig.getMaxLifeTime() != null && !pbsConfig.getMaxLifeTime().equals("0s")) { + long maxLifeTimeMillis = DurationUtils.parseDurationToSeconds(pbsConfig.getMaxLifeTime()) * 1000; + long now = System.currentTimeMillis(); + List expired = filteredList.stream() + .filter(b -> now - b.getTimestamp() > maxLifeTimeMillis) + .toList(); + toDelete.addAll(expired); + filteredList.removeAll(expired); + } + + if (pbsConfig.getMaxAmount() > 0 && filteredList.size() > pbsConfig.getMaxAmount()) { + int removeCount = filteredList.size() - pbsConfig.getMaxAmount(); + toDelete.addAll(filteredList.subList(0, removeCount)); + } + + return toDelete; + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultScheduleBackupRunnable.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultScheduleBackupRunnable.java new file mode 100644 index 0000000..0788d00 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/schedule/runnables/DefaultScheduleBackupRunnable.java @@ -0,0 +1,82 @@ +package io.github.skydynamic.quickbakcupmulti.schedule.runnables; + +import io.github.skydynamic.quickbakcupmulti.DatabaseCache; +import io.github.skydynamic.quickbakcupmulti.QuickbakcupmultiReforged; +import io.github.skydynamic.quickbakcupmulti.config.ScheduleBackupConfig; +import io.github.skydynamic.quickbakcupmulti.utils.BackupManager; +import net.minecraft.server.level.ServerPlayer; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +public class DefaultScheduleBackupRunnable implements Runnable { + public static final Class CARPET_PLAYER_CLASS; + + @Override + public void run() { + ScheduleBackupConfig scheduleBackupConfig = QuickbakcupmultiReforged.getModConfig().getScheduleBackupConfig(); + List players = new ArrayList<>(QuickbakcupmultiReforged.getServerManager().getPlayers()); + + if (scheduleBackupConfig.isRequireOnlinePlayers()) { + if (scheduleBackupConfig.isRequireOnlinePlayersIgnoreCarpetFakePlayer()) { + players.removeIf(DefaultScheduleBackupRunnable::isCarpetPlayer); + } + + if (!scheduleBackupConfig.getRequireOnlinePlayersBlacklist().isEmpty()) { + players = filterBlacklistedPlayers(players, scheduleBackupConfig.getRequireOnlinePlayersBlacklist()); + } + + if (players.isEmpty()) { + QuickbakcupmultiReforged.logger.warn("No online player meets the requirements, skip schedule backup"); + return; + } + } + + String scheduleName = "ScheduleBackup-" + QuickbakcupmultiReforged.formatTimestamp(System.currentTimeMillis()); + + if (QuickbakcupmultiReforged.getDatabase().storageExists(scheduleName)) { + QuickbakcupmultiReforged.logger.warn("Schedule backup name already exists: {}", scheduleName); + return; + } + + BackupManager.makeBackup(QuickbakcupmultiReforged.getServerManager().getCommandSource(), scheduleName, ""); + + if (QuickbakcupmultiReforged.getModConfig().isCacheDatabase()) { + DatabaseCache.updateStorageInfoCaches(); + } + + QuickbakcupmultiReforged.logger.info("Schedule backup complete: {}", scheduleName); + } + + private List filterBlacklistedPlayers(List players, List blacklist) { + List patterns = new ArrayList<>(); + for (String patternStr : blacklist) { + try { + patterns.add(Pattern.compile(patternStr)); + } catch (PatternSyntaxException e) { + QuickbakcupmultiReforged.logger.warn("Invalid pattern: {}, skip filter", patternStr); + } + } + + return players.stream() + .filter(player -> patterns.stream().noneMatch(p -> p.matcher(player.getName().getString()).matches())) + .toList(); + } + + + private static boolean isCarpetPlayer(ServerPlayer player) { + return CARPET_PLAYER_CLASS != null && CARPET_PLAYER_CLASS.isInstance(player); + } + + static { + Class clazz; + try { + clazz = Class.forName("carpet.patches.EntityPlayerMPFake"); + } catch (ClassNotFoundException e) { + clazz = null; + } + CARPET_PLAYER_CLASS = clazz; + } +} diff --git a/common/src/main/java/io/github/skydynamic/quickbakcupmulti/utils/DurationUtils.java b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/utils/DurationUtils.java new file mode 100644 index 0000000..b596937 --- /dev/null +++ b/common/src/main/java/io/github/skydynamic/quickbakcupmulti/utils/DurationUtils.java @@ -0,0 +1,57 @@ +package io.github.skydynamic.quickbakcupmulti.utils; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Random; + +public class DurationUtils { + public static long parseDurationToSeconds(String input) { + var pattern = java.util.regex.Pattern.compile("(\\d+)([a-zA-Z]+)"); + var matcher = pattern.matcher(input.trim()); + + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid jitter format: " + input); + } + + long value = Long.parseLong(matcher.group(1)); + String unit = matcher.group(2).toLowerCase(); + + return switch (unit) { + case "ms", "milli", "millis" -> value / 1000; + case "s", "sec", "second", "seconds" -> value; + case "m", "min", "minute", "minutes" -> value * 60; + case "h", "hr", "hour", "hours" -> value * 60 * 60; + case "d", "day", "days" -> value * 24 * 60 * 60; + case "w", "week", "weeks" -> value * 7 * 24 * 60 * 60; + case "mo", "mon", "month", "months" -> value * 30 * 24 * 60 * 60; + case "y", "yr", "year", "years" -> value * 365 * 24 * 60 * 60; + default -> 0; + }; + } + + public static String formatByUnit(long timestamp, String unit, ZoneId zoneId) { + LocalDateTime dateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), zoneId); + return switch (unit) { + case "hour" -> dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH")); + case "day" -> dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE); + case "week" -> String.format("%d-%02d", dateTime.getYear(), dateTime.getYear() / 52 + 1); + case "month" -> dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM")); + case "year" -> String.valueOf(dateTime.getYear()); + default -> ""; + }; + } + + public static int getRandomDurationInSeconds(long maxJitterSeconds) { + if (maxJitterSeconds <= 0) { + return 0; + } + return new Random().nextInt((int) maxJitterSeconds + 1); + } + + public static int parseAndRandom(String input) { + long parse = parseDurationToSeconds(input); + return getRandomDurationInSeconds((int) parse); + } +} diff --git a/fabric/build.gradle b/fabric/build.gradle index a3f6ea8..b71dae8 100644 --- a/fabric/build.gradle +++ b/fabric/build.gradle @@ -37,6 +37,8 @@ dependencies { common(project(path: ":${mod_id}-common:", configuration: "namedElements")) { transitive false } shadowBundle project(path: ":${mod_id}-common", configuration: "transformProductionFabric") + include("org.quartz-scheduler:quartz:2.5.0") + include("io.github.skydynamic:incremental-storage-lib:$rootProject.incremental_storage_lib_version") include("org.jetbrains.exposed:exposed-core:0.57.0") include("org.jetbrains.exposed:exposed-jdbc:0.57.0") diff --git a/gradle.properties b/gradle.properties index cb79c5a..ae93169 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,17 +2,17 @@ org.gradle.jvmargs = -Xmx2G org.gradle.parallel = true # Mod properties -mod_version = 3.1.0 +mod_version = 3.1.1 maven_group = io.github.skydynamic mod_id = quickbakcupmulti_reforged archives_name = QuickBakcupMulti-Reforged enabled_platforms = fabric,neoforge # Minecraft properties -minecraft_version = 1.21 -minecraft_supported_versions = >=1.21 <1.21.5 +minecraft_version = 1.21.4 +minecraft_supported_versions = 1.21.4 # Dependencies fabric_loader_version = 0.16.10 -fabric_api_version = 0.102.0+1.21 -neoforge_version = 21.0.167 +fabric_api_version = 0.119.3+1.21.4 +neoforge_version = 21.4.147 incremental_storage_lib_version=1.2.0+build.25062418.20 diff --git a/neoforge/build.gradle b/neoforge/build.gradle index 9866daa..5e51754 100644 --- a/neoforge/build.gradle +++ b/neoforge/build.gradle @@ -38,11 +38,15 @@ dependencies { common(project(path: ":${mod_id}-common", configuration: "namedElements")) { transitive false } shadowBundle project(path: ":${mod_id}-common", configuration: "transformProductionNeoForge") + forgeRuntimeLibrary("org.quartz-scheduler:quartz:2.5.0") + forgeRuntimeLibrary("io.github.skydynamic:incremental-storage-lib:$rootProject.incremental_storage_lib_version") forgeRuntimeLibrary("org.jetbrains.exposed:exposed-core:0.57.0") forgeRuntimeLibrary("org.jetbrains.exposed:exposed-jdbc:0.57.0") forgeRuntimeLibrary("com.h2database:h2:2.2.224") + include("org.quartz-scheduler:quartz:2.5.0") + include("io.github.skydynamic:incremental-storage-lib:$rootProject.incremental_storage_lib_version") include("org.jetbrains.exposed:exposed-core:0.57.0") include("org.jetbrains.exposed:exposed-jdbc:0.57.0")