package chatty;
import chatty.util.FileWatcher;
import chatty.util.MiscUtil;
import chatty.util.StringUtil;
import chatty.util.settings.Settings;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This class stores {@code AddressbookEntry}s (which associate a username with
* categories) and provides text commands and methods to modify/save/load those
* entries.
*
* @author tduva
*/
public class Addressbook {
private static final Logger LOGGER = Logger.getLogger(Addressbook.class.getName());
private static final Charset CHARSET = Charset.forName("UTF-8");
private final Settings settings;
/**
* Map of entries.
*/
private final Map<String, AddressbookEntry> entries = new HashMap<>();
/**
* Each category that appears in any of the entries should be collected here
* so it can be used in the GUI for more convenient editing.
*/
private final Set<String> presetCategories = new TreeSet<>();
private final Set<String> somewhatUniqueCategories = new HashSet<>();
/**
* The complete path and name of the file to save/load the entries.
*/
private final String fileName;
/**
* The complete path and name of the file to read Addressbook commands from,
* which can be used to modify the entries from outside the program.
*/
private final String importFileName;
/**
* Whether the Addressbook was already saved this session.
*/
private boolean saved;
public Addressbook(String fileName, String importFilename, Settings settings) {
this.fileName = fileName;
this.importFileName = importFilename;
this.settings = settings;
}
/**
* Set a comma-seperated list of categories that should be unqiue to one
* user when using the add/set commands.
*
* @param cats Comma-seperated list of categories (spaces are removed)
*/
public void setSomewhatUniqueCategories(String cats) {
if (cats == null) {
return;
}
synchronized(somewhatUniqueCategories) {
somewhatUniqueCategories.clear();
String[] split = cats.split(",");
for (String cat : split) {
if (!cat.trim().isEmpty()) {
somewhatUniqueCategories.add(cat.trim());
}
}
}
}
/**
* Parses text commands. Commands can be:
*
* <ul>
* <li>{@code get <name>}</li>
* <li>{@code add <name> [categories]}</li>
* <li>{@code set <name> <categories>}</li>
* <li>{@code remove <name> [categories]}</li>
* <li>{@code renameCategory <oldCategoryName> <newCategoryName>}</li>
* <li>{@code removeCategory <categoryName>}</li>
* <li>{@code change <name> <categoryChange>}</li>
* <li>{@code info}</li>
* </ul>
*
* @param text The text to parse.
* @return An appropriate text response to the command, either a message
* showing the result of the command or an error message.
* @throws NullPointerException if text is null
*/
public synchronized String command(String text) {
text = StringUtil.removeDuplicateWhitespace(text).trim();
if (text.isEmpty()) {
return "Invalid command.";
}
String[] split = text.split(" ", 2);
String command = split[0];
String[] parameters = new String[0];
String parameter = "";
if (split.length == 2) {
parameters = split[1].split(" ");
parameter = split[1].trim();
}
if (command.equals("get")) {
return commandGet(parameters);
} else if (command.equals("add")) {
return commandAdd(parameters);
} else if (command.equals("set")) {
return commandSet(parameters);
} else if (command.equals("remove")) {
return commandRemove(parameters);
} else if (command.equals("renameCategory")) {
return commandRenameCategory(parameters);
} else if (command.equals("removeCategory")) {
return commandRemoveCategory(parameters);
} else if (command.equals("change")) {
return commandChange(parameter);
} else if (command.equals("info")) {
return commandInfo();
}
return "Invalid command.";
}
/**
* Display the categories for this name, if it exists.
*
* @param parameters The array of String parameters, which in this case
* should only be the name to look up
* @return The response containing the name and categories or an appropriate
* error message.
*/
private String commandGet(String[] parameters) {
if (parameters.length == 1) {
String name = parameters[0].trim();
AddressbookEntry entry = get(name);
if (entry == null) {
return "No entry for '"+name+"'.";
}
return "'"+name+"' has categories "+categoriesToString(entry.getCategories())+".";
} else {
return "Get: Invalid number of parameters.";
}
}
/**
* Add an entry or categories to an entry.
*
* @param parameters The array of String parameters, in this case:
* {@code <name>,[categorires]}
* @return
* @see add(String, String)
*/
private String commandAdd(String[] parameters) {
if (parameters.length == 1) {
String name = parameters[0].trim();
if (add(name, "") == null) {
return "Added '" + name + "'.";
}
return "Didn't add '" + name + "' (already present).";
} else if (parameters.length == 2) {
String name = parameters[0].trim();
String categoriesString = parameters[1].trim();
Set<String> categories = getCategoriesFromString(categoriesString);
AddressbookEntry previous = get(name);
clearSomewhatUniqueCategories(categories);
AddressbookEntry result = add(name, categories);
if (result == null) {
return "Added '" + name + "' with categories "
+ "'"+categoriesString+"'.";
}
Set<String> resultCategories = result.getCategories();
if (previous.getCategories().equals(resultCategories)) {
return "Didn't change '"+name+"', categories already "
+categoriesToString(resultCategories)+".";
}
return "Changed '" + name + "', categories now "
+ categoriesToString(result.getCategories())+".";
} else {
return "Add: Invalid number of parameters.";
}
}
/**
* Changes an entries categories (creating it if necessary).
*
* @param parameter All parameters as a single String
* @return The response of the command
* @see changeCategories(Set, String)
* @see set(String, Set)
*/
private String commandChange(String parameter) {
String[] parameters = parameter.split(" ", 2);
if (parameters.length == 2) {
String name = parameters[0].trim();
AddressbookEntry current = get(name);
Set<String> categories;
if (current == null) {
categories = new HashSet<>();
} else {
categories = current.getCategories();
}
Set<String> changedCats = changeCategories(categories, parameters[1]);
set(name, changedCats);
String catOutput = categoriesToString(changedCats);
if (current == null) {
return "Added '"+name+"' with categories "+catOutput+".";
} else if (categories.equals(changedCats)) {
return "Didn't change '"+name+"', categories already "+catOutput+".";
}
return "Changed '"+name+"', categories now "
+catOutput+".";
} else {
return "Change: Invalid number of parameters.";
}
}
/**
* Set an entry to the given categories, creating it if necessary.
*
* @param parameters
* @return
* @see set(String, Set)
*/
private String commandSet(String[] parameters) {
if (parameters.length == 2) {
String name = parameters[0].trim();
String categoriesString = parameters[1].trim();
Set<String> categories = getCategoriesFromString(categoriesString);
AddressbookEntry previousEntry = get(name);
clearSomewhatUniqueCategories(categories);
set(name, categories);
String categoriesOutput = categoriesToString(categories);
if (previousEntry == null) {
return "Added '"+name+"' with categories "+categoriesOutput+".";
} else if (previousEntry.getCategories().equals(categories)) {
return "Didn't change '"+name+"', categories already "+categoriesOutput+".";
}
return "Set '"+name+"' to categories "+categoriesOutput+".";
}
return "Set: Invalid number of parameters.";
}
private void clearSomewhatUniqueCategories(Set<String> catsToCheck) {
for (String cat : catsToCheck) {
synchronized(somewhatUniqueCategories) {
if (somewhatUniqueCategories.contains(cat)) {
removeCategory(cat);
}
}
}
}
/**
* Removes the given name or categories from the given name if categories
* are given.
*
* @param parameters
* @return
* @see remove(String)
* @see remove(String, Set)
*/
private String commandRemove(String[] parameters) {
if (parameters.length == 1) {
String name = parameters[0].trim();
if (remove(name) == null) {
return "Didn't remove '" + name + "' (entry not present).";
}
return "Removed '" + name + "'.";
} else if (parameters.length == 2) {
String name = parameters[0].trim();
Set<String> categories
= getCategoriesFromString(parameters[1].trim());
AddressbookEntry currentEntry = get(name);
AddressbookEntry result = remove(name, categories);
if (result == null) {
return "Didn't remove anything from '" + name + "' (entry not present).";
}
if (result.equalsFully(currentEntry)) {
return "Didn't remove anything from '" + name + "', categories are "+categoriesToString(currentEntry.getCategories());
}
return "Removed categories " + categoriesToString(categories)
+ " from '" + name + "' (categories now "
+ categoriesToString(result.getCategories()) + ").";
} else {
return "Remove: Invalid number of parameters.";
}
}
/**
* Renames any occurences of categories of one name to the other.
*
* @param parameters
* @return
* @see renameCategory(String, String)
*/
private String commandRenameCategory(String[] parameters) {
if (parameters.length != 2) {
return "Rename category: Invalid number of parameters.";
}
String oldCategoryName = parameters[0];
String newCategoryName = parameters[1];
int result = renameCategory(oldCategoryName, newCategoryName);
return "Renamed category '"+oldCategoryName+"'->'"+newCategoryName+"' "
+ "in "+result+" entries.";
}
/**
* Removes the given categories from all entries.
*
* @param parameters An array of String parameters, in this case only the
* name of the category to remove
* @return The response containing the removed category and the number of
* entries affected or an error message.
* @see removeCategory(String)
*/
private String commandRemoveCategory(String[] parameters) {
if (parameters.length != 1) {
return "Remove category: Invalid number of parameters.";
}
int result = removeCategory(parameters[0]);
return "Removed category '"+parameters[0]+"' from "+result+" entries.";
}
/**
* Shows general info about the addressbook.
*
* @return The response containing the number of entries.
* @see getNumEntries()
*/
private String commandInfo() {
return "Number of entries: "+getNumEntries()+" / Used categories: "+getCategories();
}
/**
* Enables auto import, which means it starts looking for file changes in
* the Addressbook import file.
*/
public void enableAutoImport() {
FileWatcher.createFileWatcher(Paths.get(importFileName), new FileWatcher.FileChangedListener() {
@Override
public void fileChanged() {
LOGGER.info("[AddressbookImport] Detected file change: Auto import..");
importFromFile();
}
});
}
/**
* Reads the commands from the import file and performs them.
*/
public synchronized void importFromFile() {
Path file = Paths.get(importFileName);
try (BufferedReader reader = Files.newBufferedReader(file, CHARSET)) {
LOGGER.info("[AddressbookImport] "+file.toAbsolutePath());
String line;
while((line = reader.readLine()) != null) {
String result = command(line);
LOGGER.info(String.format("[AddressbookImport] %s [%s]", result, line));
}
} catch (IOException ex) {
LOGGER.warning("Failed importing addressbook from file: "+ex);
}
}
/**
* Adds a new entry with the given name and categories or adds the
* categories if an entry already exists.
*
* @param name The name, should not be null or empty.
* @param categories The categories as a comma-seperated String, can not be
* null.
* @return The changed entry or <tt>null</tt> if no entry for this name
* existed before.
*/
public AddressbookEntry add(String name, String categories) {
return add(name, getCategoriesFromString(categories));
}
/**
* Adds a new name with the given categories or adds the categories if the
* name already exists.
*
* @param name The name, shouldn't be empty or null.
* @param categories The categories, can be empty, but not null.
* @return The changed entry if the name already existed, or <tt>null</tt>
* if it didn't.
*/
public synchronized AddressbookEntry add(String name, Set<String> categories) {
name = StringUtil.toLowerCase(name);
addPresetCategories(categories);
if (!entries.containsKey(name)) {
set(name, categories);
return null;
} else {
AddressbookEntry currentEntry = entries.get(name);
AddressbookEntry changedEntry = new AddressbookEntry(currentEntry, categories);
entries.put(name, changedEntry);
if (!changedEntry.equalsFully(currentEntry)) {
saveOnChange();
}
return changedEntry;
}
}
/**
* Sets the categories for this name as given, replacing any already
* existing entry for this name.
*
* @param name The name, can't be empty or null
* @param categories The categories, can be empty, but not null
*/
public synchronized void set(String name, Set<String> categories) {
AddressbookEntry entry = new AddressbookEntry(name, categories);
set(entry);
}
/**
* Sets this entry, replacing any already existing entry.
*
* @param entry The entry, can't be null.
*/
public synchronized void set(AddressbookEntry entry) {
addPresetCategories(entry.getCategories());
AddressbookEntry previousEntry = entries.put(entry.getName(), entry);
if (!entry.equalsFully(previousEntry)) {
saveOnChange();
}
}
/**
* Removes the entry with the name in the given entry.
*
* @param entry
*/
public synchronized void remove(AddressbookEntry entry) {
remove(entry.getName());
}
/**
* Removes the entry for the given name.
*
* @param name The name, should not be empty or null
* @return The entry that was removed.
*/
public synchronized AddressbookEntry remove(String name) {
AddressbookEntry removedEntry = entries.remove(StringUtil.toLowerCase(name));
if (removedEntry != null) {
saveOnChange();
}
return removedEntry;
}
public AddressbookEntry remove(String name, String categories) {
return remove(name, getCategoriesFromString(categories));
}
/**
* Removes the given categories from the entry with the given name.
*
* @param name The name of the entry (can't be null or empty).
* @param categoriesToRemove The categories to remove (can't be null).
* @return The changed entry or null if no entry was found for that name.
*/
public synchronized AddressbookEntry remove(String name, Set<String> categoriesToRemove) {
name = StringUtil.toLowerCase(name);
AddressbookEntry currentEntry = entries.get(name);
if (currentEntry != null) {
Set<String> currentCategories = currentEntry.getCategories();
for (String category : categoriesToRemove) {
currentCategories.remove(category);
}
AddressbookEntry changedEntry = new AddressbookEntry(name, currentCategories);
entries.put(name, changedEntry);
if (!currentEntry.equalsFully(changedEntry)) {
saveOnChange();
}
return changedEntry;
}
return null;
}
/**
* Removes the entry with the given name and sets the new entry, possibly
* replacing an entry with the same name.
*
* @param name
* @param entry
*/
public synchronized void rename(String name, AddressbookEntry entry) {
entries.remove(StringUtil.toLowerCase(name));
set(entry);
}
/**
* Renames the category with <tt>currentName</tt> to <tt>newName</tt> in
* all entries.
*
* @param currentName The name of the category to be renamed.
* @param newName The new name of the category.
* @return The number of entries affected (that contained the category).
*/
public synchronized int renameCategory(String currentName, String newName) {
int count = 0;
for (Map.Entry<String, AddressbookEntry> entry : entries.entrySet()) {
if (entry.getValue().hasCategory(currentName)) {
AddressbookEntry changedEntry
= renameCategory(entry.getValue(), currentName, newName);
entry.setValue(changedEntry);
count++;
}
}
if (count > 0) {
saveOnChange();
}
return count;
}
/**
* Removes the category with <tt>categoryName</tt> from all entries.
*
* @param categoryName The name of the category to be removed.
* @return The number of entries affected (that contained the category).
*/
public synchronized int removeCategory(String categoryName) {
int count = 0;
for (Map.Entry<String, AddressbookEntry> entry : entries.entrySet()) {
if (entry.getValue().hasCategory(categoryName)) {
AddressbookEntry changedEntry
= renameCategory(entry.getValue(), categoryName, null);
entry.setValue(changedEntry);
count++;
}
}
if (count > 0) {
saveOnChange();
}
return count;
}
/**
* Creates a new AddressbookEntry from <tt>entry</tt> that contains the
* renamed version of <tt>oldCategoryName</tt>, or doesn't contain the
* category at all if <tt>newCategoryName</tt> is null.
*
* @param entry The entry to change.
* @param oldCategoryName The name of the category to change.
* @param newCategoryName The new name of the category, shouldn't be empty,
* can be null to not rename but instead remove the category.
* @return The changed <tt>AddressbookEntry</tt> (or rather a new one).
*/
public static AddressbookEntry renameCategory(AddressbookEntry entry, String oldCategoryName, String newCategoryName) {
Set<String> categories = entry.getCategories();
categories.remove(oldCategoryName);
if (newCategoryName != null) {
categories.add(newCategoryName);
}
return new AddressbookEntry(entry.getName(), categories);
}
/**
* Gets the {@link AddressbookEntry} for the given name.
*
* @param name The name of the entry.
* @return The <tt>AddressbookEntry</tt> or {@code null} if no entry for
* this name exists.
*/
public synchronized AddressbookEntry get(String name) {
return entries.get(StringUtil.toLowerCase(name));
}
/**
* Returns all entries.
*
* @return A list of all entries.
*/
public synchronized List<AddressbookEntry> getEntries() {
return new ArrayList<>(entries.values());
}
/**
* Turns a String of comma-seperated categories into a Set of categories.
*
* @param categoriesString The categories.
* @return The Set of categories.
*/
public static Set<String> getCategoriesFromString(String categoriesString) {
Set<String> categories = new HashSet<>();
String[] split = categoriesString.split(",");
for (String category : split) {
category = StringUtil.toLowerCase(category.trim());
if (!category.isEmpty() && !category.contains(" ")) {
categories.add(category);
}
}
return categories;
}
/**
* Returns a version of {@code present} that is modified by the changes
* specified in {@code change}. Changes are sets categories that have to be
* prepended by + to add them, - to remove them and ! to toggle them (add if
* not present and remove if present). Categories are seperated by comma,
* sets of categories are seperated by space.
*
* <p>Does not modifiy the {@code present} parameter, but performs a copy
* instead, which is then modified.</p>
*
* @param present The categories to modify
* @param change The String to change the categories
* @return The changed categories
*/
public static Set<String> changeCategories(Set<String> present, String change) {
Set<String> result = new HashSet<>(present);
Pattern p = Pattern.compile("(\\+|-|!)(\\S+)");
Matcher m = p.matcher(change);
while (m.find()) {
String action = m.group(1);
Set<String> categories = getCategoriesFromString(m.group(2));
if (action.equals("+")) {
result.addAll(categories);
} else if (action.equals("-")) {
result.removeAll(categories);
} else if (action.equals("!")) {
for (String cat : categories) {
if (present.contains(cat)) {
result.remove(cat);
} else {
result.add(cat);
}
}
}
}
return result;
}
/**
* Turns a Set of categories into a comma-seperated String of categories.
*
* @param categories The Set of categories.
* @return The String of categories.
*/
public static String getStringFromCategories(Collection<String> categories) {
return StringUtil.join(categories, ",");
}
public static String categoriesToString(Collection<String> categories) {
return "'"+getStringFromCategories(categories)+"'";
}
/**
* Loads the addressbook from file.
*/
public synchronized void loadFromFile() {
entries.clear();
// DEBUG stuff
// for (int i=0;i<10000;i++) {
// AddressbookEntry e = new AddressbookEntry("name"+i, new HashSet<String>());
// entries.put("name"+i, e);
// }
Path file = Paths.get(fileName);
try (BufferedReader reader = Files.newBufferedReader(file, CHARSET)) {
String line;
do {
line = reader.readLine();
AddressbookEntry parsedEntry = parseLine(line);
if (parsedEntry != null) {
entries.put(parsedEntry.getName(), parsedEntry);
}
} while (line != null);
} catch (IOException ex) {
LOGGER.warning("Error reading addressbook: "+ex);
// This may not make too much sense because it also shows when no
// addressbook was saved yet
//LOGGER.log(Logging.USERINFO, "Error reading addressbook.");
}
LOGGER.info("Read "+entries.size()+" addressbook entries from "+fileName);
scanCategories();
}
/**
* Parses a single line from the addressbook file and turns it into an
* <tt>AddresssbookEntry</tt>-object.
*
* @param line The line of text in the format "<tt>name cat1,cat2</tt>".
* @return The <tt>AddressbookEntry</tt> object or <tt>null</tt> if it
* wasn't a valid line.
*/
private AddressbookEntry parseLine(String line) {
if (line == null || line.isEmpty()) {
return null;
}
String[] split = line.trim().split(" ");
if (split.length == 1) {
String name = split[0];
return new AddressbookEntry(name, new HashSet<String>());
}
if (split.length == 2) {
String name = split[0];
Set<String> categories = getCategoriesFromString(split[1]);
return new AddressbookEntry(name, categories);
}
return null;
}
public synchronized void saveToFileOnce() {
if (!saved) {
saveToFile();
}
}
private void saveOnChange() {
if (settings.getBoolean("abSaveOnChange")) {
saveToFile();
}
}
/**
* Saves all entries to file.
*/
public synchronized void saveToFile() {
Path file = Paths.get(fileName);
Path tempFile = Paths.get(fileName+"-temp");
LOGGER.info("Writing addressbook to "+fileName);
System.out.println("Saving addressbook.");
try (BufferedWriter writer = Files.newBufferedWriter(tempFile, CHARSET)) {
for (AddressbookEntry entry : entries.values()) {
writer.write(makeLine(entry));
writer.newLine();
}
MiscUtil.moveFile(tempFile, file);
saved = true;
} catch (IOException ex) {
LOGGER.warning("Error writing addressbook: "+ex.getLocalizedMessage());
}
}
/**
* Creates a line to write to the file from a single <tt>AddressbookEntry</tt>.
*
* @param entry The <tt>AddressbookEntry</tt>.
* @return A line in the form "<tt>name cat1,cat2</tt>".
*/
private String makeLine(AddressbookEntry entry) {
return entry.getName()+" "+getStringFromCategories(entry.getCategories());
}
/**
* Goes through all entries and adds all found categories to a Set.
*/
private void scanCategories() {
presetCategories.clear();
for (AddressbookEntry entry : entries.values()) {
addPresetCategories(entry.getCategories());
}
}
/**
* Adds the given categories to the Set of categories.
*
* @param categories The categories to add (if not already present)
*/
private void addPresetCategories(Collection<String> categories) {
presetCategories.addAll(categories);
}
/**
* Returns a List of all categories that were found in the entries.
*
* @return The List of categories, whereas each category only appears once.
*/
public synchronized List<String> getCategories() {
return new ArrayList<>(presetCategories);
}
public synchronized int getNumEntries() {
return entries.size();
}
}