diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c8475f3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# Boka2
+ 
+There should be a description here
+
+Additional line
+
+additional line 2
diff --git a/html/fragments.html b/html/fragments.html
index 409dc08..1e0e633 100644
--- a/html/fragments.html
+++ b/html/fragments.html
@@ -85,7 +85,7 @@
       </th>
     </tr>
   </thead>
-  <tbody>
+  <tbody class="¤type¤">
     ¤rows¤
   </tbody>
 </table>
@@ -105,6 +105,25 @@
   </td>
 </tr>
 
+¤¤ product_detail_row ¤¤
+<tr>
+  <td class="status ¤status¤">
+  </td>
+  <td colspan="3">
+    <dl>
+      ¤details¤
+    </dl>
+  </td>
+</tr>
+
+¤¤ product_detail ¤¤
+<dt>
+  ¤name¤:
+</dt>
+<dd>
+  ¤value¤
+</dd>
+
 ¤¤ template_management ¤¤
 <div>
   <h2>Mallar</h2>
@@ -133,7 +152,7 @@
   </form>
 </div>
 
-¤¤ product_details ¤¤
+¤¤ product_form ¤¤
 <div id="product-details">
   <h2>Artikeldata</h2>
   <form id="product-data"
diff --git a/include/Entity.php b/include/Entity.php
new file mode 100644
index 0000000..37c5f8e
--- /dev/null
+++ b/include/Entity.php
@@ -0,0 +1,9 @@
+<?php
+abstract class Entity {
+    protected function __construct() {
+        
+    }
+
+    abstract public function matches($term, $ldap);
+}
+?>
diff --git a/include/NewPage.php b/include/NewPage.php
index ffe6a48..dd0e15e 100644
--- a/include/NewPage.php
+++ b/include/NewPage.php
@@ -49,7 +49,7 @@ class NewPage extends Page {
                               'info' => $fields,
                               'label' => '',
                               'hidden' => 'hidden'),
-                        $this->fragments['product_details']);
+                        $this->fragments['product_form']);
         return $out;
     }
 }
diff --git a/include/Page.php b/include/Page.php
index 696bed7..1d2607e 100644
--- a/include/Page.php
+++ b/include/Page.php
@@ -131,50 +131,75 @@ abstract class Page extends Responder {
     final protected function build_product_table($products) {
         $rows = '';
         foreach($products as $product) {
-            $prodlink = replace(array('id' => $product->get_id(),
-                                      'name' => $product->get_name(),
-                                      'page' => 'products'),
-                                $this->fragments['item_link']);
-            $note = 'Tillgänglig';
-            $status = $product->get_status();
-            switch($status) {
-                case 'discarded':
-                    $discarded = format_date($product->get_discardtime());
-                    $note = 'Skrotad '.$discarded;
-                    break;
-                case 'service':
-                    $service = $product->get_active_service();
-                    $note = 'På service sedan '
-                                .format_date($service->get_starttime());
-                    break;
-                case 'on_loan':
-                case 'overdue':
-                    $loan = $product->get_active_loan();
-                    $user = $loan->get_user();
-                    $replacements = array('name' => $user->get_displayname($this->ldap),
-                                          'id'   => $user->get_id(),
-                                          'page' => 'users');
-                    $userlink = replace($replacements,
-                                        $this->fragments['item_link']);
-                    $note = 'Utlånad till '.$userlink;
-                    if($loan->is_overdue()) {
-                        $note .= ', försenad';
-                    } else {
-                        $note .= ', slutdatum '
-                                .format_date($loan->get_endtime());
-                    }
-                    break;
-            }
-            $rows .= replace(array('status' => $status,
-                                   'item_link' => $prodlink,
-                                   'serial' => $product->get_serial(),
-                                   'note' => $note),
-                             $this->fragments['product_row']);
+            $rows .= $this->build_product_row($product);
         }
-        return replace(array('rows' => $rows),
+        return replace(array('rows' => $rows,
+                             'type' => 'single'),
                        $this->fragments['product_table']);
     }
 
+    final protected function build_product_row($product, $matches = null) {
+        $prodlink = replace(array('id' => $product->get_id(),
+                                  'name' => $product->get_name(),
+                                  'page' => 'products'),
+                            $this->fragments['item_link']);
+        $note = 'Tillgänglig';
+        $status = $product->get_status();
+        switch($status) {
+        case 'discarded':
+            $discarded = format_date($product->get_discardtime());
+            $note = 'Skrotad '.$discarded;
+            break;
+        case 'service':
+            $service = $product->get_active_service();
+            $note = 'På service sedan '
+                  .format_date($service->get_starttime());
+            break;
+        case 'on_loan':
+        case 'overdue':
+            $loan = $product->get_active_loan();
+            $user = $loan->get_user();
+            $replacements = array('name' => $user->get_displayname($this->ldap),
+                                  'id'   => $user->get_id(),
+                                  'page' => 'users');
+            $userlink = replace($replacements,
+                                $this->fragments['item_link']);
+            $note = 'Utlånad till '.$userlink;
+            if($loan->is_overdue()) {
+                $note .= ', försenad';
+            } else {
+                $note .= ', slutdatum '
+                      .format_date($loan->get_endtime());
+            }
+            break;
+        }
+        $out = replace(array('status'    => $status,
+                             'item_link' => $prodlink,
+                             'serial'    => $product->get_serial(),
+                             'note'      => $note),
+                       $this->fragments['product_row']);
+        if($matches) {
+            $details = $this->build_product_details($product, $matches);
+            $out .= replace(array('status'  => $status,
+                                  'details' => $details),
+                            $this->fragments['product_detail_row']);
+        }
+        return $out;
+    }
+
+    final protected function build_product_details($product, $matches) {
+        $out = '';
+        foreach($matches as $name => $value) {
+            if(is_array($value)) {
+                $value = implode(', ', $value);
+            }
+            $out .= replace(array('name'   => $product->get_label($name),
+                                  'value'  => $value),
+                            $this->fragments['product_detail']);
+        }
+        return $out;
+    }
+
     final protected function build_user_loan_table($loans) {
         $rows = '';
         foreach($loans as $loan) {
@@ -260,7 +285,8 @@ abstract class Page extends Responder {
                                    'note' => $note),
                              $this->fragments['product_row']);
         }
-        return replace(array('rows' => $rows),
+        return replace(array('rows' => $rows,
+                             'type' => 'single'),
                        $this->fragments['product_table']);
 
     }
diff --git a/include/Product.php b/include/Product.php
index 9ea76ad..1f4468c 100644
--- a/include/Product.php
+++ b/include/Product.php
@@ -1,5 +1,5 @@
 <?php
-class Product {
+class Product extends Entity {
     private $id = 0;
     private $brand = '';
     private $name = '';
@@ -43,6 +43,7 @@ class Product {
     }
     
     public function __construct($clue, $type = 'id') {
+        parent::__construct();
         $search = null;
         switch($type) {
             case 'id':
@@ -109,37 +110,145 @@ class Product {
         return true;
     }
 
-    // Ldap object must be passed to keep the arglist in sync
-    // with User->matches()
+    /*
+      Return a list of field-value mappings containing all matching search terms.
+     */
     public function matches($terms, $ldap) {
-        foreach($terms as $field => $values) {
-            $matchvalues = array();
-            if(property_exists($this, $field)) {
-                $matchvalues[] = $this->$field;
-            } else if(array_key_exists($field, $this->get_info())) {
-                $matchvalues[] = $this->get_info()[$field];
-            } else {
-                switch($field) {
-                    case 'tag':
-                        $matchvalues = $this->get_tags();
-                    case 'status':
-                        $matchvalues[] = $this->get_status();
-                    case 'fritext':
-                        $matchvalues[] = $this->brand;
-                        $matchvalues[] = $this->name;
-                        $matchvalues[] = $this->serial;
-                        $matchvalues[] = $this->invoice;
-                        $matchvalues = array_merge($matchvalues,
-                                                   $this->get_tags(),
-                                                   array_values(
-                                                       $this->get_info()));
-                }
+        $matches = array();
+
+        // Create a list mapping all basic fields to getters
+        $fields = array('brand' => 'get_brand',
+                        'name' => 'get_name',
+                        'invoice' => 'get_invoice',
+                        'serial' => 'get_serial',
+                        'status' => 'get_status');
+
+        foreach($terms as $term) {
+            $key = $term->get_key();
+            $matched = false;
+            switch($key) {
+                case 'brand':
+                case 'name':
+                case 'invoice':
+                case 'serial':
+                case 'status':
+                    // If $key is a standard field, check against its value
+                    $getter = $fields[$key];
+                    $value = $this->$getter();
+                    if(match($term, $value)) {
+                        //Record a successful match
+                        $matches[$key] = $value;
+                        $matched = true;
+                    }
+                    break;
+                case 'tag':
+                    // If $key is tag, iterate over the tags
+                    $matched_tags = $this->match_tags($term);
+                    if($matched_tags) {
+                        // Record a successful match
+                        $matched = true;
+                        if(!isset($matches['tags'])) {
+                            // This is the first list of matching tags
+                            $matches['tags'] = $matched_tags;
+                        } else {
+                            // Merge these results with existing results
+                            $matches['tags'] = array_unique(
+                                array_merge($matches['tags'],
+                                            $matched_tags));
+                        }
+                    }
+                    break;
+                case 'fritext':
+                    // if $key is fritext:
+                    // First check basic fields
+                    foreach($fields as $field => $getter) {
+                        $value = $this->$getter();
+                        if(match($term, $value)) {
+                            $matches[$field] = $value;
+                            $matched = true;
+                        }
+                    }
+                    // Then tags
+                    $matched_tags = $this->match_tags($term);
+                    if($matched_tags) {
+                        $matched = true;
+                        if(!isset($matches['tags'])) {
+                            $matches['tags'] = $matched_tags;
+                        } else {
+                            $matches['tags'] = array_unique(
+                                array_merge($matches['tags'],
+                                            $matched_tags));
+                        }
+                    }
+                    // Then custom fields
+                    foreach($this->get_info() as $field => $value) {
+                        if(match($term, $value)) {
+                            //Record a successful match
+                            $matches[$field] = $value;
+                            $matched = true;
+                        }
+                    }
+                    break;
+                default:
+                    // Handle custom fields
+                    $info = $this->get_info();
+                    if(isset($info[$key])) {
+                        // If $key is a valid custom field on this product
+                        $value = $info[$key];
+                        if(match($term, $value)) {
+                            //Record a successful match
+                            $matches[$key] = $value;
+                            $matched = true;
+                        }
+                    }
+                    break;
             }
-            if(!match($values, $matchvalues)) {
-                return false;
+            // If a mandatory match failed, the entire search has failed
+            // and we return an empty result.
+            if($term->is_mandatory() && !$matched) {
+                return array();
+            }
+            // If a negative match succeeded, the entire search has failed
+            // and we return an empty result.
+            if($term->is_negative() && $matched) {
+                return array();
             }
         }
-        return true;
+        return $matches;
+    }
+
+    private function match_tags($term) {
+        $tags = $this->get_tags();
+        $matches = array();
+        foreach($tags as $tag) {
+            if(match($term, $tag)) {
+                $matches[] = $tag;
+            }
+        }
+        return $matches;
+    }
+
+    public function get_label($name) {
+        switch($name) {
+            case 'brand':
+                return 'Tillverkare';
+                break;
+            case 'name':
+                return 'Namn';
+                break;
+            case 'invoice':
+                return 'Fakturanummer';
+                break;
+            case 'serial':
+                return 'Serienummer';
+                break;
+            case 'tags':
+                return 'Taggar';
+                break;
+            default:
+                return ucfirst($name);
+                break;
+        }
     }
 
     public function get_id() {
diff --git a/include/ProductPage.php b/include/ProductPage.php
index e385f62..afb25b1 100644
--- a/include/ProductPage.php
+++ b/include/ProductPage.php
@@ -38,12 +38,12 @@ class ProductPage extends Page {
                               $this->fragments['product_page']));
                 break;
             case 'show':
-                print($this->build_product_details());
+                print($this->build_product_form());
                 break;
         }
     }
     
-    private function build_product_details() {
+    private function build_product_form() {
         $info = '';
         foreach($this->product->get_info() as $key => $value) {
             $info .= replace(array('name' => ucfirst($key),
@@ -81,7 +81,7 @@ class ProductPage extends Page {
                 $fields['service'] = 'Avsluta service';
             }
         }
-        return replace($fields, $this->fragments['product_details']);
+        return replace($fields, $this->fragments['product_form']);
     }
 
     private function build_history_table($history) {
diff --git a/include/SearchPage.php b/include/SearchPage.php
index 0eb095c..1d47bf3 100644
--- a/include/SearchPage.php
+++ b/include/SearchPage.php
@@ -37,16 +37,33 @@ class SearchPage extends Page {
         return $out;
     }
 
+    private function search($type, $terms) {
+        $matches = array();
+        foreach(get_items($type) as $item) {
+            if($result = $item->matches($terms, $this->ldap)) {
+                $matches[] = array($item, $result);
+            }
+        }
+        return $matches;
+    }
+
     private function translate_terms($terms) {
         $matches = array();
+
+        // If there is a q-query
+        // and it contains a : character
         if(isset($terms['q']) && preg_match('/([^:]+):(.*)/',
                                             $terms['q'],
                                             $matches)) {
+            // remove the q key
             unset($terms['q']);
+            // insert the term, using whatever came before
+            // the : as the key and whatever came after as the value
             $terms[$matches[1]] = $matches[2];
         }
         $translated = array();
-        foreach($terms as $key => $value) {
+        // Translate all keys into a standard format
+        foreach($terms as $key => $values) {
             $newkey = $key;
             switch($key) {
                 case 'q':
@@ -67,17 +84,38 @@ class SearchPage extends Page {
                     $newkey = 'serial';
                     break;
                 case 'tagg':
+                case 'tags':
                     $newkey = 'tag';
                     break;
+                case 'anteckning':
+                    $newkey = 'note';
+                    break;
+                case 'e-post':
+                case 'epost':
+                case 'mail':
+                    $newkey = 'email';
+                    break;
                 case 'status':
-                    $value = $this->translate_values($value);
+                    // Translate all status values into a standard format
+                    $values = $this->translate_values($values);
                     break;
             }
-            if(!array_key_exists($newkey, $translated)) {
-                $translated[$newkey] = $value;
-            } else {
-                $temp = $translated[$newkey];
-                $translated[$newkey] = array_merge((array)$temp, (array)$value);
+            // Wrap the value in an array if it isn't one
+            if(!is_array($values)) {
+                $values = array($values);
+            }
+            // Make a SearchTerm object from each term
+            foreach($values as $value) {
+                // Check for flags
+                $flag = SearchTerm::OPTIONAL;
+                if(in_array($value[0], array(SearchTerm::MANDATORY,
+                                             SearchTerm::OPTIONAL,
+                                             SearchTerm::NEGATIVE))) {
+                    $flag = $value[0];
+                    $value = substr($value, 1);
+                }
+                // Collect the new SearchTerm
+                $translated[] = new SearchTerm($newkey, $value, $flag);
             }
         }
         return $translated;
@@ -127,48 +165,96 @@ class SearchPage extends Page {
         }
         return $translated;
     }
-
-    private function search($type, $terms) {
-        $items = get_items($type);
-        $out = array();
-        foreach($items as $item) {
-            if($item->matches($terms, $this->ldap)) {
-                $out[] = $item;
-            }
-        }
-        return $out;
-    }
     
     protected function render_body() {
         $hidden = 'hidden';
         $terms = '';
         if($this->terms) {
             $hidden = '';
-            foreach($this->terms as $key => $value) {
-                if(!is_array($value)) {
-                    $value = array($value);
-                }
-                foreach($value as $item) {
-                    $terms .= replace(array('term' => ucfirst($key).": $item",
-                                            'key' => $key,
-                                            'value' => $item),
-                                      $this->fragments['search_term']);
-                }
+            foreach($this->terms as $term) {
+                $key = $term->get_key();
+                $flag = $term->get_flag();
+                $query = $term->get_query();
+                $fullterm = ucfirst($key).": ".$flag.$query;
+                $terms .= replace(array('term' => $fullterm,
+                                        'key' => $key,
+                                        'value' => $flag.$query),
+                                  $this->fragments['search_term']);
             }
         }
-        $products = 'Inga artiklar hittade.';
+        $prod_table = 'Inga artiklar hittade.';
         if($this->product_hits) {
-            $products = $this->build_product_table($this->product_hits);
+            $products = '';
+            foreach($this->product_hits as $hit) {
+                $products .= $this->build_product_row($hit[0], $hit[1]);
+            }
+            $prod_table = replace(array('rows' => $products,
+                                        'type' => 'double'),
+                                  $this->fragments['product_table']);
         }
-        $users = 'Inga användare hittade.';
+        $user_table = 'Inga användare hittade.';
         if($this->user_hits) {
-            $users = $this->build_user_table($this->user_hits);
+            $users = array();
+            foreach($this->user_hits as $hit) {
+                $users[] = $hit[0];
+            }
+            $user_table = $this->build_user_table($users);
         }
+
         print(replace(array('terms' => $terms,
                             'hidden' => $hidden,
-                            'product_results' => $products,
-                            'user_results' => $users),
+                            'product_results' => $prod_table,
+                            'user_results' => $user_table),
                       $this->fragments['search_form']));
     }
 }
+
+class SearchTerm {
+    public const MANDATORY = '+';
+    public const OPTIONAL = '~';
+    public const NEGATIVE = '-';
+
+    private $key;
+    private $query;
+    private $flag;
+
+    public function __construct($key, $query, $flag=SearchTerm::OPTIONAL) {
+        $this->key = $key;
+        $this->query = $query;
+        $this->flag = $flag;
+    }
+
+    public function get_key() {
+        return $this->key;
+    }
+
+    public function get_query() {
+        return $this->query;
+    }
+
+    public function get_flag() {
+        return $this->flag;
+    }
+
+    public function is_optional() {
+        if($this->flag == SearchTerm::OPTIONAL) {
+            return true;
+        }
+        return false;
+    }
+
+    public function is_mandatory() {
+        if($this->flag == SearchTerm::MANDATORY) {
+            return true;
+        }
+        return false;
+    }
+
+    public function is_negative() {
+        if($this->flag == SearchTerm::NEGATIVE) {
+            return true;
+        }
+        return false;
+    }
+}
 ?>
diff --git a/include/User.php b/include/User.php
index f2cbfe0..d77475f 100644
--- a/include/User.php
+++ b/include/User.php
@@ -1,5 +1,5 @@
 <?php
-class User {
+class User extends Entity {
     private $id = 0;
     private $name = '';
     private $notes = '';
@@ -12,6 +12,7 @@ class User {
     }
 
     public function __construct($clue, $type = 'id') {
+        parent::__construct();
         $find = null;
         switch($type) {
             case 'id':
@@ -45,25 +46,61 @@ class User {
     }
 
     public function matches($terms, $ldap) {
-        foreach($terms as $field => $values) {
-            $matchvalues = array();
-            if($field == 'name') {
-                $matchvalues[] = $this->name;
-                $matchvalues[] = $this->get_displayname($ldap);
-            } else if(property_exists($this, $field)) {
-                $matchvalues[] = $this->$field;
-            } else if($field == 'fritext') {
-                $matchvalues[] = $this->name;
-                $matchvalues[] = $this->get_displayname($ldap);
-                $matchvalues[] = $this->notes;
-            } else {
-                return false;
+        $matches = array();
+        foreach($terms as $term) {
+            // Iterate over the terms
+            $matched = false;
+            $key = $term->get_key();
+            switch($key) {
+                case 'name':
+                    // If the key is name, check username and displayname
+                    $name = $this->get_name();
+                    if(match($term, $name)) {
+                        $matches['name'] = $name;
+                        $matched = true;
+                    }
+                    $dname = $this->get_displayname($ldap);
+                    if(match($term, $dname)) {
+                        $matches['displayname'] = $dname;
+                        $matched = true;
+                    }
+                    break;
+                case 'note':
+                    // If the key is note, check it.
+                    $note = $this->get_note();
+                    if($note && match($term, $note)) {
+                        $matches['note'] = $note;
+                        $matched = true;
+                    }
+                    break;
+                case 'email':
+                    $email = $this->get_email($ldap, false);
+                    if($email && match($term, $email)) {
+                        $matches['email'] = $email;
+                        $matched = true;
+                    }
+                    break;
+                case 'fritext':
+                    //Check everything if the key is fritext
+                    $name = $this->get_name();
+                    if(match($term, $name)) {
+                        $matches['name'] = $name;
+                        $matched = true;
+                    }
+                    $dname = $this->get_displayname($ldap);
+                    if(match($term, $dname)) {
+                        $matches['displayname'] = $dname;
+                        $matched = true;
+                    }
             }
-            if(!match($values, $matchvalues)) {
-                return false;
+            if($term->is_mandatory() && !$matched) {
+                return array();
+            }
+            if($term->is_negative() && $matched) {
+                return array();
             }
         }
-        return true;
+        return $matches;
     }
 
     public function get_displayname($ldap) {
@@ -78,7 +115,10 @@ class User {
         try {
             return $ldap->get_user_email($this->name);
         } catch(Exception $e) {
-            return 'Mailadress saknas';
+            if($format) {
+                return 'Mailadress saknas';
+            }
+            return false;
         }
     }
 
diff --git a/include/functions.php b/include/functions.php
index 712ee91..b85f1b3 100644
--- a/include/functions.php
+++ b/include/functions.php
@@ -253,22 +253,21 @@ function suggest_content($fieldname) {
     return $out;
 }
 
-function match($testvalues, $matchvalues) {
-    # match only presence of field (if no value given)
-    if(!$testvalues && $matchvalues) {
+function match($term, $subject) {
+    if(fnmatch('*'.$term->get_query().'*', $subject, FNM_CASEFOLD)) {
         return true;
     }
-    if(!is_array($testvalues)) {
-        $testvalues = array($testvalues);
-    }
-    foreach($testvalues as $value) {
-        foreach($matchvalues as $candidate) {
-            if(fnmatch('*'.$value.'*', $candidate, FNM_CASEFOLD)) {
-                return true;
-            }
+    return false;
+}
+
+function match_tags($searchterm, $tags) {
+    $found = array();
+    foreach($tags as $tag) {
+        if(fnmatch('*'.$tag.'*', $searchterm, FNM_CASEFOLD)) {
+            $found[] = $tag;
         }
     }
-    return false;
+    return $found;
 }
 
 function format_date($date) {
diff --git a/style.css b/style.css
index c1ec303..61c9529 100644
--- a/style.css
+++ b/style.css
@@ -104,10 +104,25 @@ tbody tr {
     background-color: #d7e0eb;
 }
 
-tbody tr:nth-child(odd) {
+tbody.single tr:nth-child(odd) {
     background-color: #ebf0f5;
 }
 
+tbody.double tr:is(:nth-child(4n+1), :nth-child(4n+2)) {
+    background-color: #ebf0f5;
+}
+
+tbody dl {
+    margin: 0;
+    display: grid;
+    grid-template-columns: 2fr 5fr;
+}
+
+tbody dd {
+    margin: 0;
+    grid-column: 2;
+}
+
 thead th, tfoot tr {
     background-color: #c3d1e2;
 }