Entity radius purge (#1030)

* Fixed world purges leaving orphaned entity data

co_entity has no world column, so /co purge r:#world deleted the kill
rows in co_block but kept every co_entity row. Those blobs became
orphans that no later purge removed.

- SQLite: copy only the co_entity rows that a retained kill row still
  references. This also drops orphans left by earlier purges.
- MySQL/DuckDB: delete the co_entity rows of the kill rows a world purge
  removes, before co_block is purged. MySQL uses a join so MariaDB and
  MySQL 5.7 do not run a dependent subquery. Global purges keep the
  cheaper time-based delete, which is equivalent. If MySQL stops between
  the two deletes, running the same purge again removes the remaining
  kill rows.

Player kills use the kill action with type 0 and a user id in data, so
they are never treated as co_entity references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Fixed SQLite purges with world and block filters purging other tables

On SQLite, /co purge r:#world i:<block> built the retain condition for
world-scoped tables without checking the block restriction, so it purged
co_container, co_chat and the other world-scoped tables in that world
although a block restriction should leave them untouched. The
entity_container/entity_interaction branch and the MySQL path already
check it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Fixed console purges with a radius purging all worlds

/co purge rejects a numeric radius, but the check only saw the radius
when the sender had a location. From the console, /co purge t:30d r:50
parsed no radius and no world, so it ran as a server-wide purge. The
radius is now also parsed against a placeholder location, so it is
rejected for every sender. r:#world is unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Added orphaned entity data cleanup to MySQL #optimize purges

MySQL deletes in place, so co_entity rows orphaned by earlier world
purges stay until something removes them. With #optimize, delete every
co_entity row that no kill row references, through a temporary table of
referenced ids (avoids an anti-join on the unindexed co_block.data), then
let OPTIMIZE reclaim the space. Failures are reported like the table
loop, so the entity_spawn link cleanup still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Moved purge conditions into PurgeFilter

PurgeFilter now builds the purge condition for every table, and the
SQLite copy, the SQLite recovery path and the MySQL/DuckDB delete all use
it instead of three hand-built copies. Behavior is unchanged; the block
restriction check that the previous commit added to the SQLite copy is
part of the shared condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Added entity kill filters to purge command

/co purge rejected entity types and actions, so mob farm kill logs could
only be removed together with all other data. New arguments on SQLite,
MySQL and DuckDB:

- a:kill purges only entity kill rows.
- i:<entity> purges kills of those entity types; it can be combined with
  block types.
- e:<entity> keeps kills of those entity types while purging the rest.

Each purged kill also removes its co_entity row. a:kill with block types,
entity types in both i: and e:, e: with only block types in i:,
non-entity exclusions and entity filters on ClickHouse are rejected. Any
a: value other than a kill alias is rejected instead of falling through
to an unrestricted purge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Added radius support to purge command

/co purge rejected any numeric radius, so kills of one entity type could
only be purged across a whole world. A radius is now accepted when the
purge is restricted to co_block rows with i: or a:kill, for example
/co purge t:30d r:50 i:zombie. r:50x10 also limits the height.

The radius becomes an x/y/z range in the co_block condition. co_entity
rows follow the purged kill rows, so no entity data is orphaned, and the
(wid,x,z,time) index already covers the range. Other tables are left
untouched, as with any restricted purge.

Rejected: a radius without i: or a:kill, WorldEdit selections, radius
purges on ClickHouse, and a numeric radius from the console (it has no
location, as in the console radius fix merged into this branch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdEuKbjcVoVinVQJq4te1f

* Made MySQL entity data purges safe against concurrent writers

The #optimize orphan sweep snapshots the co_entity ids that kill rows
reference, then deletes every row missing from the snapshot. Another
installation sharing the database could commit a kill between the two
statements and lose its entity data. The sweep now only deletes rows
inside the purge time range. A kill always writes its co_entity row with
the current time, and a purge range ends at least 24 hours ago, so a
concurrent kill never matches.

A world purge deletes the entity data of the purged kills and
then the kill rows. On MySQL a failure or cancellation between the two
left kept kill rows without their rollback data. Both deletes now run in
one transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Limited SQLite entity data cleanup to the purge time range

A SQLite purge dropped every co_entity row no kill referenced, including
rows newer than the purge range, and counted them as deleted. It now only
removes unreferenced rows inside the range, like the MySQL #optimize sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rejected purge include and exclude lists without a value

/co purge t:30d e: passed validation, parsed to no exclusions, and ran
as an unrestricted time purge. e:, and e:,,, did the same, as did an
e: at the end of the command and an empty i:. The argument check now
follows each include or exclude list across its continuation tokens and
rejects a list that ends without a value. Space-separated lists such as
e:zombie, villager still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Matched purge entity filters to kills logged under renamed types

Bukkit resolves a legacy name such as zombie_pigman to the renamed type,
so the filter only matched the id of the new name. e:zombie_pigman then
purged the old kills it was meant to keep, and i:zombie_pigman missed
them. The filter now covers every entity map id that resolves to the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rejected purges with more than one radius argument

/co purge t:30d r:50 r:#world_nether i:zombie ignored the explicit world
and purged around the player in their current world. Two r: arguments
always conflict, so the second one is now rejected before the purge
starts, whether it is a world, #global or another radius.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Applied max-radius to radius purges

A radius purge accepted any value. A radius past the int range saturated,
the area overflowed, and the purge reported success with nothing deleted.
It now stops at max-radius like lookups and rollbacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Checked radius arguments inside purge include and exclude lists

/co purge t:30d i:zombie, r:50 r:#world_nether passed the second-radius
check: the validator took r:50 as a value of the include list, then saw
r:#world_nether as the only radius. The radius and world parsers read
both. A list now continues only with plain or minecraft: namespaced
values, so a radius after a trailing comma is counted and the second one
is rejected. This is the same change as 39deec3 on entity-kill-purge-filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LFQuzYmYun57wJi2a5mqkM

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: TonyJamesStark <tonyjamesstark1@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-05 17:46:22 -06:00
1 parent 1490dc7c89
commit d5db22c23e
8 files changed
+516 -130

No files matched your search

+16 -2
View File
@@ -95,7 +95,7 @@ Purge old block data. Useful for freeing up space on your HDD if you don't need
| Command | Parameters |
| --- | --- |
| /co purge | `t:<time> r:<world> i:<include>` |
| /co purge | `t:<time> r:<world/radius> i:<include> e:<exclude> a:kill` |
For example, `/co purge t:30d` will delete all data older than one month, and only keep the last 30 days of data.
@@ -106,16 +106,30 @@ For example, `/co purge t:30d` will delete all data older than one month, and on
You can optionally specify a world in CoreProtect v19+.
For example, `/co purge t:30d r:#world_nether` will delete all data older than one month in the Nether, without removing data in any other worlds.
**Purging by Radius**
A radius can limit a purge of specific block types or entity kills to the area around you. It only applies to block and entity kill data, so it must be combined with `i:` or `a:kill`, and it can only be used in-game. A radius always applies to the world you are in, so it cannot be combined with a world such as `r:#world_nether`. The radius is limited by `max-radius` in the config, like other commands.
For example, `/co purge t:30d r:50 i:zombie` will delete zombie kills older than one month within 50 blocks of you, without removing any other data. Use `r:50x10` to also limit the height to 10 blocks above and below you.
**Purging Blocks**
You can optionally specify block types in CoreProtect v23+.
For example, `/co purge t:30d i:stone,dirt` will delete all stone and dirt data older than one month, without removing other block data.
**Purging Entity Kills**
Entity kill logs can be purged separately from other data on SQLite, MySQL, and DuckDB. Each kill also removes the saved entity data used to restore the mob on rollback.
* `/co purge t:30d a:kill` deletes all entity kills older than one month, and keeps all other data.
* `/co purge t:30d i:zombie,skeleton` deletes zombie and skeleton kills older than one month. Entity and block types can be combined in `i:`.
* `/co purge t:30d e:villager,wolf` deletes all data older than one month, but keeps villager and wolf kills.
* `/co purge t:30d a:kill e:villager r:#world` deletes entity kills older than one month in the overworld, except villager kills.
`e:` only accepts entity types. It cannot be combined with entity types in `i:`, or with only block types in `i:`, because a block restriction already keeps every kill. `a:kill` cannot be combined with block types in `i:`.
**Database Optimization**
In CoreProtect v2.15+, adding `#optimize` to the end of the command (for example, `/co purge t:30d #optimize`) will also optimize supported database tables and reclaim unused disk space. How this option is handled depends on the database backend:
* SQLite already rebuilds the database from retained data and reclaims unused file space as part of a manual purge, so `#optimize` is not needed.
* MySQL normally deletes matching rows. Adding `#optimize` also optimizes its tables to reclaim unused space.
* MySQL normally deletes matching rows. Adding `#optimize` also removes saved entity data in the purged time range that no entity kill references anymore (this requires the `CREATE TEMPORARY TABLES` privilege), then optimizes its tables to reclaim unused space.
* DuckDB deletes matching rows in one transaction and checkpoints afterward. `#optimize` has no additional effect.
* ClickHouse drops fully covered monthly partitions for an unfiltered time purge and synchronously removes rows from partial or filtered partitions. Adding `#optimize` also runs `OPTIMIZE TABLE ... FINAL`.
+5 -2
View File
@@ -76,8 +76,10 @@ HELP_PARAMS_7: "Exclude blocks/users from the {lookup|rollback|restore}."
HELP_PURGE_1: "Delete data older than specified time."
HELP_PURGE_2: "For example, \"{0}\" will delete all data older than one month, and only keep the last 30 days of data."
HELP_PURGE_3: "Specify the amount of time to purge."
HELP_PURGE_4: "Specify a world to limit the purge to."
HELP_PURGE_5: "Include specific blocks in the purge."
HELP_PURGE_4: "Specify a world or radius to limit the purge to."
HELP_PURGE_5: "Include specific blocks or entity kills in the purge."
HELP_PURGE_6: "Keep entity kills of these types."
HELP_PURGE_7: "Only purge entity kills."
HELP_PURGE_COMMAND: "Delete old block data."
HELP_RADIUS_1: "Specify a radius area."
HELP_RADIUS_2: "Examples: [r:10] (Only make changes within 10 blocks of you)"
@@ -137,6 +139,7 @@ LOOKUP_SEARCHING: "Lookup searching. Please wait..."
LOOKUP_STORAGE: "{0} {deposited|withdrew} {1} {2}."
LOOKUP_TIME: "{0} ago"
LOOKUP_USERNAME: "{0} logged in as {1}."
MAXIMUM_PURGE_RADIUS: "The maximum purge radius is {0}."
MAXIMUM_RADIUS: "The maximum {lookup|rollback|restore} radius is {0}."
MESSAGE_FILTER_TOO_SHORT: "Minimum message filter length is {0} characters."
MISSING_ACTION_USER: "To use that action, please specify a user."
@@ -70,8 +70,10 @@ public class HelpCommand {
else if (helpcommand.equals("purge") || helpcommand.equals("purges")) {
Chat.sendMessage(player, Color.DARK_AQUA + "/co purge " + Color.GREY + "<params> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_1));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "t:<time> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_3));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "r:<world> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_4));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "r:<world/radius> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_4));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "i:<include> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_5));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "e:<exclude> " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_6));
Chat.sendMessage(player, Color.DARK_AQUA + "| " + Color.GREY + "a:kill " + Color.WHITE + "- " + Phrase.build(Phrase.HELP_PURGE_7));
Chat.sendMessage(player, Color.GREY + Color.ITALIC + Phrase.build(Phrase.HELP_PURGE_2, "/co purge t:30d"));
}
else if (helpcommand.equals("reload")) {
@@ -13,6 +13,7 @@ import java.sql.Statement;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -25,9 +26,11 @@ import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import net.coreprotect.bukkit.BukkitAdapter;
import net.coreprotect.config.Config;
import net.coreprotect.config.ConfigHandler;
import net.coreprotect.consumer.Consumer;
import net.coreprotect.database.Database;
import net.coreprotect.database.PurgeFilter;
import net.coreprotect.database.PurgePolicy;
import net.coreprotect.language.Phrase;
import net.coreprotect.language.Selector;
@@ -40,6 +43,7 @@ import net.coreprotect.utility.EntityUtils;
import net.coreprotect.utility.EntitySpawnTracking;
import net.coreprotect.utility.MaterialUtils;
import net.coreprotect.utility.VersionUtils;
import net.coreprotect.utility.WorldUtils;
import net.coreprotect.utility.ErrorReporter;
public class PurgeCommand extends Consumer {
@@ -105,8 +109,31 @@ public class PurgeCommand extends Consumer {
ErrorReporter.report(exception);
}
private static String findUnsupportedPurgeArgument(String[] args) {
boolean includeContinuation = false;
/**
* Returns the entity_map ids of every name that resolves to an entity type. A renamed type keeps the kills logged
* under its old name, such as zombie_pigman for zombified_piglin.
*/
private static List<Integer> killTypeIds(EntityType entityType) {
List<Integer> ids = new ArrayList<>();
synchronized (ConfigHandler.entities) {
for (Map.Entry<String, Integer> entry : ConfigHandler.entities.entrySet()) {
try {
if (EntityUtils.getEntityType(entry.getKey()) == entityType) {
ids.add(entry.getValue());
}
}
catch (IllegalArgumentException e) {
// An entity that no longer exists in this version
}
}
}
return ids;
}
static String findUnsupportedPurgeArgument(String[] args) {
boolean listContinuation = false;
String emptyList = null; // an include or exclude argument that has no value yet
boolean hasRadius = false;
for (int i = 1; i < args.length; i++) {
String token = args[i].trim();
if (token.length() == 0) {
@@ -117,18 +144,42 @@ public class PurgeCommand extends Consumer {
argument = argument.replaceAll("\\\\", "");
argument = argument.replaceAll("'", "");
if (includeContinuation) {
includeContinuation = argument.endsWith(",");
continue;
}
if (argument.equals("#optimize")) {
continue;
}
String listValues = null;
if (argument.startsWith("i:") || argument.startsWith("include:") || argument.startsWith("item:") || argument.startsWith("items:") || argument.startsWith("b:") || argument.startsWith("block:") || argument.startsWith("blocks:")) {
String includeValues = argument.replaceAll("include:", "").replaceAll("i:", "").replaceAll("items:", "").replaceAll("item:", "").replaceAll("blocks:", "").replaceAll("block:", "").replaceAll("b:", "");
includeContinuation = includeValues.length() == 0 || includeValues.endsWith(",");
listValues = argument.replaceAll("include:", "").replaceAll("i:", "").replaceAll("items:", "").replaceAll("item:", "").replaceAll("blocks:", "").replaceAll("block:", "").replaceAll("b:", "");
}
else if (argument.startsWith("e:") || argument.startsWith("exclude:")) {
listValues = argument.replaceAll("exclude:", "").replaceAll("e:", "");
}
if (listValues != null) {
if (emptyList != null) {
return emptyList;
}
listContinuation = listValues.length() == 0 || listValues.endsWith(",");
emptyList = hasListValue(listValues) ? null : token;
continue;
}
if (listContinuation && isListValue(argument)) {
listContinuation = argument.endsWith(",");
if (hasListValue(argument)) {
emptyList = null;
}
continue;
}
// The radius, world and action parsers read a key such as r: or a: even inside a list
listContinuation = false;
if (argument.startsWith("a:") || argument.startsWith("action:")) {
// Only kills can be purged by action; any other value must not fall through to an unrestricted purge.
if (!CommandParser.parseAction(new String[] { "purge", argument }).equals(Collections.singletonList(LookupActions.ENTITY_KILL))) {
return token;
}
continue;
}
@@ -137,6 +188,11 @@ public class PurgeCommand extends Consumer {
}
if (argument.startsWith("r:") || argument.startsWith("radius:")) {
if (hasRadius) {
// A second radius conflicts with the first, such as r:50 with r:#world_nether
return token;
}
hasRadius = true;
continue;
}
@@ -145,7 +201,17 @@ public class PurgeCommand extends Consumer {
}
}
return null;
// An empty list would silently purge without that include or exclude restriction
return emptyList;
}
private static boolean hasListValue(String values) {
return values.replace(",", "").length() > 0;
}
private static boolean isListValue(String argument) {
int separator = argument.indexOf(':');
return separator < 0 || argument.substring(0, separator).equals("minecraft");
}
protected static void runCommand(final CommandSender player, boolean permission, String[] args) {
@@ -158,7 +224,7 @@ public class PurgeCommand extends Consumer {
final List<String> argExcludeUsers = CommandParser.parseExcludedUsers(player, args);
final long[] argTime = CommandParser.parseTime(args);
final int argWid = CommandParser.parseWorld(args, false, false);
final List<Integer> supportedActions = Arrays.asList();
final List<Integer> supportedActions = Arrays.asList(LookupActions.ENTITY_KILL);
long startTime = argTime[1] > 0 ? argTime[0] : 0;
long endTime = argTime[1] > 0 ? argTime[1] : argTime[0];
@@ -192,8 +258,21 @@ public class PurgeCommand extends Consumer {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co purge t:<time>"));
return;
}
// Without a location (console), a numeric radius parses to null and the purge would run server-wide
if (argRadius != null || CommandParser.parseRadius(args, player, new Location(null, 0, 0, 0)) != null) {
if (CommandParser.parseWorldEdit(args)) {
Chat.sendMessage(player, new ChatMessage(Phrase.build(Phrase.INVALID_WORLD)).build());
return;
}
if (argRadius != null && argRadius[0] == -1) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_RADIUS));
return;
}
int maxRadius = Config.getGlobal().MAX_RADIUS;
if (argRadius != null && maxRadius > 0 && argRadius[0] > maxRadius) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MAXIMUM_PURGE_RADIUS, Integer.toString(maxRadius)));
return;
}
if (argRadius == null && CommandParser.parseRadius(args, player, new Location(null, 0, 0, 0)) != null) {
// A numeric radius without a location (console) must not fall through to a world-wide purge
Chat.sendMessage(player, new ChatMessage(Phrase.build(Phrase.INVALID_WORLD)).build());
return;
}
@@ -219,18 +298,14 @@ public class PurgeCommand extends Consumer {
}
StringBuilder restrict = new StringBuilder();
String includeBlock = "";
List<Integer> includeBlockIds = new ArrayList<>();
String includeEntity = "";
List<Integer> includeEntityIds = new ArrayList<>();
boolean hasBlock = false;
boolean item = false;
boolean entity = false;
int restrictCount = 0;
if (argBlocks.size() > 0) {
StringBuilder includeListMaterial = new StringBuilder();
StringBuilder includeListEntity = new StringBuilder();
for (Object restrictTarget : argBlocks) {
String targetName = "";
@@ -238,17 +313,10 @@ public class PurgeCommand extends Consumer {
targetName = ((Material) restrictTarget).name();
int blockId = MaterialUtils.getBlockId(targetName, false);
includeBlockIds.add(blockId);
if (includeListMaterial.length() == 0) {
includeListMaterial = includeListMaterial.append(blockId);
}
else {
includeListMaterial.append(",").append(blockId);
}
/* Include legacy IDs */
int legacyId = BukkitAdapter.ADAPTER.getLegacyBlockId((Material) restrictTarget);
if (legacyId > 0) {
includeListMaterial.append(",").append(legacyId);
includeBlockIds.add(legacyId);
}
@@ -257,26 +325,13 @@ public class PurgeCommand extends Consumer {
hasBlock = true;
}
else if (restrictTarget instanceof EntityType) {
targetName = ((EntityType) restrictTarget).name();
if (includeListEntity.length() == 0) {
includeListEntity = includeListEntity.append(EntityUtils.getEntityId(targetName, false));
}
else {
includeListEntity.append(",").append(EntityUtils.getEntityId(targetName, false));
}
includeEntityIds.addAll(killTypeIds((EntityType) restrictTarget));
targetName = ((EntityType) restrictTarget).name().toLowerCase(Locale.ROOT);
entity = true;
}
else if (restrictTarget instanceof String) {
int blockId = MaterialUtils.getBlockId((String) restrictTarget, false);
includeBlockIds.add(blockId);
if (includeListMaterial.length() == 0) {
includeListMaterial = includeListMaterial.append(blockId);
}
else {
includeListMaterial.append(",").append(blockId);
}
targetName = ((String) restrictTarget).toLowerCase(Locale.ROOT);
hasBlock = true;
@@ -291,15 +346,36 @@ public class PurgeCommand extends Consumer {
restrictCount++;
}
includeBlock = includeListMaterial.toString();
includeEntity = includeListEntity.toString();
}
if (entity) {
List<Integer> excludeEntityIds = new ArrayList<>();
StringBuilder exclude = new StringBuilder();
for (Object excludeTarget : argExclude.keySet()) {
if (!(excludeTarget instanceof EntityType)) {
// Purge exclusions only support entity types (kills to keep).
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "e:" + excludeTarget.toString().toLowerCase(Locale.ROOT)));
return;
}
excludeEntityIds.addAll(killTypeIds((EntityType) excludeTarget));
exclude.append(exclude.length() == 0 ? "" : ", ").append(((EntityType) excludeTarget).name().toLowerCase(Locale.ROOT));
}
if (!argExcludeUsers.isEmpty()) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.INVALID_PARAMETER, "e:" + argExcludeUsers.get(0)));
return;
}
boolean killsOnly = argAction.contains(LookupActions.ENTITY_KILL);
boolean entityFilter = entity || killsOnly || !excludeEntityIds.isEmpty();
boolean excludeWithoutKills = !excludeEntityIds.isEmpty() && hasBlock && !entity; // a block restriction keeps every kill
if ((killsOnly && hasBlock) || (entity && !excludeEntityIds.isEmpty()) || excludeWithoutKills || ((entityFilter || argRadius != null) && ConfigHandler.databaseType.isClickHouse())) {
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.ACTION_NOT_SUPPORTED));
return;
}
if (argRadius != null && !hasBlock && !entity && !killsOnly) {
// A radius only limits co_block rows, so the purge must be restricted to them
Chat.sendMessage(player, Color.DARK_AQUA + "CoreProtect " + Color.WHITE + "- " + Phrase.build(Phrase.MISSING_PARAMETERS, "/co purge t:<time> r:<radius> i:<include>"));
return;
}
boolean optimizeCheck = false;
for (String arg : args) {
@@ -310,11 +386,22 @@ public class PurgeCommand extends Consumer {
}
final StringBuilder restrictTargets = restrict;
final String includeBlockFinal = includeBlock;
final StringBuilder excludeTargets = exclude;
final List<Integer> includeBlockIdsFinal = List.copyOf(includeBlockIds);
final List<Integer> includeEntityIdsFinal = List.copyOf(includeEntityIds);
final List<Integer> excludeEntityIdsFinal = List.copyOf(excludeEntityIds);
final boolean killsOnlyFinal = killsOnly;
final boolean optimize = optimizeCheck;
final boolean hasBlockRestriction = hasBlock;
final int restrictCountFinal = restrictCount;
final int purgeWorldId = argRadius != null ? WorldUtils.getWorldId(location.getWorld().getName()) : argWid;
String restrictSelector = Selector.FIRST; // block
if (hasBlock && entity) {
restrictSelector = Selector.THIRD; // target
}
else if (entity) {
restrictSelector = Selector.SECOND; // entity
}
final String restrictKind = restrictSelector;
class BasicThread implements Runnable {
@@ -341,6 +428,7 @@ public class PurgeCommand extends Consumer {
long timeStart = startTime > 0 ? (timestamp - startTime) : 0;
long timeEnd = timestamp - endTime;
long removed = 0;
PurgeFilter purgeFilter = new PurgeFilter(timeStart, timeEnd, purgeWorldId, argRadius, includeBlockIdsFinal, includeEntityIdsFinal, excludeEntityIdsFinal, killsOnlyFinal);
for (int i = 0; i <= 5; i++) {
requirePurgeNotCancelled();
@@ -375,7 +463,11 @@ public class PurgeCommand extends Consumer {
activePurgeThread = Thread.currentThread();
requirePurgeNotCancelled();
if (argWid > 0) {
if (argRadius != null) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_STARTED, location.getWorld().getName()));
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_RADIUS, argRadius[0].toString(), (argRadius[0] == 1 ? Selector.FIRST : Selector.SECOND)));
}
else if (argWid > 0) {
String worldName = CommandParser.parseWorldName(args, false);
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_STARTED, worldName));
}
@@ -383,8 +475,11 @@ public class PurgeCommand extends Consumer {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_STARTED, "#global"));
}
if (hasBlockRestriction) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_INCLUDE, restrictTargets.toString(), Selector.FIRST, Selector.FIRST, (restrictCountFinal == 1 ? Selector.FIRST : Selector.SECOND))); // include
if (restrictCountFinal > 0) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_INCLUDE, restrictTargets.toString(), Selector.FIRST, restrictKind, (restrictCountFinal == 1 ? Selector.FIRST : Selector.SECOND))); // include
}
if (excludeEntityIdsFinal.size() > 0) {
Chat.sendGlobalMessage(player, Phrase.build(Phrase.ROLLBACK_INCLUDE, excludeTargets.toString(), Selector.SECOND, Selector.SECOND, (excludeEntityIdsFinal.size() == 1 ? Selector.FIRST : Selector.SECOND))); // exclude
}
Chat.sendGlobalMessage(player, Phrase.build(Phrase.PURGE_NOTICE_1));
@@ -493,33 +588,17 @@ public class PurgeCommand extends Consumer {
boolean error = false;
if (!excludeTables.contains(table)) {
try {
boolean purge = true;
String timeLimit = "";
if (table.equals("entity_spawn")) {
timeLimit = " WHERE removed=0 OR block_rowid IN(SELECT rowid FROM " + purgePrefix + "block) OR kill_rowid IN(SELECT rowid FROM " + purgePrefix + "entity) OR rowid IN(SELECT entity_spawn_rowid FROM " + purgePrefix + "entity_container) OR rowid IN(SELECT entity_spawn_rowid FROM " + purgePrefix + "entity_interaction)";
}
else if (PurgePolicy.isPurgeable(table)) {
String blockRestriction = "(";
if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) {
blockRestriction = "action IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") OR type NOT IN(" + includeBlockFinal + ") OR (type IN(" + includeBlockFinal + ") AND ";
else if (table.equals("entity")) {
timeLimit = " WHERE " + purgeFilter.entityRetainCondition(purgePrefix + "block");
}
else if (hasBlockRestriction) {
purge = false;
}
if (argWid > 0 && PurgePolicy.isWorldScoped(table)) {
if (table.equals("entity_container") || table.equals("entity_interaction")) {
if (purge) {
String worldMatch = "(wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))";
timeLimit = " WHERE (" + worldMatch + " AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "')) OR NOT " + worldMatch;
}
}
else if (purge) {
timeLimit = " WHERE (" + blockRestriction + "wid = '" + argWid + "' AND (time >= '" + timeEnd + "' OR time < '" + timeStart + "'))) OR (wid != '" + argWid + "')";
}
}
else if (argWid == 0 && purge) {
timeLimit = " WHERE " + blockRestriction + "(time >= '" + timeEnd + "' OR time < '" + timeStart + "'))";
else {
String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix);
if (purgeCondition != null) {
timeLimit = " WHERE NOT (" + purgeCondition + ")";
}
}
query = "INSERT INTO " + purgePrefix + table + insertColumns + " SELECT " + selectColumns + " FROM " + ConfigHandler.prefix + table + timeLimit;
@@ -572,35 +651,21 @@ public class PurgeCommand extends Consumer {
}
try {
boolean purge = PurgePolicy.isPurgeable(table);
String blockRestriction = "";
if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) {
blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND ";
}
else if (hasBlockRestriction) {
purge = false;
}
String worldRestriction = "";
if (argWid > 0 && PurgePolicy.isWorldScoped(table)) {
if (table.equals("entity_container") || table.equals("entity_interaction")) {
worldRestriction = " AND (wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))";
}
else {
worldRestriction = " AND wid = '" + argWid + "'";
}
}
else if (argWid > 0) {
purge = false;
}
if (purge) {
query = "DELETE FROM " + purgePrefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction;
if (table.equals("entity")) {
query = purgeFilter.deleteUnreferencedEntities(purgePrefix + "entity", purgePrefix + "block");
preparedStmt = preparePurgeStatement(connection, query);
preparedStmt.execute();
preparedStmt.close();
}
else {
String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix);
if (purgeCondition != null) {
query = "DELETE FROM " + purgePrefix + table + " WHERE " + purgeCondition;
preparedStmt = preparePurgeStatement(connection, query);
preparedStmt.execute();
preparedStmt.close();
}
}
}
catch (Exception e) {
reportPurgeFailure(e);
@@ -644,35 +709,47 @@ public class PurgeCommand extends Consumer {
if (!ConfigHandler.databaseType.isSQLite()) {
try {
boolean purge = PurgePolicy.isPurgeable(table);
String blockRestriction = "";
if (hasBlockRestriction && PurgePolicy.supportsBlockRestriction(table)) {
blockRestriction = "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND type IN(" + includeBlockFinal + ") AND ";
String purgeCondition = purgeFilter.deleteCondition(table, ConfigHandler.prefix);
boolean deleteEntities = purgeCondition != null && table.equals("block") && purgeFilter.removesKills() && !purgeFilter.purgesEntitiesByTime();
// Kill rows and their entity data go together, or the kept kills lose their rollback data
boolean atomic = deleteEntities && ConfigHandler.databaseType.isMySQL();
if (atomic) {
connection.setAutoCommit(false);
}
else if (hasBlockRestriction) {
purge = false;
}
String worldRestriction = "";
if (argWid > 0 && PurgePolicy.isWorldScoped(table)) {
if (table.equals("entity_container") || table.equals("entity_interaction")) {
worldRestriction = " AND (wid = '" + argWid + "' OR entity_spawn_rowid IN(SELECT rowid FROM " + ConfigHandler.prefix + "entity_spawn WHERE current_wid = '" + argWid + "'))";
}
else {
worldRestriction = " AND wid = '" + argWid + "'";
}
}
else if (argWid > 0) {
purge = false;
}
if (purge) {
query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + blockRestriction + "time < '" + timeEnd + "' AND time >= '" + timeStart + "'" + worldRestriction;
try {
long deleted = 0;
if (deleteEntities) {
query = purgeFilter.deleteEntitiesOfPurgedKills(ConfigHandler.databaseType, ConfigHandler.prefix);
preparedStmt = preparePurgeStatement(connection, query);
removed = removed + preparedStmt.executeUpdate();
deleted = deleted + preparedStmt.executeUpdate();
preparedStmt.close();
}
if (purgeCondition != null) {
query = "DELETE FROM " + ConfigHandler.prefix + table + " WHERE " + purgeCondition;
preparedStmt = preparePurgeStatement(connection, query);
deleted = deleted + preparedStmt.executeUpdate();
preparedStmt.close();
}
if (atomic) {
connection.commit();
connection.setAutoCommit(true);
}
removed = removed + deleted;
}
catch (Exception e) {
if (atomic) {
try {
connection.rollback();
connection.setAutoCommit(true);
}
catch (SQLException rollbackException) {
e.addSuppressed(rollbackException);
}
}
throw e;
}
}
catch (Exception e) {
if (ConfigHandler.databaseType.isDuckDB()) {
@@ -688,6 +765,32 @@ public class PurgeCommand extends Consumer {
}
}
if (ConfigHandler.databaseType.isMySQL() && optimize) {
try {
for (String sweepQuery : PurgeFilter.mysqlOrphanSweepSetup(ConfigHandler.prefix)) {
preparedStmt = preparePurgeStatement(connection, sweepQuery);
preparedStmt.execute();
preparedStmt.close();
}
preparedStmt = preparePurgeStatement(connection, purgeFilter.mysqlOrphanSweepDelete(ConfigHandler.prefix));
removed = removed + preparedStmt.executeUpdate();
preparedStmt.close();
}
catch (Exception e) {
reportPurgeFailure(e);
}
finally {
try {
preparedStmt = preparePurgeStatement(connection, PurgeFilter.mysqlOrphanSweepTeardown(ConfigHandler.prefix));
preparedStmt.execute();
preparedStmt.close();
}
catch (Exception e) {
reportPurgeFailure(e);
}
}
}
requirePurgeNotCancelled();
String retainedPrefix = ConfigHandler.databaseType.isSQLite() ? purgePrefix : ConfigHandler.prefix;
query = "UPDATE " + retainedPrefix + "entity_spawn SET kill_rowid=NULL WHERE kill_rowid IS NOT NULL AND NOT EXISTS (SELECT 1 FROM " + retainedPrefix + "entity WHERE " + retainedPrefix + "entity.rowid=" + retainedPrefix + "entity_spawn.kill_rowid)";
@@ -447,7 +447,7 @@ public class TabHandler implements TabCompleter {
return StringUtil.copyPartialMatches(argument1, completions, new ArrayList<>(completions.size()));
}
else if (argument0.equals("purge") && sender.hasPermission("coreprotect.purge")) {
List<String> completions = new ArrayList<>(Arrays.asList("t:", "r:", "i:"));
List<String> completions = new ArrayList<>(Arrays.asList("t:", "r:", "i:", "e:", "a:kill"));
return StringUtil.copyPartialMatches(argument1, completions, new ArrayList<>(completions.size()));
}
else if (hasLookupCommand(argument0, sender)) {
@@ -464,10 +464,10 @@ public class TabHandler implements TabCompleter {
String argument2 = arg2.toLowerCase(Locale.ROOT);
if (argument1.startsWith("t:")) {
List<String> completions = new ArrayList<>(Arrays.asList("r:", "i:"));
List<String> completions = new ArrayList<>(Arrays.asList("r:", "i:", "e:", "a:kill"));
return StringUtil.copyPartialMatches(argument2, completions, new ArrayList<>(completions.size()));
}
else if (argument1.startsWith("r:") || argument1.startsWith("i:")) {
else if (argument1.startsWith("r:") || argument1.startsWith("i:") || argument1.startsWith("e:") || argument1.startsWith("a:")) {
List<String> completions = new ArrayList<>(Arrays.asList("t:"));
return StringUtil.copyPartialMatches(argument2, completions, new ArrayList<>(completions.size()));
}
@@ -0,0 +1,258 @@
package net.coreprotect.database;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
import net.coreprotect.model.action.LookupActions;
/**
* Selects the rows that one /co purge removes on the relational backends (SQLite, MySQL, DuckDB).
*
* <p>
* co_entity has no world or type column: each row belongs to the co_block kill row whose data column holds its
* rowid. The statements built here remove co_entity rows through those kill rows, so a scoped purge never leaves
* entity data behind without the kill row that references it. Player kills also use the kill action, but they
* store type 0 and a user id in data, so they are never treated as co_entity references.
*/
public final class PurgeFilter {
private final long timeStart;
private final long timeEnd;
private final int worldId;
private final Integer[] radius;
private final List<Integer> blockTypes;
private final List<Integer> entityTypes;
private final List<Integer> excludedEntityTypes;
private final boolean killsOnly;
/**
* @param timeStart
* the oldest time (inclusive) to purge
* @param timeEnd
* the newest time (exclusive) to purge
* @param worldId
* the world to purge, or 0 for every world
* @param radius
* the area to purge, as returned by CommandParser.parseRadius, or null for the whole world. It limits
* co_block rows only, so it counts as a restriction that leaves the other tables untouched.
* @param blockTypes
* material ids (i:) that restrict the purge to those co_block rows, or an empty list
* @param entityTypes
* entity type ids (i:) that restrict the purge to kills of those types, or an empty list
* @param excludedEntityTypes
* entity type ids (e:) whose kills the purge keeps, or an empty list
* @param killsOnly
* true (a:kill) to restrict the purge to kill rows
*/
public PurgeFilter(long timeStart, long timeEnd, int worldId, Integer[] radius, List<Integer> blockTypes, List<Integer> entityTypes, List<Integer> excludedEntityTypes, boolean killsOnly) {
this.timeStart = timeStart;
this.timeEnd = timeEnd;
this.worldId = worldId;
this.radius = radius == null ? null : radius.clone();
this.blockTypes = List.copyOf(blockTypes);
this.entityTypes = List.copyOf(entityTypes);
this.excludedEntityTypes = List.copyOf(excludedEntityTypes);
this.killsOnly = killsOnly;
}
/**
* Returns whether the purge is limited to selected co_block rows, which leaves the other tables untouched.
*/
private boolean restrictsTables() {
return radius != null || !blockTypes.isEmpty() || !entityTypes.isEmpty() || killsOnly;
}
/**
* Returns whether the purge removes co_block kill rows. A restriction to materials alone keeps every kill row.
*/
public boolean removesKills() {
return blockTypes.isEmpty() || !entityTypes.isEmpty() || killsOnly;
}
/**
* Returns whether co_entity rows can be removed by time alone. This holds when every kill row in the time range
* is purged, because a co_entity row always has the same time as its kill row.
*/
public boolean purgesEntitiesByTime() {
return worldId <= 0 && !restrictsTables() && excludedEntityTypes.isEmpty();
}
/**
* Builds the condition that matches the rows this purge removes from a table.
*
* @param table
* the table name without prefix
* @param prefix
* the prefix of the tables that subqueries read from
* @return the SQL condition, or null when the purge keeps every row of the table
*/
public String deleteCondition(String table, String prefix) {
if (table.equals("block")) {
return blockCondition("");
}
if (table.equals("entity")) {
return purgesEntitiesByTime() ? timeCondition("") : null;
}
if (!PurgePolicy.isPurgeable(table) || restrictsTables()) {
return null;
}
if (worldId <= 0) {
return timeCondition("");
}
if (!PurgePolicy.isWorldScoped(table)) {
return null;
}
if (table.equals("entity_container") || table.equals("entity_interaction")) {
return timeCondition("") + " AND (wid = " + worldId + " OR entity_spawn_rowid IN(SELECT rowid FROM " + prefix + "entity_spawn WHERE current_wid = " + worldId + "))";
}
return timeCondition("") + " AND wid = " + worldId;
}
/**
* Builds the condition that matches the co_block rows this purge removes.
*
* @param qualifier
* the table alias to prefix each column with, such as "b.", or an empty string
* @return the SQL condition
*/
private String blockCondition(String qualifier) {
StringBuilder condition = new StringBuilder(timeCondition(qualifier));
if (worldId > 0) {
condition.append(" AND ").append(qualifier).append("wid = ").append(worldId);
}
if (radius != null) {
condition.append(" AND ").append(qualifier).append("x >= ").append(radius[1]).append(" AND ").append(qualifier).append("x <= ").append(radius[2]);
if (radius[3] != null) {
condition.append(" AND ").append(qualifier).append("y >= ").append(radius[3]).append(" AND ").append(qualifier).append("y <= ").append(radius[4]);
}
condition.append(" AND ").append(qualifier).append("z >= ").append(radius[5]).append(" AND ").append(qualifier).append("z <= ").append(radius[6]);
}
List<String> restrictions = new ArrayList<>();
if (!blockTypes.isEmpty()) {
restrictions.add("(" + qualifier + "action NOT IN(" + LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN + ") AND " + qualifier + "type IN(" + joinIds(blockTypes) + "))");
}
if (!entityTypes.isEmpty()) {
restrictions.add("(" + qualifier + "action = " + LookupActions.ENTITY_KILL + " AND " + qualifier + "type IN(" + joinIds(entityTypes) + "))");
}
else if (killsOnly) {
restrictions.add(qualifier + "action = " + LookupActions.ENTITY_KILL);
}
if (!restrictions.isEmpty()) {
condition.append(" AND (").append(String.join(" OR ", restrictions)).append(")");
}
if (!excludedEntityTypes.isEmpty()) {
condition.append(" AND NOT (").append(qualifier).append("action = ").append(LookupActions.ENTITY_KILL).append(" AND ").append(qualifier).append("type IN(").append(joinIds(excludedEntityTypes)).append("))");
}
return condition.toString();
}
/**
* Builds the MySQL or DuckDB statement that deletes the co_entity rows of the kill rows this purge removes. Run it
* before the co_block delete, while those kill rows still exist.
*
* @param databaseType
* the MySQL or DuckDB backend
* @param prefix
* the table prefix
* @return the SQL statement
*/
public String deleteEntitiesOfPurgedKills(DatabaseType databaseType, String prefix) {
if (databaseType.isMySQL()) {
// The join form avoids a dependent subquery on MySQL 5.7 and MariaDB.
return "DELETE e FROM " + prefix + "entity AS e INNER JOIN " + prefix + "block AS b ON b.data = e.rowid WHERE " + killReference("b.") + " AND " + blockCondition("b.");
}
return "DELETE FROM " + prefix + "entity WHERE rowid IN(SELECT data FROM " + prefix + "block WHERE " + killReference("") + " AND " + blockCondition("") + ")";
}
/**
* Builds the SQLite copy condition that keeps the co_entity rows outside the purge time range, and those inside it
* that a retained kill row still references.
*
* @param retainedBlockTable
* the co_block table that holds the rows the purge keeps
* @return the SQL condition
*/
public String entityRetainCondition(String retainedBlockTable) {
return "NOT (" + timeCondition("") + ") OR rowid IN(" + killReferences(retainedBlockTable) + ")";
}
/**
* Builds the statement that deletes co_entity rows inside the purge time range that no kill row references.
*
* @param entityTable
* the co_entity table to clean
* @param blockTable
* the co_block table that holds the kill rows
* @return the SQL statement
*/
public String deleteUnreferencedEntities(String entityTable, String blockTable) {
return "DELETE FROM " + entityTable + " WHERE " + timeCondition("") + " AND rowid NOT IN(" + killReferences(blockTable) + ")";
}
/**
* Builds the MySQL statements that prepare an orphan sweep: a temporary table of the co_entity ids that kill rows
* reference. The table avoids an anti-join on the unindexed co_block.data column.
*
* @param prefix
* the table prefix
* @return the SQL statements, in order
*/
public static List<String> mysqlOrphanSweepSetup(String prefix) {
String keepTable = prefix + "entity_keep";
List<String> statements = new ArrayList<>();
statements.add("DROP TEMPORARY TABLE IF EXISTS " + keepTable);
statements.add("CREATE TEMPORARY TABLE " + keepTable + " (rowid INT NOT NULL PRIMARY KEY) ENGINE=InnoDB");
statements.add("INSERT IGNORE INTO " + keepTable + " (rowid) " + killReferences(prefix + "block"));
return statements;
}
/**
* Builds the MySQL statement that deletes co_entity rows no kill row references, such as rows left by earlier
* world purges. Run {@link #mysqlOrphanSweepSetup(String)} first.
*
* <p>
* Only rows inside the purge time range are deleted. Another installation sharing the database can write a kill
* after the setup snapshot, but its co_entity row gets the current time, which is always newer than the range.
*
* @param prefix
* the table prefix
* @return the SQL statement
*/
public String mysqlOrphanSweepDelete(String prefix) {
return "DELETE e FROM " + prefix + "entity AS e LEFT JOIN " + prefix + "entity_keep AS k ON k.rowid = e.rowid WHERE k.rowid IS NULL AND " + timeCondition("e.");
}
/**
* Builds the MySQL statement that drops the temporary table of an orphan sweep.
*
* @param prefix
* the table prefix
* @return the SQL statement
*/
public static String mysqlOrphanSweepTeardown(String prefix) {
return "DROP TEMPORARY TABLE IF EXISTS " + prefix + "entity_keep";
}
private String timeCondition(String qualifier) {
return qualifier + "time < " + timeEnd + " AND " + qualifier + "time >= " + timeStart;
}
private static String killReferences(String blockTable) {
return "SELECT data FROM " + blockTable + " WHERE " + killReference("") + " AND data IS NOT NULL";
}
private static String killReference(String qualifier) {
return qualifier + "action = " + LookupActions.ENTITY_KILL + " AND " + qualifier + "type <> 0";
}
private static String joinIds(List<Integer> ids) {
StringJoiner joiner = new StringJoiner(",");
for (Integer id : ids) {
joiner.add(String.valueOf(id));
}
return joiner.toString();
}
}
@@ -113,8 +113,10 @@ public class Language {
phrases.put(Phrase.HELP_PURGE_1, "Delete data older than specified time.");
phrases.put(Phrase.HELP_PURGE_2, "For example, \"{0}\" will delete all data older than one month, and only keep the last 30 days of data.");
phrases.put(Phrase.HELP_PURGE_3, "Specify the amount of time to purge.");
phrases.put(Phrase.HELP_PURGE_4, "Specify a world to limit the purge to.");
phrases.put(Phrase.HELP_PURGE_5, "Include specific blocks in the purge.");
phrases.put(Phrase.HELP_PURGE_4, "Specify a world or radius to limit the purge to.");
phrases.put(Phrase.HELP_PURGE_5, "Include specific blocks or entity kills in the purge.");
phrases.put(Phrase.HELP_PURGE_6, "Keep entity kills of these types.");
phrases.put(Phrase.HELP_PURGE_7, "Only purge entity kills.");
phrases.put(Phrase.HELP_PURGE_COMMAND, "Delete old block data.");
phrases.put(Phrase.HELP_RADIUS_1, "Specify a radius area.");
phrases.put(Phrase.HELP_RADIUS_2, "Examples: [r:10] (Only make changes within 10 blocks of you)");
@@ -174,6 +176,7 @@ public class Language {
phrases.put(Phrase.LOOKUP_STORAGE, "{0} {deposited|withdrew} {1} {2}.");
phrases.put(Phrase.LOOKUP_TIME, "{0} ago");
phrases.put(Phrase.LOOKUP_USERNAME, "{0} logged in as {1}.");
phrases.put(Phrase.MAXIMUM_PURGE_RADIUS, "The maximum purge radius is {0}.");
phrases.put(Phrase.MAXIMUM_RADIUS, "The maximum {lookup|rollback|restore} radius is {0}.");
phrases.put(Phrase.MESSAGE_FILTER_TOO_SHORT, "Minimum message filter length is {0} characters.");
phrases.put(Phrase.MISSING_ACTION_USER, "To use that action, please specify a user.");
@@ -90,6 +90,8 @@ public enum Phrase {
HELP_PURGE_3,
HELP_PURGE_4,
HELP_PURGE_5,
HELP_PURGE_6,
HELP_PURGE_7,
HELP_PURGE_COMMAND,
HELP_RADIUS_1,
HELP_RADIUS_2,
@@ -145,6 +147,7 @@ public enum Phrase {
LOOKUP_STORAGE,
LOOKUP_TIME,
LOOKUP_USERNAME,
MAXIMUM_PURGE_RADIUS,
MAXIMUM_RADIUS,
MESSAGE_FILTER_TOO_SHORT,
MISSING_ACTION_USER,