package chatty.gui; import chatty.Helper; import chatty.User; import java.awt.Color; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.logging.Logger; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; /** * Checks if a given String matches the saved highlight items. * * @author tduva */ public class Highlighter { private static final Logger LOGGER = Logger.getLogger(Highlighter.class.getName()); private static final int LAST_HIGHLIGHTED_TIMEOUT = 10*1000; private final Map<String, Long> lastHighlighted = new HashMap<>(); private final List<HighlightItem> items = new ArrayList<>(); private Pattern usernamePattern; private Color lastMatchColor; private boolean lastMatchNoNotification; private boolean lastMatchNoSound; // Settings private boolean highlightUsername; private boolean highlightNextMessages; /** * Clear current items and load the new ones. * * @param newItems * @throws NullPointerException if newItems is null */ public void update(List<String> newItems) { items.clear(); for (String item : newItems) { if (item != null && !item.isEmpty()) { items.add(new HighlightItem(item)); } } } /** * Sets the current username. * * @param username */ public void setUsername(String username) { if (username == null) { usernamePattern = null; } else { // Create pattern to match username on word boundaries try { usernamePattern = Pattern.compile("(?i).*\\b"+username+"\\b.*"); } catch (PatternSyntaxException ex) { LOGGER.warning("Invalid regex for username: " + ex.getLocalizedMessage()); usernamePattern = null; } } } /** * Sets whether the username should be highlighted. * * @param highlighted */ public void setHighlightUsername(boolean highlighted) { this.highlightUsername = highlighted; } public void setHighlightNextMessages(boolean highlight) { this.highlightNextMessages = highlight; } public boolean check(User fromUser, String text) { if (checkMatch(fromUser, text)) { if (fromUser != null) { addMatch(fromUser.getName()); } return true; } return false; } /** * Returns the color for the last match, which can be used to make the * highlight appear in the appropriate custom color. * * @return The {@code Color} or {@code null} if no color was specified */ public Color getLastMatchColor() { return lastMatchColor; } public boolean getLastMatchNoNotification() { return lastMatchNoNotification; } public boolean getLastMatchNoSound() { return lastMatchNoSound; } /** * Checks whether the given message consisting of username and text should * be highlighted. * * @param userName The name of the user who send the message * @param text The text of the message * @return true if the message should be highlighted, false otherwise */ private boolean checkMatch(User user, String text) { lastMatchColor = null; lastMatchNoNotification = false; lastMatchNoSound = false; String lowercaseText = text.toLowerCase(); // Try to match own name first (if enabled) if (highlightUsername && usernamePattern != null && usernamePattern.matcher(text).matches()) { return true; } // Then try to match against the items for (HighlightItem item : items) { if (item.matches(user, text, lowercaseText)) { lastMatchColor = item.getColor(); lastMatchNoNotification = item.noNotification(); lastMatchNoSound = item.noSound(); return true; } } // Then see if there is a recent match if (highlightNextMessages && user != null && hasRecentMatch(user.getName())) { return true; } return false; } private void addMatch(String fromUsername) { lastHighlighted.put(fromUsername, System.currentTimeMillis()); } private boolean hasRecentMatch(String fromUsername) { clearRecentMatches(); return lastHighlighted.containsKey(fromUsername); } private void clearRecentMatches() { Iterator<Map.Entry<String, Long>> it = lastHighlighted.entrySet().iterator(); while (it.hasNext()) { if (System.currentTimeMillis() - it.next().getValue() > LAST_HIGHLIGHTED_TIMEOUT) { it.remove(); } } } /** * A single item that itself parses the item String and prepares it for * matching. The item can be asked whether it matches a message. * * A message matches the item if the message text contains the text of this * item (case-insensitive). * * Prefixes that change this behaviour: * user: - to match the exact username the message is from * cs: - to match the following term case-sensitive * re: - to match as regex * chan: - to match when the user is on this channel (can be a * comma-seperated list) * !chan: - same as chan: but inverted * status:m * * An item can be prefixed with a user:username, so the username as well * as the item after it has to match. */ static class HighlightItem { private String username; private Pattern usernamePattern; private Pattern pattern; private String caseSensitive; private String caseInsensitive; private String startsWith; private String category; private final Set<String> notChannels = new HashSet<>(); private final Set<String> channels = new HashSet<>(); private String channelCategory; private String channelCategoryNot; private String categoryNot; private Color color; private boolean noNotification; private boolean noSound; private boolean appliesToInfo; private final Set<Status> statusReq = new HashSet<>(); private final Set<Status> statusReqNot = new HashSet<>(); enum Status { MOD("m"), SUBSCRIBER("s"), BROADCASTER("b"), ADMIN("a"), STAFF("f"), TURBO("t"), ANY_MOD("M"), GLOBAL_MOD("g"), BOT("r"); private final String id; Status(String id) { this.id = id; } } HighlightItem(String item) { prepare(item); } /** * Prepare an item for matching by checking for prefixes and handling * the different types accordingly. * * @param item */ private void prepare(String item) { item = item.trim(); if (item.startsWith("re:") && item.length() > 3) { compilePattern(item.substring(3)); } else if (item.startsWith("w:") && item.length() > 2) { compilePattern("(?i).*\\b"+item.substring(2)+"\\b.*"); } else if (item.startsWith("wcs:") && item.length() > 4) { compilePattern(".*\\b"+item.substring(4)+"\\b.*"); } else if (item.startsWith("cs:") && item.length() > 3) { caseSensitive = item.substring(3); } else if (item.startsWith("start:") && item.length() > 6) { startsWith = item.substring(6).toLowerCase(); } else if (item.startsWith("cat:")) { category = parsePrefix(item, "cat:"); } else if (item.startsWith("!cat:")) { categoryNot = parsePrefix(item, "!cat:"); } else if (item.startsWith("user:")) { username = parsePrefix(item, "user:").toLowerCase(Locale.ENGLISH); } else if (item.startsWith("reuser:")) { String regex = parsePrefix(item, "reuser:").toLowerCase(Locale.ENGLISH); compileUsernamePattern(regex); } else if (item.startsWith("chan:")) { parseListPrefix(item, "chan:"); } else if (item.startsWith("!chan:")) { parseListPrefix(item, "!chan:"); } else if (item.startsWith("chanCat:")) { channelCategory = parsePrefix(item, "chanCat:"); } else if (item.startsWith("!chanCat:")) { channelCategoryNot = parsePrefix(item, "!chanCat:"); } else if (item.startsWith("color:")) { color = HtmlColors.decode(parsePrefix(item, "color:")); } else if (item.startsWith("status:")) { String status = parsePrefix(item, "status:"); parseStatus(status, true); } else if (item.startsWith("!status:")) { String status = parsePrefix(item, "!status:"); parseStatus(status, false); } else if (item.startsWith("config:")) { parseListPrefix(item, "config:"); } else { caseInsensitive = item.toLowerCase(); } } /** * Find status ids in the status: or !status: prefix and save the ones * that were found as requirement. Characters that do not represent a * status id are ignored. * * @param status The String containing the status ids * @param shouldBe Whether this is a requirement where the status should * be there or should NOT be there (status:/!status:) */ private void parseStatus(String status, boolean shouldBe) { for (Status s : Status.values()) { if (status.contains(s.id)) { if (shouldBe) { statusReq.add(s); } else { statusReqNot.add(s); } } } } /** * Parse a prefix with a parameter, also prepare() following items (if * present). * * @param item The input to parse the stuff from * @param prefix The name of the prefix, used to remove the prefix to * get the value * @return The value of the prefix */ private String parsePrefix(String item, String prefix) { String[] split = item.split(" ", 2); if (split.length == 2) { // There is something after this prefix, so prepare that just // like another item (but of course added to this object). prepare(split[1]); } return split[0].substring(prefix.length()); } private void parseListPrefix(String item, String prefix) { parseList(parsePrefix(item, prefix), prefix); } /** * Parses a comma-seperated list of a prefix. * * @param list The String containing the list * @param prefix The prefix for this list, used to determine what to do * with the found list items */ private void parseList(String list, String prefix) { String[] split2 = list.split(","); for (String part : split2) { if (!part.isEmpty()) { if (prefix.equals("chan:")) { channels.add(Helper.toChannel(part)); } else if (prefix.equals("!chan:")) { notChannels.add(Helper.toChannel(part)); } else if (prefix.equals("config:")) { if (part.equals("silent")) { noSound = true; } else if (part.equals("!notify")) { noNotification = true; } else if (part.equals("info")) { appliesToInfo = true; } } } } } /** * Compiles a pattern (regex) and sets it as pattern. * * @param patternString */ private void compilePattern(String patternString) { try { pattern = Pattern.compile(patternString); } catch (PatternSyntaxException ex) { LOGGER.warning("Invalid regex: " + ex); } } private void compileUsernamePattern(String patternString) { try { usernamePattern = Pattern.compile(patternString); } catch (PatternSyntaxException ex) { LOGGER.warning("Invalid username regex: " + ex); } } /** * Check whether a message matches this item. * * @param lowercaseUsername The username in lowercase * @param text The text as received * @param lowercaseText The text in lowercase (minor optimization, so * it doesn't have to be made lowercase for every item) * @return true if it matches, false otherwise */ public boolean matches(User user, String text, String lowercaseText) { if (pattern != null && !pattern.matcher(text).matches()) { return false; } if (caseSensitive != null && !text.contains(caseSensitive)) { return false; } if (caseInsensitive != null && !lowercaseText.contains(caseInsensitive)) { return false; } if (startsWith != null && !lowercaseText.startsWith(startsWith)) { return false; } if (user == null) { return appliesToInfo; } // Everything else from here is user-based if (user != null && appliesToInfo) { return false; } if (username != null && !username.equals(user.getName())) { return false; } if (usernamePattern != null && !usernamePattern.matcher(user.getName()).matches()) { return false; } if (category != null && !user.hasCategory(category)) { return false; } if (categoryNot != null && user.hasCategory(categoryNot)) { return false; } if (!channels.isEmpty() && !channels.contains(user.getChannel())) { return false; } if (!notChannels.isEmpty() && notChannels.contains(user.getChannel())) { return false; } if (channelCategory != null && !user.hasCategory(channelCategory, user.getChannel())) { return false; } if (channelCategoryNot != null && user.hasCategory(channelCategoryNot, user.getChannel())) { return false; } if (!checkStatus(user, statusReq)) { return false; } if (!checkStatus(user, statusReqNot)) { return false; } return true; } /** * Check if the status of a User matches the given requirements. It is * valid to either give the statusReq (requires status to BE there) or * statusReqNot (requires status to NOT BE there) set of requirements. * The set may contain only some or even no requirements. * * @param user The user to check against * @param req The set of status requirements * @return true if the requirements match, false otherwise (depending * on which set of requirements was given, statusReq or statusReqNot, * only one requirement has to match or all have to match) */ private boolean checkStatus(User user, Set<Status> req) { // No requirement, so always matching if (req.isEmpty()) { return true; } /** * If this checks the requirements that SHOULD be there, then this * is an OR relation (only one of the available requirements has to * match, so it will return true at the first matching requirement, * false otherwise). * * Otherwise, then this is an AND relation (all of the available * requirements have to match, so it will return false at the first * requirement that doesn't match, true if all match). */ boolean or = req == statusReq; if (req.contains(Status.MOD) && user.isModerator()) { return or; } if (req.contains(Status.SUBSCRIBER) && user.isSubscriber()) { return or; } if (req.contains(Status.ADMIN) && user.isAdmin()) { return or; } if (req.contains(Status.STAFF) && user.isStaff()) { return or; } if (req.contains(Status.BROADCASTER) && user.isBroadcaster()) { return or; } if (req.contains(Status.TURBO) && user.hasTurbo()) { return or; } if (req.contains(Status.GLOBAL_MOD) && user.isGlobalMod()) { return or; } if (req.contains(Status.BOT) && user.isBot()) { return or; } if (req.contains(Status.ANY_MOD) && user.hasModeratorRights()) { return or; } return !or; } /** * Get the color defined for this entry, if any. * * @return The Color or null if none was defined for this entry */ public Color getColor() { return color; } public boolean noNotification() { return noNotification; } public boolean noSound() { return noSound; } } }