package me.desht.scrollingmenusign;
import me.desht.dhutils.*;
import me.desht.scrollingmenusign.enums.SMSAccessRights;
import me.desht.scrollingmenusign.util.SMSUtil;
import me.desht.scrollingmenusign.views.action.MenuDeleteAction;
import me.desht.scrollingmenusign.views.action.RepaintAction;
import me.desht.scrollingmenusign.views.action.TitleAction;
import me.desht.scrollingmenusign.views.action.ViewUpdateAction;
import org.apache.commons.lang.StringUtils;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.MemoryConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import java.io.File;
import java.util.*;
/**
* Represents a menu object
*/
public class SMSMenu extends Observable implements SMSPersistable, SMSUseLimitable, ConfigurationListener, Comparable<SMSMenu> {
public static final String FAKE_SPACE = "\u203f";
public static final String AUTOSORT = "autosort";
public static final String DEFAULT_CMD = "defcmd";
public static final String OWNER = "owner";
public static final String TITLE = "title";
public static final String ACCESS = "access";
public static final String REPORT_USES = "report_uses";
public static final String GROUP = "group";
private final String name;
private final List<SMSMenuItem> items = new ArrayList<SMSMenuItem>();
private final Map<String, Integer> itemMap = new HashMap<String, Integer>();
private final SMSRemainingUses uses;
private final AttributeCollection attributes; // menu attributes to be displayed and/or edited by players
private String title; // cache colour-parsed version of the title attribute
private UUID ownerId; // cache owner's UUID (could be null)
private boolean autosave;
private boolean inThaw;
/**
* Construct a new menu
*
* @param name Name of the menu
* @param title Title of the menu
* @param owner Owner of the menu
* @throws SMSException If there is already a menu at this location
* @deprecated use {@link SMSMenu(String,String,Player)} or {@link SMSMenu(String,String, org.bukkit.plugin.Plugin )}
*/
@Deprecated
public SMSMenu(String name, String title, String owner) {
this.name = name;
this.uses = new SMSRemainingUses(this);
this.attributes = new AttributeCollection(this);
registerAttributes();
setAttribute(OWNER, owner == null ? ScrollingMenuSign.CONSOLE_OWNER : owner);
ownerId = ScrollingMenuSign.CONSOLE_UUID;
setAttribute(TITLE, title);
autosave = true;
}
/**
* Construct a new menu
*
* @param name Name of the menu
* @param title Title of the menu
* @param owner Owner of the menu
* @throws SMSException If there is already a menu at this location
*/
public SMSMenu(String name, String title, Player owner) {
this.name = name;
this.uses = new SMSRemainingUses(this);
this.attributes = new AttributeCollection(this);
registerAttributes();
setAttribute(OWNER, owner == null ? ScrollingMenuSign.CONSOLE_OWNER : owner.getName());
ownerId = owner == null ? ScrollingMenuSign.CONSOLE_UUID : owner.getUniqueId();
setAttribute(TITLE, title);
autosave = true;
}
/**
* Construct a new menu
*
* @param name Name of the menu
* @param title Title of the menu
* @param owner Owner of the menu
* @throws SMSException If there is already a menu at this location
*/
public SMSMenu(String name, String title, Plugin owner) {
this.name = name;
this.uses = new SMSRemainingUses(this);
this.attributes = new AttributeCollection(this);
registerAttributes();
setAttribute(OWNER, owner == null ? ScrollingMenuSign.CONSOLE_OWNER : "[" + owner.getName() + "]");
ownerId = ScrollingMenuSign.CONSOLE_UUID;
setAttribute(TITLE, title);
autosave = true;
}
/**
* Construct a new menu from a frozen configuration object.
*
* @param node A ConfigurationSection containing the menu's properties
* @throws SMSException If there is already a menu at this location
*/
@SuppressWarnings("unchecked")
public SMSMenu(ConfigurationSection node) {
SMSPersistence.mustHaveField(node, "name");
SMSPersistence.mustHaveField(node, "title");
SMSPersistence.mustHaveField(node, "owner");
inThaw = true;
this.name = node.getString("name");
this.uses = new SMSRemainingUses(this, node.getConfigurationSection("usesRemaining"));
this.attributes = new AttributeCollection(this);
registerAttributes();
String id = node.getString("owner_id");
if (id != null && !id.isEmpty()) {
this.ownerId = UUID.fromString(id);
} else {
this.ownerId = ScrollingMenuSign.CONSOLE_UUID;
}
// migration of group -> owner_group access in 2.4.0
if (!node.contains("group") && node.contains(ACCESS) && node.getString(ACCESS).equals("GROUP")) {
LogUtils.info("menu " + name + ": migrate GROUP -> OWNER_GROUP access");
node.set("access", "OWNER_GROUP");
}
for (String k : node.getKeys(false)) {
if (!node.isConfigurationSection(k) && attributes.hasAttribute(k)) {
setAttribute(k, node.getString(k));
}
}
List<Map<String, Object>> items = (List<Map<String, Object>>) node.getList("items");
if (items != null) {
for (Map<String, Object> item : items) {
MemoryConfiguration itemNode = new MemoryConfiguration();
// need to expand here because the item may contain a usesRemaining object - item could contain a nested map
SMSPersistence.expandMapIntoConfig(itemNode, item);
SMSMenuItem menuItem = new SMSMenuItem(this, itemNode);
SMSMenuItem actual = menuItem.uniqueItem();
if (!actual.getLabel().equals(menuItem.getLabel()))
LogUtils.warning("Menu '" + getName() + "': duplicate item '" + menuItem.getLabelStripped() + "' renamed to '" + actual.getLabelStripped() + "'");
addItem(actual);
}
}
inThaw = false;
autosave = true;
}
public void setAttribute(String k, String val) {
SMSValidate.isTrue(attributes.contains(k), "No such view attribute: " + k);
attributes.set(k, val);
}
private void registerAttributes() {
attributes.registerAttribute(AUTOSORT, false, "Always keep the menu sorted?");
attributes.registerAttribute(DEFAULT_CMD, "", "Default command to run if item has no command");
attributes.registerAttribute(OWNER, "", "Player who owns this menu");
attributes.registerAttribute(GROUP, "", "Permission group for this menu");
attributes.registerAttribute(TITLE, "", "The menu's displayed title");
attributes.registerAttribute(ACCESS, SMSAccessRights.ANY, "Who may use this menu");
attributes.registerAttribute(REPORT_USES, true, "Tell the player when remaining uses have changed?");
}
public Map<String, Object> freeze() {
HashMap<String, Object> map = new HashMap<String, Object>();
List<Map<String, Object>> l = new ArrayList<Map<String, Object>>();
for (SMSMenuItem item : items) {
l.add(item.freeze());
}
for (String key : attributes.listAttributeKeys(false)) {
if (key.equals(TITLE)) {
map.put(key, SMSUtil.escape(attributes.get(key).toString()));
} else {
map.put(key, attributes.get(key).toString());
}
}
map.put("name", getName());
map.put("items", l);
map.put("usesRemaining", uses.freeze());
map.put("owner_id", getOwnerId() == null ? "" : getOwnerId().toString());
return map;
}
public AttributeCollection getAttributes() {
return attributes;
}
/**
* Get the menu's unique name
*
* @return Name of this menu
*/
public String getName() {
return name;
}
/**
* Get the menu's title string
*
* @return The title string
*/
public String getTitle() {
return title;
}
/**
* Set the menu's title string
*
* @param newTitle The new title string
*/
public void setTitle(String newTitle) {
attributes.set(TITLE, newTitle);
}
/**
* Get the menu's owner string
*
* @return Name of the menu's owner
*/
public String getOwner() {
return attributes.get(OWNER).toString();
}
/**
* Set the menu's owner string.
*
* @param owner Name of the menu's owner
*/
public void setOwner(String owner) {
attributes.set(OWNER, owner);
}
/**
* Get the menu's permission group.
*
* @return the group
*/
public String getGroup() {
return attributes.get(GROUP).toString();
}
/**
* Set the menu's permission group.
*
* @param group Name of the menu's group
*/
public void setGroup(String group) {
attributes.set(GROUP, group);
}
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(UUID ownerId) {
this.ownerId = ownerId;
}
/**
* Get the menu's autosave status - will menus be automatically saved to disk when modified?
*
* @return true or false
*/
public boolean isAutosave() {
return autosave;
}
/**
* Set the menu's autosave status - will menus be automatically saved to disk when modified?
*
* @param autosave true or false
* @return the previous autosave status - true or false
*/
public boolean setAutosave(boolean autosave) {
boolean prevAutosave = this.autosave;
this.autosave = autosave;
if (autosave) {
autosave();
}
return prevAutosave;
}
/**
* Get the menu's autosort status - will menu items be automatically sorted when added?
*
* @return true or false
*/
public boolean isAutosort() {
return (Boolean) attributes.get(AUTOSORT);
}
/**
* Set the menu's autosort status - will menu items be automatically sorted when added?
*
* @param autosort true or false
*/
public void setAutosort(boolean autosort) {
setAttribute(AUTOSORT, Boolean.toString(autosort));
}
/**
* Get the menu's default command. This command will be used if the menu item
* being executed has a missing command.
*
* @return The default command string
*/
public String getDefaultCommand() {
return attributes.get(DEFAULT_CMD).toString();
}
/**
* Set the menu's default command. This command will be used if the menu item
* being executed has a missing command.
*
* @param defaultCommand the default command to set
*/
public void setDefaultCommand(String defaultCommand) {
setAttribute(DEFAULT_CMD, defaultCommand);
}
/**
* Get a list of all the items in the menu
*
* @return A list of the items
*/
public List<SMSMenuItem> getItems() {
return items;
}
/**
* Get the number of items in the menu
*
* @return The number of items
*/
public int getItemCount() {
return items.size();
}
/**
* Get the item at the given numeric index
*
* @param index 1-based numeric index
* @return The menu item at that index or null if out of range and mustExist is false
* @throws SMSException if the index is out of range and mustExist is true
*/
public SMSMenuItem getItemAt(int index, boolean mustExist) {
if (index < 1 || index > items.size()) {
if (mustExist) {
throw new SMSException("Index " + index + " out of range.");
} else {
return null;
}
} else {
return items.get(index - 1);
}
}
/**
* Get the item at the given numeric index.
*
* @param index 1-based numeric index
* @return the menu item at that index or null if out of range
*/
public SMSMenuItem getItemAt(int index) {
return getItemAt(index, false);
}
/**
* Get the menu item matching the given label.
*
* @param wanted the label to match (case-insensitive)
* @return the menu item with that label, or null if no matching item
*/
public SMSMenuItem getItem(String wanted) {
return getItem(wanted, false);
}
/**
* Get the menu item matching the given label
*
* @param wanted The label to match (case-insensitive)
* @param mustExist If true and the label is not in the menu, throw an exception
* @return The menu item with that label, or null if no matching item and mustExist is false
* @throws SMSException if no matching item and mustExist is true
*/
public SMSMenuItem getItem(String wanted, boolean mustExist) {
if (items.size() != itemMap.size())
rebuildItemMap(); // workaround for Heroes 1.4.8 which calls menu.getItems().clear
Integer idx = itemMap.get(ChatColor.stripColor(wanted.replace(FAKE_SPACE, " ")));
if (idx == null) {
if (mustExist) {
throw new SMSException("No such item '" + wanted + "' in menu " + getName());
} else {
return null;
}
}
return getItemAt(idx);
}
/**
* Get the index of the item matching the given label
*
* @param wanted The label to match (case-insensitive)
* @return 1-based item index, or -1 if no matching item
*/
public int indexOfItem(String wanted) {
if (items.size() != itemMap.size())
rebuildItemMap(); // workaround for Heroes 1.4.8 which calls menu.getItems().clear
int index = -1;
try {
index = Integer.parseInt(wanted);
} catch (NumberFormatException e) {
String l = ChatColor.stripColor(wanted.replace(FAKE_SPACE, " "));
if (itemMap.containsKey(l))
index = itemMap.get(l);
}
return index;
}
/**
* Append a new item to the menu
*
* @param label Label of the item to add
* @param command Command to be run when the item is selected
* @param message Feedback text to be shown when the item is selected
* @deprecated use {@link #addItem(SMSMenuItem)}
*/
@Deprecated
public void addItem(String label, String command, String message) {
addItem(new SMSMenuItem(this, label, command, message));
}
/**
* Append a new item to the menu
*
* @param item The item to be added
*/
public void addItem(SMSMenuItem item) {
insertItem(items.size() + 1, item);
}
/**
* Insert new item in the menu, at the given position.
*
* @param pos the position to insert at
* @param label label of the new item
* @param command command to be run
* @param message feedback message text
* @deprecated use {@link #insertItem(int, SMSMenuItem)}
*/
@Deprecated
public void insertItem(int pos, String label, String command, String message) {
insertItem(pos, new SMSMenuItem(this, label, command, message));
}
/**
* Insert a new item in the menu, at the given position.
*
* @param item The item to insert
* @param pos The position to insert (1-based index)
*/
public void insertItem(int pos, SMSMenuItem item) {
if (items.size() != itemMap.size())
rebuildItemMap(); // workaround for Heroes 1.4.8 which calls menu.getItems().clear
if (item == null)
throw new NullPointerException();
String l = item.getLabelStripped();
if (itemMap.containsKey(l)) {
throw new SMSException("Duplicate label '" + l + "' not allowed in menu '" + getName() + "'.");
}
if (pos > items.size()) {
items.add(item);
itemMap.put(l, items.size());
} else {
items.add(pos - 1, item);
rebuildItemMap();
}
if (isAutosort()) {
Collections.sort(items);
if (pos <= items.size()) rebuildItemMap();
}
setChanged();
autosave();
}
/**
* Replace an existing menu item. The label must already be present in the menu,
* or an exception will be thrown.
*
* @param label Label of the menu item
* @param command The command to be run
* @param message The feedback message
* @throws SMSException if the label isn't present in the menu
* @deprecated use {@link #replaceItem(SMSMenuItem)}
*/
@Deprecated
public void replaceItem(String label, String command, String message) {
replaceItem(new SMSMenuItem(this, label, command, message));
}
/**
* Replace an existing menu item. The new item's label must already be present in
* the menu.
*
* @param item the replacement menu item
* @throws SMSException if the new item's label isn't present in the menu
*/
public void replaceItem(SMSMenuItem item) {
if (items.size() != itemMap.size())
rebuildItemMap(); // workaround for Heroes 1.4.8 which calls menu.getItems().clear
String l = item.getLabelStripped();
if (!itemMap.containsKey(l)) {
throw new SMSException("Label '" + l + "' is not in the menu.");
}
int idx = itemMap.get(l);
items.set(idx - 1, item);
itemMap.put(l, idx);
setChanged();
autosave();
}
/**
* Replace the menu item at the given 1-based position. The new label must not already be
* present in the menu or an exception will be thrown - duplicates are not allowed.
*
* @param pos the position to replace at
* @param label label of the replacement item
* @param command command for the replacement item
* @param message feedback message text for the replacement item
* @deprecated use {@link #replaceItem(int, SMSMenuItem)}
*/
@Deprecated
public void replaceItem(int pos, String label, String command, String message) {
replaceItem(pos, new SMSMenuItem(this, label, command, message));
}
/**
* Replace the menu item at the given 1-based position. The new label must not already be
* present in the menu or an exception will be thrown - duplicates are not allowed.
*
* @param pos the position to replace at
* @param item the new menu item
* @throws SMSException if the new menu item's label already exists in this menu
*/
public void replaceItem(int pos, SMSMenuItem item) {
if (items.size() != itemMap.size())
rebuildItemMap(); // workaround for Heroes 1.4.8 which calls menu.getItems().clear
String l = item.getLabelStripped();
if (pos < 1 || pos > items.size()) {
throw new SMSException("Index " + pos + " out of range.");
}
if (itemMap.containsKey(l) && pos != itemMap.get(l)) {
throw new SMSException("Duplicate label '" + l + "' not allowed in menu '" + getName() + "'.");
}
itemMap.remove(items.get(pos - 1).getLabelStripped());
items.set(pos - 1, item);
itemMap.put(l, pos);
setChanged();
autosave();
}
/**
* Rebuild the label->index mapping for the menu. Needed if the menu order changes
* (insertion, removal, sorting...)
*/
private void rebuildItemMap() {
itemMap.clear();
for (int i = 0; i < items.size(); i++) {
itemMap.put(items.get(i).getLabelStripped(), i + 1);
}
}
/**
* Sort the menu's items by label text - see {@link SMSMenuItem#compareTo(SMSMenuItem)}
*/
public void sortItems() {
Collections.sort(items);
rebuildItemMap();
setChanged();
autosave();
}
/**
* Remove an item from the menu by matching label. If the label string is
* just an integer value, remove the item at that 1-based numeric index.
*
* @param indexStr The label to search for and remove
* @throws IllegalArgumentException if the label does not exist in the menu
*/
public void removeItem(String indexStr) {
if (StringUtils.isNumeric(indexStr)) {
removeItem(Integer.parseInt(indexStr));
} else {
String stripped = ChatColor.stripColor(indexStr);
SMSValidate.isTrue(itemMap.containsKey(stripped), "No such label '" + indexStr + "' in menu '" + getName() + "'.");
removeItem(itemMap.get(stripped));
}
}
/**
* Remove an item from the menu by numeric index
*
* @param index 1-based index of the item to remove
*/
public void removeItem(int index) {
// Java lists are 0-indexed, our signs are 1-indexed
items.remove(index - 1);
rebuildItemMap();
setChanged();
autosave();
}
/**
* Remove all items from a menu
*/
public void removeAllItems() {
items.clear();
itemMap.clear();
setChanged();
autosave();
}
/**
* Permanently delete a menu, dereferencing the object and removing saved data from disk.
*/
void deletePermanent() {
try {
setChanged();
notifyObservers(new MenuDeleteAction(null, true));
ScrollingMenuSign.getInstance().getMenuManager().unregisterMenu(getName());
SMSPersistence.unPersist(this);
} catch (SMSException e) {
// Should not get here
LogUtils.warning("Impossible: deletePermanent got SMSException?" + e.getMessage());
}
}
/**
* Temporarily delete a menu. The menu object is dereferenced but saved menu data is not
* deleted from disk.
*/
void deleteTemporary() {
try {
setChanged();
notifyObservers(new MenuDeleteAction(null, false));
ScrollingMenuSign.getInstance().getMenuManager().unregisterMenu(getName());
} catch (SMSException e) {
// Should not get here
LogUtils.warning("Impossible: deleteTemporary got SMSException? " + e.getMessage());
}
}
public void autosave() {
// we only save menus which have been registered via SMSMenu.addMenu()
if (isAutosave() && ScrollingMenuSign.getInstance().getMenuManager().checkForMenu(getName())) {
SMSPersistence.save(this);
}
}
/**
* Check if the given player has access right for this menu.
*
* @param player The player to check
* @return True if the player may use this view, false if not
*/
public boolean hasOwnerPermission(Player player) {
SMSAccessRights access = (SMSAccessRights) getAttributes().get(ACCESS);
return access.isAllowedToUse(player, ownerId, getOwner(), getGroup());
}
/**
* Get the usage limit details for this menu.
*
* @return The usage limit details
*/
public SMSRemainingUses getUseLimits() {
return uses;
}
@Override
public String getLimitableName() {
return getName();
}
/**
* Returns a printable representation of the number of uses remaining for this item.
*
* @return Formatted usage information
*/
String formatUses() {
return uses.toString();
}
/**
* Returns a printable representation of the number of uses remaining for this item, for the given player.
*
* @param sender Command sender to retrieve the usage information for
* @return Formatted usage information
*/
@Override
public String formatUses(CommandSender sender) {
if (sender instanceof Player) {
return uses.toString((Player) sender);
} else {
return formatUses();
}
}
@Override
public File getSaveFolder() {
return DirectoryStructure.getMenusFolder();
}
@Override
public String getDescription() {
return "menu";
}
@Override
public Object onConfigurationValidate(ConfigurationManager configurationManager, String key, Object oldVal, Object newVal) {
if (key.equals(ACCESS)) {
SMSAccessRights access = (SMSAccessRights) newVal;
if (access != SMSAccessRights.ANY && ownerId == null && !inThaw) {
throw new SMSException("View must be owned by a player to change access control to " + access);
} else if (access == SMSAccessRights.GROUP && ScrollingMenuSign.permission == null) {
throw new SMSException("Cannot use GROUP access control (no permission group support available)");
}
} else if (key.equals(TITLE)) {
return SMSUtil.unEscape(newVal.toString());
} else if (key.equals(OWNER) && newVal.toString().equals("&console")) {
// migration of owner field from pre-2.0.0: "&console" => "[console]"
return ScrollingMenuSign.CONSOLE_OWNER;
}
return newVal;
}
@Override
public void onConfigurationChanged(ConfigurationManager configurationManager, String key, Object oldVal, Object newVal) {
if (key.equals(AUTOSORT) && (Boolean) newVal) {
sortItems();
} else if (key.equals(TITLE)) {
title = newVal.toString();
setChanged();
notifyObservers(new TitleAction(null, oldVal.toString(), newVal.toString()));
} else if (key.equals(OWNER) && !inThaw) {
final String owner = newVal.toString();
if (owner.isEmpty() || owner.equals(ScrollingMenuSign.CONSOLE_OWNER)) {
ownerId = ScrollingMenuSign.CONSOLE_UUID;
} else if (MiscUtil.looksLikeUUID(owner)) {
ownerId = UUID.fromString(owner);
String name = Bukkit.getOfflinePlayer(ownerId).getName();
setAttribute(OWNER, name == null ? "?" : name);
} else if (!owner.equals("?")) {
@SuppressWarnings("deprecation") Player p = Bukkit.getPlayer(owner);
if (p != null) {
ownerId = p.getUniqueId();
} else {
updateOwnerAsync(owner);
}
}
}
autosave();
}
private void updateOwnerAsync(final String owner) {
final UUIDFetcher uf = new UUIDFetcher(Arrays.asList(owner));
Bukkit.getScheduler().runTaskAsynchronously(ScrollingMenuSign.getInstance(), new Runnable() {
@Override
public void run() {
try {
Map<String,UUID> res = uf.call();
if (res.containsKey(owner)) {
ownerId = res.get(owner);
} else {
LogUtils.warning("Menu [" + getName() + "]: no known UUID for player: " + owner);
ownerId = ScrollingMenuSign.CONSOLE_UUID;
}
} catch (Exception e) {
LogUtils.warning("Menu [" + getName() + "]: can't retrieve UUID for player: " + owner + ": " + e.getMessage());
}
}
});
}
/**
* Check if this menu is owned by the given player.
*
* @param player the player to check
* @return true if the menu is owned by the given player, false otherwise
*/
public boolean isOwnedBy(Player player) {
return player.getUniqueId().equals(ownerId);
}
/**
* Require that the given command sender is allowed to modify this menu, and throw a SMSException if not.
*
* @param sender The command sender to check
*/
public void ensureAllowedToModify(CommandSender sender) {
if (sender instanceof Player) {
Player player = (Player) sender;
if (!isOwnedBy(player) && !PermissionUtils.isAllowedTo(player, "scrollingmenusign.edit.any")) {
throw new SMSException("You don't have permission to modify that menu.");
}
}
}
@Override
public int compareTo(SMSMenu o) {
return getName().compareTo(o.getName());
}
public void forceUpdate(ViewUpdateAction action) {
setChanged();
notifyObservers(action);
}
}