VCard format: correctly co/decode escaped values
[jvcard.git] / src / be / nikiroo / jvcard / BaseClass.java
index cb07948aac144d6aee8406e0f87882c394e11738..026c5b43c0951a02ceb69e3bbda049be4303945c 100644 (file)
@@ -1,22 +1,33 @@
 package be.nikiroo.jvcard;
 
+import java.security.InvalidParameterException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.Iterator;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.ListIterator;
 
+import be.nikiroo.jvcard.resources.StringUtils;
+
 /**
  * This class is basically a List with a parent and a "dirty" state check. It
  * sends all commands down to the initial list, but will mark itself and its
  * children as dirty or not when needed.
  * 
- * All child elements can identify their parent.
+ * <p>
+ * All child elements can identify their parent, and must not be added to 2
+ * different objects without without first being removed from the previous one.
+ * </p>
  * 
+ * <p>
  * The dirty state is bubbling up (when dirty = true) or down (when dirty =
  * false) -- so, making changes to a child element will also mark its parent as
  * "dirty", and marking an element as pristine will also affect all its child
  * elements.
+ * </p>
  * 
  * @author niki
  *
@@ -28,6 +39,20 @@ public abstract class BaseClass<E extends BaseClass<?>> implements List<E> {
        protected BaseClass<?> parent;
        private List<E> list;
 
+       private Comparator<E> comparator = new Comparator<E>() {
+               @Override
+               public int compare(E o1, E o2) {
+                       if (o1 == null && o2 == null)
+                               return 0;
+                       if (o1 == null && o2 != null)
+                               return -1;
+                       if (o1 != null && o2 == null)
+                               return 1;
+
+                       return o1.getId().compareTo(o2.getId());
+               }
+       };
+
        /**
         * Create a new {@link BaseClass} with the items in the given list as its
         * descendants.
@@ -83,22 +108,295 @@ public abstract class BaseClass<E extends BaseClass<?>> implements List<E> {
         *            the list of new elements
         */
        public void replaceListContent(List<E> list) {
-               List<E> del = new ArrayList<E>();
-               List<E> add = new ArrayList<E>();
+               List<E> del = new LinkedList<E>();
+               List<E> add = new LinkedList<E>();
 
-               for (E oldChild : this) {
-                       if (!list.contains(oldChild)) {
-                               del.add(oldChild);
+               if (!compare(list, add, del, del, add)) {
+                       removeAll(del);
+                       addAll(add);
+               }
+       }
+
+       /**
+        * Compare the elements contained in <tt>this</tt> with those in the given
+        * {@link List}. It will return TRUE in case of equality, will return FALSE
+        * if not.
+        * 
+        * If not equals, the differences will be represented by the given
+        * {@link List}s if they are not NULL.
+        * <ul>
+        * <li><tt>added</tt> will represent the elements in <tt>list</tt> but not
+        * in <tt>this</tt></li>
+        * <li><tt>removed</tt> will represent the elements in <tt>this</tt> but not
+        * in <tt>list</tt></li>
+        * <li><tt>from<tt> will represent the elements in <tt>list</tt> that are
+        * already contained in <tt>this</tt> but are not equals to them (the
+        * original element from <tt>this</tt> is stored here)</li>
+        * <li><tt>to<tt> will represent the elements in <tt>list</tt> that are
+        * already contained in <tt>this</tt> but are not equals to them (the
+        * changed element from <tt>list</tt> is stored here)</li>
+        * </ul>
+        * 
+        * @param list
+        *            the list of new elements
+        * @param added
+        *            the list to add the <tt>added</tt> elements to, or NULL
+        * @param removed
+        *            the list to add the <tt>removed</tt> elements to, or NULL
+        * @param from
+        *            the map to add the <tt>from</tt> elements, or NULL
+        * @param to
+        *            the map to add the <tt>to</tt> elements, or NULL
+        * 
+        * @return TRUE if the elements are identical
+        */
+       @SuppressWarnings({ "unchecked", "rawtypes" })
+       public boolean compare(List<E> list, List<E> added, List<E> removed,
+                       List<E> from, List<E> to) {
+               Collections.sort(this.list, comparator);
+
+               List<E> mine = new LinkedList<E>(this.list);
+               List<E> other = new LinkedList<E>(list);
+
+               Collections.sort(other, comparator);
+
+               boolean equ = true;
+               E here = mine.size() > 0 ? mine.remove(0) : null;
+               E there = other.size() > 0 ? other.remove(0) : null;
+
+               while (here != null || there != null) {
+                       if (here == null
+                                       || (there != null && comparator.compare(here, there) > 0)) {
+                               if (added != null)
+                                       added.add(there);
+                               there = null;
+                               equ = false;
+                       } else if (there == null || comparator.compare(here, there) < 0) {
+                               if (removed != null)
+                                       removed.add(here);
+                               here = null;
+                               equ = false;
+                       } else {
+                               // they represent the same item
+                               if (!((BaseClass) here).isEquals(there, false)) {
+                                       if (from != null)
+                                               from.add(here);
+                                       if (to != null)
+                                               to.add(there);
+                                       equ = false;
+                               }
+                               here = null;
+                               there = null;
                        }
+
+                       if (here == null && mine.size() > 0)
+                               here = mine.remove(0);
+                       if (there == null && other.size() > 0)
+                               there = other.remove(0);
                }
-               for (E newChild : list) {
-                       if (!contains(newChild)) {
-                               add.add(newChild);
+
+               return equ;
+       }
+
+       /**
+        * Check if the given instance and this one represent the same objects (they
+        * may have different states).
+        * 
+        * @param other
+        *            the other instance
+        * 
+        * @return TRUE if they represent the same object
+        */
+       public boolean isSame(BaseClass<E> other) {
+               if (other == null)
+                       return false;
+
+               if (!getClass().equals(other.getClass()))
+                       return false;
+
+               return getId().equals(other.getId());
+       }
+
+       /**
+        * Check if the given instance and this one are equivalent (both objects in
+        * the same state, all child elements equivalent).
+        * 
+        * @param other
+        *            the other instance
+        * 
+        * @param contentOnly
+        *            do not check the state of the object itslef, only its content
+        * 
+        * @return TRUE if they are equivalent
+        */
+       @SuppressWarnings({ "unchecked", "rawtypes" })
+       public boolean isEquals(BaseClass<E> other, boolean contentOnly) {
+               if (other == null)
+                       return false;
+
+               if (size() != other.size())
+                       return false;
+
+               if (!contentOnly) {
+                       if (!isSame(other))
+                               return false;
+
+                       if (!getState().equals(other.getState()))
+                               return false;
+               }
+
+               Collections.sort(list, comparator);
+               Collections.sort(other.list, other.comparator);
+               for (int index = 0; index < size(); index++) {
+                       if (!((BaseClass) get(index)).isEquals(other.get(index), false))
+                               return false;
+               }
+
+               return true;
+       }
+
+       /**
+        * Get the recursive state of the current object, i.e., its children
+        * included. It represents the full state information about this object's
+        * children. It may not contains spaces nor new lines.
+        * 
+        * <p>
+        * Not that this state is <b>lossy</b>. You cannot retrieve the data from
+        * the state, it can only be used as an ID to check if data are identical.
+        * </p>
+        * 
+        * @param self
+        *            also include state information about the current object itself
+        *            (as opposed to its children)
+        * 
+        * @return a {@link String} representing the current content state of this
+        *         object, i.e., its children included
+        */
+       public String getContentState(boolean self) {
+               StringBuilder builder = new StringBuilder();
+               buildContentStateRaw(builder, self);
+               return StringUtils.getHash(builder.toString());
+       }
+
+       /**
+        * Return the (first) child element with the given ID or NULL if not found.
+        * 
+        * @param id
+        *            the id to look for
+        * 
+        * @return the child element or NULL
+        */
+       public E getById(String id) {
+               for (E child : this) {
+                       if (id == null) {
+                               if (child.getId() == null)
+                                       return child;
+                       } else {
+                               if (id.equals(child.getId()))
+                                       return child;
                        }
                }
 
-               removeAll(del);
-               addAll(add);
+               return null;
+       }
+
+       /**
+        * Return a {@link String} that can be used to identify this object in DEBUG
+        * mode, i.e., a "toString" method that can identify the object's content
+        * but still be readable in a log.
+        * 
+        * @param depth
+        *            the depth into which to descend (0 = only this object, not its
+        *            children)
+        * 
+        * @return the debug {@link String}
+        */
+       public String getDebugInfo(int depth) {
+               StringBuilder builder = new StringBuilder();
+               getDebugInfo(builder, depth, 0);
+               return builder.toString();
+       }
+
+       /**
+        * Return the current ID of this object -- it is allowed to change over time
+        * (so, do not cache it).
+        * 
+        * @return the current ID
+        */
+       abstract public String getId();
+
+       /**
+        * Get the state of the current object, children <b>not included</b>. It
+        * represents the full state information about this object, but do not check
+        * its children (see {@link BaseClass#getContentState()} for that). It may
+        * not contains spaces nor new lines.
+        * 
+        * <p>
+        * Not that this state is <b>lossy</b>. You cannot retrieve the data from
+        * the state, it can only be used as an ID to check if thw data are
+        * identical.
+        * </p>
+        * 
+        * @return a {@link String} representing the current state of this object,
+        *         children not included
+        */
+       abstract public String getState();
+
+       /**
+        * Get the recursive state of the current object, i.e., its children
+        * included. It represents the full state information about this object's
+        * children.
+        * 
+        * It is not hashed.
+        * 
+        * @param builder
+        *            the {@link StringBuilder} that will represent the current
+        *            content state of this object, i.e., its children included
+        * @param self
+        *            also include state information about the current object itself
+        *            (as opposed to its children)
+        */
+       void buildContentStateRaw(StringBuilder builder, boolean self) {
+               Collections.sort(this.list, comparator);
+               if (self)
+                       builder.append(getState());
+               for (E child : this) {
+                       child.buildContentStateRaw(builder, true);
+               }
+       }
+
+       /**
+        * Populate a {@link StringBuilder} that can be used to identify this object
+        * in DEBUG mode, i.e., a "toString" method that can identify the object's
+        * content but still be readable in a log.
+        * 
+        * @param depth
+        *            the depth into which to descend (0 = only this object, not its
+        *            children)
+        * 
+        * @param tab
+        *            the current tabulation increment
+        */
+       void getDebugInfo(StringBuilder builder, int depth, int tab) {
+               for (int i = 0; i < tab; i++)
+                       builder.append("        ");
+               builder.append(getContentState(false) + "       " + getId());
+
+               if (depth > 0)
+                       builder.append(": [");
+
+               if (depth > 0) {
+                       for (E child : this) {
+                               builder.append("\n");
+                               child.getDebugInfo(builder, depth - 1, tab + 1);
+                       }
+               }
+               if (depth > 0) {
+                       builder.append("\n");
+                       for (int i = 0; i < tab; i++)
+                               builder.append("        ");
+                       builder.append("]");
+               }
        }
 
        /**
@@ -135,6 +433,39 @@ public abstract class BaseClass<E extends BaseClass<?>> implements List<E> {
                }
        }
 
+       /**
+        * Escape the given value to VCF standard.
+        * 
+        * @param value
+        *            the value to escape
+        * 
+        * @return the escaped value
+        */
+       protected String escape(String value) {
+               if (value == null)
+                       return null;
+
+               return value.replaceAll(",", "\\\\,").replaceAll(";", "\\\\;")
+                               .replaceAll("\n", "\\\\n");
+       }
+
+       /**
+        * Escape the given value to VCF standard.
+        * 
+        * @param value
+        *            the value to escape
+        * 
+        * @return the escaped value
+        */
+       protected String unescape(String value) {
+               if (value == null)
+                       return null;
+
+               return value.replaceAll("\\\\,", ",").replaceAll("\\\\;", ";")
+
+               .replaceAll("\\\\n", "\n");
+       }
+
        /**
         * Each element that leaves the parent will pass trough here.
         * 
@@ -142,6 +473,12 @@ public abstract class BaseClass<E extends BaseClass<?>> implements List<E> {
         *            the element to remove from this
         */
        private void _leave(E child) {
+               if (child.parent != null && child.parent != this) {
+                       throw new InvalidParameterException(
+                                       "You are removing this child from its rightful parent, it must be yours to do so");
+               }
+
+               child.parent = null;
                setDirty();
        }
 
@@ -162,6 +499,11 @@ public abstract class BaseClass<E extends BaseClass<?>> implements List<E> {
         *            the element to add to this
         */
        private void _enter(E child, boolean initialLoad) {
+               if (child.parent != null && child.parent != this) {
+                       throw new InvalidParameterException(
+                                       "You are stealing this child from its rightful parent, you must remove it first");
+               }
+
                child.setParent(this);
                if (!initialLoad) {
                        setDirty();