diff --git a/src/com/fsck/k9/search/ConditionsTreeNode.java b/src/com/fsck/k9/search/ConditionsTreeNode.java new file mode 100644 index 000000000..fbb31a7b9 --- /dev/null +++ b/src/com/fsck/k9/search/ConditionsTreeNode.java @@ -0,0 +1,365 @@ +package com.fsck.k9.search; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Stack; + +import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; + +import com.fsck.k9.search.SearchSpecification.ATTRIBUTE; +import com.fsck.k9.search.SearchSpecification.SEARCHFIELD; +import com.fsck.k9.search.SearchSpecification.SearchCondition; + +/** + * This class stores search conditions. It's basically a boolean expression binary tree. + * The output will be SQL queries ( obtained by traversing inorder ). + * + * TODO removing conditions from the tree + * TODO implement NOT as a node again + * + * @author dzan + */ +public class ConditionsTreeNode implements Parcelable{ + + public enum OPERATOR { + AND, OR, CONDITION; + } + + public ConditionsTreeNode mLeft; + public ConditionsTreeNode mRight; + public ConditionsTreeNode mParent; + + /* + * If mValue isn't CONDITION then mCondition contains a real + * condition, otherwise it's null. + */ + public OPERATOR mValue; + public SearchCondition mCondition; + + /* + * Used for storing and retrieving the tree to/from the database. + * The algorithm is called "modified preorder tree traversal". + */ + public int mLeftMPTTMarker; + public int mRightMPTTMarker; + + + /////////////////////////////////////////////////////////////// + // Static Helpers to restore a tree from a database cursor + /////////////////////////////////////////////////////////////// + /** + * Builds a condition tree starting from a database cursor. The cursor + * should point to rows representing the nodes of the tree. + * + * @param cursor Cursor pointing to the first of a bunch or rows. Each rows + * should contains 1 tree node. + * @return A condition tree. + */ + public static ConditionsTreeNode buildTreeFromDB(Cursor cursor) { + Stack stack = new Stack(); + ConditionsTreeNode tmp = null; + + // root node + if (cursor.moveToFirst()) { + tmp = buildNodeFromRow(cursor); + stack.push(tmp); + } + + // other nodes + while (cursor.moveToNext()) { + tmp = buildNodeFromRow(cursor); + if (tmp.mRightMPTTMarker < stack.peek().mRightMPTTMarker ){ + stack.peek().mLeft = tmp; + stack.push(tmp); + } else { + while (stack.peek().mRightMPTTMarker < tmp.mRightMPTTMarker) { + stack.pop(); + } + stack.peek().mRight = tmp; + } + } + return tmp; + } + + /** + * Converts a single database row to a single condition node. + * + * @param cursor Cursor pointing to the row we want to convert. + * @return A single ConditionsTreeNode + */ + private static ConditionsTreeNode buildNodeFromRow(Cursor cursor) { + ConditionsTreeNode result = null; + SearchCondition condition = null; + + OPERATOR tmpValue = ConditionsTreeNode.OPERATOR.valueOf(cursor.getString(5)); + + if (tmpValue == OPERATOR.CONDITION) { + condition = new SearchCondition(SEARCHFIELD.valueOf(cursor.getString(0)), + ATTRIBUTE.valueOf(cursor.getString(2)), cursor.getString(1)); + } + + result = new ConditionsTreeNode(condition); + result.mValue = tmpValue; + result.mLeftMPTTMarker = cursor.getInt(3); + result.mRightMPTTMarker = cursor.getInt(4); + + return result; + } + + + /////////////////////////////////////////////////////////////// + // Constructors + /////////////////////////////////////////////////////////////// + public ConditionsTreeNode(SearchCondition condition) { + mParent = null; + mCondition = condition; + mValue = OPERATOR.CONDITION; + } + + public ConditionsTreeNode(ConditionsTreeNode parent, OPERATOR op) { + mParent = parent; + mValue = op; + mCondition = null; + } + + + /////////////////////////////////////////////////////////////// + // Public modifiers + /////////////////////////////////////////////////////////////// + /** + * Adds the expression as the second argument of an AND + * clause to this node. + * + * @param expr Expression to 'AND' with. + * @return New top AND node. + * @throws Exception + */ + public ConditionsTreeNode and(ConditionsTreeNode expr) throws Exception { + return add(expr, OPERATOR.AND); + } + + /** + * Adds the expression as the second argument of an OR + * clause to this node. + * + * @param expr Expression to 'OR' with. + * @return New top OR node. + * @throws Exception + */ + public ConditionsTreeNode or(ConditionsTreeNode expr) throws Exception { + return add(expr, OPERATOR.OR); + } + + /** + * This applies the MPTT labeling to the subtree of which this node + * is the root node. + * + * For a description on MPTT see: + * http://www.sitepoint.com/hierarchical-data-database-2/ + */ + public void applyMPTTLabel() { + applyMPTTLabel(1); + } + + + /////////////////////////////////////////////////////////////// + // Public accessors + /////////////////////////////////////////////////////////////// + /** + * Returns the condition stored in this node. + * @return Condition stored in the node. + */ + public SearchCondition getCondition() { + return mCondition; + } + + + /** + * This will traverse the tree inorder and call toString recursively resulting + * in a valid SQL where clause. + */ + @Override + public String toString() { + return (mLeft == null ? "" : "(" + mLeft + ")") + + " " + ( mCondition == null ? mValue.name() : mCondition ) + " " + + (mRight == null ? "" : "(" + mRight + ")") ; + } + + /** + * Get a set of all the leaves in the tree. + * @return Set of all the leaves. + */ + public HashSet getLeafSet() { + HashSet leafSet = new HashSet(); + return getLeafSet(leafSet); + } + + /** + * Returns a list of all the nodes in the subtree of which this node + * is the root. The list contains the nodes in a pre traversal order. + * + * @return List of all nodes in subtree in preorder. + */ + public List preorder() { + ArrayList result = new ArrayList(); + Stack stack = new Stack(); + stack.push(this); + + while(!stack.isEmpty()) { + ConditionsTreeNode current = stack.pop( ); + if( current.mLeft != null ) stack.push( current.mLeft ); + if( current.mRight != null ) stack.push( current.mRight ); + result.add(current); + } + + return result; + } + + + /////////////////////////////////////////////////////////////// + // Private class logic + /////////////////////////////////////////////////////////////// + /** + * Adds two new ConditionTreeNodes, one for the operator and one for the + * new condition. The current node will end up on the same level as the + * one provided in the arguments, they will be siblings. Their common + * parent node will be one containing the operator provided in the arguments. + * The method will update all the required references so the tree ends up in + * a valid state. + * + * This method only supports node arguments with a null parent node. + * + * @param Node to add. + * @param Operator that will connect the new node with this one. + * @return New parent node, containing the operator. + * @throws Exception Throws when the provided new node does not have a null parent. + */ + private ConditionsTreeNode add(ConditionsTreeNode node, OPERATOR op) throws Exception{ + if (node.mParent != null) { + throw new Exception("Can only add new expressions from root node down."); + } + + ConditionsTreeNode tmpNode = new ConditionsTreeNode(mParent, op); + tmpNode.mLeft = this; + tmpNode.mRight = node; + + if (mParent != null) { + mParent.updateChild(this, tmpNode); + } + this.mParent = tmpNode; + + node.mParent = tmpNode; + + return tmpNode; + } + + /** + * Helper method that replaces a child of the current node with a new node. + * If the provided old child node was the left one, left will be replaced with + * the new one. Same goes for the right one. + * + * @param oldChild Old child node to be replaced. + * @param newChild New child node. + */ + private void updateChild(ConditionsTreeNode oldChild, ConditionsTreeNode newChild) { + // we can compare objects id's because this is the desired behaviour in this case + if (mLeft == oldChild) { + mLeft = newChild; + } else if (mRight == oldChild) { + mRight = newChild; + } + } + + /** + * Recursive function to gather all the leaves in the subtree of which + * this node is the root. + * + * @param leafSet Leafset that's being built. + * @return Set of leaves being completed. + */ + private HashSet getLeafSet(HashSet leafSet) { + // if we ended up in a leaf, add ourself and return + if (mLeft == null && mRight == null) { + leafSet.add(this); + return leafSet; + // we didn't end up in a leaf + } else { + if (mLeft != null) { + mLeft.getLeafSet(leafSet); + } + + if (mRight != null) { + mRight.getLeafSet(leafSet); + } + return leafSet; + } + } + + /** + * This applies the MPTT labeling to the subtree of which this node + * is the root node. + * + * For a description on MPTT see: + * http://www.sitepoint.com/hierarchical-data-database-2/ + */ + private int applyMPTTLabel(int label) { + mLeftMPTTMarker = label; + if (mLeft != null){ + label = mLeft.applyMPTTLabel(label += 1); + } + if (mRight != null){ + label = mRight.applyMPTTLabel(label += 1); + } + ++label; + mRightMPTTMarker = label; + return label; + } + + + /////////////////////////////////////////////////////////////// + // Parcelable + // + // This whole class has to be parcelable because it's passed + // on through intents. + /////////////////////////////////////////////////////////////// + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mValue.ordinal()); + dest.writeParcelable(mCondition, flags); + dest.writeParcelable(mLeft, flags); + dest.writeParcelable(mRight, flags); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public ConditionsTreeNode createFromParcel(Parcel in) { + return new ConditionsTreeNode(in); + } + + public ConditionsTreeNode[] newArray(int size) { + return new ConditionsTreeNode[size]; + } + }; + + private ConditionsTreeNode(Parcel in) { + mValue = OPERATOR.values()[in.readInt()]; + mCondition = in.readParcelable(ConditionsTreeNode.class.getClassLoader()); + mLeft = in.readParcelable(ConditionsTreeNode.class.getClassLoader()); + mRight = in.readParcelable(ConditionsTreeNode.class.getClassLoader()); + mParent = null; + if (mLeft != null) { + mLeft.mParent = this; + } + if (mRight != null) { + mRight.mParent = this; + } + } +} diff --git a/src/com/fsck/k9/search/LocalSearch.java b/src/com/fsck/k9/search/LocalSearch.java new file mode 100644 index 000000000..48ca3ca7c --- /dev/null +++ b/src/com/fsck/k9/search/LocalSearch.java @@ -0,0 +1,374 @@ +package com.fsck.k9.search; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.fsck.k9.mail.Flag; + +/** + * This class represents a local search. + + * Removing conditions could be done through matching there unique id in the leafset and then + * removing them from the tree. + * + * @author dzan + * + * TODO implement a complete addAllowedFolder method + * TODO conflicting conditions check on add + * TODO duplicate condition checking? + * TODO assign each node a unique id that's used to retrieve it from the leaveset and remove. + * + */ + +public class LocalSearch implements SearchSpecification { + + private String mName; + private boolean mPredefined; + + // since the uuid isn't in the message table it's not in the tree neither + private HashSet mAccountUuids = new HashSet(); + private ConditionsTreeNode mConditions = null; + private HashSet mLeafSet = new HashSet(); + + + /////////////////////////////////////////////////////////////// + // Constructors + /////////////////////////////////////////////////////////////// + /** + * Use this only if the search won't be saved. Saved searches need + * a name! + */ + public LocalSearch(){} + + /** + * + * @param name + */ + public LocalSearch(String name) { + this.mName = name; + } + + /** + * Use this constructor when you know what you'r doing. Normally it's only used + * when restoring these search objects from the database. + * + * @param name Name of the search + * @param searchConditions SearchConditions, may contains flags and folders + * @param accounts Relative Account's uuid's + * @param predefined Is this a predefined search or a user created one? + */ + protected LocalSearch(String name, ConditionsTreeNode searchConditions, + String accounts, boolean predefined) { + this(name); + mConditions = searchConditions; + mPredefined = predefined; + mLeafSet = new HashSet(); + if (mConditions != null) { + mLeafSet.addAll(mConditions.getLeafSet()); + } + + // initialize accounts + if (accounts != null) { + for (String account : accounts.split(",")) { + mAccountUuids.add(account); + } + } else { + // impossible but still not unrecoverable + } + } + + + /////////////////////////////////////////////////////////////// + // Public manipulation methods + /////////////////////////////////////////////////////////////// + /** + * Sets the name of the saved search. If one existed it will + * be overwritten. + * + * @param name Name to be set. + */ + public void setName(String name) { + this.mName = name; + } + + /** + * Add a new account to the search. When no accounts are + * added manually we search all accounts on the device. + * + * @param uuid Uuid of the account to be added. + */ + public void addAccountUuid(String uuid) { + if (uuid.equals(ALL_ACCOUNTS)) { + mAccountUuids.clear(); + } + mAccountUuids.add(uuid); + } + + /** + * Adds all the account uuids in the provided array to + * be matched by the seach. + * + * @param accountUuids + */ + public void addAccountUuids(String[] accountUuids) { + for (String acc : accountUuids) { + addAccountUuid(acc); + } + } + + /** + * Removes an account UUID from the current search. + * + * @param uuid Account UUID to remove. + * @return True if removed, false otherwise. + */ + public boolean removeAccountUuid(String uuid) { + return mAccountUuids.remove(uuid); + } + + /** + * Adds the provided node as the second argument of an AND + * clause to this node. + * + * @param field Message table field to match against. + * @param string Value to look for. + * @param contains Attribute to use when matching. + * + * @throws IllegalConditionException + */ + public void and(SEARCHFIELD field, String value, ATTRIBUTE attribute) { + and(new SearchCondition(field, attribute, value)); + } + + /** + * Adds the provided condition as the second argument of an AND + * clause to this node. + * + * @param condition Condition to 'AND' with. + * @return New top AND node, new root. + */ + public ConditionsTreeNode and(SearchCondition condition) { + ConditionsTreeNode tmp = new ConditionsTreeNode(condition); + return and(tmp); + } + + /** + * Adds the provided node as the second argument of an AND + * clause to this node. + * + * @param node Node to 'AND' with. + * @return New top AND node, new root. + */ + public ConditionsTreeNode and(ConditionsTreeNode node) { + try { + mLeafSet.add(node); + + if (mConditions == null) { + mConditions = node; + return node; + } + + mConditions = mConditions.and(node); + return mConditions; + } catch (Exception e) { + // IMPOSSIBLE! + return null; + } + } + + /** + * Adds the provided condition as the second argument of an OR + * clause to this node. + * + * @param condition Condition to 'OR' with. + * @return New top OR node, new root. + */ + public ConditionsTreeNode or(SearchCondition condition) { + ConditionsTreeNode tmp = new ConditionsTreeNode(condition); + return or(tmp); + } + + /** + * Adds the provided node as the second argument of an OR + * clause to this node. + * + * @param node Node to 'OR' with. + * @return New top OR node, new root. + */ + public ConditionsTreeNode or(ConditionsTreeNode node) { + try { + mLeafSet.add(node); + + if (mConditions == null) { + mConditions = node; + return node; + } + + mConditions = mConditions.or(node); + return mConditions; + } catch (Exception e) { + // IMPOSSIBLE! + return null; + } + } + + /** + * Add all the flags to this node as required flags. The + * provided flags will be combined using AND with the root. + * + * @param requiredFlags Array of required flags. + */ + public void allRequiredFlags(Flag[] requiredFlags) { + if (requiredFlags != null) { + for (Flag f : requiredFlags) { + and(new SearchCondition(SEARCHFIELD.FLAG, ATTRIBUTE.CONTAINS, f.name())); + } + } + } + + /** + * Add all the flags to this node as forbidden flags. The + * provided flags will be combined using AND with the root. + * + * @param forbiddenFlags Array of forbidden flags. + */ + public void allForbiddenFlags(Flag[] forbiddenFlags) { + if (forbiddenFlags != null) { + for (Flag f : forbiddenFlags) { + and(new SearchCondition(SEARCHFIELD.FLAG, ATTRIBUTE.NOT_CONTAINS, f.name())); + } + } + } + + /** + * TODO + * FOR NOW: And the folder with the root. + * + * Add the folder as another folder to search in. The folder + * will be added AND to the root if no 'folder subtree' was found. + * Otherwise the folder will be added OR to that tree. + * + * @param name Name of the folder to add. + */ + public void addAllowedFolder(String name) { + /* + * TODO find folder sub-tree + * - do and on root of it & rest of search + * - do or between folder nodes + */ + and(new SearchCondition(SEARCHFIELD.FOLDER, ATTRIBUTE.EQUALS, name)); + } + + /* + * TODO make this more advanced! + * This is a temporarely solution that does NOT WORK for + * real searches because of possible extra conditions to a folder requirement. + */ + public List getFolderNames() { + ArrayList results = new ArrayList(); + for (ConditionsTreeNode node : mLeafSet) { + if (node.mCondition.field == SEARCHFIELD.FOLDER + && node.mCondition.attribute == ATTRIBUTE.EQUALS) { + results.add(node.mCondition.value); + } + } + return results; + } + + /** + * Gets the leafset of the related condition tree. + * + * @return All the leaf conditions as a set. + */ + public Set getLeafSet() { + return mLeafSet; + } + + /////////////////////////////////////////////////////////////// + // Public accesor methods + /////////////////////////////////////////////////////////////// + /** + * Returns the name of the saved search. + * + * @return Name of the search. + */ + public String getName() { + return mName; + } + + /** + * Checks if this search was hard coded and shipped with K-9 + * + * @return True is search was shipped with K-9 + */ + public boolean isPredefined() { + return mPredefined; + } + + /** + * Returns all the account uuids that this search will try to + * match against. + * + * @return Array of account uuids. + */ + @Override + public String[] getAccountUuids() { + if (mAccountUuids.size() == 0) { + return new String[] {SearchSpecification.ALL_ACCOUNTS}; + } + + String[] tmp = new String[mAccountUuids.size()]; + mAccountUuids.toArray(tmp); + return tmp; + } + + /** + * Get the condition tree. + * + * @return The root node of the related conditions tree. + */ + @Override + public ConditionsTreeNode getConditions() { + return mConditions; + } + + /////////////////////////////////////////////////////////////// + // Parcelable + /////////////////////////////////////////////////////////////// + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mName); + dest.writeByte((byte) (mPredefined ? 1 : 0)); + dest.writeStringList(new ArrayList(mAccountUuids)); + dest.writeParcelable(mConditions, flags); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public LocalSearch createFromParcel(Parcel in) { + return new LocalSearch(in); + } + + public LocalSearch[] newArray(int size) { + return new LocalSearch[size]; + } + }; + + public LocalSearch(Parcel in) { + mName = in.readString(); + mPredefined = in.readByte() == 1; + mAccountUuids.addAll(in.createStringArrayList()); + mConditions = in.readParcelable(LocalSearch.class.getClassLoader()); + mLeafSet = mConditions.getLeafSet(); + } +} \ No newline at end of file diff --git a/src/com/fsck/k9/search/SearchSpecification.java b/src/com/fsck/k9/search/SearchSpecification.java index e08032ff5..3c422c64e 100644 --- a/src/com/fsck/k9/search/SearchSpecification.java +++ b/src/com/fsck/k9/search/SearchSpecification.java @@ -1,19 +1,184 @@ - package com.fsck.k9.search; -import com.fsck.k9.mail.Flag; - -public interface SearchSpecification { - - public Flag[] getRequiredFlags(); - - public Flag[] getForbiddenFlags(); - - public boolean isIntegrate(); - - public String getQuery(); +import android.os.Parcel; +import android.os.Parcelable; +public interface SearchSpecification extends Parcelable { + + /** + * Get all the uuids of accounts this search acts on. + * @return Array of uuids. + */ public String[] getAccountUuids(); + + /** + * Returns the search's name if it was named. + * @return Name of the search. + */ + public String getName(); + + /** + * Returns the root node of the condition tree accompanying + * the search. + * + * @return Root node of conditions tree. + */ + public ConditionsTreeNode getConditions(); + + /* + * Some meta names for certain conditions. + */ + public static final String ALL_ACCOUNTS = "allAccounts"; + public static final String GENERIC_INBOX_NAME = "genericInboxName"; + + /////////////////////////////////////////////////////////////// + // ATTRIBUTE enum + /////////////////////////////////////////////////////////////// + public enum ATTRIBUTE { + CONTAINS(false), EQUALS(false), STARTSWITH(false), ENDSWITH(false), + NOT_CONTAINS(true), NOT_EQUALS(true), NOT_STARTSWITH(true), NOT_ENDSWITH(true); + + private boolean mNegation; + + private ATTRIBUTE(boolean negation) { + this.mNegation = negation; + } + + public String formQuery(String value) { + String queryPart = ""; + + switch (this) { + case NOT_CONTAINS: + case CONTAINS: + queryPart = "'%"+value+"%'"; + break; + case NOT_EQUALS: + case EQUALS: + queryPart = "'"+value+"'"; + break; + case NOT_STARTSWITH: + case STARTSWITH: + queryPart = "'%"+value+"'"; + break; + case NOT_ENDSWITH: + case ENDSWITH: + queryPart = "'"+value+"%'"; + break; + default: queryPart = "'"+value+"'"; + } + + return (mNegation ? " NOT LIKE " : " LIKE ") + queryPart; + } + }; + + /////////////////////////////////////////////////////////////// + // SEARCHFIELD enum + /////////////////////////////////////////////////////////////// + /* + * Using an enum in order to have more robust code. Users ( & coders ) + * are prevented from passing illegal fields. No database overhead + * when invalid fields passed. + * + * By result, only the fields in here are searchable. + * + * Fields not in here at this moment ( and by effect not searchable ): + * id, html_content, internal_date, message_id, + * preview, mime_type + * + */ + public enum SEARCHFIELD { + SUBJECT("subject"), DATE("date"), UID("uid"), FLAG("flags"), + SENDER("sender_list"), TO("to_list"), CC("cc_list"), FOLDER("folder_id"), + BCC("bcc_list"), REPLY_TO("reply_to_list"), MESSAGE("text_content"), + ATTACHMENT_COUNT("attachment_count"), DELETED("deleted"); - public String[] getFolderNames(); + private String dbName; + + private SEARCHFIELD(String dbName) { + this.dbName = dbName; + } + + public String getDatabaseName() { + return dbName; + } + } + + + /////////////////////////////////////////////////////////////// + // SearchCondition class + /////////////////////////////////////////////////////////////// + /** + * This class represents 1 value for a certain search field. One + * value consists of three things: + * an attribute: equals, starts with, contains,... + * a searchfield: date, flags, sender, subject,... + * a value: "apple", "jesse",.. + * + * @author dzan + */ + public class SearchCondition implements Parcelable{ + public String value; + public ATTRIBUTE attribute; + public SEARCHFIELD field; + + public SearchCondition(SEARCHFIELD field, ATTRIBUTE attribute, String value) { + this.value = value; + this.attribute = attribute; + this.field = field; + } + + private SearchCondition(Parcel in) { + this.value = in.readString(); + this.attribute = ATTRIBUTE.values()[in.readInt()]; + this.field = SEARCHFIELD.values()[in.readInt()]; + } + + public String toHumanString() { + return field.toString() + attribute.toString(); + } + + @Override + public String toString() { + return field.getDatabaseName() + attribute.formQuery(value); + } + + @Override + public boolean equals(Object o) { + if (o instanceof SearchCondition) { + SearchCondition tmp = (SearchCondition) o; + if (tmp.attribute == attribute + && tmp.value.equals(value) + && tmp.field == field) { + return true; + } else { + return false; + } + } else { + return false; + } + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(value); + dest.writeInt(attribute.ordinal()); + dest.writeInt(field.ordinal()); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + public SearchCondition createFromParcel(Parcel in) { + return new SearchCondition(in); + } + + public SearchCondition[] newArray(int size) { + return new SearchCondition[size]; + } + }; + } } \ No newline at end of file