diff --git a/ImageAnalyzer/.gitattributes b/ImageAnalyzer/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..ea6aa14ebc2b6e62c55617c5a681521808e75d3c
--- /dev/null
+++ b/ImageAnalyzer/.gitattributes
@@ -0,0 +1,13 @@
+*.java                   text diff=java
+
+*.txt                   text
+*.sh                   text
+*.mf                   text
+*.xml                   text
+*.form                   text
+*.properties                   text
+*.html                   text diff=html
+*.dox                   text
+Doxyfile                text
+
+*.py                  text diff=python
diff --git a/ImageAnalyzer/.gitignore b/ImageAnalyzer/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..9baf66d36165ea3285adcd4b3e8d7692c614c58d
--- /dev/null
+++ b/ImageAnalyzer/.gitignore
@@ -0,0 +1,44 @@
+
+#ignore thumbnails created by windows
+Thumbs.db
+#Ignore files build by Visual Studio
+*.obj
+*.exe
+*.pdb
+*.user
+*.aps
+*.pch
+*.vspscc
+*_i.c
+*_p.c
+*.ncb
+*.suo
+*.tlb
+*.tlh
+*.bak
+*.cache
+*.ilk
+*.log
+[Bb]in
+[Dd]ebug*/
+*.lib
+*.sbr
+obj/
+
+_ReSharper*/
+[Tt]est[Rr]esult*
+
+#Ignore Java class files
+*.class
+build/*
+
+*/javadoc/*
+
+nbproject/private/*
+
+*.hprof
+*.nps
+
+
+
+
diff --git a/ImageAnalyzer/LICENSE-2.0.txt b/ImageAnalyzer/LICENSE-2.0.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d645695673349e3947e8e5ae42332d0ac3164cd7
--- /dev/null
+++ b/ImageAnalyzer/LICENSE-2.0.txt
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/ImageAnalyzer/build.xml b/ImageAnalyzer/build.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4cbe0d1139dc67398397732492952b226228b632
--- /dev/null
+++ b/ImageAnalyzer/build.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- You may freely edit this file. See harness/README in the NetBeans platform -->
+<!-- for some information on what you could do (e.g. targets to override). -->
+<!-- If you delete this file and reopen the project it will be recreated. -->
+<project name="com.basistech.imageanalysis" default="netbeans" basedir=".">
+    <description>Builds, tests, and runs the project com.basistech.imageanalysis.</description>
+    <import file="nbproject/build-impl.xml"/>
+</project>
diff --git a/ImageAnalyzer/manifest.mf b/ImageAnalyzer/manifest.mf
new file mode 100644
index 0000000000000000000000000000000000000000..537c9cf8b0c579a62a14b5dd04cfac05de0096c0
--- /dev/null
+++ b/ImageAnalyzer/manifest.mf
@@ -0,0 +1,5 @@
+Manifest-Version: 1.0
+OpenIDE-Module: org.sleuthkit.autopsy.imageanalyzer
+OpenIDE-Module-Localizing-Bundle: org/sleuthkit/autopsy/imageanalyzer/Bundle.properties
+OpenIDE-Module-Specification-Version: 1.0
+
diff --git a/ImageAnalyzer/nbproject/build-impl.xml b/ImageAnalyzer/nbproject/build-impl.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4cfa3e50100a2da8fd5d74c482882dd8d8f86c13
--- /dev/null
+++ b/ImageAnalyzer/nbproject/build-impl.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+*** GENERATED FROM project.xml - DO NOT EDIT  ***
+***         EDIT ../build.xml INSTEAD         ***
+-->
+<project name="com.basistech.imageanalysis-impl" basedir="..">
+    <fail message="Please build using Ant 1.7.1 or higher.">
+        <condition>
+            <not>
+                <antversion atleast="1.7.1"/>
+            </not>
+        </condition>
+    </fail>
+    <property file="nbproject/private/platform-private.properties"/>
+    <property file="nbproject/platform.properties"/>
+    <macrodef name="property" uri="http://www.netbeans.org/ns/nb-module-project/2">
+        <attribute name="name"/>
+        <attribute name="value"/>
+        <sequential>
+            <property name="@{name}" value="${@{value}}"/>
+        </sequential>
+    </macrodef>
+    <macrodef name="evalprops" uri="http://www.netbeans.org/ns/nb-module-project/2">
+        <attribute name="property"/>
+        <attribute name="value"/>
+        <sequential>
+            <property name="@{property}" value="@{value}"/>
+        </sequential>
+    </macrodef>
+    <property file="${user.properties.file}"/>
+    <nbmproject2:property name="harness.dir" value="nbplatform.${nbplatform.active}.harness.dir" xmlns:nbmproject2="http://www.netbeans.org/ns/nb-module-project/2"/>
+    <nbmproject2:property name="nbplatform.active.dir" value="nbplatform.${nbplatform.active}.netbeans.dest.dir" xmlns:nbmproject2="http://www.netbeans.org/ns/nb-module-project/2"/>
+    <nbmproject2:evalprops property="cluster.path.evaluated" value="${cluster.path}" xmlns:nbmproject2="http://www.netbeans.org/ns/nb-module-project/2"/>
+    <fail message="Path to 'platform' cluster missing in $${cluster.path} property or using corrupt Netbeans Platform (missing harness).">
+        <condition>
+            <not>
+                <contains string="${cluster.path.evaluated}" substring="platform"/>
+            </not>
+        </condition>
+    </fail>
+    <import file="${harness.dir}/build.xml"/>
+</project>
diff --git a/ImageAnalyzer/nbproject/genfiles.properties b/ImageAnalyzer/nbproject/genfiles.properties
new file mode 100644
index 0000000000000000000000000000000000000000..96475b54027488db631912d1bc90e97456b1f298
--- /dev/null
+++ b/ImageAnalyzer/nbproject/genfiles.properties
@@ -0,0 +1,8 @@
+build.xml.data.CRC32=673987f7
+build.xml.script.CRC32=eee7b5a4
+build.xml.stylesheet.CRC32=a56c6a5b@2.56.1
+# This file is used by a NetBeans-based IDE to track changes in generated files such as build-impl.xml.
+# Do not edit this file. You may delete it but then the IDE will never regenerate such files for you.
+nbproject/build-impl.xml.data.CRC32=673987f7
+nbproject/build-impl.xml.script.CRC32=92d95aee
+nbproject/build-impl.xml.stylesheet.CRC32=238281d1@2.56.1
diff --git a/ImageAnalyzer/nbproject/platform.properties b/ImageAnalyzer/nbproject/platform.properties
new file mode 100644
index 0000000000000000000000000000000000000000..dc5c66275b4a0c1c6a3996f1c88f19733042e27a
--- /dev/null
+++ b/ImageAnalyzer/nbproject/platform.properties
@@ -0,0 +1,7 @@
+cluster.path=\
+    ${nbplatform.active.dir}/autopsy:\
+    ${nbplatform.active.dir}/harness:\
+    ${nbplatform.active.dir}/java:\
+    ${nbplatform.active.dir}/platform
+nbjdk.active=JDK_1.8u20
+nbplatform.active=Autopsy_Dev
diff --git a/ImageAnalyzer/nbproject/project.properties b/ImageAnalyzer/nbproject/project.properties
new file mode 100644
index 0000000000000000000000000000000000000000..259ddd1e7dda6b0126cd1d1c9ab0825146a01e21
--- /dev/null
+++ b/ImageAnalyzer/nbproject/project.properties
@@ -0,0 +1,13 @@
+file.reference.commons-pool2-2.0-javadoc.jar=release/modules/ext/commons-pool2-2.0-javadoc.jar
+file.reference.commons-pool2-2.0-sources.jar=release/modules/ext/commons-pool2-2.0-sources.jar
+file.reference.commons-pool2-2.0.jar=release/modules/ext/commons-pool2-2.0.jar
+file.reference.controlsfx-8.0.6.jar=release/modules/ext/controlsfx-8.0.6.jar
+file.reference.jcip-annotations-src.jar=release/modules/ext/jcip-annotations-src.jar
+file.reference.jcip-annotations.jar=release/modules/ext/jcip-annotations.jar
+file.reference.sqlite-jdbc-3.7.8-SNAPSHOT.jar=release/modules/ext/sqlite-jdbc-3.7.8-SNAPSHOT.jar
+javac.source=1.8
+javac.compilerargs=-Xlint -Xlint:-serial
+license.file=LICENSE-2.0.txt
+nbm.homepage=http://www.sleuthkit.org/
+nbm.needs.restart=true
+project.license=eureka
diff --git a/ImageAnalyzer/nbproject/project.xml b/ImageAnalyzer/nbproject/project.xml
new file mode 100644
index 0000000000000000000000000000000000000000..29a4d98eaa4aec7a4bfb71946fb7dfcd753bbdd3
--- /dev/null
+++ b/ImageAnalyzer/nbproject/project.xml
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://www.netbeans.org/ns/project/1">
+    <type>org.netbeans.modules.apisupport.project</type>
+    <configuration>
+        <data xmlns="http://www.netbeans.org/ns/nb-module-project/3">
+            <code-name-base>org.sleuthkit.autopsy.imageanalyzer</code-name-base>
+            <standalone/>
+            <module-dependencies>
+                <dependency>
+                    <code-name-base>org.netbeans.api.progress</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <release-version>1</release-version>
+                        <specification-version>1.32.1</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.netbeans.modules.options.api</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <release-version>1</release-version>
+                        <specification-version>1.31.2</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.netbeans.modules.settings</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <release-version>1</release-version>
+                        <specification-version>1.38.2</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.awt</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>7.55.1</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.dialogs</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>7.28.1</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.explorer</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>6.50.3</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.modules</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>7.35.1</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.nodes</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>7.33.2</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.util</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>8.29.3</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.util.lookup</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>8.19.1</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.openide.windows</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <specification-version>6.60.1</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.sleuthkit.autopsy.core</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <release-version>10</release-version>
+                        <specification-version>10.0.11</specification-version>
+                    </run-dependency>
+                </dependency>
+                <dependency>
+                    <code-name-base>org.sleuthkit.autopsy.corelibs</code-name-base>
+                    <build-prerequisite/>
+                    <compile-dependency/>
+                    <run-dependency>
+                        <release-version>3</release-version>
+                        <specification-version>1.1</specification-version>
+                    </run-dependency>
+                </dependency>
+            </module-dependencies>
+            <public-packages/>
+            <class-path-extension>
+                <runtime-relative-path>ext/controlsfx-8.0.6.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/controlsfx-8.0.6.jar</binary-origin>
+            </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/jcip-annotations.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/jcip-annotations.jar</binary-origin>
+            </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/commons-pool2-2.0-sources.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/commons-pool2-2.0-sources.jar</binary-origin>
+            </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/commons-pool2-2.0.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/commons-pool2-2.0.jar</binary-origin>
+            </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/sqlite-jdbc-3.7.8-SNAPSHOT.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/sqlite-jdbc-3.7.8-SNAPSHOT.jar</binary-origin>
+            </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/jcip-annotations-src.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/jcip-annotations-src.jar</binary-origin>
+            </class-path-extension>
+            <class-path-extension>
+                <runtime-relative-path>ext/commons-pool2-2.0-javadoc.jar</runtime-relative-path>
+                <binary-origin>release/modules/ext/commons-pool2-2.0-javadoc.jar</binary-origin>
+            </class-path-extension>
+        </data>
+    </configuration>
+</project>
diff --git a/ImageAnalyzer/release/modules/ext/commons-pool2-2.0-javadoc.jar b/ImageAnalyzer/release/modules/ext/commons-pool2-2.0-javadoc.jar
new file mode 100644
index 0000000000000000000000000000000000000000..1d1e4226b3ae330e68c7a482942f49eab56627ff
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/commons-pool2-2.0-javadoc.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/commons-pool2-2.0-sources.jar b/ImageAnalyzer/release/modules/ext/commons-pool2-2.0-sources.jar
new file mode 100644
index 0000000000000000000000000000000000000000..2dfe051f95116795fa0976ba1c77ef1a781de728
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/commons-pool2-2.0-sources.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/commons-pool2-2.0.jar b/ImageAnalyzer/release/modules/ext/commons-pool2-2.0.jar
new file mode 100644
index 0000000000000000000000000000000000000000..be6d84f92eaf67b9986cc19fc95a5795ef4ff131
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/commons-pool2-2.0.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/controlsfx-8.0.6.jar b/ImageAnalyzer/release/modules/ext/controlsfx-8.0.6.jar
new file mode 100644
index 0000000000000000000000000000000000000000..2f2d1badd0facb374d5c2519ce3525ff8693c222
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/controlsfx-8.0.6.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/controlsfx-8.0.6_20.jar b/ImageAnalyzer/release/modules/ext/controlsfx-8.0.6_20.jar
new file mode 100644
index 0000000000000000000000000000000000000000..39a73c0b9acb47fb4ab23fc5922313885d0e8e91
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/controlsfx-8.0.6_20.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/jcip-annotations-src.jar b/ImageAnalyzer/release/modules/ext/jcip-annotations-src.jar
new file mode 100644
index 0000000000000000000000000000000000000000..bf52a507df2418b68041436fa3df27c4176cc494
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/jcip-annotations-src.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/jcip-annotations.jar b/ImageAnalyzer/release/modules/ext/jcip-annotations.jar
new file mode 100644
index 0000000000000000000000000000000000000000..ea263af054bd0e3403fb131c3961e8a4964d07e6
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/jcip-annotations.jar differ
diff --git a/ImageAnalyzer/release/modules/ext/sqlite-jdbc-3.7.8-SNAPSHOT.jar b/ImageAnalyzer/release/modules/ext/sqlite-jdbc-3.7.8-SNAPSHOT.jar
new file mode 100644
index 0000000000000000000000000000000000000000..bcea83745ab32246e315dd0c53209dc6a0cd39b9
Binary files /dev/null and b/ImageAnalyzer/release/modules/ext/sqlite-jdbc-3.7.8-SNAPSHOT.jar differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/AutopsyListener.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/AutopsyListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..5adbb2512d8d51c92e2a043a6c308a6a86c7b9e6
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/AutopsyListener.java
@@ -0,0 +1,165 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import javax.swing.SwingUtilities;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.ingest.IngestManager;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.Content;
+
+/** Singleton aggregator for listeners that hook into case and ingest modules.
+ * This class depends on clients to hook up listeners to Autopsy. */
+public class AutopsyListener {
+
+    private static final Logger LOGGER = Logger.getLogger(AutopsyListener.class.getName());
+
+    private final EurekaController controller = EurekaController.getDefault();
+
+    private final PropertyChangeListener ingestJobEventListener = new IngestJobEventListener();
+
+    private final PropertyChangeListener ingestModuleEventListener = new IngestModuleEventListener();
+
+    private final PropertyChangeListener caseListener = new CaseListener();
+
+    public PropertyChangeListener getIngestJobEventListener() {
+        return ingestJobEventListener;
+    }
+
+    public PropertyChangeListener getIngestModuleEventListener() {
+        return ingestModuleEventListener;
+    }
+
+    public PropertyChangeListener getCaseListener() {
+        return caseListener;
+    }
+
+    private static AutopsyListener instance;
+
+    private AutopsyListener() {
+    }
+
+    synchronized public static AutopsyListener getDefault() {
+        if (instance == null) {
+            instance = new AutopsyListener();
+        }
+        return instance;
+    }
+
+    /** listener for ingest events */
+    private class IngestJobEventListener implements PropertyChangeListener {
+
+        @Override
+        synchronized public void propertyChange(PropertyChangeEvent evt) {
+            switch (IngestManager.IngestJobEvent.valueOf(evt.getPropertyName())) {
+                case COMPLETED:
+                    if (controller.isListeningEnabled()) {
+                        if (IngestManager.getInstance().isIngestRunning() == false) {
+                            // @@@ Add some logic to not do this if we've done it in the past second
+                            controller.queueTask(controller.new MarkAllFilesAsAnalyzed());
+                        }
+                    } else {
+                        //TODO can we do anything usefull here?
+                    }
+            }
+        }
+    }
+
+    /** listener for ingest events */
+    private class IngestModuleEventListener implements PropertyChangeListener {
+
+        @Override
+        synchronized public void propertyChange(PropertyChangeEvent evt) {
+            switch (IngestManager.IngestModuleEvent.valueOf(evt.getPropertyName())) {
+
+                case CONTENT_CHANGED:
+                    //TODO: do we need to do anything here?  -jm
+                    break;
+                case DATA_ADDED:
+                    /* we could listen to DATA events and progressivly update
+                     * files, and get data from DataSource ingest modules, but
+                     * given that most modules don't post new artifacts in the
+                     * events and eureka would have to query for them, without
+                     * knowing which are the new ones, we just ignore these
+                     * events for now. The relevant data should all be captured
+                     * by file done event, anyways -jm */
+                    break;
+                case FILE_DONE:
+                    /** getOldValue has fileID, getNewValue has {@link Abstractfile}
+                     *
+                     * {@link IngestManager#fireModuleDataEvent(org.sleuthkit.autopsy.ingest.ModuleDataEvent) fireModuleDataEvent} */
+                    AbstractFile file = (AbstractFile) evt.getNewValue();
+                    if (controller.isListeningEnabled()) {
+
+                        if (EurekaModule.isSupportedAndNotKnown(file)) {
+                            //this file should be included and we don't already know about it from hash sets (NSRL)
+                            controller.queueTask(controller.new UpdateFile(file));
+                        } else if (EurekaModule.getAllSupportedExtensions().contains(file.getNameExtension())) {
+                            //doing this check results in fewer tasks queued up, and faster completion of db update
+                            //this file would have gotten scooped up in initial grab, but actually we don't need it
+                            controller.queueTask(controller.new RemoveFile(file));
+                        }
+                    } else {
+                        controller.setStale(true);
+                        //TODO: keep track of waht we missed for later
+                    }
+                    break;
+            }
+        }
+    }
+
+    /** listener for case events */
+    private class CaseListener implements PropertyChangeListener {
+
+        @Override
+        synchronized public void propertyChange(PropertyChangeEvent evt) {
+
+            switch (Case.Events.valueOf(evt.getPropertyName())) {
+                case CURRENT_CASE:
+                    Case newCase = (Case) evt.getNewValue();
+                    if (newCase != null) { // case has been opened
+                        //connect db, groupmanager, start worker thread
+                        controller.setCase(newCase);
+
+                    } else { // case is closing
+                        //close eureka window
+                        SwingUtilities.invokeLater(EurekaModule::closeTopComponent);
+                        controller.reset();
+                    }
+                    break;
+
+                case DATA_SOURCE_ADDED:
+                    //copy all file data to eureka databse
+                    Content newDataSource = (Content) evt.getNewValue();
+                    if (controller.isListeningEnabled()) {
+                        controller.queueTask(controller.new PrePopulateDataSourceFiles(newDataSource.getId()));
+                    } else {
+                        controller.setStale(true);
+                        //TODO: keep track of what we missed for later
+                    }
+                    break;
+            }
+        }
+    }
+
+ 
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/Bundle.properties b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/Bundle.properties
new file mode 100644
index 0000000000000000000000000000000000000000..5e5062ea192208f43fc214b3ed8e4429a4b511e9
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/Bundle.properties
@@ -0,0 +1,8 @@
+OpenIDE-Module-Long-Description=\
+    New image and video analyzer that has been designed to make performing image-intensive investigations more efficient.  \
+    This work has been funded by DHS S&T and this is a beta release. \
+    It is not available on the sleuthkit.org site and has been distributed to limited users.
+OpenIDE-Module-Name=Image Analysis (Beta)
+OpenIDE-Module-Short-Description=Advanced image and video analyzer
+EurekaOptionsPanel.enabledByDefaultBox.text=Enable listening to ingest for new cases.
+EurekaOptionsPanel.enabledForCaseBox.text=Enable listening to ingest for current case.  
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaController.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaController.java
new file mode 100644
index 0000000000000000000000000000000000000000..fe5a680a48f01e0e7c4c26ee031bb43978096925
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaController.java
@@ -0,0 +1,877 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.beans.Observable;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyBooleanWrapper;
+import javafx.beans.property.ReadOnlyIntegerProperty;
+import javafx.beans.property.ReadOnlyIntegerWrapper;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
+import javafx.beans.property.SimpleListProperty;
+import javafx.collections.FXCollections;
+import javafx.concurrent.Task;
+import javafx.concurrent.Worker;
+import static javafx.concurrent.Worker.State.CANCELLED;
+import static javafx.concurrent.Worker.State.FAILED;
+import static javafx.concurrent.Worker.State.READY;
+import static javafx.concurrent.Worker.State.RUNNING;
+import static javafx.concurrent.Worker.State.SCHEDULED;
+import static javafx.concurrent.Worker.State.SUCCEEDED;
+import javafx.geometry.Insets;
+import javafx.scene.Node;
+import javafx.scene.control.ProgressIndicator;
+import javafx.scene.layout.Background;
+import javafx.scene.layout.BackgroundFill;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.StackPane;
+import javafx.scene.paint.Color;
+import net.jcip.annotations.GuardedBy;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.netbeans.api.progress.ProgressHandle;
+import org.netbeans.api.progress.ProgressHandleFactory;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableDB;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupKey;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupManager;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupViewState;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.Grouping;
+import org.sleuthkit.autopsy.imageanalyzer.gui.EurekaToolbar;
+import org.sleuthkit.autopsy.imageanalyzer.gui.NoGroupsDialog;
+import org.sleuthkit.autopsy.imageanalyzer.gui.SummaryTablePane;
+import org.sleuthkit.autopsy.imageanalyzer.progress.ProgressAdapterBase;
+import org.sleuthkit.autopsy.ingest.IngestManager;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TskCoreException;
+import org.sleuthkit.datamodel.TskData;
+
+/**
+ * Acts as the controller in GroupManager - GroupListPane - EurekaController MVC
+ * Trio
+ *
+ * Connects different parts of Eureka together and is hub for flow of control.
+ */
+public class EurekaController implements FileUpdateListener {
+
+    private static final Logger LOGGER = Logger.getLogger(EurekaController.class.getName());
+
+    private final Region infoOverLayBackground = new Region() {
+        {
+            setBackground(new Background(new BackgroundFill(Color.GREY, CornerRadii.EMPTY, Insets.EMPTY)));
+            setOpacity(.4);
+        }
+    };
+
+    private static EurekaController instance;
+
+    public static synchronized EurekaController getDefault() {
+        if (instance == null) {
+            instance = new EurekaController();
+        }
+        return instance;
+    }
+
+    /** list based stack to hold history, 'top' is at index 0; */
+    @javax.annotation.concurrent.GuardedBy("this")
+    private final ObservableStack<GroupViewState> historyStack = new ObservableStack<>();
+
+    @javax.annotation.concurrent.GuardedBy("this")
+    private final ObservableStack<GroupViewState> forwardStack = new ObservableStack<>();
+
+    private final ReadOnlyBooleanWrapper listeningEnabled = new ReadOnlyBooleanWrapper(false);
+
+    private final ReadOnlyIntegerWrapper queueSizeProperty = new ReadOnlyIntegerWrapper(0);
+
+    private final ReadOnlyBooleanWrapper regroupDisabled = new ReadOnlyBooleanWrapper(false);
+
+    private final ReadOnlyBooleanWrapper stale = new ReadOnlyBooleanWrapper(false);
+
+    private final ReadOnlyBooleanWrapper metaDataCollapsed = new ReadOnlyBooleanWrapper(false);
+
+    private final FileIDSelectionModel selectionModel = FileIDSelectionModel.getInstance();
+
+    private DBWorkerThread dbWorkerThread;
+
+    private DrawableDB db;
+
+    private final GroupManager groupManager = new GroupManager(this);
+
+    private StackPane fullUIStackPane;
+
+    private StackPane centralStackPane;
+
+    private Node infoOverlay;
+
+    synchronized public ObservableStack<GroupViewState> getHistoryStack() {
+        return historyStack;
+    }
+
+    synchronized public ObservableStack<GroupViewState> getForwardStack() {
+        return forwardStack;
+    }
+
+    public ReadOnlyBooleanProperty getMetaDataCollapsed() {
+        return metaDataCollapsed.getReadOnlyProperty();
+    }
+
+    public void setMetaDataCollapsed(Boolean metaDataCollapsed) {
+        this.metaDataCollapsed.set(metaDataCollapsed);
+    }
+
+    private GroupViewState getViewState() {
+        return viewState.get();
+    }
+
+    public ReadOnlyBooleanProperty regroupDisabled() {
+        return regroupDisabled.getReadOnlyProperty();
+    }
+
+    private final ReadOnlyObjectWrapper<GroupViewState> viewState = new ReadOnlyObjectWrapper<>();
+
+    private void setViewState(GroupViewState newViewState) {
+        viewState.set(newViewState);
+    }
+
+    public ReadOnlyObjectProperty<GroupViewState> viewState() {
+        return viewState.getReadOnlyProperty();
+    }
+
+    /**
+     * the list of tasks queued to run in the uiBGTaskExecutor. By keeping this
+     * list we can cancel them more gracefully than by {@link ExecutorService#shutdownNow()
+     */
+    @GuardedBy("bgTasks")
+    private final SimpleListProperty<Future<?>> bgTasks = new SimpleListProperty<>(FXCollections.observableArrayList());
+
+    /** an executor to submit async ui related background tasks to. */
+    final ExecutorService bgTaskExecutor = Executors.newSingleThreadExecutor(new BasicThreadFactory.Builder().namingPattern("ui task -%d").build());
+
+    public synchronized FileIDSelectionModel getSelectionModel() {
+
+        return selectionModel;
+    }
+
+    public GroupManager getGroupManager() {
+        return groupManager;
+    }
+
+    public void setListeningEnabled(boolean enabled) {
+        listeningEnabled.set(enabled);
+    }
+
+    ReadOnlyBooleanProperty listeningEnabled() {
+        return listeningEnabled.getReadOnlyProperty();
+    }
+
+    boolean isListeningEnabled() {
+        return listeningEnabled.get();
+    }
+
+    void setStale(Boolean b) {
+        Platform.runLater(() -> {
+            stale.set(b);
+        });
+        if (Case.isCaseOpen()) {
+            new PerCaseProperties(Case.getCurrentCase()).setConfigSetting(EurekaModule.MODULE_NAME, PerCaseProperties.STALE, b.toString());
+        }
+    }
+
+    public ReadOnlyBooleanProperty stale() {
+        return stale.getReadOnlyProperty();
+    }
+
+    boolean isStale() {
+        return stale.get();
+    }
+
+    private EurekaController() {
+
+        listeningEnabled.addListener((observable, oldValue, newValue) -> {
+            if (newValue && !oldValue) {
+                queueTask(new CopyAnalyzedFiles());
+            }
+        });
+
+        groupManager.getAnalyzedGroups().addListener((Observable o) -> {
+            checkForGroups();
+        });
+
+        groupManager.getUnSeenGroups().addListener((Observable observable) -> {
+            if (groupManager.getUnSeenGroups().size() > 0 && (getViewState() == null || getViewState().getGroup() == null)) {
+                setViewState(GroupViewState.tile(groupManager.getUnSeenGroups().get(0)));
+            }
+        });
+        regroupDisabled.addListener((Observable observable) -> {
+            checkForGroups();
+        });
+
+        IngestManager.getInstance().addIngestModuleEventListener((evt) -> {
+            Platform.runLater(this::updateRegroupDisabled);
+        });
+        IngestManager.getInstance().addIngestJobEventListener((evt) -> {
+            Platform.runLater(this::updateRegroupDisabled);
+        });
+//        metaDataCollapsed.bind(EurekaToolbar.getDefault().showMetaDataProperty());
+    }
+
+    /** submit a background {@link Task} to be queued for execution
+     * by the thread pool.
+     *
+     * @param task */
+    public void submitBGTask(final Task<?> task) {
+        //listen to task state and remove task from list of tasks once it is 'done'
+        task.stateProperty().addListener((observableState, oldState, newState) -> {
+            switch (newState) {
+                case READY:
+                case SCHEDULED:
+                case RUNNING:
+                    break;
+
+                case FAILED:
+                    LOGGER.log(Level.WARNING, "task :" + task.getTitle() + " failed", task.getException());
+                case CANCELLED:
+                case SUCCEEDED:
+                    Platform.runLater(() -> {
+                        synchronized (bgTasks) {
+                            bgTasks.remove(task);
+                        }
+                    });
+                    break;
+            }
+        });
+
+        synchronized (bgTasks) {
+            bgTasks.add(task);
+        }
+
+        bgTaskExecutor.execute(task);
+    }
+
+    synchronized public void pushGroup(GroupViewState viewState, boolean force) {
+        final GroupViewState currentViewState = getViewState();
+
+        if (force || currentViewState.equals(viewState) == false) {
+            historyStack.push(currentViewState);
+            setViewState(viewState);
+            if (viewState.equals(forwardStack.peek())) {
+                forwardStack.pop();
+            } else {
+                forwardStack.clear();
+            }
+        }
+    }
+
+    synchronized public GroupViewState goForward() {
+
+        final GroupViewState currentZoom = getViewState();
+
+        final GroupViewState fpeek = forwardStack.peek();
+
+        if (fpeek != null && currentZoom.equals(fpeek) == false) {
+            historyStack.push(currentZoom);
+            setViewState(fpeek);
+            forwardStack.pop();
+        }
+        return fpeek;
+    }
+
+    synchronized public GroupViewState goBack() {
+        final GroupViewState currentZoom = getViewState();
+        final GroupViewState peek = historyStack.peek();
+
+        if (peek != null && peek.equals(currentZoom) == false) {
+            forwardStack.push(currentZoom);
+            setViewState(historyStack.pop());
+        } else if (peek != null && peek.equals(currentZoom)) {
+            historyStack.pop();
+            return goBack();
+        }
+        return peek;
+    }
+
+    private void updateRegroupDisabled() {
+        regroupDisabled.set(getFileUpdateQueueSizeProperty().get() > 0 || IngestManager.getInstance().isIngestRunning());
+    }
+
+    /** Check if there are any fully analyzed groups available
+     * from the GroupManager and remove blocking progress spinners if there
+     * are. If there aren't, add a blocking progress spinner with appropriate
+     * message. */
+    public final void checkForGroups() {
+        if (groupManager.getAnalyzedGroups().isEmpty()) {
+            if (IngestManager.getInstance().isIngestRunning()) {
+                if (listeningEnabled.get() == false) {
+                    replaceNotification(fullUIStackPane,
+                                        new NoGroupsDialog("No groups are fully analyzed but listening to ingest is disabled. "
+                                                + " No groups will be available until ingest is finished and listening is re-enabled."));
+                } else {
+                    replaceNotification(fullUIStackPane,
+                                        new NoGroupsDialog("No groups are fully analyzed yet, but ingest is still ongoing.  Please Wait.",
+                                                           new ProgressIndicator()));
+                }
+
+            } else if (getFileUpdateQueueSizeProperty().get() > 0) {
+                replaceNotification(fullUIStackPane,
+                                    new NoGroupsDialog("No groups are fully analyzed yet, but image / video data is still being populated.  Please Wait.",
+                                                       new ProgressIndicator()));
+            } else if (db != null && db.countAllFiles() <= 0) { // there are no files in db
+                replaceNotification(fullUIStackPane,
+                                    new NoGroupsDialog("There are no images/videos in the added datasources."));
+
+            } else if (!groupManager.isRegrouping()) {
+                replaceNotification(centralStackPane,
+                                    new NoGroupsDialog("There are no fully analyzed groups to display:"
+                                            + "  the current Group By setting resulted in no groups, "
+                                            + "or no groups are fully analyzed but ingest is not running."));
+            }
+//            else {
+//                replaceNotification(fullUIStackPane,
+//                                    new NoGroupsDialog("Please wait while the images/videos are re grouped.",
+//                                            new ProgressIndicator()));
+//
+//            }
+            // }
+
+        } else {
+            clearNotification();
+        }
+    }
+
+    private void clearNotification() {
+        //remove the ingest spinner
+        if (fullUIStackPane != null) {
+            fullUIStackPane.getChildren().remove(infoOverlay);
+        }
+        //remove the ingest spinner
+        if (centralStackPane != null) {
+            centralStackPane.getChildren().remove(infoOverlay);
+        }
+    }
+
+    private void replaceNotification(StackPane stackPane, Node newNode) {
+        clearNotification();
+
+        infoOverlay = new StackPane(infoOverLayBackground, newNode);
+        if (stackPane != null) {
+            stackPane.getChildren().add(infoOverlay);
+        }
+    }
+
+    private void restartWorker() {
+        if (dbWorkerThread != null) {
+            dbWorkerThread.cancelAllTasks();
+        }
+        dbWorkerThread = new DBWorkerThread();
+
+        getFileUpdateQueueSizeProperty().addListener((o) -> {
+            Platform.runLater(this::updateRegroupDisabled);
+        });
+
+        Thread th = new Thread(dbWorkerThread);
+        th.setDaemon(false); // we want it to go away when it is done
+        th.start();
+    }
+
+    /**
+     * initialize the controller for a specific case.
+     *
+     * @param c
+     */
+    public synchronized void setCase(Case c) {
+
+        this.db = DrawableDB.getDrawableDB(c.getCaseDirectory(), this);
+        db.addUpdatedFileListener(this);
+        setListeningEnabled(EurekaModule.isEnabledforCase(c));
+        setStale(EurekaModule.isCaseStale(c));
+
+        // if we add this line icons are made as files are analyzed rather than on demand.
+        // db.addUpdatedFileListener(IconCache.getDefault());
+        restartWorker();
+
+        groupManager.setDB(db);
+        SummaryTablePane.getDefault().handleCategoryChanged(Collections.EMPTY_LIST);
+    }
+
+    /**
+     * handle {@link FileUpdateEvent} sent from Db when files are
+     * inserted/updated
+     *
+     * @param evt
+     */
+    @Override
+    synchronized public void handleFileUpdate(FileUpdateEvent evt) {
+        final Collection<Long> fileIDs = evt.getUpdatedFiles();
+        switch (evt.getUpdateType()) {
+            case FILE_REMOVED:
+                for (final long fileId : fileIDs) {
+                    //get grouping(s) this file would be in
+                    Set<GroupKey> groupsForFile = groupManager.getGroupKeysForFileID(fileId);
+
+                    for (GroupKey gk : groupsForFile) {
+                        groupManager.removeFromGroup(gk, fileId);
+                    }
+                }
+
+                break;
+            case FILE_UPDATED:
+
+                /**
+                 * TODO: is there a way to optimize this to avoid quering to db
+                 * so much. the problem is that as a new files are analyzed they
+                 * might be in new groups( if we are grouping by say make or
+                 * model)
+                 *
+                 * TODO: Should this be a InnerTask so it can be done by the
+                 * WorkerThread? Is it already done by worker thread because
+                 * handlefileUpdate is invoked through call on db in UpdateTask
+                 * innertask? -jm
+                 */
+                for (final long fileId : fileIDs) {
+
+                    //get grouping(s) this file would be in
+                    Set<GroupKey> groupsForFile = groupManager.getGroupKeysForFileID(fileId);
+
+                    for (GroupKey gk : groupsForFile) {
+                        Grouping g = groupManager.getGroupForKey(gk);
+
+                        //if there is aleady a group that was previously deemed fully analyzed, then add this newly analyzed file to it.
+                        if (g != null) {
+                            g.addFile(fileId);
+                        } ////if there wasn't already a group check if there should be one now
+                        else {
+                            //TODO: use method in groupmanager ?
+                            List<Long> checkAnalyzed = groupManager.checkAnalyzed(gk);
+                            if (checkAnalyzed != null) { // => the group is analyzed, so add it to the ui
+                                groupManager.populateAnalyzedGroup(gk, checkAnalyzed);
+                            }
+                        }
+                    }
+                }
+
+                Category.fireChange(fileIDs);
+                if (evt.getChangedAttribute() == DrawableAttribute.TAGS) {
+                    TagUtils.fireChange(fileIDs);
+                }
+                break;
+        }
+    }
+
+    /**
+     * reset the state of the controller (eg if the case is closed)
+     */
+    public synchronized void reset() {
+        LOGGER.info("resetting EurekaControler to initial state.");
+        selectionModel.clearSelection();
+        Platform.runLater(() -> {
+            viewState.set(null);
+        });
+
+        EurekaToolbar.getDefault().reset();
+        groupManager.clear();
+        if (db != null) {
+            db.closeDBCon();
+        }
+        db = null;
+    }
+
+    /**
+     * add InnerTask to the queue that the worker thread gets its work from
+     *
+     * @param innerTask
+     */
+    final void queueTask(InnerTask innerTask) {
+        // @@@ We could make a lock for the worker thread
+        if (dbWorkerThread == null) {
+            restartWorker();
+        }
+        dbWorkerThread.addTask(innerTask);
+    }
+
+    public DrawableFile getFileFromId(Long fileID) throws TskCoreException {
+        return db.getFileFromID(fileID);
+    }
+
+    public void setStacks(StackPane fullUIStack, StackPane centralStack) {
+        fullUIStackPane = fullUIStack;
+        this.centralStackPane = centralStack;
+        Platform.runLater(this::checkForGroups);
+    }
+
+    public final ReadOnlyIntegerProperty getFileUpdateQueueSizeProperty() {
+        return queueSizeProperty.getReadOnlyProperty();
+    }
+
+    public ReadOnlyIntegerProperty bgTaskQueueSizeProperty() {
+        return bgTasks.sizeProperty();
+    }
+
+    // @@@ REVIEW IF THIS SHOLD BE STATIC...
+    //TODO: concept seems like  the controller deal with how much work to do at a given time
+    // @@@ review this class for synchronization issues (i.e. reset and cancel being called, add, etc.)
+    private class DBWorkerThread implements Runnable {
+
+        // true if the process was requested to stop.  Currently no way to reset it
+        private volatile boolean cancelled = false;
+
+        // list of tasks to run
+        private final BlockingQueue<InnerTask> workQueue = new LinkedBlockingQueue();
+
+        /**
+         * Cancel all of the queued up tasks and the currently scheduled task.
+         * Note that after you cancel, you cannot submit new jobs to this
+         * thread.
+         */
+        public void cancelAllTasks() {
+            cancelled = true;
+            for (InnerTask it : workQueue) {
+                it.cancel();
+            }
+            workQueue.clear();
+            queueSizeProperty.set(workQueue.size());
+        }
+
+        /**
+         * Add a task for the worker thread to perform
+         *
+         * @param it
+         */
+        public void addTask(InnerTask it) {
+            workQueue.add(it);
+            queueSizeProperty.set(workQueue.size());
+        }
+
+        @Override
+        public void run() {
+            // nearly infinite loop waiting for tasks
+            while (true) {
+                if (cancelled) {
+                    return;
+                }
+                try {
+                    // @@@ Could probably do something more fancy here and check if we've been canceled every now and then
+                    InnerTask it = workQueue.take();
+                    if (it.cancelled == false) {
+                        it.run();
+                    }
+
+                    queueSizeProperty.set(workQueue.size());
+
+                } catch (InterruptedException ex) {
+                    Exceptions.printStackTrace(ex);
+                }
+
+            }
+        }
+    }
+
+    public SleuthkitCase getSleuthKitCase() throws IllegalStateException {
+        if (Case.isCaseOpen()) {
+            return Case.getCurrentCase().getSleuthkitCase();
+        } else {
+            throw new IllegalStateException("No Case is open!");
+        }
+    }
+
+    /**
+     * Abstract base class for task to be done on {@link DBWorkerThread}
+     */
+    static public abstract class InnerTask extends ProgressAdapterBase implements Runnable {
+
+        protected volatile boolean cancelled = false;
+
+        public void cancel() {
+            updateState(Worker.State.CANCELLED);
+        }
+
+        protected boolean isCancelled() {
+            return getState() == Worker.State.CANCELLED;
+        }
+    }
+
+    /**
+     * Abstract base class for tasks associated with an obj id in the database
+     */
+    static private abstract class TaskWithID extends InnerTask {
+
+        protected Long obj_id;    // id of image or file
+
+        public TaskWithID(Long id) {
+            super();
+            this.obj_id = id;
+        }
+
+        public Long getId() {
+            return obj_id;
+        }
+    }
+
+    /**
+     * Task to mark all unanalyzed files in the DB as analyzed. Just to make
+     * sure that all are displayed. Added because there were rare cases where
+     * something failed and a file was never marked as analyzed and therefore
+     * never displayed. This task should go into the queue at the end after all
+     * of the update tasks.
+     */
+    class MarkAllFilesAsAnalyzed extends InnerTask {
+
+        @Override
+        public void run() {
+            db.markAllFilesAnalyzed();
+//            checkForGroups();
+        }
+    }
+
+    /**
+     * task that updates one file in database with results from ingest
+     */
+    class UpdateFile extends InnerTask {
+
+        private final AbstractFile file;
+
+        public UpdateFile(AbstractFile f) {
+            super();
+            this.file = f;
+        }
+
+        /**
+         * Update a file in the database
+         */
+        @Override
+        public void run() {
+            DrawableFile drawableFile = DrawableFile.create(file, true);
+            db.updateFile(drawableFile);
+        }
+    }
+
+    /**
+     * task that updates one file in database with results from ingest
+     */
+    class RemoveFile extends InnerTask {
+
+        private final AbstractFile file;
+
+        public RemoveFile(AbstractFile f) {
+            super();
+            this.file = f;
+        }
+
+        /**
+         * Update a file in the database
+         */
+        @Override
+        public void run() {
+            boolean removeFile = db.removeFile(file.getId());
+        }
+    }
+
+    /** Task that runs when eureka listening is (re) enabled.
+     *
+     * Uses the presence of TSK_FILE_TYPE_SIG attributes as a approximation to
+     * 'analyzed'. Grabs all files with supported image/video mime types, and
+     * adds
+     * them to the Drawable DB */
+    class CopyAnalyzedFiles extends InnerTask {
+
+        final private String DRAWABLE_QUERY = "name LIKE '%." + StringUtils.join(EurekaModule.getAllSupportedExtensions(), "' or name LIKE '%.") + "'";
+
+        private ProgressHandle progressHandle = ProgressHandleFactory.createHandle("populating analyzed image/video database");
+
+        @Override
+        public void run() {
+            progressHandle.start();
+            updateMessage("populating analyzed image/video database");
+
+            try {
+                //grap all files with supported mime types
+                final List<AbstractFile> files = getSleuthKitCase().findAllFilesWhere(DRAWABLE_QUERY + " or tsk_files.obj_id in (select tsk_files.obj_id from tsk_files , blackboard_artifacts,  blackboard_attributes"
+                        + " where  blackboard_artifacts.obj_id = tsk_files.obj_id"
+                        + " and blackboard_attributes.artifact_id = blackboard_artifacts.artifact_id"
+                        + " and blackboard_artifacts.artifact_type_id = " + BlackboardArtifact.ARTIFACT_TYPE.TSK_GEN_INFO.getTypeID()
+                        + " and blackboard_attributes.attribute_type_id = " + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_FILE_TYPE_SIG.getTypeID()
+                        + " and blackboard_attributes.value_text in ('" + StringUtils.join(EurekaModule.getSupportedMimes(), "','") + "'))");
+                progressHandle.switchToDeterminate(files.size());
+
+                updateProgress(0.0);
+
+                //do in transaction
+                DrawableDB.DrawableTransaction tr = db.beginTransaction();
+                int units = 0;
+                for (final AbstractFile f : files) {
+                    if (cancelled) {
+                        LOGGER.log(Level.WARNING, "task cancelled: not all contents may be transfered to database");
+                        progressHandle.finish();
+                        break;
+                    }
+                    final Boolean hasMimeType = EurekaModule.hasSupportedMimeType(f);
+                    final boolean known = f.getKnown() == TskData.FileKnown.KNOWN;
+
+                    if (known) {
+                        db.removeFile(f.getId(), tr);  //remove known files
+                    } else {
+                        if (hasMimeType == null) {
+                            if (EurekaModule.isSupported(f)) {
+                                //no mime type but supported => not add as not analyzed
+                                db.updatefile(DrawableFile.create(f, false), tr);
+                            } else {
+                                //no mime type, not supported  => remove ( how dd we get here)
+                                db.removeFile(f.getId(), tr);
+                            }
+                        } else {
+                            if (hasMimeType) {  // supported mimetype => analyzed
+                                db.updatefile(DrawableFile.create(f, true), tr);
+                            } else { //unsupported mimtype => analyzed but shouldn't include
+                                db.removeFile(f.getId(), tr);
+                            }
+                        }
+                    }
+
+                    units++;
+                    final int prog = units;
+                    progressHandle.progress(f.getName(), units);
+                    updateProgress(prog - 1 / (double) files.size());
+                    updateMessage(f.getName());
+                }
+
+                progressHandle.finish();
+
+                progressHandle = ProgressHandleFactory.createHandle("commiting image/video database");
+                updateMessage("commiting image/video database");
+                updateProgress(1.0);
+
+                progressHandle.start();
+                db.commitTransaction(tr, true);
+
+            } catch (TskCoreException ex) {
+                Logger.getLogger(PrePopulateDataSourceFiles.class.getName()).log(Level.WARNING, "failed to transfer all database contents", ex);
+            } catch (IllegalStateException ex) {
+                Logger.getLogger(PrePopulateDataSourceFiles.class.getName()).log(Level.SEVERE, "Case was closed out from underneath CopyDataSource task", ex);
+            }
+
+            progressHandle.finish();
+
+            updateMessage(
+                    "");
+            updateProgress(
+                    -1.0);
+            setStale(false);
+        }
+
+    }
+
+    /** task that does pre-ingest copy over of files from a new datasource
+     * with
+     * (uses fs_obj_id to identify files from new datasource) *
+     *
+     * TODO: create methods to simplify progress value/text updates to both
+     * netbeans and eureka progress/status */
+    class PrePopulateDataSourceFiles extends TaskWithID {
+
+        /** @TODO: for initial grab is there any better way than by
+         * extension?
+         *
+         * in file_done listener we look at file type id attributes and fall
+         * back on jpeg signatures and extensions to check for supported
+         * images */
+        // (name like '.jpg' or name like '.png' ...)
+        final private String DRAWABLE_QUERY = "name LIKE '%." + StringUtils.join(EurekaModule.getAllSupportedExtensions(), "' or name LIKE '%.") + "'";
+
+        private ProgressHandle progressHandle = ProgressHandleFactory.createHandle("prepopulating image/video database");
+
+        public PrePopulateDataSourceFiles(Long id) {
+            super(id);
+        }
+
+        /** Copy files from a newly added data source into the DB */
+        @Override
+        public void run() {
+            progressHandle.start();
+            updateMessage("prepopulating image/video database");
+
+            /* Get all "drawable" files, based on extension. After ingest we
+             * use
+             * file type id module and if necessary jpeg signature matching
+             * to
+             * add remove files */
+            final List<AbstractFile> files;
+            try {
+                files = getSleuthKitCase().findAllFilesWhere(DRAWABLE_QUERY + "and fs_obj_id = " + this.obj_id);
+                progressHandle.switchToDeterminate(files.size());
+
+                updateProgress(0.0);
+
+                //do in transaction
+                DrawableDB.DrawableTransaction tr = db.beginTransaction();
+                int units = 0;
+                for (final AbstractFile f : files) {
+                    if (cancelled) {
+                        LOGGER.log(Level.WARNING, "task cancelled: not all contents may be transfered to database");
+                        progressHandle.finish();
+                        break;
+                    }
+                    db.updatefile(DrawableFile.create(f, false), tr);
+                    units++;
+                    final int prog = units;
+                    progressHandle.progress(f.getName(), units);
+                    updateProgress(prog - 1 / (double) files.size());
+                    updateMessage(f.getName());
+                }
+
+                progressHandle.finish();
+                progressHandle = ProgressHandleFactory.createHandle("commiting image/video database");
+                updateMessage("commiting image/video database");
+                updateProgress(1.0);
+
+                progressHandle.start();
+                db.commitTransaction(tr, false);
+
+            } catch (TskCoreException ex) {
+                Logger.getLogger(PrePopulateDataSourceFiles.class.getName()).log(Level.WARNING, "failed to transfer all database contents", ex);
+            } catch (IllegalStateException ex) {
+                Logger.getLogger(PrePopulateDataSourceFiles.class.getName()).log(Level.SEVERE, "Case was closed out from underneath CopyDataSource task", ex);
+            }
+
+            progressHandle.finish();
+
+            updateMessage("");
+            updateProgress(-1.0);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaModule.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaModule.java
new file mode 100644
index 0000000000000000000000000000000000000000..3f41e27e535f398a3da780eaf8bbc7cea8494cb2
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaModule.java
@@ -0,0 +1,192 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Sets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.Set;
+import java.util.logging.Level;
+import javax.imageio.ImageIO;
+import org.apache.commons.lang3.StringUtils;
+import org.openide.windows.Mode;
+import org.openide.windows.TopComponent;
+import org.openide.windows.WindowManager;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.ImageUtils;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableDB;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.TskCoreException;
+import org.sleuthkit.datamodel.TskData;
+
+/** static definitions and utilities for the Eureka Module
+ *
+ */
+public class EurekaModule {
+
+    static private final Logger LOGGER = Logger.getLogger(EurekaModule.class.getName());
+
+    static final String MODULE_NAME = EurekaModule.class.getSimpleName();
+
+    static private final Set<String> videoExtensions
+            = Sets.newHashSet("aaf", "3gp", "asf", "avi", "m1v", "m2v", "m4v", "mp4",
+                              "mov", "mpeg", "mpg", "mpe", "mp4", "rm", "wmv", "mpv",
+                              "flv", "swf");
+
+    static private final Set<String> imageExtensions = Sets.newHashSet(ImageIO.getReaderFileSuffixes());
+
+    static private final Set<String> supportedExtensions = Sets.union(imageExtensions, videoExtensions);
+
+    static private final Set<String> imageMimes = Sets.newHashSet("image/jpeg", "image/bmp", "image/gif", "image/png");
+
+    static private final Set<String> videoMimes = Sets.newHashSet("video/mp4", "video/x-flv", "video/x-javafx");
+
+    static private final Set<String> supportedMimes = Sets.union(imageMimes, videoMimes);
+
+    public static Set<String> getSupportedMimes() {
+        return Collections.unmodifiableSet(supportedMimes);
+    }
+
+    private EurekaModule() {
+    }
+
+    static boolean isEnabledforCase(Case c) {
+        if (c != null) {
+            String enabledforCaseProp = new PerCaseProperties(c).getConfigSetting(EurekaModule.MODULE_NAME, PerCaseProperties.ENABLED);
+            return StringUtils.isNotBlank(enabledforCaseProp) ? Boolean.valueOf(enabledforCaseProp) : EurekaPreferences.isEnabledByDefault();
+        } else {
+            return false;
+        }
+    }
+
+    public static boolean isCaseStale(Case c) {
+        if (c != null) {
+            String stale = new PerCaseProperties(c).getConfigSetting(EurekaModule.MODULE_NAME, PerCaseProperties.STALE);
+            return StringUtils.isNotBlank(stale) ? Boolean.valueOf(stale) : false;
+        } else {
+            return false;
+        }
+    }
+
+    public static Set<String> getAllSupportedExtensions() {
+        return supportedExtensions;
+    }
+
+    public static Boolean isSupported(AbstractFile file) {
+        //if there were no file type attributes, or we failed to read it, fall back on extension and jpeg header
+        return Optional.ofNullable(hasSupportedMimeType(file)).orElseGet(
+                () -> supportedExtensions.contains(getFileExtension(file)) || ImageUtils.isJpegFileHeader(file));
+    }
+
+    /**
+     *
+     * @param file
+     *
+     * @return true if the file had a TSK_FILE_TYPE_SIG attribute on a
+     *         TSK_GEN_INFO that is in the supported list. False if there was an
+     *         unsupported attribute, null if no attributes were found
+     */
+    public static Boolean hasSupportedMimeType(AbstractFile file) {
+        try {
+            ArrayList<BlackboardAttribute> fileSignatureAttrs = file.getGenInfoAttributes(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_FILE_TYPE_SIG);
+            if (fileSignatureAttrs.isEmpty() == false) {
+                return fileSignatureAttrs.stream().anyMatch(attr -> supportedMimes.contains(attr.getValueString()));
+            }
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.INFO, "failed to read TSK_FILE_TYPE_SIG attribute for " + file.getName(), ex);
+        }
+        return null;
+    }
+
+    /** @param file
+     *
+     * @return
+     */
+    public static boolean isVideoFile(AbstractFile file) {
+        try {
+            ArrayList<BlackboardAttribute> fileSignatureAttrs = file.getGenInfoAttributes(BlackboardAttribute.ATTRIBUTE_TYPE.TSK_FILE_TYPE_SIG);
+            if (fileSignatureAttrs.isEmpty() == false) {
+                return fileSignatureAttrs.stream().anyMatch(attr -> videoMimes.contains(attr.getValueString()));
+            }
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.INFO, "failed to read TSK_FILE_TYPE_SIG attribute for " + file.getName(), ex);
+        }
+        //if there were no file type attributes, or we failed to read it, fall back on extension
+        return videoExtensions.contains(getFileExtension(file));
+    }
+
+    private static String getFileExtension(AbstractFile file) {
+        return Iterables.getLast(Arrays.asList(StringUtils.split(file.getName(), '.')), "");
+    }
+
+    /**
+     * Is the given file 'supported' and not 'known'(nsrl hash hit). If so we
+     * should include it in {@link DrawableDB} and UI
+     *
+     * @param abstractFile
+     *
+     * @return true if the given {@link AbstractFile} is 'supported' and not
+     *         'known', else false
+     */
+    static public boolean isSupportedAndNotKnown(AbstractFile abstractFile) {
+        return (abstractFile.getKnown() != TskData.FileKnown.KNOWN) && EurekaModule.isSupported(abstractFile);
+    }
+
+    //TODO: this doesn ot really belong here, move it to EurekaController? Module?
+    @ThreadConfined(type = ThreadConfined.ThreadType.UI)
+    public static void closeTopComponent() {
+        final TopComponent etc = WindowManager.getDefault().findTopComponent("EurekaTopComponent");
+        if (etc != null) {
+            try {
+                etc.close();
+            } catch (Exception e) {
+                LOGGER.log(Level.SEVERE, "failed to close EurekaTopComponent", e);
+            }
+        }
+    }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.UI)
+    public static void openTopComponent() {
+        //TODO:eventually move to this model, throwing away everything and rebuilding controller groupmanager etc for each case.
+//        synchronized (OpenTimelineAction.class) {
+//            if (timeLineController == null) {
+//                timeLineController = new TimeLineController();
+//                LOGGER.log(Level.WARNING, "Failed to get TimeLineController from lookup. Instantiating one directly.S");
+//            }
+//        }
+//        timeLineController.openTimeLine();
+        final EurekaTopComponent EurekaTc = (EurekaTopComponent) WindowManager.getDefault().findTopComponent("EurekaTopComponent");
+        if (EurekaTc != null) {
+            WindowManager.getDefault().isTopComponentFloating(EurekaTc);
+            Mode mode = WindowManager.getDefault().findMode("timeline");
+            if (mode != null) {
+                mode.dockInto(EurekaTc);
+
+            }
+            EurekaTc.open();
+            EurekaTc.requestActive();
+        }
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanel.form b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanel.form
new file mode 100644
index 0000000000000000000000000000000000000000..e811ca7e4002a750ab16d439073342f753cdd511
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanel.form
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.5" maxVersion="1.9" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+  </AuxValues>
+
+  <Layout>
+    <DimensionLayout dim="0">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Group type="102" attributes="0">
+              <EmptySpace max="-2" attributes="0"/>
+              <Group type="103" groupAlignment="0" attributes="0">
+                  <Component id="enabledForCaseBox" alignment="0" min="-2" max="-2" attributes="0"/>
+                  <Component id="enabledByDefaultBox" alignment="0" min="-2" max="-2" attributes="0"/>
+              </Group>
+              <EmptySpace max="32767" attributes="0"/>
+          </Group>
+      </Group>
+    </DimensionLayout>
+    <DimensionLayout dim="1">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Group type="102" alignment="0" attributes="0">
+              <EmptySpace max="-2" attributes="0"/>
+              <Component id="enabledByDefaultBox" min="-2" max="-2" attributes="0"/>
+              <EmptySpace type="separate" max="-2" attributes="0"/>
+              <Component id="enabledForCaseBox" min="-2" max="-2" attributes="0"/>
+              <EmptySpace max="32767" attributes="0"/>
+          </Group>
+      </Group>
+    </DimensionLayout>
+  </Layout>
+  <SubComponents>
+    <Component class="javax.swing.JCheckBox" name="enabledByDefaultBox">
+      <Properties>
+        <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+          <ResourceString bundle="org/sleuthkit/autopsy/imageanalyzer/Bundle.properties" key="EurekaOptionsPanel.enabledByDefaultBox.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+        </Property>
+      </Properties>
+    </Component>
+    <Component class="javax.swing.JCheckBox" name="enabledForCaseBox">
+      <Properties>
+        <Property name="text" type="java.lang.String" editor="org.netbeans.modules.i18n.form.FormI18nStringEditor">
+          <ResourceString bundle="org/sleuthkit/autopsy/imageanalyzer/Bundle.properties" key="EurekaOptionsPanel.enabledForCaseBox.text" replaceFormat="org.openide.util.NbBundle.getMessage({sourceFileName}.class, &quot;{key}&quot;)"/>
+        </Property>
+      </Properties>
+    </Component>
+  </SubComponents>
+</Form>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanel.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..7c6d7d35273c8101457e33e9cf108dffc5bbf150
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanel.java
@@ -0,0 +1,115 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.awt.event.ActionEvent;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.ingest.IngestManager;
+
+/** The Image/Video Analyzer panel in the NetBeans provided Options Dialogs
+ * accessed via Tool -> Options */
+final class EurekaOptionsPanel extends javax.swing.JPanel {
+
+    EurekaOptionsPanel(EurekaOptionsPanelController controller) {
+        initComponents();
+
+        IngestManager.getInstance().addIngestJobEventListener(evt -> {
+            enabledForCaseBox.setEnabled(Case.isCaseOpen() && IngestManager.getInstance().isIngestRunning() == false);
+        });
+
+        enabledByDefaultBox.addActionListener((ActionEvent e) -> {
+            controller.changed();
+        });
+
+        enabledForCaseBox.addActionListener((ActionEvent e) -> {
+            controller.changed();
+        });
+    }
+
+    /** This method is called from within the constructor to
+     * initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is
+     * always regenerated by the Form Editor.
+     */
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        enabledByDefaultBox = new javax.swing.JCheckBox();
+        enabledForCaseBox = new javax.swing.JCheckBox();
+
+        org.openide.awt.Mnemonics.setLocalizedText(enabledByDefaultBox, org.openide.util.NbBundle.getMessage(EurekaOptionsPanel.class, "EurekaOptionsPanel.enabledByDefaultBox.text")); // NOI18N
+
+        org.openide.awt.Mnemonics.setLocalizedText(enabledForCaseBox, org.openide.util.NbBundle.getMessage(EurekaOptionsPanel.class, "EurekaOptionsPanel.enabledForCaseBox.text")); // NOI18N
+
+        javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
+        this.setLayout(layout);
+        layout.setHorizontalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addGroup(layout.createSequentialGroup()
+                .addContainerGap()
+                .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+                    .addComponent(enabledForCaseBox)
+                    .addComponent(enabledByDefaultBox))
+                .addContainerGap(javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE))
+        );
+        layout.setVerticalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addGroup(layout.createSequentialGroup()
+                .addContainerGap()
+                .addComponent(enabledByDefaultBox)
+                .addGap(18, 18, 18)
+                .addComponent(enabledForCaseBox)
+                .addContainerGap(javax.swing.GroupLayout.DEFAULT_SIZE, Short.MAX_VALUE))
+        );
+    }// </editor-fold>//GEN-END:initComponents
+
+    /** {@inheritDoc} */
+    void load() {
+        enabledByDefaultBox.setSelected(EurekaPreferences.isEnabledByDefault());
+        if (Case.isCaseOpen() && IngestManager.getInstance().isIngestRunning() == false) {
+            enabledForCaseBox.setEnabled(true);
+            enabledForCaseBox.setSelected(EurekaModule.isEnabledforCase(Case.getCurrentCase()));
+        } else {
+            enabledForCaseBox.setEnabled(false);
+            enabledForCaseBox.setSelected(enabledByDefaultBox.isSelected());
+        }
+    }
+
+    /** {@inheritDoc } */
+    void store() {
+        EurekaPreferences.setEnabledByDefault(enabledByDefaultBox.isSelected());
+        EurekaController.getDefault().setListeningEnabled(enabledForCaseBox.isSelected());
+        if (Case.isCaseOpen()) {
+            new PerCaseProperties(Case.getCurrentCase()).setConfigSetting(EurekaModule.MODULE_NAME, PerCaseProperties.ENABLED, Boolean.toString(enabledForCaseBox.isSelected()));
+        }
+    }
+
+    /** {@inheritDoc }
+     *
+     * @return true, since there is no way for this form to be invalid */
+    boolean valid() {
+        // TODO check whether form is consistent and complete
+        return true;
+    }
+
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    private javax.swing.JCheckBox enabledByDefaultBox;
+    private javax.swing.JCheckBox enabledForCaseBox;
+    // End of variables declaration//GEN-END:variables
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanelController.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanelController.java
new file mode 100644
index 0000000000000000000000000000000000000000..849ea034c9989dbdddd3ead0e9e4c0d19e47996d
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaOptionsPanelController.java
@@ -0,0 +1,107 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.beans.PropertyChangeListener;
+import java.beans.PropertyChangeSupport;
+import javax.swing.JComponent;
+import javax.swing.SwingUtilities;
+import org.netbeans.spi.options.OptionsPanelController;
+import org.openide.util.HelpCtx;
+import org.openide.util.Lookup;
+
+@OptionsPanelController.TopLevelRegistration(
+        categoryName = "#OptionsCategory_Name_Options",
+        iconBase = "org/sleuthkit/autopsy/imageanalyzer/images/lightbulb.png",
+        keywords = "#OptionsCategory_Keywords_Options",
+        keywordsCategory = "Options"
+)
+@org.openide.util.NbBundle.Messages({"OptionsCategory_Name_Options=Image / Video Analzyer", "OptionsCategory_Keywords_Options=image video analyzer category "})
+public final class EurekaOptionsPanelController extends OptionsPanelController {
+
+    private EurekaOptionsPanel panel;
+
+    private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
+
+    private boolean changed;
+
+    @Override
+    public void update() {
+        getPanel().load();
+        changed = false;
+    }
+
+    @Override
+    public void applyChanges() {
+        SwingUtilities.invokeLater(() -> {
+            getPanel().store();
+            changed = false;
+        });
+    }
+
+    @Override
+    public void cancel() {
+        // need not do anything special, if no changes have been persisted yet
+    }
+
+    @Override
+    public boolean isValid() {
+        return getPanel().valid();
+    }
+
+    @Override
+    public boolean isChanged() {
+        return changed;
+    }
+
+    @Override
+    public HelpCtx getHelpCtx() {
+        return null; // new HelpCtx("...ID") if you have a help set
+    }
+
+    @Override
+    public JComponent getComponent(Lookup masterLookup) {
+        return getPanel();
+    }
+
+    @Override
+    public void addPropertyChangeListener(PropertyChangeListener l) {
+        pcs.addPropertyChangeListener(l);
+    }
+
+    @Override
+    public void removePropertyChangeListener(PropertyChangeListener l) {
+        pcs.removePropertyChangeListener(l);
+    }
+
+    private EurekaOptionsPanel getPanel() {
+        if (panel == null) {
+            panel = new EurekaOptionsPanel(this);
+        }
+        return panel;
+    }
+
+    void changed() {
+        if (!changed) {
+            changed = true;
+            pcs.firePropertyChange(OptionsPanelController.PROP_CHANGED, false, true);
+        }
+        pcs.firePropertyChange(OptionsPanelController.PROP_VALID, null, null);
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaPreferences.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaPreferences.java
new file mode 100644
index 0000000000000000000000000000000000000000..6c3525b78f8b4f6fbf114ba059d882cbe056c063
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaPreferences.java
@@ -0,0 +1,50 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.util.prefs.PreferenceChangeListener;
+import java.util.prefs.Preferences;
+import org.openide.util.NbPreferences;
+
+/**
+ *
+ */
+public class EurekaPreferences {
+
+    private static final Preferences preferences = NbPreferences.forModule(EurekaPreferences.class);
+
+    static final String ENABLED_BY_DEFAULT = "enabled_by_default";
+
+    static boolean isEnabledByDefault() {
+        final boolean aBoolean = preferences.getBoolean(ENABLED_BY_DEFAULT, true);
+        return aBoolean;
+    }
+
+    static void setEnabledByDefault(boolean b) {
+        preferences.putBoolean(ENABLED_BY_DEFAULT, b);
+    }
+
+    static void addChangeListener(PreferenceChangeListener l) {
+        preferences.addPreferenceChangeListener(l);
+    }
+
+    static void removeChangeListener(PreferenceChangeListener l) {
+        preferences.removePreferenceChangeListener(l);
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaTopComponent.form b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaTopComponent.form
new file mode 100644
index 0000000000000000000000000000000000000000..cf17948941e39dcf077b382efeb030fa88f4007f
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaTopComponent.form
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<Form version="1.4" maxVersion="1.8" type="org.netbeans.modules.form.forminfo.JPanelFormInfo">
+  <AuxValues>
+    <AuxValue name="FormSettings_autoResourcing" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_autoSetComponentName" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_generateFQN" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_generateMnemonicsCode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_i18nAutoMode" type="java.lang.Boolean" value="true"/>
+    <AuxValue name="FormSettings_layoutCodeTarget" type="java.lang.Integer" value="1"/>
+    <AuxValue name="FormSettings_listenerGenerationStyle" type="java.lang.Integer" value="0"/>
+    <AuxValue name="FormSettings_variablesLocal" type="java.lang.Boolean" value="false"/>
+    <AuxValue name="FormSettings_variablesModifier" type="java.lang.Integer" value="2"/>
+  </AuxValues>
+
+  <Layout>
+    <DimensionLayout dim="0">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Component id="eurekaJFXPanel" pref="532" max="32767" attributes="0"/>
+          <Component id="listView1" min="-2" max="-2" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+    <DimensionLayout dim="1">
+      <Group type="103" groupAlignment="0" attributes="0">
+          <Component id="eurekaJFXPanel" pref="389" max="32767" attributes="0"/>
+          <Component id="listView1" min="-2" max="-2" attributes="0"/>
+      </Group>
+    </DimensionLayout>
+  </Layout>
+  <SubComponents>
+    <Component class="org.openide.explorer.view.ListView" name="listView1">
+      <Properties>
+        <Property name="maximumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+          <Dimension value="[0, 0]"/>
+        </Property>
+        <Property name="minimumSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+          <Dimension value="[0, 0]"/>
+        </Property>
+        <Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
+          <Dimension value="[0, 0]"/>
+        </Property>
+      </Properties>
+    </Component>
+    <Container class="javafx.embed.swing.JFXPanel" name="eurekaJFXPanel">
+      <AuxValues>
+        <AuxValue name="JavaCodeGenerator_CreateCodeCustom" type="java.lang.String" value="new EurekaPanel()"/>
+      </AuxValues>
+
+      <Layout class="org.netbeans.modules.form.compat2.layouts.DesignAbsoluteLayout">
+        <Property name="useNullLayout" type="boolean" value="true"/>
+      </Layout>
+    </Container>
+  </SubComponents>
+</Form>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaTopComponent.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaTopComponent.java
new file mode 100644
index 0000000000000000000000000000000000000000..6acfe5bd80cd2056dcb207d81ed43a32d38fd757
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/EurekaTopComponent.java
@@ -0,0 +1,133 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import org.netbeans.api.settings.ConvertAsProperties;
+import org.openide.explorer.ExplorerManager;
+import org.openide.explorer.ExplorerUtils;
+import org.openide.util.Lookup;
+import org.openide.util.NbBundle.Messages;
+import org.openide.windows.TopComponent;
+import org.sleuthkit.autopsy.imageanalyzer.gui.EurekaPanel;
+
+/**
+ * Top component which displays eureka interface.
+ */
+@ConvertAsProperties(
+        dtd = "-//org.sleuthkit.autopsy.imageanalyzer//Eureka//EN",
+        autostore = false)
+@TopComponent.Description(
+        preferredID = "EurekaTopComponent",
+        //iconBase = "org/sleuthkit/autopsy/imageanalyzer/images/lightbulb.png",
+        persistenceType = TopComponent.PERSISTENCE_NEVER)
+@TopComponent.Registration(mode = "timeline", openAtStartup = false)
+@Messages({
+    "CTL_EurekaAction=Image/Video Analysis",
+    "CTL_EurekaTopComponent=Image/Video Analysis",
+    "HINT_EurekaTopComponent=This is a Image/Video Analysis window"
+})
+public final class EurekaTopComponent extends TopComponent implements ExplorerManager.Provider, Lookup.Provider {
+
+    public final static String PREFERRED_ID = "EurekaTopComponent";
+
+    private final ExplorerManager em = new ExplorerManager();
+
+    private final Lookup lookup;
+
+    public EurekaTopComponent() {
+
+        setName(Bundle.CTL_EurekaTopComponent());
+        setToolTipText(Bundle.HINT_EurekaTopComponent());
+
+        // ...and initialization of lookup variable
+        lookup = (ExplorerUtils.createLookup(em, getActionMap()));
+        initComponents();
+
+        ((EurekaPanel) eurekaJFXPanel).initScene();
+    }
+
+    public EurekaPanel getEurekaJFXPanel() {
+        return (EurekaPanel) eurekaJFXPanel;
+    }
+
+    /**
+     * This method is called from within the constructor to initialize the form.
+     * WARNING: Do NOT modify this code. The content of this method is always
+     * regenerated by the Form Editor.
+     */
+    // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents
+    private void initComponents() {
+
+        listView1 = new org.openide.explorer.view.ListView();
+        eurekaJFXPanel = new EurekaPanel();
+
+        listView1.setMaximumSize(new java.awt.Dimension(0, 0));
+        listView1.setMinimumSize(new java.awt.Dimension(0, 0));
+        listView1.setPreferredSize(new java.awt.Dimension(0, 0));
+
+        javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this);
+        this.setLayout(layout);
+        layout.setHorizontalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addComponent(eurekaJFXPanel, javax.swing.GroupLayout.DEFAULT_SIZE, 532, Short.MAX_VALUE)
+            .addComponent(listView1, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)
+        );
+        layout.setVerticalGroup(
+            layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING)
+            .addComponent(eurekaJFXPanel, javax.swing.GroupLayout.DEFAULT_SIZE, 389, Short.MAX_VALUE)
+            .addComponent(listView1, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)
+        );
+    }// </editor-fold>//GEN-END:initComponents
+    // Variables declaration - do not modify//GEN-BEGIN:variables
+    private javafx.embed.swing.JFXPanel eurekaJFXPanel;
+    private org.openide.explorer.view.ListView listView1;
+    // End of variables declaration//GEN-END:variables
+
+    @Override
+    public void componentOpened() {
+
+    }
+
+    @Override
+    public void componentClosed() {
+        //TODO: we could do some cleanup here
+    }
+
+    void writeProperties(java.util.Properties p) {
+        // better to version settings since initial version as advocated at
+        // http://wiki.apidesign.org/wiki/PropertyFiles
+        p.setProperty("version", "1.0");
+        // TODO store your settings
+    }
+
+    void readProperties(java.util.Properties p) {
+        String version = p.getProperty("version");
+        // TODO read your settings according to their version
+    }
+
+    @Override
+    public ExplorerManager getExplorerManager() {
+        return em;
+    }
+
+    @Override
+    public Lookup getLookup() {
+        return lookup;
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FXMLConstructor.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FXMLConstructor.java
new file mode 100644
index 0000000000000000000000000000000000000000..1d4cbc39d321c7c2a6bc90f0863dd20695b8febd
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FXMLConstructor.java
@@ -0,0 +1,141 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.Node;
+import org.openide.util.Exceptions;
+
+/**
+ * This class support both programmer productivity by abstracting frequently
+ * used code to load FXML-defined GUI components, and code performance by
+ * implementing a caching FXMLLoader as described at
+ * http://stackoverflow.com/questions/11734885/javafx2-very-poor-performance-when-adding-custom-made-fxmlpanels-to-gridpane.
+ */
+public class FXMLConstructor {
+
+    private static final CachingClassLoader CACHING_CLASS_LOADER = new CachingClassLoader((FXMLLoader.getDefaultClassLoader()));
+
+    static public void construct(Node n, String fxmlFileName) {
+        FXMLLoader fxmlLoader = new FXMLLoader(n.getClass().getResource(fxmlFileName));
+        fxmlLoader.setRoot(n);
+        fxmlLoader.setController(n);
+        fxmlLoader.setClassLoader(CACHING_CLASS_LOADER);
+
+        try {
+            fxmlLoader.load();
+        } catch (IOException exception) {
+            try {
+                fxmlLoader.setClassLoader(FXMLLoader.getDefaultClassLoader());
+                fxmlLoader.load();
+            } catch (IOException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        }
+    }
+
+    /**
+     * The default FXMLLoader does not cache information about previously loaded
+     * FXML files. See
+     * http://stackoverflow.com/questions/11734885/javafx2-very-poor-performance-when-adding-custom-made-fxmlpanels-to-gridpane.
+     * for more details. As a partial workaround, we cache information on
+     * previously loaded classes. This does not solve all performance issues,
+     * but is a big improvement.
+     */
+    static public class CachingClassLoader extends ClassLoader {
+
+        private final Map<String, Class> classes = new HashMap<String, Class>();
+
+        private final ClassLoader parent;
+
+        public CachingClassLoader(ClassLoader parent) {
+            this.parent = parent;
+        }
+
+        @Override
+        public Class<?> loadClass(String name) throws ClassNotFoundException {
+            Class<?> c = findClass(name);
+            if (c == null) {
+                throw new ClassNotFoundException(name);
+            }
+            return c;
+        }
+
+        @Override
+        protected Class<?> findClass(String className) throws ClassNotFoundException {
+// System.out.print("try to load " + className); 
+            if (classes.containsKey(className)) {
+                Class<?> result = classes.get(className);
+                return result;
+            } else {
+                try {
+                    Class<?> result = parent.loadClass(className);
+// System.out.println(" -> success!"); 
+                    classes.put(className, result);
+                    return result;
+                } catch (ClassNotFoundException ignore) {
+// System.out.println(); 
+                    classes.put(className, null);
+                    return null;
+                }
+            }
+        }
+
+        // ========= delegating methods ============= 
+        @Override
+        public URL getResource(String name) {
+            return parent.getResource(name);
+        }
+
+        @Override
+        public Enumeration<URL> getResources(String name) throws IOException {
+            return parent.getResources(name);
+        }
+
+        @Override
+        public String toString() {
+            return parent.toString();
+        }
+
+        @Override
+        public void setDefaultAssertionStatus(boolean enabled) {
+            parent.setDefaultAssertionStatus(enabled);
+        }
+
+        @Override
+        public void setPackageAssertionStatus(String packageName, boolean enabled) {
+            parent.setPackageAssertionStatus(packageName, enabled);
+        }
+
+        @Override
+        public void setClassAssertionStatus(String className, boolean enabled) {
+            parent.setClassAssertionStatus(className, enabled);
+        }
+
+        @Override
+        public void clearAssertionStatus() {
+            parent.clearAssertionStatus();
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileIDSelectionModel.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileIDSelectionModel.java
new file mode 100644
index 0000000000000000000000000000000000000000..c7e2e2a0900a14facb960c26aa4da89d9fb9809e
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileIDSelectionModel.java
@@ -0,0 +1,113 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.util.Arrays;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.collections.ObservableSet;
+import org.sleuthkit.autopsy.coreutils.Logger;
+
+/** Singleton that manages set of fileIds, as well as
+ * last selected fileID.
+ * NOTE: When we had synchronization on selected and lastSelectedProp we got
+ * deadlocks with the tiles during selection
+ */
+public class FileIDSelectionModel {
+
+    private static final Logger LOGGER = Logger.getLogger(FileIDSelectionModel.class.getName());
+
+    private static FileIDSelectionModel instance;
+
+    private final ObservableSet<Long> selected = FXCollections.observableSet();
+
+    private final ReadOnlyObjectWrapper<Long> lastSelectedProp = new ReadOnlyObjectWrapper<>();
+
+    public static synchronized FileIDSelectionModel getInstance() {
+        if (instance == null) {
+            instance = new FileIDSelectionModel();
+        }
+        return instance;
+    }
+
+    public FileIDSelectionModel() {
+        super();
+    }
+
+    public void toggleSelection(Long id) {
+        boolean contained = selected.contains(id);
+
+        if (contained) {
+            selected.remove(id);
+            setLastSelected(null);
+        } else {
+            selected.add(id);
+            setLastSelected(id);
+        }
+    }
+
+    public void clearAndSelectAll(Long... ids) {
+        selected.clear();
+        selected.addAll(Arrays.asList(ids));
+        setLastSelected(ids[ids.length - 1]);
+    }
+
+    public void clearAndSelect(Long id) {
+        selected.clear();
+        selected.add(id);
+        setLastSelected(id);
+    }
+
+    public void select(Long id) {
+        selected.add(id);
+        setLastSelected(id);
+    }
+
+    public void deSelect(Long id) {
+        selected.remove(id);
+        setLastSelected(null);
+    }
+
+    public void clearSelection() {
+        selected.clear();
+        setLastSelected(null);
+    }
+
+    public boolean isSelected(Long id) {
+        return selected.contains(id);
+    }
+
+    public ReadOnlyObjectProperty<Long> lastSelectedProperty() {
+        return lastSelectedProp.getReadOnlyProperty();
+    }
+
+    private void setLastSelected(Long id) {
+        lastSelectedProp.set(id);
+    }
+
+    public ObservableSet<Long> getSelected() {
+        return selected;
+    }
+
+    public void clearAndSelectAll(ObservableList<Long> ids) {
+        clearAndSelectAll(ids.toArray(new Long[ids.size()]));
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileUpdateEvent.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileUpdateEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..3703a380ee3e65377d00fd14c9b7873e108e364f
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileUpdateEvent.java
@@ -0,0 +1,70 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+public class FileUpdateEvent {
+
+    private final Set<Long> updatedFiles;
+
+    private final DrawableAttribute changedAttribute;
+
+    private final UpdateType updateType;
+
+    public UpdateType getUpdateType() {
+        return updateType;
+    }
+
+    public Collection<Long> getUpdatedFiles() {
+        return updatedFiles;
+    }
+
+    public DrawableAttribute getChangedAttribute() {
+        return changedAttribute;
+    }
+
+    public FileUpdateEvent(Collection<? extends Long> updatedFiles, DrawableAttribute changedAttribute, UpdateType updateType) {
+        this.updatedFiles = new HashSet<>(updatedFiles);
+        this.changedAttribute = changedAttribute;
+        this.updateType = updateType;
+    }
+
+    public FileUpdateEvent(Collection<? extends Long> updatedFiles, DrawableAttribute changedAttribute) {
+        this.updatedFiles = new HashSet<>(updatedFiles);
+        this.changedAttribute = changedAttribute;
+        this.updateType = UpdateType.FILE_UPDATED;
+    }
+
+    public FileUpdateEvent(Collection<? extends Long> updatedFiles) {
+        this.updatedFiles = new HashSet<>(updatedFiles);
+        changedAttribute = null;
+        this.updateType = UpdateType.FILE_UPDATED;
+    }
+
+    static public enum UpdateType {
+
+        FILE_UPDATED, FILE_REMOVED;
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileUpdateListener.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileUpdateListener.java
new file mode 100644
index 0000000000000000000000000000000000000000..f0fced781b46e96f7307ca50d97c9d76ddb2badc
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/FileUpdateListener.java
@@ -0,0 +1,29 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.util.EventListener;
+
+/**
+ *
+ */
+public interface FileUpdateListener extends EventListener {
+
+    public void handleFileUpdate(FileUpdateEvent evt);
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/IconCache.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/IconCache.java
new file mode 100644
index 0000000000000000000000000000000000000000..6b192441370e405be9ad8b58c73a1690c1404262
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/IconCache.java
@@ -0,0 +1,207 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import com.google.common.cache.Cache;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import javafx.beans.property.SimpleIntegerProperty;
+import javafx.embed.swing.SwingFXUtils;
+import javafx.scene.image.Image;
+import javafx.scene.image.WritableImage;
+import javax.imageio.IIOException;
+import javax.imageio.ImageIO;
+import org.apache.commons.lang3.concurrent.BasicThreadFactory;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.corelibs.ScalrWrapper;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.ReadContentInputStream;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** Manages creation and access of icons. Keeps a cache in memory of most
+ * recently used icons, and a disk cache of all icons. */
+public class IconCache {
+
+    static private IconCache instance;
+
+    private static final int MAX_ICON_SIZE = 300;
+
+    private static final Logger LOGGER = Logger.getLogger(IconCache.class.getName());
+
+    private static final Cache<Long, Optional<Image>> cache = CacheBuilder.newBuilder().maximumSize(1000).softValues().expireAfterAccess(10, TimeUnit.MINUTES).build();
+
+    public SimpleIntegerProperty iconSize = new SimpleIntegerProperty(200);
+
+    private final Executor imageSaver = Executors.newSingleThreadExecutor(new BasicThreadFactory.Builder().namingPattern("icon saver-%d").build());
+
+    private IconCache() {
+
+    }
+
+    synchronized static public IconCache getDefault() {
+        if (instance == null) {
+            instance = new IconCache();
+        }
+        return instance;
+    }
+
+    public Image get(DrawableFile file) {
+        try {
+            return cache.get(file.getId(), () -> load(file)).orElse(null);
+        } catch (CacheLoader.InvalidCacheLoadException | ExecutionException ex) {
+            LOGGER.log(Level.WARNING, "failed to load icon for file: " + file.getName(), ex);
+            return null;
+        }
+    }
+
+    public Image get(Long fileID) {
+        try {
+            return get(EurekaController.getDefault().getFileFromId(fileID));
+        } catch (TskCoreException ex) {
+            Exceptions.printStackTrace(ex);
+            return null;
+        }
+    }
+
+    public Optional<Image> load(DrawableFile file) throws IIOException {
+
+        Image icon = null;
+        File cacheFile;
+        try {
+            cacheFile = getCacheFile(file.getId());
+        } catch (IllegalStateException e) {
+            LOGGER.log(Level.WARNING, "can't load icon when no case is open");
+            return Optional.empty();
+        }
+
+        // If a thumbnail file is already saved locally
+        if (cacheFile.exists()) {
+            try {
+                int dim = iconSize.get();
+                icon = new Image(cacheFile.toURI().toURL().toString(), dim, dim, true, false, true);
+            } catch (MalformedURLException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        }
+
+        if (icon == null) {
+            //  Logger.getAnonymousLogger().warning("wrong size cache found for image " + getName());
+            icon = generateAndSaveIcon(file);
+        }
+        return Optional.ofNullable(icon);
+
+    }
+
+    private static File getCacheFile(long id) {
+        return new File(Case.getCurrentCase().getCacheDirectory() + File.separator + id + ".png");
+    }
+
+    private Image generateAndSaveIcon(final DrawableFile file) {
+        Image img;
+        //TODO: should we wrap this in a BufferedInputStream? -jm
+        try (ReadContentInputStream inputStream = new ReadContentInputStream(file.getAbstractFile())) {
+            img = new Image(inputStream, MAX_ICON_SIZE, MAX_ICON_SIZE, true, true);
+            if (img.isError()) {
+                LOGGER.log(Level.WARNING, "problem loading image: {0}. {1}", new Object[]{file.getName(), img.getException().getLocalizedMessage()});
+                return fallbackToSwingImage(file);
+            } else {
+                imageSaver.execute(() -> {
+                    saveIcon(file, img);
+                });
+            }
+        } catch (IOException ex) {
+            return fallbackToSwingImage(file);
+        }
+
+        return img;
+
+    }
+    /* Generate a scaled image */
+
+    private Image fallbackToSwingImage(final DrawableFile file) {
+        final BufferedImage generateSwingIcon = generateSwingIcon(file);
+        if (generateSwingIcon != null) {
+            WritableImage toFXImage = SwingFXUtils.toFXImage(generateSwingIcon, null);
+            if (toFXImage != null) {
+                imageSaver.execute(() -> {
+                    saveIcon(file, toFXImage);
+                });
+            }
+
+            return toFXImage;
+        } else {
+            return null;
+        }
+    }
+
+    private BufferedImage generateSwingIcon(DrawableFile file) {
+        try (ReadContentInputStream inputStream = new ReadContentInputStream(file.getAbstractFile())) {
+            BufferedImage bi = ImageIO.read(inputStream);
+            if (bi == null) {
+                LOGGER.log(Level.WARNING, "No image reader for file: {0}", file.getName());
+                return null;
+            } else {
+                try {
+                    if (Math.max(bi.getWidth(), bi.getHeight()) > MAX_ICON_SIZE) {
+                        bi = ScalrWrapper.resizeFast(bi, iconSize.get());
+                    }
+                } catch (IllegalArgumentException e) {
+                    LOGGER.log(Level.WARNING, "scalr could not scale image to 0: {0}", file.getName());
+                } catch (OutOfMemoryError e) {
+                    LOGGER.log(Level.WARNING, "scalr could not scale image (too large): {0}", file.getName());
+                    return null;
+                }
+            }
+            return bi;
+        } catch (IOException ex) {
+            LOGGER.log(Level.WARNING, "Could not read image: " + file.getName(), ex);
+            return null;
+        }
+    }
+
+    private void saveIcon(final DrawableFile file, final Image bi) {
+        try {
+            /* save the icon in a background thread. profiling
+             * showed that it can take as much time as making
+             * the icon? -bc
+             *
+             * We don't do this now as it doesn't fit the
+             * current model of ui-related backgroiund tasks,
+             * and there might be complications to not just
+             * blocking (eg having more than one task to
+             * create the same icon -jm */
+            File f = getCacheFile(file.getId());
+            ImageIO.write(SwingFXUtils.fromFXImage(bi, null), "png", f);
+        } catch (IOException ex) {
+            LOGGER.log(Level.WARNING, "failed to save generated icon ", ex);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/LoggedTask.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/LoggedTask.java
new file mode 100644
index 0000000000000000000000000000000000000000..58847bf63dc3af264b275b21f9cb098845fd40ad
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/LoggedTask.java
@@ -0,0 +1,75 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import org.sleuthkit.autopsy.imageanalyzer.progress.ProgressAdapter;
+import java.util.logging.Level;
+import javafx.concurrent.Task;
+import org.openide.util.Cancellable;
+import org.sleuthkit.autopsy.coreutils.Logger;
+
+/**
+ *
+ */
+public abstract class LoggedTask<T> extends Task<T> implements Cancellable, ProgressAdapter {
+
+    private static final Logger LOGGER = Logger.getLogger(LoggedTask.class.getName());
+    private final String taskName;
+    private final Boolean logStateChanges;
+
+    public String getTaskName() {
+        return taskName;
+    }
+
+    public LoggedTask(String taskName, Boolean logStateChanges) {
+        this.taskName = taskName;
+        this.logStateChanges = logStateChanges;
+    }
+
+    @Override
+    protected void cancelled() {
+        super.cancelled();
+        if (logStateChanges) {
+            LOGGER.log(Level.WARNING, "{0} cancelled!", taskName);
+        }
+    }
+
+    @Override
+    protected void failed() {
+        super.failed();
+        LOGGER.log(Level.SEVERE, taskName + " failed", getException());
+
+    }
+
+    @Override
+    protected void scheduled() {
+        super.scheduled();
+        if (logStateChanges) {
+            LOGGER.log(Level.INFO, "{0} scheduled", taskName);
+        }
+    }
+
+    @Override
+    protected void succeeded() {
+        super.succeeded();
+        if (logStateChanges) {
+            LOGGER.log(Level.INFO, "{0} succeeded", taskName);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ObservableStack.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ObservableStack.java
new file mode 100644
index 0000000000000000000000000000000000000000..096fa54b91ec93141f9c39ed4f86512a1c3c4a78
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ObservableStack.java
@@ -0,0 +1,59 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2014 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import javafx.beans.property.SimpleListProperty;
+import javafx.collections.FXCollections;
+
+/**
+ *
+ */
+public class ObservableStack<T> extends SimpleListProperty<T> {
+
+    public ObservableStack() {
+        super(FXCollections.synchronizedObservableList(FXCollections.observableArrayList()));
+    }
+
+    public void push(T item) {
+        synchronized (this) {
+            add(0, item);
+        }
+    }
+
+    public T pop() {
+        synchronized (this) {
+            if (isEmpty()) {
+                return null;
+            } else {
+                return remove(0);
+            }
+        }
+    }
+
+    public T peek() {
+        synchronized (this) {
+            if (isEmpty()) {
+                return null;
+            } else {
+                return get(0);
+            }
+        }
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/OnStart.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/OnStart.java
new file mode 100644
index 0000000000000000000000000000000000000000..7234fa1b9509d701a005f76d19a9ddb7574e8746
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/OnStart.java
@@ -0,0 +1,55 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import javafx.application.Platform;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.ingest.IngestManager;
+
+/**
+ *
+ * The {@link OnStart} annotation tells NetBeans to invoke this class's
+ * {@link EurekaModule#run()} method
+ */
+@org.openide.modules.OnStart
+public class OnStart implements Runnable {
+
+    static private final Logger LOGGER = Logger.getLogger(OnStart.class.getName());
+
+    /**
+     * make sure that the eureka listeners get setup as early as possible, and
+     * do other setup stuff.
+     *
+     * This method is invoked by virtue of the {@link OnStart} annotation on the
+     * {@link EurekaModule} class
+     */
+    @Override
+    public void run() {
+        Platform.setImplicitExit(false);
+
+        LOGGER.info("setting up eureka listeners");
+
+        IngestManager.getInstance().addIngestJobEventListener(AutopsyListener.getDefault().getIngestJobEventListener());
+        IngestManager.getInstance().addIngestModuleEventListener(AutopsyListener.getDefault().getIngestModuleEventListener());
+
+        Case.addPropertyChangeListener(AutopsyListener.getDefault().getCaseListener());
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/PerCaseProperties.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/PerCaseProperties.java
new file mode 100644
index 0000000000000000000000000000000000000000..6f8b2c89e7633f7d92f683833bb71624f0810405
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/PerCaseProperties.java
@@ -0,0 +1,297 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.logging.Level;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+
+/**
+ *
+ */
+public class PerCaseProperties {
+
+    public static final String ENABLED = "enabled";
+
+    public static final String STALE = "stale";
+
+    private final Case c;
+
+    /** the constructor */
+    PerCaseProperties(Case c) {
+        this.c = c;
+    }
+
+    /**
+     * Makes a new config file of the specified name. Do not include the
+     * extension.
+     *
+     * @param moduleName - The name of the config file to make
+     *
+     * @return True if successfully created, false if already exists or an error
+     *         is thrown.
+     */
+    public synchronized boolean makeConfigFile(String moduleName) {
+        if (!configExists(moduleName)) {
+            File propPath = new File(getPropertyPath(moduleName));
+            File parent = new File(propPath.getParent());
+            if (!parent.exists()) {
+                parent.mkdirs();
+            }
+            Properties props = new Properties();
+            try {
+                propPath.createNewFile();
+                try (FileOutputStream fos = new FileOutputStream(propPath)) {
+                    props.store(fos, "");
+                }
+            } catch (IOException e) {
+                Logger.getLogger(PerCaseProperties.class.getName()).log(Level.WARNING, "Was not able to create a new properties file.", e); //NON-NLS
+                return false;
+            }
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Determines if a given properties file exists or not.
+     *
+     * @param moduleName - The name of the config file to evaluate
+     *
+     * @return true if the config exists, false otherwise.
+     */
+    public synchronized boolean configExists(String moduleName) {
+        File f = new File(c.getCaseDirectory() + File.separator + moduleName + ".properties");
+        return f.exists();
+    }
+
+    public synchronized boolean settingExists(String moduleName, String settingName) {
+        if (!configExists(moduleName)) {
+            return false;
+        }
+        try {
+            Properties props = fetchProperties(moduleName);
+            return (props.getProperty(settingName) != null);
+        } catch (IOException e) {
+            return false;
+        }
+
+    }
+
+    /**
+     * Returns the path of the given properties file.
+     *
+     * @param moduleName - The name of the config file to evaluate
+     *
+     * @return The path of the given config file. Returns null if the config
+     *         file doesn't exist.
+     */
+    private synchronized String getPropertyPath(String moduleName) {
+        return c.getCaseDirectory() + File.separator + moduleName + ".properties"; //NON-NLS
+    }
+
+    /**
+     * Returns the given properties file's setting as specific by settingName.
+     *
+     * @param moduleName  - The name of the config file to read from.
+     * @param settingName - The setting name to retrieve.
+     *
+     * @return - the value associated with the setting.
+     *
+     * @throws IOException
+     */
+    public synchronized String getConfigSetting(String moduleName, String settingName) {
+        if (!configExists(moduleName)) {
+            makeConfigFile(moduleName);
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.INFO, "File did not exist. Created file [" + moduleName + ".properties]"); //NON-NLS NON-NLS
+        }
+
+        try {
+            Properties props = fetchProperties(moduleName);
+
+            return props.getProperty(settingName);
+        } catch (IOException e) {
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.WARNING, "Could not read config file [" + moduleName + "]", e); //NON-NLS
+            return null;
+        }
+
+    }
+
+    /**
+     * Returns the given properties file's map of settings.
+     *
+     * @param moduleName - the name of the config file to read from.
+     *
+     * @return - the map of all key:value pairs representing the settings of the
+     *         config.
+     *
+     * @throws IOException
+     */
+    public synchronized Map< String, String> getConfigSettings(String moduleName) {
+
+        if (!configExists(moduleName)) {
+            makeConfigFile(moduleName);
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.INFO, "File did not exist. Created file [" + moduleName + ".properties]"); //NON-NLS NON-NLS
+        }
+        try {
+            Properties props = fetchProperties(moduleName);
+
+            Set<String> keys = props.stringPropertyNames();
+            Map<String, String> map = new HashMap<>();
+
+            for (String s : keys) {
+                map.put(s, props.getProperty(s));
+            }
+
+            return map;
+        } catch (IOException e) {
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.WARNING, "Could not read config file [" + moduleName + "]", e); //NON-NLS
+            return null;
+        }
+    }
+
+    /**
+     * Sets the given properties file to the given setting map.
+     *
+     * @param moduleName - The name of the module to be written to.
+     * @param settings   - The mapping of all key:value pairs of settings to add
+     *                   to the config.
+     */
+    public synchronized void setConfigSettings(String moduleName, Map<String, String> settings) {
+        if (!configExists(moduleName)) {
+            makeConfigFile(moduleName);
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.INFO, "File did not exist. Created file [" + moduleName + ".properties]"); //NON-NLS NON-NLS
+        }
+        try {
+            Properties props = fetchProperties(moduleName);
+
+            for (Map.Entry<String, String> kvp : settings.entrySet()) {
+                props.setProperty(kvp.getKey(), kvp.getValue());
+            }
+
+            File path = new File(getPropertyPath(moduleName));
+            try (FileOutputStream fos = new FileOutputStream(path)) {
+                props.store(fos, "Changed config settings(batch)"); //NON-NLS
+            }
+        } catch (IOException e) {
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.WARNING, "Property file exists for [" + moduleName + "] at [" + getPropertyPath(moduleName) + "] but could not be loaded.", e); //NON-NLS NON-NLS NON-NLS
+        }
+    }
+
+    /**
+     * Sets the given properties file to the given settings.
+     *
+     * @param moduleName  - The name of the module to be written to.
+     * @param settingName - The name of the setting to be modified.
+     * @param settingVal  - the value to set the setting to.
+     */
+    public synchronized void setConfigSetting(String moduleName, String settingName, String settingVal) {
+        if (!configExists(moduleName)) {
+            makeConfigFile(moduleName);
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.INFO, "File did not exist. Created file [" + moduleName + ".properties]"); //NON-NLS NON-NLS
+        }
+
+        try {
+            Properties props = fetchProperties(moduleName);
+
+            props.setProperty(settingName, settingVal);
+
+            File path = new File(getPropertyPath(moduleName));
+            try (FileOutputStream fos = new FileOutputStream(path)) {
+                props.store(fos, "Changed config settings(single)"); //NON-NLS
+            }
+        } catch (IOException e) {
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.WARNING, "Property file exists for [" + moduleName + "] at [" + getPropertyPath(moduleName) + "] but could not be loaded.", e); //NON-NLS NON-NLS NON-NLS
+        }
+    }
+
+    /**
+     * Removes the given key from the given properties file.
+     *
+     * @param moduleName - The name of the properties file to be modified.
+     * @param key        - the name of the key to remove.
+     */
+    public synchronized void removeProperty(String moduleName, String key) {
+        if (!configExists(moduleName)) {
+            makeConfigFile(moduleName);
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.INFO, "File did not exist. Created file [" + moduleName + ".properties]"); //NON-NLS NON-NLS
+        }
+
+        try {
+            if (getConfigSetting(moduleName, key) != null) {
+                Properties props = fetchProperties(moduleName);
+
+                props.remove(key);
+                File path = new File(getPropertyPath(moduleName));
+                try (FileOutputStream fos = new FileOutputStream(path)) {
+                    props.store(fos, "Removed " + key); //NON-NLS
+                }
+            }
+        } catch (IOException e) {
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.WARNING, "Could not remove property from file, file not found", e); //NON-NLS
+        }
+    }
+
+    /**
+     * Returns the properties file as specified by moduleName.
+     *
+     * @param moduleName
+     *
+     * @return Properties file as specified by moduleName.
+     *
+     * @throws IOException
+     */
+    private Properties fetchProperties(String moduleName) throws IOException {
+        if (!configExists(moduleName)) {
+            makeConfigFile(moduleName);
+            Logger.getLogger(PerCaseProperties.class.getName()).log(Level.INFO, "File did not exist. Created file [" + moduleName + ".properties]"); //NON-NLS NON-NLS
+        }
+        Properties props;
+        try (InputStream inputStream = new FileInputStream(getPropertyPath(moduleName))) {
+            props = new Properties();
+            props.load(inputStream);
+        }
+        return props;
+    }
+
+//    /**
+//     * Gets the property file as specified.
+//     *
+//     * @param moduleName
+//     *
+//     * @return A new file handle, returns null if the file does not exist.
+//     */
+//    public File getPropertyFile(String moduleName) {
+//        String path = getPropertyPath(moduleName);
+//        if (path == null) {
+//            return null;
+//        } else {
+//            return new File(getPropertyPath(moduleName));
+//        }
+//    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/TagUtils.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/TagUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..362af325685d87afe4c23f6288533c9033a562f3
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/TagUtils.java
@@ -0,0 +1,156 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.logging.Level;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.SplitMenuButton;
+import javafx.scene.image.ImageView;
+import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.casemodule.services.TagsManager;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.actions.AddDrawableTagAction;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Contains static methods for dealing with Tags in Eureka
+ */
+public class TagUtils {
+
+    private static final String follow_Up = "Follow Up";
+
+    private static TagName followUpTagName;
+
+    private final static List<TagListener> listeners = new ArrayList<>();
+
+    synchronized public static TagName getFollowUpTagName() throws TskCoreException {
+        if (followUpTagName == null) {
+            followUpTagName = getTagName(follow_Up);
+        }
+        return followUpTagName;
+    }
+
+    static public Collection<TagName> getNonCategoryTagNames() {
+        List<TagName> nonCatTagNames = new ArrayList<>();
+        List<TagName> allTagNames;
+        try {
+            allTagNames = Case.getCurrentCase().getServices().getTagsManager().getAllTagNames();
+            for (TagName tn : allTagNames) {
+                if (tn.getDisplayName().startsWith(Category.CATEGORY_PREFIX) == false) {
+                    nonCatTagNames.add(tn);
+                }
+            }
+        } catch (TskCoreException | IllegalStateException ex) {
+            Logger.getLogger(TagUtils.class.getName()).log(Level.WARNING, "couldn't access case", ex);
+        }
+
+        return nonCatTagNames;
+    }
+
+    synchronized static public TagName getTagName(String displayName) throws TskCoreException {
+        try {
+            final TagsManager tagsManager = Case.getCurrentCase().getServices().getTagsManager();
+
+            for (TagName tn : tagsManager.getAllTagNames()) {
+                if (displayName.equals(tn.getDisplayName())) {
+                    return tn;
+                }
+            }
+            try {
+                return tagsManager.addTagName(displayName);
+            } catch (TagsManager.TagNameAlreadyExistsException ex) {
+                throw new TskCoreException("tagame exists but wasn't found", ex);
+            }
+        } catch (IllegalStateException ex) {
+            Logger.getLogger(TagUtils.class.getName()).log(Level.SEVERE, "Case was closed out from underneath", ex);
+            throw new TskCoreException("Case was closed out from underneath", ex);
+        }
+    }
+
+    /**
+     * convert a collection of TagNames to a 'pretty' string by concatenating
+     * their displaynames separated by a semi-colons
+     *
+     * Collection<TagName> => "tagname1, tagname2, tagname3"
+     *
+     * @param tagNames
+     *
+     * @return
+     */
+    static public String collectionToString(Collection<TagName> tagNames) {
+        ArrayList<Object> stringNames = new ArrayList<>();
+        for (TagName tn : tagNames) {
+            stringNames.add(tn.getDisplayName());
+        }
+        final String join = StringUtils.join(stringNames, "; ");
+        return join;
+    }
+
+    public static void fireChange(Collection<Long> ids) {
+        synchronized (listeners) {
+            for (TagListener list : listeners) {
+                list.handleTagsChanged(ids);
+            }
+        }
+    }
+
+    public static void registerListener(TagListener aThis) {
+        synchronized (listeners) {
+            listeners.add(aThis);
+        }
+    }
+
+    public static void unregisterListener(TagListener aThis) {
+        synchronized (listeners) {
+            listeners.remove(aThis);
+        }
+    }
+
+    /**
+     *
+     * @param tn            the value of tn
+     * @param eurekaToolbar the value of eurekaToolbar
+     */
+    static public MenuItem createSelTagMenuItem(final TagName tn, final SplitMenuButton tagSelectedMenuButton) {
+        final MenuItem menuItem = new MenuItem(tn.getDisplayName(), new ImageView(DrawableAttribute.TAGS.getIcon()));
+        menuItem.setOnAction(new EventHandler<ActionEvent>() {
+            @Override
+            public void handle(ActionEvent t) {
+                AddDrawableTagAction.getInstance().addTag(tn, "");
+                tagSelectedMenuButton.setText(tn.getDisplayName());
+                tagSelectedMenuButton.setOnAction(this);
+            }
+        });
+        return menuItem;
+    }
+
+    public static interface TagListener {
+
+        public void handleTagsChanged(Collection<Long> ids);
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ThreadConfined.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ThreadConfined.java
new file mode 100644
index 0000000000000000000000000000000000000000..2e33930be94ebcdbd69096e5173eee3f486b3e74
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ThreadConfined.java
@@ -0,0 +1,22 @@
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.SOURCE)
+@Target({ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.FIELD})
+@Inherited
+@Documented
+public @interface ThreadConfined {
+
+    ThreadType type();
+
+    public enum ThreadType {
+
+        ANY, UI, NOT_UI
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ThreadUtils.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ThreadUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..e1a34571e842456a7650f40efe3522c5bb938c2b
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/ThreadUtils.java
@@ -0,0 +1,92 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicReference;
+import javafx.application.Platform;
+import javax.swing.SwingUtilities;
+
+/**
+ *
+ */
+public class ThreadUtils {
+
+    /**
+     * analogous to {@link SwingUtilities#invokeAndWait(java.lang.Runnable) with a return value.
+     * Should be used extremely cautiausly as it blocks the edt thread.
+     *
+     * @param <E> The type of the return value generated by the given Callable,
+     *            r.
+     * @param r   A callable to be invoked on the ui thread.
+     *
+     * @return the result of the Callable's call method;
+     */
+    public static <E> E invokeAndWait(final Callable<E> r) {
+        final AtomicReference<E> ref = new AtomicReference<>();
+        final AtomicReference<Exception> except = new AtomicReference<>();
+        try {
+            SwingUtilities.invokeAndWait(() -> {
+                try {
+                    ref.set(r.call());
+                } catch (Exception e) {
+                    except.set(e);
+                }
+            });
+        } catch (InterruptedException | InvocationTargetException ex) {
+            throw new RuntimeException(ex);
+        } finally {
+            if (except.get() != null) {
+                throw new RuntimeException(except.get());
+            } else {
+                return ref.get();
+            }
+        }
+    }
+
+    /**
+     * analogous to {@link SwingUtilities#invokeAndWait(java.lang.Runnable) for JavaFX.
+     * Should be used extremely cautiausly as it blocks the JavaFx UI  thread.
+     *
+     * @param runnable the runnable to be invoked on the javafx ui thred
+     *
+     * @throws InterruptedException
+     * @throws ExecutionException
+     */
+    public static void runAndWait(Runnable runnable) throws InterruptedException, ExecutionException {
+        FutureTask future = new FutureTask(runnable, null);
+        Platform.runLater(future);
+        future.get();
+    }
+
+    /**
+     *
+     * @param r the value of r
+     */
+    public static void runNowOrLater(Runnable r) {
+        if (Platform.isFxApplicationThread()) {
+            r.run();
+        } else {
+            Platform.runLater(r);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/AddDrawableTagAction.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/AddDrawableTagAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..ee39b503ddaab312a34b5d9d3c1a6f65ec815cec
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/AddDrawableTagAction.java
@@ -0,0 +1,117 @@
+/*
+ * Autopsy Forensic Browser
+ * 
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.FileIDSelectionModel;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateEvent;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import javafx.scene.control.Menu;
+import javax.swing.JOptionPane;
+import javax.swing.SwingWorker;
+import org.openide.util.Utilities;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Instances of this Action allow users to apply tags to content.
+ *
+ * //TODO: since we are not using actionsGlobalContext anymore and this has
+ * diverged from autopsy action, make this extend from controlsfx Action
+ */
+public class AddDrawableTagAction extends AddTagAction {
+    
+    private static final Logger LOGGER = Logger.getLogger(AddDrawableTagAction.class.getName());
+
+    // This class is a singleton to support multi-selection of nodes, since
+    // org.openide.nodes.NodeOp.findActions(Node[] nodes) will only pick up an Action if every 
+    // node in the array returns a reference to the same action object from Node.getActions(boolean).    
+    private static AddDrawableTagAction instance;
+    
+    public static synchronized AddDrawableTagAction getInstance() {
+        if (null == instance) {
+            instance = new AddDrawableTagAction();
+        }
+        return instance;
+    }
+    
+    private AddDrawableTagAction() {
+    }
+    
+    public Menu getPopupMenu() {
+        return new TagMenu();
+    }
+    
+    @Override
+    protected String getActionDisplayName() {
+        return Utilities.actionsGlobalContext().lookupAll(AbstractFile.class).size() > 1 ? "Tag Files" : "Tag File";
+    }
+    
+    @Override
+    public void addTag(TagName tagName, String comment) {
+        Set<Long> selectedFiles = new HashSet<>(FileIDSelectionModel.getInstance().getSelected());
+        
+        new SwingWorker<Void, Void>() {
+            
+            @Override
+            protected Void doInBackground() throws Exception {
+                for (Long fileID : selectedFiles) {
+                    try {
+                        DrawableFile file = EurekaController.getDefault().getFileFromId(fileID);
+                        LOGGER.log(Level.INFO, "tagging {0} with {1} and comment {2}", new Object[]{file.getName(), tagName.getDisplayName(), comment});
+                        Case.getCurrentCase().getServices().getTagsManager().addContentTag(file, tagName, comment);
+                    } catch (IllegalStateException ex) {
+                        LOGGER.log(Level.SEVERE, "Case was closed out from underneath Updatefile task", ex);
+                    } catch (TskCoreException ex) {
+                        LOGGER.log(Level.SEVERE, "Error tagging result", ex);
+                        JOptionPane.showMessageDialog(null, "Unable to tag " + fileID + ".", "Tagging Error", JOptionPane.ERROR_MESSAGE);
+                    }
+
+                    //make sure rest of ui  hears category change.
+                    EurekaController.getDefault().handleFileUpdate(new FileUpdateEvent(Collections.singleton(fileID), DrawableAttribute.TAGS));
+
+                   
+                }
+                
+                refreshDirectoryTree();
+                return null;
+            }
+            
+            @Override
+            protected void done() {
+                super.done();
+                try {
+                    get();
+                } catch (InterruptedException | ExecutionException ex) {
+                    LOGGER.log(Level.SEVERE, "unexpected exception while tagging files", ex);
+                }
+            }
+            
+        }.execute();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/AddTagAction.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/AddTagAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..f832800c00e9b30f449220cd9b4b1b1e96d6366a
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/AddTagAction.java
@@ -0,0 +1,158 @@
+/*
+ * Autopsy Forensic Browser
+ * 
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import java.lang.reflect.InvocationTargetException;
+import java.util.List;
+import java.util.logging.Level;
+import javafx.event.ActionEvent;
+import javafx.scene.control.Menu;
+import javafx.scene.control.MenuItem;
+import javax.swing.SwingUtilities;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.actions.GetTagNameAndCommentDialog;
+import org.sleuthkit.autopsy.actions.GetTagNameDialog;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.casemodule.services.TagsManager;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.directorytree.DirectoryTreeTopComponent;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * An abstract base class for Actions that allow users to tag SleuthKit data
+ * model objects.
+ */
+abstract class AddTagAction {
+
+    protected void refreshDirectoryTree() {
+        // The way the "directory tree" currently works, a new tags sub-tree 
+        // needs to be made to reflect the results of invoking tag Actions. 
+        SwingUtilities.invokeLater(() -> DirectoryTreeTopComponent.findInstance().refreshContentTreeSafe());
+
+    }
+
+    protected static final String NO_COMMENT = "";
+
+    AddTagAction() {
+    }
+
+    /**
+     * Template method to allow derived classes to provide a string for for a
+     * menu item label.
+     */
+    abstract protected String getActionDisplayName();
+
+    /**
+     * Template method to allow derived classes to add the indicated tag and
+     * comment to one or more a SleuthKit data model objects.
+     */
+    abstract protected void addTag(TagName tagName, String comment);
+
+    /**
+     * Instances of this class implement a context menu user interface for
+     * creating or selecting a tag name for a tag and specifying an optional tag
+     * comment.
+     */
+    // @@@ This user interface has some significant usability issues and needs
+    // to be reworked.
+    protected class TagMenu extends Menu {
+
+        TagMenu() {
+            super(getActionDisplayName());
+
+            // Get the current set of tag names.
+            TagsManager tagsManager = Case.getCurrentCase().getServices().getTagsManager();
+            List<TagName> tagNames = null;
+            try {
+                tagNames = tagsManager.getAllTagNames();
+            } catch (TskCoreException ex) {
+                Logger.getLogger(TagsManager.class.getName()).log(Level.SEVERE, "Failed to get tag names", ex);
+            }
+
+            // Create a "Quick Tag" sub-menu.
+            Menu quickTagMenu = new Menu("Quick Tag");
+            getItems().add(quickTagMenu);
+
+            // Each tag name in the current set of tags gets its own menu item in
+            // the "Quick Tags" sub-menu. Selecting one of these menu items adds
+            // a tag with the associated tag name. 
+            if (null != tagNames && !tagNames.isEmpty()) {
+                for (final TagName tagName : tagNames) {
+                    if (tagName.getDisplayName().startsWith(Category.CATEGORY_PREFIX) == false) {
+                        MenuItem tagNameItem = new MenuItem(tagName.getDisplayName());
+                        tagNameItem.setOnAction((ActionEvent t) -> {
+                            addTag(tagName, NO_COMMENT);
+                            refreshDirectoryTree();
+                        });
+                        quickTagMenu.getItems().add(tagNameItem);
+                    }
+                }
+            } else {
+                MenuItem empty = new MenuItem("No tags");
+                empty.setDisable(true);
+                quickTagMenu.getItems().add(empty);
+            }
+
+            //   quickTagMenu.addSeparator();
+            // The "Quick Tag" menu also gets an "Choose Tag..." menu item.
+            // Selecting this item initiates a dialog that can be used to create
+            // or select a tag name and adds a tag with the resulting name.
+            MenuItem newTagMenuItem = new MenuItem("New Tag...");
+            newTagMenuItem.setOnAction((ActionEvent t) -> {
+                try {
+                    SwingUtilities.invokeAndWait(() -> {
+                        TagName tagName = GetTagNameDialog.doDialog();
+                        if (tagName != null) {
+                            addTag(tagName, NO_COMMENT);
+                            refreshDirectoryTree();
+                        }
+                    });
+                } catch (InterruptedException | InvocationTargetException ex) {
+                    Exceptions.printStackTrace(ex);
+                }
+            });
+            quickTagMenu.getItems().add(newTagMenuItem);
+
+            // Create a "Choose Tag and Comment..." menu item. Selecting this item initiates
+            // a dialog that can be used to create or select a tag name with an 
+            // optional comment and adds a tag with the resulting name.
+            MenuItem tagAndCommentItem = new MenuItem("Tag and Comment...");
+            tagAndCommentItem.setOnAction((ActionEvent t) -> {
+                try {
+                    SwingUtilities.invokeAndWait(() -> {
+                        GetTagNameAndCommentDialog.TagNameAndComment tagNameAndComment = GetTagNameAndCommentDialog.doDialog();
+                        if (null != tagNameAndComment) {
+                            if (tagNameAndComment.getTagName().getDisplayName().startsWith(Category.CATEGORY_PREFIX)) {
+                                new CategorizeAction().addTag(tagNameAndComment.getTagName(), tagNameAndComment.getComment());
+                            } else {
+                                AddDrawableTagAction.getInstance().addTag(tagNameAndComment.getTagName(), tagNameAndComment.getComment());
+                            }
+                            refreshDirectoryTree();
+                        }
+                    });
+                } catch (InterruptedException | InvocationTargetException ex) {
+                    Exceptions.printStackTrace(ex);
+                }
+            });
+            getItems().add(tagAndCommentItem);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/Back.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/Back.java
new file mode 100644
index 0000000000000000000000000000000000000000..e003612fb30d18401f7f18ff2475ed43c649f554
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/Back.java
@@ -0,0 +1,50 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2014 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import javafx.event.ActionEvent;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyCodeCombination;
+import org.controlsfx.control.action.AbstractAction;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+
+/**
+ *
+ */
+public class Back extends AbstractAction {
+
+    private static final Image BACK_IMAGE = new Image("/org/sleuthkit/autopsy/imageanalyzer/images/arrow-180.png", 16, 16, true, true, true);
+
+    private final EurekaController controller;
+
+    public Back(EurekaController controller) {
+        super("Back");
+        setGraphic(new ImageView(BACK_IMAGE));
+        setAccelerator(new KeyCodeCombination(KeyCode.LEFT, KeyCodeCombination.ALT_DOWN));
+        this.controller = controller;
+        disabledProperty().bind(controller.getHistoryStack().sizeProperty().isEqualTo(0));
+    }
+
+    @Override
+    public void handle(ActionEvent ae) {
+        controller.goBack();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/CategorizeAction.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/CategorizeAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..785f75f1c6b9291ef34fd68e7fd49e7de029a4c5
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/CategorizeAction.java
@@ -0,0 +1,157 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.FileIDSelectionModel;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateEvent;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupKey;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.logging.Level;
+import javafx.event.ActionEvent;
+import javafx.scene.control.Menu;
+import javafx.scene.control.MenuItem;
+import javafx.scene.input.KeyCodeCombination;
+import javax.swing.JOptionPane;
+import javax.swing.SwingWorker;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.ContentTag;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Adaptation of Tag Actions to enforce category-tag uniqueness
+ *
+ * TODO: since we are not using actionsGlobalContext anymore and this has
+ * diverged from autopsy action, make this extend from controlsfx Action
+ */
+public class CategorizeAction extends AddTagAction {
+
+    private static final Logger LOGGER = Logger.getLogger(CategorizeAction.class.getName());
+
+    private final EurekaController controller;
+
+    public CategorizeAction() {
+        super();
+        this.controller = EurekaController.getDefault();
+    }
+
+    static public Menu getPopupMenu() {
+        return new CategoryMenu();
+    }
+
+    @Override
+    protected String getActionDisplayName() {
+        return "Categorize";
+    }
+
+    @Override
+    public void addTag(TagName tagName, String comment) {
+        Set<Long> selectedFiles = new HashSet<>(FileIDSelectionModel.getInstance().getSelected());
+
+        //TODO: should this get submitted to controller rather than a swingworker ? -jm
+        new SwingWorker<Object, Object>() {
+
+            @Override
+            protected Object doInBackground() throws Exception {
+                Logger.getAnonymousLogger().log(Level.INFO, "categorizing{0} as {1}", new Object[]{selectedFiles.toString(), tagName.getDisplayName()});
+                for (Long fileID : selectedFiles) {
+
+                    try {
+                        DrawableFile file = controller.getFileFromId(fileID);
+
+                        Category oldCat = file.getCategory();
+                        // remove file from old category group
+                        controller.getGroupManager().removeFromGroup(new GroupKey(DrawableAttribute.CATEGORY, oldCat), fileID);
+
+                        //remove old category tag if necessary
+                        List<ContentTag> allContentTags = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByContent(file);
+
+                        for (ContentTag ct : allContentTags) {
+                            //this is bad: treating tags as categories as long as thier names start with prefix
+                            //TODO:  abandon using tags for categories and instead add a new column to DrawableDB
+                            if (ct.getName().getDisplayName().startsWith(Category.CATEGORY_PREFIX)) {
+                                LOGGER.log(Level.INFO, "removing old category from {0}", file.getName());
+                                Case.getCurrentCase().getServices().getTagsManager().deleteContentTag(ct);
+                            }
+                        }
+
+                        if (tagName != Category.ZERO.getTagName()) { // no tags for cat-0
+                            Case.getCurrentCase().getServices().getTagsManager().addContentTag(file, tagName, comment);
+                        }
+                        //make sure rest of ui  hears category change.
+                        controller.handleFileUpdate(
+                                new FileUpdateEvent(Collections.singleton(fileID), DrawableAttribute.CATEGORY));
+
+                    } catch (TskCoreException ex) {
+                        LOGGER.log(Level.SEVERE, "Error categorizing result", ex);
+                        JOptionPane.showMessageDialog(null, "Unable to categorize " + fileID + ".", "Categorizing Error", JOptionPane.ERROR_MESSAGE);
+                    }
+                }
+
+                refreshDirectoryTree();
+                return null;
+            }
+
+            @Override
+            protected void done() {
+                super.done();
+                try {
+                    get();
+                } catch (InterruptedException | ExecutionException ex) {
+                    LOGGER.log(Level.SEVERE, "unexpected exception while categorizing files", ex);
+                }
+            }
+
+        }.execute();
+
+    }
+
+    /**
+     * Instances of this class implement a context menu user interface for
+     * selecting a category
+     */
+    static protected class CategoryMenu extends Menu {
+
+        CategoryMenu() {
+            super("Categorize");
+
+            // Each category get an item in the sub-menu. Selecting one of these menu items adds
+            // a tag with the associated category.
+            for (final Category cat : Category.values()) {
+
+                MenuItem categoryItem = new MenuItem(cat.getDisplayName());
+                categoryItem.setOnAction((ActionEvent t) -> {
+                    final CategorizeAction categorizeAction = new CategorizeAction();
+                    categorizeAction.addTag(cat.getTagName(), NO_COMMENT);
+                });
+                categoryItem.setAccelerator(new KeyCodeCombination(cat.getHotKeycode()));
+                getItems().add(categoryItem);
+            }
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/Forward.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/Forward.java
new file mode 100644
index 0000000000000000000000000000000000000000..fcd69ec5a4aa887dc531034351c21a55700731b6
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/Forward.java
@@ -0,0 +1,50 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2014 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import javafx.event.ActionEvent;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyCodeCombination;
+import org.controlsfx.control.action.AbstractAction;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+
+/**
+ *
+ */
+public class Forward extends AbstractAction {
+
+    private static final Image BACK_IMAGE = new Image("/org/sleuthkit/autopsy/imageanalyzer/images/arrow.png", 16, 16, true, true, true);
+
+    private final EurekaController controller;
+
+    public Forward(EurekaController controller) {
+        super("Forward");
+        setGraphic(new ImageView(BACK_IMAGE));
+        setAccelerator(new KeyCodeCombination(KeyCode.RIGHT, KeyCodeCombination.ALT_DOWN));
+        this.controller = controller;
+        disabledProperty().bind(controller.getForwardStack().sizeProperty().isEqualTo(0));
+    }
+
+    @Override
+    public void handle(ActionEvent ae) {
+        controller.goForward();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/NextUnseenGroup.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/NextUnseenGroup.java
new file mode 100644
index 0000000000000000000000000000000000000000..783456174195f8445ee30b6ee1f7696bb1873521
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/NextUnseenGroup.java
@@ -0,0 +1,54 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupViewState;
+import javafx.beans.Observable;
+import javafx.event.ActionEvent;
+import javafx.scene.image.ImageView;
+import org.controlsfx.control.action.AbstractAction;
+
+/**
+ *
+ */
+public class NextUnseenGroup extends AbstractAction {
+
+    private final EurekaController controller;
+
+    public NextUnseenGroup(EurekaController controller) {
+        super("Next Unseen group");
+        this.controller = controller;
+        setGraphic(new ImageView("/org/sleuthkit/autopsy/imageanalyzer/images/control-double.png"));
+        disabledProperty().set(controller.getGroupManager().getUnSeenGroups().size() <= 1);
+        controller.getGroupManager().getUnSeenGroups().addListener((Observable observable) -> {
+            disabledProperty().set(controller.getGroupManager().getUnSeenGroups().size() <= 1);
+        });
+    }
+
+    @Override
+    public void handle(ActionEvent event) {
+        if (controller.viewState() != null && controller.viewState().get() != null && controller.viewState().get().getGroup() != null) {
+            controller.getGroupManager().markGroupSeen(controller.viewState().get().getGroup());
+        }
+        if (controller.getGroupManager().getUnSeenGroups().size() > 0) {
+            controller.pushGroup(GroupViewState.tile(controller.getGroupManager().getUnSeenGroups().get(0)), false);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/OpenAction.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/OpenAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..80b58ae886e6f1f72733f93cd28ee2b1d6f3913f
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/OpenAction.java
@@ -0,0 +1,121 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import java.awt.Component;
+import javax.swing.JButton;
+import javax.swing.JOptionPane;
+import org.openide.awt.ActionID;
+import org.openide.awt.ActionReference;
+import org.openide.awt.ActionRegistration;
+import org.openide.util.HelpCtx;
+import org.openide.util.NbBundle.Messages;
+import org.openide.util.actions.CallableSystemAction;
+import org.openide.windows.WindowManager;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.core.Installer;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaModule;
+
+@ActionID(category = "Tools",
+          id = "org.sleuthkit.autopsy.imageanalyzer.OpenAction")
+@ActionReference(path = "Menu/Tools" /* , position = 333 */)
+@ActionRegistration( //        iconBase = "org/sleuthkit/autopsy/imageanalyzer/images/lightbulb.png",
+        lazy = false,
+        displayName = "#CTL_OpenAction")
+@Messages("CTL_OpenAction=Analyze Images/Videos")
+public final class OpenAction extends CallableSystemAction {
+
+    private final String EUREKA = "Analyze Images/Videos";
+
+    private static final boolean fxInited = Installer.isJavaFxInited();
+
+    private static final Logger LOGGER = Logger.getLogger(OpenAction.class.getName());
+
+    public OpenAction() {
+        super();
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return Case.isCaseOpen() && fxInited && Case.getCurrentCase().getRootObjectsCount() > 0;
+    }
+
+    /** Returns the toolbar component of this action
+     *
+     * @return component the toolbar button */
+    @Override
+    public Component getToolbarPresenter() {
+        JButton toolbarButton = new JButton(this);
+        toolbarButton.setText(EUREKA);
+        toolbarButton.addActionListener(this);
+
+        return toolbarButton;
+    }
+
+    @Override
+    public void performAction() {
+
+        //check case
+        if (!Case.existsCurrentCase()) {
+            return;
+        }
+        final Case currentCase = Case.getCurrentCase();
+
+        if (EurekaModule.isCaseStale(currentCase)) {
+            //case is stale, ask what to do
+            int answer = JOptionPane.showConfirmDialog(WindowManager.getDefault().getMainWindow(), "The image / video databse may be out of date. "
+                                                       + "Do you want to update and listen for further ingest results?\n"
+                                                       + "  Choosing 'no' will display the out of date results."
+                                                       + " Choosing 'cancel' will close the image /video analyzer",
+                                                       "The image / video databse may be out of date. ", JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE);
+
+            switch (answer) {
+                case JOptionPane.YES_OPTION:
+                    EurekaController.getDefault().setListeningEnabled(true);
+                //fall through
+                case JOptionPane.NO_OPTION:
+                    EurekaModule.openTopComponent();
+                    break;
+                case JOptionPane.CANCEL_OPTION:
+                    break; //do nothing
+
+            }
+        } else {
+            //case is not stale, just open it
+            EurekaModule.openTopComponent();
+        }
+    }
+
+    @Override
+    public String getName() {
+        return EUREKA;
+    }
+
+    @Override
+    public HelpCtx getHelpCtx() {
+        return HelpCtx.DEFAULT_HELP;
+    }
+
+    @Override
+    public boolean asynchronous() {
+        return false; // run on edt
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/OpenHelpAction.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/OpenHelpAction.java
new file mode 100644
index 0000000000000000000000000000000000000000..0fbf4062f82330658b60d14dd23f524ae230152a
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/OpenHelpAction.java
@@ -0,0 +1,51 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import java.awt.Desktop;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.IOException;
+import java.net.URI;
+import org.openide.awt.ActionID;
+import org.openide.awt.ActionReference;
+import org.openide.awt.ActionRegistration;
+import org.openide.util.Exceptions;
+import org.openide.util.NbBundle.Messages;
+
+@ActionID(
+        category = "Help",
+        id = "org.sleuthkit.autopsy.imageanalyzer.actions.OpenHelpAction"
+)
+@ActionRegistration(
+        displayName = "#CTL_OpenHelpAction"
+)
+@ActionReference(path = "Menu/Help", position = 350)
+@Messages("CTL_OpenHelpAction=Image / Video Analyzer Help")
+public final class OpenHelpAction implements ActionListener {
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        try {
+            Desktop.getDesktop().browse(URI.create("http://www.google.com"));
+        } catch (IOException ex) {
+            Exceptions.printStackTrace(ex);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/SwingMenuItemAdapter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/SwingMenuItemAdapter.java
new file mode 100644
index 0000000000000000000000000000000000000000..2d35d2f92641b1f91814147a8fe7ead166a138f6
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/actions/SwingMenuItemAdapter.java
@@ -0,0 +1,87 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.actions;
+
+import javafx.event.ActionEvent;
+import javafx.scene.control.Menu;
+import javafx.scene.control.MenuItem;
+import javax.swing.JMenu;
+import javax.swing.JMenuItem;
+import javax.swing.JPopupMenu;
+import javax.swing.MenuElement;
+
+public class SwingMenuItemAdapter extends MenuItem {
+
+    JMenuItem jMenuItem;
+
+    SwingMenuItemAdapter(final JMenuItem jMenuItem) {
+        super(jMenuItem.getText());
+        this.jMenuItem = jMenuItem;
+        setOnAction((ActionEvent t) -> {
+            jMenuItem.doClick();
+        });
+    }
+
+    public static MenuItem create(MenuElement jmenuItem) {
+        if (jmenuItem instanceof JMenu) {
+            return new SwingMenuAdapter((JMenu) jmenuItem);
+        } else if (jmenuItem instanceof JPopupMenu) {
+            return new SwingMenuAdapter((JPopupMenu) jmenuItem);
+        } else {
+            return new SwingMenuItemAdapter((JMenuItem) jmenuItem);
+        }
+
+    }
+}
+
+class SwingMenuAdapter extends Menu {
+
+    private final MenuElement jMenu;
+
+    SwingMenuAdapter(final JMenu jMenu) {
+        super(jMenu.getText());
+        this.jMenu = jMenu;
+        buildChildren(jMenu);
+
+    }
+
+    SwingMenuAdapter(JPopupMenu jPopupMenu) {
+        super(jPopupMenu.getLabel());
+        this.jMenu = jPopupMenu;
+
+        buildChildren(jMenu);
+    }
+
+    private void buildChildren(MenuElement jMenu) {
+
+        for (MenuElement menuE : jMenu.getSubElements()) {
+            if (menuE instanceof JMenu) {
+                getItems().add(SwingMenuItemAdapter.create((JMenu) menuE));
+            } else if (menuE instanceof JMenuItem) {
+                getItems().add(SwingMenuItemAdapter.create((JMenuItem) menuE));
+            } else if (menuE instanceof JPopupMenu) {
+                buildChildren(menuE);
+            } else {
+
+                System.out.println(menuE.toString());
+//                throw new UnsupportedOperationException();
+            }
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/Category.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/Category.java
new file mode 100644
index 0000000000000000000000000000000000000000..3d9e9dfc1c5611625eb7497cdfc9cfe6990394a5
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/Category.java
@@ -0,0 +1,160 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.datamodel;
+
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.actions.CategorizeAction;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.SplitMenuButton;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyCode;
+import javafx.scene.paint.Color;
+import javax.annotation.concurrent.GuardedBy;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ *
+ */
+public enum Category implements Comparable<Category> {
+
+    ZERO(Color.LIGHTGREY, 0, "CAT-0, Uncategorized"),
+    ONE(Color.RED, 1, "CAT-1,  Child Exploitation (Illegal)"),
+    TWO(Color.ORANGE, 2, "CAT-2, Child Exploitation (Non-Illegal/Age Difficult)"),
+    THREE(Color.YELLOW, 3, "CAT-3, CGI/Animation (Child Exploitive)"),
+    FOUR(Color.BISQUE, 4, "CAT-4,  Exemplar/Comparison (Internal Use Only)"),
+    FIVE(Color.GREEN, 5, "CAT-5, Non-pertinent");
+
+    final static private Map<String, Category> nameMap = new HashMap();
+
+    private static List<Category> valuesList = Arrays.asList(values());
+
+    static {
+        for (Category cat : values()) {
+            nameMap.put(cat.displayName, cat);
+        }
+    }
+
+    @GuardedBy("listeners")
+    private final static Set<CategoryListener> listeners = new HashSet<>();
+
+    public static void fireChange(Collection<Long> ids) {
+        synchronized (listeners) {
+            for (CategoryListener list : listeners) {
+                list.handleCategoryChanged(ids);
+            }
+        }
+    }
+
+    public static void registerListener(CategoryListener aThis) {
+        synchronized (listeners) {
+            listeners.add(aThis);
+        }
+    }
+
+    public static void unregisterListener(CategoryListener aThis) {
+        synchronized (listeners) {
+            listeners.remove(aThis);
+        }
+    }
+
+    public KeyCode getHotKeycode() {
+        return KeyCode.getKeyCode(Integer.toString(id));
+    }
+
+    public static final String CATEGORY_PREFIX = "CAT-";
+
+    private TagName tagName;
+
+    public static List<Category> valuesList() {
+        return valuesList;
+    }
+
+    private Color color;
+
+    private String displayName;
+
+    private int id;
+
+    public Color getColor() {
+        return color;
+    }
+
+    public String getDisplayName() {
+        return displayName;
+    }
+
+    @Override
+    public String toString() {
+        return displayName;
+    }
+
+    static public Category fromDisplayName(String displayName) {
+        return nameMap.get(displayName);
+    }
+
+    private Category(Color color, int id, String name) {
+        this.color = color;
+        this.displayName = name;
+        this.id = id;
+    }
+
+    public TagName getTagName() {
+
+        if (tagName == null) {
+            try {
+                tagName = TagUtils.getTagName(displayName);
+            } catch (TskCoreException ex) {
+                Logger.getLogger(Category.class.getName()).log(Level.SEVERE, "failed to get TagName for " + displayName, ex);
+            }
+        }
+        return tagName;
+    }
+
+    public MenuItem createSelCatMenuItem(final SplitMenuButton catSelectedMenuButton) {
+        final MenuItem menuItem = new MenuItem(this.getDisplayName(), new ImageView(DrawableAttribute.CATEGORY.getIcon()));
+        menuItem.setOnAction(new EventHandler<ActionEvent>() {
+            @Override
+            public void handle(ActionEvent t) {
+                //                        EurekaTileSelectionModel.getInstance().clearAndSelectAll(getTiles().toArray(new DrawableTile[getTiles().size()]));
+                new CategorizeAction().addTag(Category.this.getTagName(), "");
+                catSelectedMenuButton.setText(Category.this.getDisplayName());
+                catSelectedMenuButton.setOnAction(this);
+            }
+        });
+        return menuItem;
+    }
+
+    public static interface CategoryListener {
+
+        public void handleCategoryChanged(Collection<Long> ids);
+
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableAttribute.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableAttribute.java
new file mode 100644
index 0000000000000000000000000000000000000000..32a8a120fc72fe981424b8ac911ece81c259b02a
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableAttribute.java
@@ -0,0 +1,141 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.datamodel;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import javafx.beans.property.ReadOnlyStringWrapper;
+import javafx.beans.property.StringProperty;
+import javafx.scene.image.Image;
+import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.datamodel.TagName;
+
+/** psuedo-enum of attributes to filter, sort, and group on. They
+ * mostly correspond to the columns in the db.
+ *
+ * TODO: Review and refactor DrawableAttribute related code with an eye to usage
+ * of type paramaters and multivalued attributes
+ */
+public class DrawableAttribute<T> {
+
+    public final static DrawableAttribute<String> NAME
+            = new DrawableAttribute<>(AttributeName.NAME, "Name", true, "folder-rename.png");
+
+    public final static DrawableAttribute<Boolean> ANALYZED
+            = new DrawableAttribute<>(AttributeName.ANALYZED, "Analyzed", true, "");
+
+    /** since categories are really just tags in autopsy, they are not dealt
+     * with in the DrawableDB. they have special code in various places
+     * to make this transparent.
+     *
+     * //TODO: this had lead to awkward hard to maintain code, and little
+     * advantage. move categories into DrawableDB
+     */
+    public final static DrawableAttribute<Category> CATEGORY
+            = new DrawableAttribute<>(AttributeName.CATEGORY, "Category", false, "category-icon.png");
+
+    public final static DrawableAttribute<Collection<TagName>> TAGS
+            = new DrawableAttribute<>(AttributeName.TAGS, "Tags", false, "tag_red.png");
+
+    public final static DrawableAttribute<String> PATH
+            = new DrawableAttribute<>(AttributeName.PATH, "Path", true, "folder_picture.png");
+
+    public final static DrawableAttribute<Long> CREATED_TIME
+            = new DrawableAttribute<>(AttributeName.CREATED_TIME, "Created Time", true, "clock--plus.png");
+
+    public final static DrawableAttribute<Long> MODIFIED_TIME
+            = new DrawableAttribute<>(AttributeName.MODIFIED_TIME, "Modified Time", true, "clock--pencil.png");
+
+    public final static DrawableAttribute<String> MAKE
+            = new DrawableAttribute<>(AttributeName.MAKE, "Camera Make", true, "camera.png");
+
+    public final static DrawableAttribute<String> MODEL
+            = new DrawableAttribute<>(AttributeName.MODEL, "Camera Model", true, "camera.png");
+
+    //TODO: should this be DrawableAttribute<Collection<String>>?
+    public final static DrawableAttribute<String> HASHSET
+            = new DrawableAttribute<>(AttributeName.HASHSET, "Hashset", false, "hashset_hits.png");
+
+    public final static DrawableAttribute<Long> OBJ_ID
+            = new DrawableAttribute<>(AttributeName.OBJ_ID, "Internal Object ID", true, "");
+
+    public final static DrawableAttribute<Number> WIDTH
+            = new DrawableAttribute<>(AttributeName.WIDTH, "Width", true, "arrow-resize.png");
+
+    public final static DrawableAttribute<Number> HEIGHT
+            = new DrawableAttribute<>(AttributeName.HEIGHT, "Height", true, "arrow-resize-090.png");
+
+    final private static List< DrawableAttribute<?>> groupables
+            = Arrays.asList(PATH, HASHSET, CATEGORY, TAGS, MAKE, MODEL);
+
+    final private static List<DrawableAttribute<?>> values
+            = Arrays.asList(NAME, ANALYZED, CATEGORY, TAGS, PATH, CREATED_TIME,
+                            MODIFIED_TIME, HASHSET, CATEGORY, MAKE, MODEL, OBJ_ID,
+                            WIDTH, HEIGHT);
+
+    private DrawableAttribute(AttributeName name, String displayName, Boolean isDBColumn, String imageName) {
+        this.attrName = name;
+        this.displayName = new ReadOnlyStringWrapper(displayName);
+        this.isDBColumn = isDBColumn;
+        this.imageName = imageName;
+    }
+
+    private Image icon;
+
+    public final boolean isDBColumn;
+
+    public final AttributeName attrName;
+
+    private final StringProperty displayName;
+
+    private final String imageName;
+
+    public Image getIcon() {
+        if (icon == null) {
+            if (StringUtils.isBlank(imageName) == false) {
+                this.icon = new Image("org/sleuthkit/autopsy/imageanalyzer/images/" + imageName, true);
+            }
+        }
+        return icon;
+    }
+
+    public static List<DrawableAttribute<?>> getGroupableAttrs() {
+        return groupables;
+    }
+
+    public static List<DrawableAttribute<?>> getValues() {
+        return Collections.unmodifiableList(values);
+    }
+
+    public StringProperty displayName() {
+        return displayName;
+    }
+
+    public String getDisplayName() {
+        return displayName.get();
+    }
+
+    public static enum AttributeName {
+
+        NAME, ANALYZED, CATEGORY, TAGS, PATH, CREATED_TIME, MODIFIED_TIME, MAKE,
+        MODEL, HASHSET, OBJ_ID, WIDTH, HEIGHT;
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableDB.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableDB.java
new file mode 100644
index 0000000000000000000000000000000000000000..766b11047536d97315bcaec5cbf1a5945fc099cb
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableDB.java
@@ -0,0 +1,1146 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.datamodel;
+
+import java.io.File;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.logging.Level;
+import javax.swing.SortOrder;
+import org.apache.commons.lang.StringUtils;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateEvent;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateListener;
+import static org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute.AttributeName.CATEGORY;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupKey;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupManager;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupSortBy;
+import static org.sleuthkit.autopsy.imageanalyzer.grouping.GroupSortBy.GROUP_BY_VALUE;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.ContentTag;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+import org.sqlite.SQLiteJDBCLoader;
+
+/** This class is the public interface to the Image / Video Analyzer SQLite
+ * database. This class borrows a lot of ideas and techniques (for good or ill)
+ * from {@link  SleuthkitCase}.
+ *
+ * TODO: Creating an abstract base class for sqlite databases* may make sense
+ * in the future. see also {@link EventsDB} */
+public class DrawableDB {
+
+    private static final java.util.logging.Logger LOGGER = Logger.getLogger(DrawableDB.class.getName());
+
+    //column name constants//////////////////////
+    private static final String ANALYZED = "analyzed";
+
+    private static final String OBJ_ID = "obj_id";
+
+    private static final String HASH_SET_NAME = "hash_set_name";
+
+    private final PreparedStatement insertHashSetStmt;
+
+    private final PreparedStatement groupSeenQueryStmt;
+
+    private final PreparedStatement insertGroupStmt;
+
+    private final List<PreparedStatement> preparedStatements = new ArrayList<>();
+
+    private final PreparedStatement removeFileStmt;
+
+    private final PreparedStatement updateGroupStmt;
+
+    private final PreparedStatement selectHashSetStmt;
+
+    private final PreparedStatement selectHashSetNamesStmt;
+
+    private final PreparedStatement insertHashHitStmt;
+
+    private final PreparedStatement insertFileStmt;
+
+    private final PreparedStatement pathGroupStmt;
+
+    private final PreparedStatement nameGroupStmt;
+
+    private final PreparedStatement created_timeGroupStmt;
+
+    private final PreparedStatement modified_timeGroupStmt;
+
+    private final PreparedStatement makeGroupStmt;
+
+    private final PreparedStatement modelGroupStmt;
+
+    private final PreparedStatement analyzedGroupStmt;
+
+    private final PreparedStatement hashSetGroupStmt;
+
+    /** map from {@link DrawableAttribute} to the {@link PreparedStatement} thet
+     * is used to select groups for that attribute */
+    private final Map<DrawableAttribute, PreparedStatement> groupStatementMap = new HashMap<>();
+
+    /** list of observers to be notified if the database changes */
+    private final HashSet<FileUpdateListener> updateListeners = new HashSet<>();
+
+    private GroupManager manager;
+
+    private EurekaController controller;
+
+    private final String dbPath;
+
+    volatile private Connection con;
+
+    private static final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(true); //use fairness policy
+
+    private static final Lock DBLock = rwLock.writeLock(); //using exclusing lock for all db ops for now
+
+    static {//make sure sqlite driver is loaded // possibly redundant
+        try {
+            Class.forName("org.sqlite.JDBC");
+        } catch (ClassNotFoundException ex) {
+            LOGGER.log(Level.SEVERE, "Failed to load sqlite JDBC driver", ex);
+        }
+    }
+
+    //////////////general database logic , mostly borrowed from sleuthkitcase
+    /**
+     * Lock to protect against concurrent write accesses to case database and to
+     * block readers while database is in write transaction. Should be utilized
+     * by all db code where underlying storage supports max. 1 concurrent writer
+     * MUST always call dbWriteUnLock() as early as possible, in the same thread
+     * where dbWriteLock() was called
+     */
+    public static void dbWriteLock() {
+        //Logger.getLogger("LOCK").log(Level.INFO, "Locking " + rwLock.toString());
+        DBLock.lock();
+    }
+
+    /**
+     * Release previously acquired write lock acquired in this thread using
+     * dbWriteLock(). Call in "finally" block to ensure the lock is always
+     * released.
+     */
+    public static void dbWriteUnlock() {
+        //Logger.getLogger("LOCK").log(Level.INFO, "UNLocking " + rwLock.toString());
+        DBLock.unlock();
+    }
+
+    /**
+     * Lock to protect against read while it is in a write transaction
+     * state.
+     * Supports multiple concurrent readers if there is no writer. MUST
+     * always call dbReadUnLock() as early as possible, in the same thread
+     * where dbReadLock() was called.
+     */
+    void dbReadLock() {
+        DBLock.lock();
+    }
+
+    /**
+     * Release previously acquired read lock acquired in this thread using
+     * dbReadLock(). Call in "finally" block to ensure the lock is always
+     * released.
+     */
+    void dbReadUnlock() {
+        DBLock.unlock();
+    }
+
+    /** @param dbPath the path to the db file
+     *
+     * @throws SQLException if there is problem creating or configuring the db */
+    private DrawableDB(String dbPath) throws SQLException, ExceptionInInitializerError {
+        this.dbPath = dbPath;
+
+        if (initializeDB()) {
+            insertFileStmt = prepareStatement(
+                    "INSERT OR REPLACE INTO drawable_files (obj_id , path, name, created_time, modified_time, make, model, analyzed) "
+                    + "VALUES (?,?,?,?,?,?,?,?)");
+            removeFileStmt = prepareStatement("delete from drawable_files where obj_id = ?");
+
+            pathGroupStmt = prepareStatement("select obj_id , analyzed from drawable_files where  path  = ? ", DrawableAttribute.PATH);
+            nameGroupStmt = prepareStatement("select obj_id , analyzed from drawable_files where  name  = ? ", DrawableAttribute.NAME);
+            created_timeGroupStmt = prepareStatement("select obj_id , analyzed from drawable_files where  created_time  = ? ", DrawableAttribute.CREATED_TIME);
+            modified_timeGroupStmt = prepareStatement("select obj_id , analyzed from drawable_files where  modified_time  = ? ", DrawableAttribute.MODIFIED_TIME);
+            makeGroupStmt = prepareStatement("select obj_id , analyzed from drawable_files where  make  = ? ", DrawableAttribute.MAKE);
+            modelGroupStmt = prepareStatement("select obj_id , analyzed from drawable_files where  model  = ? ", DrawableAttribute.MODEL);
+            analyzedGroupStmt = prepareStatement("Select obj_id , analyzed from drawable_files where analyzed = ?", DrawableAttribute.ANALYZED);
+            hashSetGroupStmt = prepareStatement("select drawable_files.obj_id as obj_id, analyzed from drawable_files natural join hash_sets natural join hash_set_hits  where hash_sets.hash_set_name = ?", DrawableAttribute.HASHSET);
+
+            updateGroupStmt = prepareStatement("update groups set seen = 1 where value = ? and attribute = ?");
+            insertGroupStmt = prepareStatement("insert or replace into groups (value, attribute) values (?,?)");
+
+            groupSeenQueryStmt = prepareStatement("select seen from groups where value = ? and attribute = ?");
+
+            selectHashSetNamesStmt = prepareStatement("SELECT DISTINCT hash_set_name FROM hash_sets");
+            insertHashSetStmt = prepareStatement("insert or ignore into hash_sets (hash_set_name)  values (?)");
+            selectHashSetStmt = prepareStatement("select hash_set_id from hash_sets where hash_set_name = ?");
+
+            insertHashHitStmt = prepareStatement("insert or ignore into hash_set_hits (hash_set_id, obj_id) values (?,?)");
+
+        } else {
+            throw new ExceptionInInitializerError();
+        }
+    }
+
+    /** create PreparedStatement with the supplied string, and add the new
+     * statement to the list of PreparedStatements used in {@link DrawableDB#closeStatements()
+     *
+     * @param stmtString the string representation of the sqlite statement to
+     *                   prepare
+     *
+     * @return the prepared statement
+     *
+     * @throws SQLException if unable to prepare the statement */
+    private PreparedStatement prepareStatement(String stmtString) throws SQLException {
+        PreparedStatement prepareStatement = con.prepareStatement(stmtString);
+        preparedStatements.add(prepareStatement);
+        return prepareStatement;
+    }
+
+    /** calls {@link DrawableDB#prepareStatement(java.lang.String) ,
+     *  and then add the statement to the groupStatmentMap used to lookup
+     * statements by the attribute/column they group on
+     *
+     * @param stmtString the string representation of the sqlite statement to
+     *                   prepare
+     * @param attr       the {@link DrawableAttribute} this query groups by
+     *
+     * @return the prepared statement
+     *
+     * @throws SQLExceptionif unable to prepare the statement */
+    private PreparedStatement prepareStatement(String stmtString, DrawableAttribute<?> attr) throws SQLException {
+        PreparedStatement prepareStatement = prepareStatement(stmtString);
+        if (attr != null) {
+            groupStatementMap.put(attr, prepareStatement);
+        }
+
+        return prepareStatement;
+    }
+
+    /** public factory method. Creates and opens a connection to a new
+     * database * at the given path.
+     *
+     * @param dbPath
+     *
+     * @return
+     */
+    public static DrawableDB getDrawableDB(String dbPath, EurekaController controller) {
+
+        try {
+            DrawableDB drawableDB = new DrawableDB(dbPath + File.separator + "drawable.db");
+            drawableDB.controller = controller;
+            return drawableDB;
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "sql error creating database connection", ex);
+            return null;
+        } catch (ExceptionInInitializerError ex) {
+            LOGGER.log(Level.SEVERE, "error creating database connection", ex);
+            return null;
+        }
+    }
+
+    private void setPragmas() throws SQLException {
+
+        //this should match Sleuthkit db setupt
+        try (Statement statement = con.createStatement()) {
+            //reduce i/o operations, we have no OS crash recovery anyway
+            statement.execute("PRAGMA synchronous = OFF;");
+            //allow to query while in transaction - no need read locks
+            statement.execute("PRAGMA read_uncommitted = True;");
+
+            //TODO: do we need this?
+            statement.execute("PRAGMA foreign_keys = ON");
+
+            //TODO: test this
+            statement.execute("PRAGMA journal_mode  = MEMORY");
+//
+            //we don't use this feature, so turn it off for minimal speed up on queries
+            //this is deprecated and not recomended
+            statement.execute("PRAGMA count_changes = OFF;");
+            //this made a big difference to query speed
+            statement.execute("PRAGMA temp_store = MEMORY");
+            //this made a modest improvement in query speeds
+            statement.execute("PRAGMA cache_size = 50000");
+            //we never delete anything so...
+            statement.execute("PRAGMA auto_vacuum = 0");
+        }
+
+        try {
+            LOGGER.log(Level.INFO, String.format("sqlite-jdbc version %s loaded in %s mode",
+                                                 SQLiteJDBCLoader.getVersion(), SQLiteJDBCLoader.isNativeMode()
+                                                                                ? "native" : "pure-java"));
+        } catch (Exception exception) {
+            LOGGER.log(Level.WARNING, "exception while checking sqlite-jdbc version and mode", exception);
+        }
+
+    }
+
+    /** create the table and indices if they don't already exist
+     *
+     * @return the number of rows in the table , count > 0 indicating an
+     *         existing table */
+    private boolean initializeDB() {
+        try {
+            if (isClosed()) {
+                openDBCon();
+            }
+            setPragmas();
+
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "problem accessing  database", ex);
+            return false;
+        }
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE TABLE  if not exists drawable_files "
+                    + "( obj_id INTEGER PRIMARY KEY, "
+                    + " path VARCHAR(255), "
+                    + " name VARCHAR(255), "
+                    + " created_time integer, "
+                    + " modified_time integer, "
+                    + " make VARCHAR(255), "
+                    + " model VARCHAR(255), "
+                    + " analyzed integer DEFAULT 0)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "problem creating drawable_files table", ex);
+            return false;
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE TABLE  if not exists groups "
+                    + "(group_id INTEGER PRIMARY KEY, "
+                    + " value VARCHAR(255) not null, "
+                    + " attribute VARCHAR(255) not null, "
+                    + " seen integer DEFAULT 0, "
+                    + " UNIQUE(value, attribute) )";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "problem creating groups table", ex);
+            return false;
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE TABLE  if not exists hash_sets "
+                    + "( hash_set_id INTEGER primary key,"
+                    + " hash_set_name VARCHAR(255) UNIQUE NOT NULL)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "problem creating hash_sets table", ex);
+            return false;
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE TABLE  if not exists hash_set_hits "
+                    + "(hash_set_id INTEGER REFERENCES hash_sets(hash_set_id) not null, "
+                    + " obj_id INTEGER REFERENCES drawable_files(obj_id) not null, "
+                    + " PRIMARY KEY (hash_set_id, obj_id))";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "problem creating hash_set_hits table", ex);
+            return false;
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE UNIQUE INDEX if not exists obj_id_idx ON drawable_files(obj_id)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem creating obj_id_idx", ex);
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE  INDEX if not exists path_idx ON drawable_files(path)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem creating path_idx", ex);
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE  INDEX if not exists name_idx ON drawable_files(name)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem creating name_idx", ex);
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE  INDEX if not exists make_idx ON drawable_files(make)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem creating make_idx", ex);
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE  INDEX if not exists model_idx ON drawable_files(model)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem creating model_idx", ex);
+        }
+
+        try (Statement stmt = con.createStatement()) {
+            String sql = "CREATE  INDEX if not exists analyzed_idx ON drawable_files(analyzed)";
+            stmt.execute(sql);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem creating analyzed_idx", ex);
+        }
+
+        return true;
+    }
+
+    @Override
+    public void finalize() throws Throwable {
+        try {
+            closeDBCon();
+        } finally {
+            super.finalize();
+        }
+    }
+
+    public void closeDBCon() {
+        if (con != null) {
+            try {
+                closeStatements();
+                con.close();
+            } catch (SQLException ex) {
+                LOGGER.log(Level.WARNING, "Failed to close connection to drawable.db", ex);
+            }
+        }
+        con = null;
+    }
+
+    public void openDBCon() {
+        try {
+            if (con == null || con.isClosed()) {
+                con = DriverManager.getConnection("jdbc:sqlite:" + dbPath);
+            }
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "Failed to open connection to drawable.db", ex);
+        }
+    }
+
+    public boolean isClosed() throws SQLException {
+        if (con == null) {
+            return true;
+        }
+        return con.isClosed();
+    }
+
+    /** get all the hash set names used in the db
+     *
+     * @return a set of the names of all the hash sets that have hash set hits */
+    public Set<String> getHashSetNames() {
+        Set<String> names = new HashSet<>();
+        // "SELECT DISTINCT hash_set_name FROM hash_sets"
+        dbReadLock();
+        try (ResultSet rs = selectHashSetNamesStmt.executeQuery();) {
+            while (rs.next()) {
+                names.add(rs.getString(HASH_SET_NAME));
+            }
+        } catch (SQLException sQLException) {
+            LOGGER.log(Level.WARNING, "failed to get hash set names", sQLException);
+        } finally {
+            dbReadUnlock();
+        }
+        return names;
+    }
+
+    public boolean isGroupSeen(GroupKey groupKey) {
+        dbReadLock();
+        try {
+            groupSeenQueryStmt.clearParameters();
+            groupSeenQueryStmt.setString(1, groupKey.getValueDisplayName());
+            groupSeenQueryStmt.setString(2, groupKey.getAttribute().attrName.toString());
+            try (ResultSet rs = groupSeenQueryStmt.executeQuery()) {
+                while (rs.next()) {
+                    return rs.getBoolean("seen");
+                }
+            }
+        } catch (SQLException ex) {
+            Exceptions.printStackTrace(ex);
+        } finally {
+            dbReadUnlock();
+        }
+        return false;
+    }
+
+    public void markGroupSeen(GroupKey gk) {
+        dbWriteLock();
+        try {
+            //PreparedStatement updateGroup = con.prepareStatement("update groups set seen = 1 where value = ? and attribute = ?");
+            updateGroupStmt.clearParameters();
+            updateGroupStmt.setString(1, gk.getValueDisplayName());
+            updateGroupStmt.setString(2, gk.getAttribute().attrName.toString());
+            updateGroupStmt.execute();
+        } catch (SQLException ex) {
+            Exceptions.printStackTrace(ex);
+        }
+        dbWriteUnlock();
+    }
+
+    public boolean removeFile(long id) {
+        DrawableTransaction trans = beginTransaction();
+        boolean removeFile = removeFile(id, trans);
+        commitTransaction(trans, true);
+        return removeFile;
+    }
+
+    public void updateFile(DrawableFile f) {
+        DrawableTransaction trans = beginTransaction();
+        updatefile(f, trans);
+        commitTransaction(trans, true);
+    }
+
+    /**
+     * use transactions to update files
+     *
+     * @param f
+     * @param tr
+     *
+     *
+     *
+     */
+    public void updatefile(DrawableFile f, DrawableTransaction tr) {
+
+        //TODO:      implement batch version -jm
+        if (tr.isClosed()) {
+            throw new IllegalArgumentException("can't update database with closed transaction");
+        }
+        dbWriteLock();
+        try {
+
+            // "INSERT OR REPLACE INTO drawable_files (path, name, created_time, modified_time, make, model, analyzed)"
+            insertFileStmt.setLong(1, f.getId());
+            insertFileStmt.setString(2, f.getDrawablePath());
+            insertFileStmt.setString(3, f.getName());
+            insertFileStmt.setLong(4, f.getCrtime());
+            insertFileStmt.setLong(5, f.getMtime());
+            insertFileStmt.setString(6, f.getMake());
+            insertFileStmt.setString(7, f.getModel());
+            insertFileStmt.setInt(8, f.isAnalyzed() ? 1 : 0);
+            insertFileStmt.executeUpdate();
+
+            final List<String> hashSetNames = (List<String>) f.getValueOfAttribute(DrawableAttribute.HASHSET);
+
+            if (hashSetNames.isEmpty() == false) {
+                for (String name : hashSetNames) {
+
+                    // "insert or ignore into hash_sets (hash_set_name)  values (?)"
+                    insertHashSetStmt.setString(1, name);
+                    insertHashSetStmt.executeUpdate();
+
+                    //TODO: use nested select to get hash_set_id rather than seperate statement/query
+                    //"select hash_set_id from hash_sets where hash_set_name = ?"
+                    selectHashSetStmt.setString(1, name);
+                    try (ResultSet rs = selectHashSetStmt.executeQuery()) {
+                        while (rs.next()) {
+                            int hashsetID = rs.getInt("hash_set_id");
+                            //"insert or ignore into hash_set_hits (hash_set_id, obj_id) values (?,?)";
+                            insertHashHitStmt.setInt(1, hashsetID);
+                            insertHashHitStmt.setLong(2, f.getId());
+                            insertHashHitStmt.executeUpdate();
+                            break;
+                        }
+                    }
+                }
+            }
+
+            //and update all groups this file is in
+            for (DrawableAttribute attr : DrawableAttribute.getGroupableAttrs()) {
+                Object valueOfAttribute = f.getValueOfAttribute(attr);
+                Collection vals;
+                if (valueOfAttribute instanceof Collection) {
+                    vals = (Collection) valueOfAttribute;
+                } else {
+                    vals = Collections.singleton(valueOfAttribute);
+                }
+                for (Object val : vals) {
+                    insertGroup(val.toString(), attr);
+                }
+            }
+
+            tr.addUpdatedFile(f.getId());
+
+        } catch (SQLException ex) {
+            LOGGER.log(Level.SEVERE, "failed to update file" + f.getName(), ex);
+        } finally {
+            dbWriteUnlock();
+        }
+    }
+
+    public DrawableTransaction beginTransaction() {
+        return new DrawableTransaction();
+    }
+
+    public void commitTransaction(DrawableTransaction tr, Boolean notify) {
+        if (tr.isClosed()) {
+            throw new IllegalArgumentException("can't close already closed transaction");
+        }
+        tr.commit(notify);
+    }
+
+    public void addUpdatedFileListener(FileUpdateListener l) {
+        updateListeners.add(l);
+    }
+
+    /**
+     *
+     * @param f the value of f
+     */
+    private void fireUpdatedFiles(Collection<Long> f) {
+        for (FileUpdateListener listener : updateListeners) {
+            listener.handleFileUpdate(new FileUpdateEvent(f, null, FileUpdateEvent.UpdateType.FILE_UPDATED));
+        }
+    }
+
+    private void fireRemovedFiles(Collection<Long> f) {
+        for (FileUpdateListener listener : updateListeners) {
+            listener.handleFileUpdate(new FileUpdateEvent(f, null, FileUpdateEvent.UpdateType.FILE_REMOVED));
+        }
+    }
+
+    public Boolean isFileAnalyzed(DrawableFile f) {
+        return isFileAnalyzed(f.getId());
+    }
+
+    public Boolean isFileAnalyzed(long fileId) {
+        dbReadLock();
+        try (Statement stmt = con.createStatement();
+             ResultSet analyzedQuery = stmt.executeQuery("select analyzed from drawable_files where obj_id = " + fileId)) {
+            while (analyzedQuery.next()) {
+                return analyzedQuery.getBoolean(ANALYZED);
+            }
+        } catch (SQLException ex) {
+            Exceptions.printStackTrace(ex);
+        } finally {
+            dbReadUnlock();
+        }
+
+        return false;
+    }
+
+    public Boolean areFilesAnalyzed(Collection<Long> fileIds) {
+
+        dbReadLock();
+        try (Statement stmt = con.createStatement();
+             //Can't make this a preprared statement because of the IN ( ... )
+             ResultSet analyzedQuery = stmt.executeQuery("select count(analyzed) as analyzed from drawable_files where analyzed = 1 and obj_id in (" + StringUtils.join(fileIds, ", ") + ")")) {
+            while (analyzedQuery.next()) {
+                return analyzedQuery.getInt(ANALYZED) == fileIds.size();
+            }
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "problem counting analyzed files: ", ex);
+        } finally {
+            dbReadUnlock();
+        }
+
+        return false;
+    }
+
+    public Boolean isGroupAnalyzed(GroupKey gk) {
+        dbReadLock();
+        try {
+            List<Long> fileIDsInGroup = getFileIDsInGroup(gk);
+
+            try (Statement stmt = con.createStatement();
+                 //Can't make this a preprared statement because of the IN ( ... )
+                 ResultSet analyzedQuery = stmt.executeQuery("select count(analyzed) as analyzed from drawable_files where analyzed = 1 and obj_id in (" + StringUtils.join(fileIDsInGroup, ", ") + ")")) {
+                while (analyzedQuery.next()) {
+                    return analyzedQuery.getInt(ANALYZED) == fileIDsInGroup.size();
+                }
+            } catch (SQLException ex) {
+                LOGGER.log(Level.WARNING, "problem counting analyzed files: ", ex);
+            }
+        } catch (TskCoreException tskCoreException) {
+            LOGGER.log(Level.WARNING, "problem counting analyzed files: ", tskCoreException);
+        } finally {
+            dbReadUnlock();
+        }
+
+        return false;
+    }
+
+    /**
+     * Find and return list of all ids of files matching the specific
+     * Where clause
+     *
+     * @param sqlWhereClause a SQL where clause appropriate for the desired
+     *                       files (do not begin the WHERE clause with the word WHERE!)
+     *
+     * @return a list of file ids each of which satisfy the given WHERE clause
+     *
+     * @throws TskCoreException
+     */
+    public List<Long> findAllFileIdsWhere(String sqlWhereClause) throws TskCoreException {
+        Statement statement = null;
+        ResultSet rs = null;
+        List<Long> ret = new ArrayList<>();
+        dbReadLock();
+        try {
+            statement = con.createStatement();
+            rs = statement.executeQuery("SELECT obj_id FROM drawable_files WHERE " + sqlWhereClause);
+            while (rs.next()) {
+                ret.add(rs.getLong(1));
+            }
+        } catch (SQLException e) {
+            throw new TskCoreException("SQLException thrown when calling 'DrawableDB.findAllFileIdsWhere(): " + sqlWhereClause, e);
+        } finally {
+            if (rs != null) {
+                try {
+                    rs.close();
+                } catch (SQLException ex) {
+                    LOGGER.log(Level.SEVERE, "Error closing result set after executing  findAllFileIdsWhere", ex);
+                }
+            }
+            if (statement != null) {
+                try {
+                    statement.close();
+                } catch (SQLException ex) {
+                    LOGGER.log(Level.SEVERE, "Error closing statement after executing  findAllFileIdsWhere", ex);
+                }
+            }
+            dbReadUnlock();
+        }
+        return ret;
+    }
+
+    /**
+     *
+     *
+     *
+     * @param groupBy
+     * @param sortBy
+     * @param sortOrder
+     *
+     * @return
+     */
+    public <A> List<A> findValuesForAttribute(DrawableAttribute<A> groupBy, GroupSortBy sortBy, SortOrder sortOrder) {
+
+        List<A> vals = new ArrayList<>();
+
+        switch (groupBy.attrName) {
+            case ANALYZED:
+            case CATEGORY:
+            case HASHSET:
+                //these are somewhat special cases for now as they have fixed values, or live in the main autopsy database
+                //they should have special handling at a higher level of the stack.
+                throw new UnsupportedOperationException();
+            default:
+                dbReadLock();
+                //TODO: convert this to prepared statement 
+                StringBuilder query = new StringBuilder("select " + groupBy.attrName.name() + ", count(*) from drawable_files group by " + groupBy.attrName.name());
+
+                String orderByClause = "";
+                switch (sortBy) {
+                    case GROUP_BY_VALUE:
+                        orderByClause = " order by " + groupBy.attrName.name();
+                        break;
+                    case FILE_COUNT:
+                        orderByClause = " order by count(*)";
+                        break;
+                    case NONE:
+//                    case PRIORITY:
+                        break;
+                }
+
+                query.append(orderByClause);
+
+                if (orderByClause.equals("") == false) {
+                    String sortOrderClause = "";
+
+                    switch (sortOrder) {
+                        case DESCENDING:
+                            sortOrderClause = " DESC";
+                            break;
+                        case ASCENDING:
+                            sortOrderClause = " ASC";
+                            break;
+                        default:
+                            orderByClause = "";
+                    }
+                    query.append(sortOrderClause);
+                }
+
+                try (Statement stmt = con.createStatement();
+                     ResultSet valsResults = stmt.executeQuery(query.toString())) {
+
+                    while (valsResults.next()) {
+                        final Object object = valsResults.getObject(groupBy.attrName.name());
+                        vals.add((A) object);
+                    }
+                } catch (SQLException ex) {
+                    LOGGER.log(Level.WARNING, "Unable to get values for attribute", ex);
+                } finally {
+                    dbReadUnlock();
+                }
+        }
+
+        return vals;
+    }
+
+    public void insertGroup(final String value, DrawableAttribute groupBy) {
+        dbWriteLock();
+
+        try {
+            //PreparedStatement insertGroup = con.prepareStatement("insert or ignore into groups (value, attribute, seen) values (?,?,0)");
+            insertGroupStmt.clearParameters();
+            insertGroupStmt.setString(1, value);
+            insertGroupStmt.setString(2, groupBy.attrName.name());
+            insertGroupStmt.execute();
+        } catch (SQLException sQLException) {
+            LOGGER.log(Level.SEVERE, "Unable to insert group", sQLException);
+        } finally {
+            dbWriteUnlock();
+        }
+    }
+
+    /**
+     * @param id       the obj_id of the file to return
+     * @param analyzed the analyzed state of the file
+     *
+     * @return a DrawableFile for the given obj_id and analyzed state
+     *
+     * @throws TskCoreException if unable to get a file from the currently open
+     *                          {@link SleuthkitCase}
+     */
+    private DrawableFile getFileFromID(Long id, boolean analyzed) throws TskCoreException {
+        try {
+            return DrawableFile.create(controller.getSleuthKitCase().getAbstractFileById(id), analyzed);
+        } catch (IllegalStateException ex) {
+            LOGGER.log(Level.SEVERE, "there is no case open; failed to load file with id: " + id, ex);
+            return null;
+        }
+    }
+
+    /**
+     * @param id the obj_id of the file to return
+     *
+     * @return a DrawableFile for the given obj_id
+     *
+     * @throws TskCoreException if unable to get a file from the currently open
+     *                          {@link SleuthkitCase}
+     */
+    public DrawableFile getFileFromID(Long id) throws TskCoreException {
+        try {
+            return DrawableFile.create(controller.getSleuthKitCase().getAbstractFileById(id),
+                                       areFilesAnalyzed(Collections.singleton(id)));
+        } catch (IllegalStateException ex) {
+            LOGGER.log(Level.SEVERE, "there is no case open; failed to load file with id: " + id, ex);
+            return null;
+        }
+    }
+
+    public List<Long> getFileIDsInGroup(GroupKey groupKey) throws TskCoreException {
+
+        if (groupKey.getAttribute().isDBColumn) {
+            switch (groupKey.getAttribute().attrName) {
+                case CATEGORY:
+                    return manager.getFileIDsWithCategory((Category) groupKey.getValue());
+                case TAGS:
+                    return manager.getFileIDsWithTag((TagName) groupKey.getValue());
+            }
+        }
+        List<Long> files = new ArrayList<>();
+        dbReadLock();
+        try {
+            PreparedStatement statement = getGroupStatment(groupKey.getAttribute());
+            statement.setObject(1, groupKey.getValue());
+
+            try (ResultSet valsResults = statement.executeQuery()) {
+                while (valsResults.next()) {
+                    files.add(valsResults.getLong(OBJ_ID));
+                }
+            }
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "failed to get file for group:" + groupKey.getAttribute() + " == " + groupKey.getValue(), ex);
+        } finally {
+            dbReadUnlock();
+        }
+
+        return files;
+    }
+
+    public List<DrawableFile> getFilesInGroup(GroupKey key) throws TskCoreException {
+        List<DrawableFile> files = new ArrayList<>();
+        dbReadLock();
+        try {
+            PreparedStatement statement = null;
+
+            /* I hate this! not flexible/generic/maintainable we could have the
+             * DrawableAttribute provide/create/configure the correct statement
+             * but they shouldn't be coupled like that -jm */
+            switch (key.getAttribute().attrName) {
+                case CATEGORY:
+                    return getFilesWithCategory((Category) key.getValue());
+                default:
+                    statement = getGroupStatment(key.getAttribute());
+            }
+
+            statement.setObject(1, key.getValue());
+            //I hate this! not flexible/generic/maintainable -jm
+            //even special enums like for blackboard artifacts seems clunky
+//            switch (key.getAttribute().getValueType()) {
+//                case STRING:
+//                    statement.setString(1, (String) key.getValue());
+//                    break;
+//                case LONG:
+//                    statement.setLong(1, ((Integer) key.getValue()).longValue());
+//                    break;
+//                case BOOLEAN:
+//                    statement.setInt(1, (int) key.getValue());
+//            }
+            try (ResultSet valsResults = statement.executeQuery()) {
+                while (valsResults.next()) {
+                    files.add(getFileFromID(valsResults.getLong(OBJ_ID), valsResults.getBoolean(ANALYZED)));
+                }
+            }
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "failed to get file for group:" + key.getAttribute() + " == " + key.getValue(), ex);
+        } finally {
+            dbReadUnlock();
+        }
+
+        return files;
+    }
+
+    private void closeStatements() throws SQLException {
+        for (PreparedStatement pStmt : preparedStatements) {
+            pStmt.close();
+        }
+    }
+
+    public List<DrawableFile> getFilesWithCategory(Category cat) throws TskCoreException, IllegalArgumentException {
+        try {
+            List<DrawableFile> files = new ArrayList<>();
+            List<ContentTag> contentTags = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByTagName(cat.getTagName());
+            for (ContentTag ct : contentTags) {
+                if (ct.getContent() instanceof AbstractFile) {
+                    files.add(DrawableFile.create((AbstractFile) ct.getContent(), isFileAnalyzed(ct.getContent().getId())));
+                }
+            }
+            return files;
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.WARNING, "TSK error getting files in Category:" + cat.getDisplayName(), ex);
+            throw ex;
+        }
+    }
+
+    private PreparedStatement getGroupStatment(DrawableAttribute groupBy) {
+        return groupStatementMap.get(groupBy);
+
+    }
+
+    public int countAllFiles() {
+        int result = -1;
+        dbReadLock();
+        try (ResultSet rs = con.createStatement().executeQuery("select count(*) as COUNT from drawable_files")) {
+            while (rs.next()) {
+
+                result = rs.getInt("COUNT");
+                break;
+            }
+        } catch (SQLException ex) {
+            Exceptions.printStackTrace(ex);
+        } finally {
+            dbReadUnlock();
+        }
+        return result;
+    }
+
+    /**
+     * delete the row with obj_id = id.
+     *
+     * @param id the obj_id of the row to be deleted
+     *
+     * @return true if a row was deleted, 0 if not.
+     */
+    public boolean removeFile(long id, DrawableTransaction tr) {
+        if (tr.isClosed()) {
+            throw new IllegalArgumentException("can't update database with closed transaction");
+        }
+        int valsResults = 0;
+        dbWriteLock();
+
+        try {
+            //"delete from drawable_files where (obj_id = " + id + ")"
+            removeFileStmt.setLong(1, id);
+            removeFileStmt.executeUpdate();
+            tr.addRemovedFile(id);
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "failed to delete row for obj_id = " + id, ex);
+        } finally {
+            dbWriteUnlock();
+        }
+
+        //indicates succesfull removal of 1 file
+        return valsResults == 1;
+    }
+
+    /**
+     * Mark all un analyzed files as analyzed.
+     *
+     * TODO: This is a hack we only do because their is
+     * a bug that even after ingest is done, their are sometimes still files
+     * that are not marked as analyzed. Ultimately we should track down the
+     * underlying bug. -jm
+     *
+     * @return the ids of files that we marked as analyzed
+     */
+    public ArrayList<Long> markAllFilesAnalyzed() {
+        DrawableTransaction trans = beginTransaction();
+        ArrayList<Long> ids = new ArrayList<>();
+        dbWriteLock();
+        try (Statement statement = con.createStatement();
+             ResultSet executeQuery = statement.executeQuery("select obj_id from drawable_files where analyzed = 0")) {
+
+            while (executeQuery.next()) {
+                ids.add(executeQuery.getLong("obj_id"));
+            }
+
+            if (ids.isEmpty() == false) {
+                Logger.getAnonymousLogger().log(Level.INFO, "marking as analyzed " + ids);
+                statement.executeUpdate("update drawable_files set analyzed = 1 where obj_id in (" + StringUtils.join(ids, ",") + ")");
+                trans.updatedFiles.addAll(ids);
+            }
+        } catch (SQLException ex) {
+            LOGGER.log(Level.WARNING, "failed to mark files as analyzed", ex);
+        } finally {
+            dbWriteUnlock();
+        }
+
+        trans.commit(Boolean.TRUE);
+
+        return ids;
+    }
+
+    public class MultipleTransactionException extends IllegalStateException {
+
+        private static final String CANNOT_HAVE_MORE_THAN_ONE_OPEN_TRANSACTIO = "cannot have more than one open transaction";
+
+        public MultipleTransactionException() {
+            super(CANNOT_HAVE_MORE_THAN_ONE_OPEN_TRANSACTIO);
+        }
+    }
+
+    /**
+     * inner class that can reference access database connection
+     */
+    public class DrawableTransaction {
+
+        private final Set<Long> updatedFiles;
+
+        private final Set<Long> removedFiles;
+
+        private boolean closed = false;
+
+        /**
+         * factory creation method
+         *
+         * @param con the {@link  ava.sql.Connection}
+         *
+         * @return a LogicalFileTransaction for the given connection
+         *
+         * @throws SQLException
+         */
+        private DrawableTransaction() {
+            this.updatedFiles = new HashSet<>();
+            this.removedFiles = new HashSet<>();
+            //get the write lock, released in close()
+            dbWriteLock();
+            try {
+                con.setAutoCommit(false);
+
+            } catch (SQLException ex) {
+                LOGGER.log(Level.SEVERE, "failed to set auto-commit to to false", ex);
+            }
+
+        }
+
+        synchronized public void rollback() {
+            if (!closed) {
+                try {
+                    con.rollback();
+                    updatedFiles.clear();
+                } catch (SQLException ex1) {
+                    LOGGER.log(Level.SEVERE, "Exception while attempting to rollback!!", ex1);
+                } finally {
+                    close();
+                }
+            }
+        }
+
+        synchronized private void commit(Boolean notify) {
+            if (!closed) {
+                try {
+                    con.commit();
+                    // make sure we close before we update, bc they'll need locks
+                    close();
+
+                    if (notify) {
+                        fireUpdatedFiles(updatedFiles);
+                        fireRemovedFiles(removedFiles);
+                    }
+                } catch (SQLException ex) {
+                    LOGGER.log(Level.SEVERE, "Error commiting drawable.db.", ex);
+                    rollback();
+                }
+            }
+        }
+
+        synchronized private void close() {
+            if (!closed) {
+                try {
+                    con.setAutoCommit(true);
+                } catch (SQLException ex) {
+                    LOGGER.log(Level.SEVERE, "Error setting auto-commit to true.", ex);
+                } finally {
+                    closed = true;
+                    dbWriteUnlock();
+                }
+            }
+        }
+
+        synchronized public Boolean isClosed() {
+            return closed;
+        }
+
+        synchronized private void addUpdatedFile(Long f) {
+            updatedFiles.add(f);
+        }
+
+        synchronized private void addRemovedFile(long id) {
+            removedFiles.add(id);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableFile.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableFile.java
new file mode 100644
index 0000000000000000000000000000000000000000..c29d125790c839a84cc280718ada9a77e487ecd4
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/DrawableFile.java
@@ -0,0 +1,370 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.datamodel;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.logging.Level;
+import javafx.beans.property.SimpleBooleanProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.scene.Node;
+import javafx.scene.image.Image;
+import javafx.util.Pair;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.text.WordUtils;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaModule;
+import static org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute.AttributeName.WIDTH;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import static org.sleuthkit.datamodel.BlackboardAttribute.TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.BYTE;
+import static org.sleuthkit.datamodel.BlackboardAttribute.TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.DOUBLE;
+import static org.sleuthkit.datamodel.BlackboardAttribute.TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.INTEGER;
+import static org.sleuthkit.datamodel.BlackboardAttribute.TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.LONG;
+import static org.sleuthkit.datamodel.BlackboardAttribute.TSK_BLACKBOARD_ATTRIBUTE_VALUE_TYPE.STRING;
+import org.sleuthkit.datamodel.Content;
+import org.sleuthkit.datamodel.ContentTag;
+import org.sleuthkit.datamodel.ContentVisitor;
+import org.sleuthkit.datamodel.SleuthkitItemVisitor;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * @TODO: There is something I don't understand or have done wrong about
+ * implementing this class,as it is unreadable by
+ * {@link ReadContentInputStream}. As a work around I kept a reference to the
+ * original {@link AbstractFile} to use when reading the image. -jm
+ */
+public abstract class DrawableFile<T extends AbstractFile> extends AbstractFile {
+
+    public static DrawableFile create(AbstractFile abstractFileById, boolean b) {
+        if (EurekaModule.isVideoFile(abstractFileById)) {
+            return new VideoFile(abstractFileById, b);
+        } else {
+            return new ImageFile(abstractFileById, b);
+        }
+    }
+
+    public static DrawableFile create(Long id, boolean b) throws TskCoreException, IllegalStateException {
+
+        AbstractFile abstractFileById = Case.getCurrentCase().getSleuthkitCase().getAbstractFileById(id);
+        if (EurekaModule.isVideoFile(abstractFileById)) {
+            return new VideoFile(abstractFileById, b);
+        } else {
+            return new ImageFile(abstractFileById, b);
+        }
+    }
+
+    private String drawablePath;
+
+    abstract public boolean isVideo();
+
+    protected T file;
+
+    private final SimpleBooleanProperty analyzed;
+
+    private final SimpleObjectProperty<Category> category = new SimpleObjectProperty<>(null);
+
+    private final ObservableList<String> hashHitSetNames = FXCollections.observableArrayList();
+
+    public ObservableList<String> getHashHitSetNames() {
+        return hashHitSetNames;
+    }
+
+    private String make;
+
+    private String model;
+
+    protected DrawableFile(T file, Boolean analyzed) {
+        /* @TODO: the two 'new Integer(0).shortValue()' values and null are
+         * placeholders because the super constructor expects values i can't get
+         * easily at the moment */
+
+        super(file.getSleuthkitCase(), file.getId(), file.getAttrType(), file.getAttrId(), file.getName(), file.getType(), file.getMetaAddr(), (int) file.getMetaSeq(), file.getDirType(), file.getMetaType(), null, new Integer(0).shortValue(), file.getSize(), file.getCtime(), file.getCrtime(), file.getAtime(), file.getMtime(), new Integer(0).shortValue(), file.getUid(), file.getGid(), file.getMd5Hash(), file.getKnown(), file.getParentPath());
+        this.analyzed = new SimpleBooleanProperty(analyzed);
+        this.file = file;
+        updateHashSetHits();
+    }
+
+    @Override
+    public boolean isRoot() {
+        return false;
+    }
+
+    @Override
+    public <T> T accept(SleuthkitItemVisitor<T> v) {
+
+        return file.accept(v);
+    }
+
+    @Override
+    public <T> T accept(ContentVisitor<T> v) {
+        return file.accept(v);
+    }
+
+    @Override
+    public List<Content> getChildren() throws TskCoreException {
+        return new ArrayList<>();
+    }
+
+    @Override
+    public List<Long> getChildrenIds() throws TskCoreException {
+        return new ArrayList<>();
+    }
+
+    public ObservableList<Pair<DrawableAttribute, ? extends Object>> getAttributesList() {
+        final ObservableList<Pair<DrawableAttribute, ? extends Object>> attributeList = FXCollections.observableArrayList();
+        for (DrawableAttribute attr : DrawableAttribute.getValues()) {
+            attributeList.add(new Pair<>(attr, getValueOfAttribute(attr)));
+        }
+        return attributeList;
+    }
+
+    public Object getValueOfAttribute(DrawableAttribute attr) {
+        switch (attr.attrName) {
+            case OBJ_ID:
+                return file.getId();
+            case PATH:
+                return getDrawablePath();
+            case NAME:
+                return getName();
+            case CREATED_TIME:
+                return getCrtimeAsDate();
+            case MODIFIED_TIME:
+                return getMtimeAsDate();
+            case MAKE:
+                if (make == null) {
+                    make = WordUtils.capitalizeFully((String) getValueOfBBAttribute(BlackboardArtifact.ARTIFACT_TYPE.TSK_METADATA_EXIF, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_DEVICE_MAKE));
+                }
+                return make;
+            case MODEL:
+                if (model == null) {
+                    model = WordUtils.capitalizeFully((String) getValueOfBBAttribute(BlackboardArtifact.ARTIFACT_TYPE.TSK_METADATA_EXIF, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_DEVICE_MODEL));
+                }
+                return model;
+
+            case CATEGORY:
+
+                updateCategory();
+
+                return category.get();
+
+            case TAGS:
+                try {
+                    List<ContentTag> contentTagsByContent = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByContent(this);
+                    Set<TagName> values = new HashSet<>();
+
+                    for (ContentTag ct : contentTagsByContent) {
+                        values.add(ct.getName());
+                    }
+                    return new ArrayList<>(values);
+
+                } catch (TskCoreException ex) {
+                    Logger.getAnonymousLogger().log(Level.WARNING, "problem looking up " + attr.getDisplayName() + " for " + file.getName(), ex);
+                    return new ArrayList<>();
+                }
+
+            case ANALYZED:
+                return analyzed.get();
+            case HASHSET:
+                updateHashSetHits();
+                return hashHitSetNames;
+            case WIDTH:
+                return getWidth();
+            case HEIGHT:
+                return getHeight();
+
+            default:
+                throw new UnsupportedOperationException("DrawableFile.getValueOfAttribute does not yet support " + attr.getDisplayName());
+
+        }
+    }
+
+    public List<? extends Object> getValuesOfBBAttribute(BlackboardArtifact.ARTIFACT_TYPE artType, BlackboardAttribute.ATTRIBUTE_TYPE attrType) {
+        ArrayList<Object> vals = new ArrayList<>();
+        try {
+            //why doesn't file.getArtifacts() work?
+            //TODO: this seams like overkill, use a more targeted query
+            ArrayList<BlackboardArtifact> artifacts = getAllArtifacts();
+
+            for (BlackboardArtifact artf : artifacts) {
+                if (artf.getArtifactTypeID() == artType.getTypeID()) {
+                    for (BlackboardAttribute attr : artf.getAttributes()) {
+                        if (attr.getAttributeTypeID() == attrType.getTypeID()) {
+
+                            switch (attr.getValueType()) {
+                                case BYTE:
+                                    vals.add(attr.getValueBytes());
+                                case DOUBLE:
+                                    vals.add(attr.getValueDouble());
+                                case INTEGER:
+                                    vals.add(attr.getValueInt());
+                                case LONG:
+                                    vals.add(attr.getValueLong());
+                                case STRING:
+                                    vals.add(attr.getValueString());
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (TskCoreException ex) {
+            Logger.getAnonymousLogger().log(Level.WARNING, "problem looking up {0}/{1}" + " " + " for {2}", new Object[]{artType.getDisplayName(), attrType.getDisplayName(), getName()});
+        }
+
+        return vals;
+    }
+
+    private Object getValueOfBBAttribute(BlackboardArtifact.ARTIFACT_TYPE artType, BlackboardAttribute.ATTRIBUTE_TYPE attrType) {
+        try {
+
+            //why doesn't file.getArtifacts() work?
+            //TODO: this seams like overkill, use a more targeted query
+            ArrayList<BlackboardArtifact> artifacts = file.getArtifacts(artType);// getAllArtifacts();
+
+            for (BlackboardArtifact artf : artifacts) {
+                if (artf.getArtifactTypeID() == artType.getTypeID()) {
+                    for (BlackboardAttribute attr : artf.getAttributes()) {
+                        if (attr.getAttributeTypeID() == attrType.getTypeID()) {
+                            switch (attr.getValueType()) {
+                                case BYTE:
+                                    return attr.getValueBytes();
+                                case DOUBLE:
+                                    return attr.getValueDouble();
+                                case INTEGER:
+                                    return attr.getValueInt();
+                                case LONG:
+                                    return attr.getValueLong();
+                                case STRING:
+                                    return attr.getValueString();
+                            }
+                        }
+                    }
+                }
+            }
+        } catch (TskCoreException ex) {
+            Logger.getAnonymousLogger().log(Level.WARNING, "problem looking up {0}/{1}" + " " + " for {2}", new Object[]{artType.getDisplayName(), attrType.getDisplayName(), getName()});
+        }
+        return "";
+    }
+
+    public String getMake() {
+        return getValueOfAttribute(DrawableAttribute.MAKE).toString();
+    }
+
+    public String getModel() {
+        return getValueOfAttribute(DrawableAttribute.MODEL).toString();
+    }
+
+    public void setCategory(Category category) {
+        categoryProperty().set(category);
+
+    }
+
+    public Category getCategory() {
+        return (Category) getValueOfAttribute(DrawableAttribute.CATEGORY);
+    }
+
+    public SimpleObjectProperty<Category> categoryProperty() {
+        return category;
+    }
+
+    public void updateCategory() {
+        try {
+            List<ContentTag> contentTagsByContent = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByContent(this);
+            Category cat = null;
+            for (ContentTag ct : contentTagsByContent) {
+                if (ct.getName().getDisplayName().startsWith(Category.CATEGORY_PREFIX)) {
+                    cat = Category.fromDisplayName(ct.getName().getDisplayName());
+                    break;
+                }
+            }
+            if (cat == null) {
+                category.set(Category.ZERO);
+            } else {
+                category.set(cat);
+            }
+        } catch (TskCoreException ex) {
+            Logger.getLogger(DrawableFile.class.getName()).log(Level.WARNING, "problem looking up category for file " + this.getName(), ex);
+        }
+    }
+
+    public abstract Node getFullsizeDisplayNode();
+
+    public abstract Image getIcon();
+
+    public void setAnalyzed(Boolean analyzed) {
+        this.analyzed.set(analyzed);
+    }
+
+    public boolean isAnalyzed() {
+        return analyzed.get();
+    }
+
+    public T getAbstractFile() {
+        return this.file;
+    }
+
+    private void updateHashSetHits() {
+        hashHitSetNames.setAll((Collection<? extends String>) getValuesOfBBAttribute(BlackboardArtifact.ARTIFACT_TYPE.TSK_HASHSET_HIT, BlackboardAttribute.ATTRIBUTE_TYPE.TSK_SET_NAME));
+    }
+
+    abstract Number getWidth();
+
+    abstract Number getHeight();
+
+    private static final String[] SLASH = new String[]{"/"};
+
+    private static final String[] DOUBLE_SLASH = new String[]{"//"};
+
+    public String getDrawablePath() {
+        if (drawablePath != null) {
+            return drawablePath;
+        } else {
+            try {
+                drawablePath = StringUtils.removeEnd(getUniquePath(), getName());
+//                drawablePath = StringUtils.replaceEachRepeatedly(drawablePath, DOUBLE_SLASH, SLASH);
+                return drawablePath;
+            } catch (TskCoreException ex) {
+                Logger.getLogger(DrawableFile.class.getName()).log(Level.WARNING, "failed to get drawablePath from " + getName());
+                return "";
+            }
+        }
+    }
+
+    private long getRootID() throws TskCoreException {
+
+        Content myParent = getParent();
+        long id = -1;
+
+        while (myParent != null) {
+            id = myParent.getId();
+            myParent = myParent.getParent();
+        }
+
+        return id;
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/ImageFile.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/ImageFile.java
new file mode 100644
index 0000000000000000000000000000000000000000..70c0c050521e5852c61bbacd08ecc39f2df7aeac
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/ImageFile.java
@@ -0,0 +1,118 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.datamodel;
+
+import org.sleuthkit.autopsy.imageanalyzer.IconCache;
+import org.sleuthkit.autopsy.imageanalyzer.gui.Fitable;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.lang.ref.SoftReference;
+import java.util.logging.Level;
+import javafx.embed.swing.SwingFXUtils;
+import javafx.scene.Node;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javax.imageio.ImageIO;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.ReadContentInputStream;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Eureka data model object that represents an image file. It is a
+ * wrapper(/decorator?/adapter?) around {@link AbstractFile} and provides
+ * methods to get an icon sized and a full sized {@link  Image}.
+ *
+ *
+ */
+public class ImageFile extends DrawableFile {
+
+    private SoftReference<Image> imageRef;
+
+    ImageFile(AbstractFile f, Boolean analyzed) {
+        super(f, analyzed);
+
+    }
+
+    ImageFile(Long id, Boolean analyzed) throws TskCoreException {
+        super(Case.getCurrentCase().getSleuthkitCase().getAbstractFileById(id), analyzed);
+    }
+
+    @Override
+    public Image getIcon() {
+        return IconCache.getDefault().get(this);
+    }
+
+    @Override
+    public Node getFullsizeDisplayNode() {
+        return new FitableImageView(getFullSizeImage());
+    }
+
+    public Image getFullSizeImage() {
+        Image image = null;
+        if (imageRef != null) {
+            image = imageRef.get();
+        }
+
+        if (image == null) {
+
+            try (ReadContentInputStream readContentInputStream = new ReadContentInputStream(this.getAbstractFile())) {
+                BufferedImage read = ImageIO.read(readContentInputStream);
+                image = SwingFXUtils.toFXImage(read, null);
+            } catch (IOException | NullPointerException ex) {
+                Logger.getLogger(ImageFile.class.getName()).log(Level.WARNING, "unable to read file" + getName());
+                return null;
+            }
+            imageRef = new SoftReference<>(image);
+        }
+        return image;
+    }
+
+    @Override
+    Number getWidth() {
+        final Image fullSizeImage = getFullSizeImage();
+        if (fullSizeImage != null) {
+            return fullSizeImage.getWidth();
+        }
+        return -1;
+    }
+
+    @Override
+    Number getHeight() {
+        final Image fullSizeImage = getFullSizeImage();
+        if (fullSizeImage != null) {
+            return fullSizeImage.getHeight();
+        }
+        return -1;
+    }
+
+    @Override
+    public boolean isVideo() {
+        return false;
+    }
+
+    private class FitableImageView extends ImageView implements Fitable {
+
+        public FitableImageView(Image image) {
+            super(image);
+        }
+
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/VideoFile.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/VideoFile.java
new file mode 100644
index 0000000000000000000000000000000000000000..2bb16fe5b3ca334b18569e04b971ff420164a44c
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/datamodel/VideoFile.java
@@ -0,0 +1,132 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.datamodel;
+
+import org.sleuthkit.autopsy.imageanalyzer.gui.MediaControl;
+import java.io.File;
+import java.io.IOException;
+import java.lang.ref.SoftReference;
+import java.nio.file.Paths;
+import java.util.logging.Level;
+import javafx.scene.Node;
+import javafx.scene.image.Image;
+import javafx.scene.media.Media;
+import javafx.scene.media.MediaException;
+import javafx.scene.media.MediaPlayer;
+import javafx.scene.text.Text;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.datamodel.ContentUtils;
+import org.sleuthkit.datamodel.AbstractFile;
+
+public class VideoFile extends DrawableFile {
+
+    private static final Image VIDEO_ICON = new Image("org/sleuthkit/autopsy/imageanalyzer/images/Clapperboard.png");
+
+    VideoFile(AbstractFile file, Boolean analyzed) {
+        super(file, analyzed);
+    }
+
+    @Override
+    public Image getIcon() {
+        //TODO: implement video thumbnailing here
+        return VIDEO_ICON;
+    }
+    SoftReference<Media> mediaRef;
+
+    public Media getMedia() throws IOException, MediaException {
+        Media media = null;
+        if (mediaRef != null) {
+            media = mediaRef.get();
+        }
+        if (media != null) {
+            return media;
+        }
+        final File cacheFile = getCacheFile(this.getId());
+        if (cacheFile.exists() == false) {
+
+            ContentUtils.writeToFile(this.getAbstractFile(), cacheFile);
+
+        }
+        try {
+            media = new Media(Paths.get(cacheFile.getAbsolutePath()).toUri().toString());
+            mediaRef = new SoftReference<>(media);
+            return media;
+        } catch (MediaException ex) {
+            throw ex;
+        }
+    }
+
+    @Override
+    public Node getFullsizeDisplayNode() {
+        final File cacheFile = getCacheFile(this.getId());
+
+        try {
+            if (cacheFile.exists() == false) {
+                ContentUtils.writeToFile(this.getAbstractFile(), cacheFile);
+            }
+            final Media media = getMedia();
+            final MediaPlayer mediaPlayer = new MediaPlayer(media);
+            final MediaControl mediaView = new MediaControl(mediaPlayer);
+            mediaPlayer.statusProperty().addListener((observableStatus, oldStatus, newStatus) -> {
+                Logger.getAnonymousLogger().log(Level.INFO, "media player: {0}", newStatus);
+            });
+            return mediaView;
+        } catch (IOException ex) {
+            Logger.getLogger(VideoFile.class.getName()).log(Level.SEVERE, "failed to initialize MediaControl for file " + getName(), ex);
+            return new Text(ex.getLocalizedMessage() + "\nSee the logs for details.");
+        } catch (MediaException ex) {
+            Logger.getLogger(VideoFile.class.getName()).log(Level.SEVERE, ex.getType() + " Failed to initialize MediaControl for file " + getName(), ex);
+            Logger.getLogger(VideoFile.class.getName()).log(Level.SEVERE, "caused by " + ex.getCause().getLocalizedMessage(), ex.getCause());
+            return new Text(ex.getType() + "\nSee the logs for details.");
+        } catch (OutOfMemoryError ex) {
+            Logger.getLogger(VideoFile.class.getName()).log(Level.SEVERE, "failed to initialize MediaControl for file " + getName(), ex);
+            return new Text("There was a problem playing video file.\nSee the logs for details.");
+        }
+    }
+
+    private File getCacheFile(long id) {
+        return new File(Case.getCurrentCase().getCacheDirectory() + File.separator + id);
+    }
+
+    @Override
+    Number getWidth() {
+        try {
+            return getMedia().getWidth();
+        } catch (IOException | MediaException ex) {
+//            Exceptions.printStackTrace(ex);
+            return -1;
+        }
+    }
+
+    @Override
+    public boolean isVideo() {
+        return true;
+    }
+
+    @Override
+    Number getHeight() {
+        try {
+            return getMedia().getHeight();
+        } catch (IOException | MediaException ex) {
+//            Exceptions.printStackTrace(ex);
+            return -1;
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.css b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.css
new file mode 100644
index 0000000000000000000000000000000000000000..937a27b23e50a40cf9841074b4b02b477aedf6f2
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.css
@@ -0,0 +1,6 @@
+.list-cell:odd {
+    -fx-background-color: transparent; /* derive(-fx-control-inner-background,-5%); */
+}
+.list-cell:even {
+    -fx-background-color: transparent; /* derive(-fx-control-inner-background,-5%); */
+}
\ No newline at end of file
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..b9e198f27458b23c6d57e87679cae828b537963e
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.fxml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.net.*?>
+<?import java.util.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.paint.*?>
+
+<fx:root type="javafx.scene.control.TitledPane" alignment="TOP_LEFT" maxHeight="-Infinity" maxWidth="1.7976931348623157E308" minWidth="-1.0" text="filter label" wrapText="true" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2">
+  <content>
+    <VBox fx:id="filtersBox" prefHeight="-1.0" prefWidth="-1.0" />
+  </content>
+  <graphic>
+    <HBox alignment="CENTER_LEFT">
+      <children>
+        <CheckBox fx:id="selectedBox" mnemonicParsing="false" selected="true" />
+      </children>
+      <padding>
+        <Insets left="5.0" />
+      </padding>
+    </HBox>
+  </graphic>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.java
new file mode 100644
index 0000000000000000000000000000000000000000..d2294c700d08827e6093b088beadad22a00bf895
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FilterPane.java
@@ -0,0 +1,99 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering;
+
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.FilterRow;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.AtomicFilter;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.UnionFilter;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import java.net.URL;
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.TreeMap;
+import javafx.collections.ListChangeListener;
+import javafx.fxml.FXML;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.TitledPane;
+import javafx.scene.layout.VBox;
+
+/**
+ * FXML Controller class
+ *
+ */
+public class FilterPane extends TitledPane {
+
+    @FXML
+    private ResourceBundle resources;
+    @FXML
+    private URL location;
+    @FXML
+    private VBox filtersBox;
+    @FXML
+    private CheckBox selectedBox;
+    private UnionFilter<AtomicFilter> filter;
+    private Map<AtomicFilter, FilterRow> filterRowMap = new TreeMap<>(AtomicFilter.ALPHABETIC_COMPARATOR);
+
+    @FXML
+    void initialize() {
+        assert filtersBox != null : "fx:id=\"filtersBox\" was not injected: check your FXML file 'FilterPane.fxml'.";
+        assert selectedBox != null : "fx:id=\"selectedBox\" was not injected: check your FXML file 'FilterPane.fxml'.";
+    }
+
+    public FilterPane() {
+        FXMLConstructor.construct(this, "FilterPane.fxml");
+    }
+
+    private void rebuildChildren() {
+        filtersBox.getChildren().clear();
+        for (FilterRow af : filterRowMap.values()) {
+            filtersBox.getChildren().add(af);
+        }
+    }
+
+    void setFilter(UnionFilter<AtomicFilter> filter) {
+//TODO : do this more reasonably
+        this.filter = filter;
+        this.setText(filter.getDisplayName());
+        filterRowMap.clear();
+        filtersBox.getChildren().clear();
+        for (AtomicFilter af : filter.subFilters) {
+            final FilterRow filterRow = af.getUI();
+            filterRowMap.put(af, filterRow);
+        }
+        rebuildChildren();
+        
+        this.filter.subFilters.addListener(new ListChangeListener<AtomicFilter>() {
+            @Override
+            public void onChanged(ListChangeListener.Change<? extends AtomicFilter> change) {
+                while (change.next()) {
+                    for (AtomicFilter af : change.getAddedSubList()) {
+                        FilterRow filterRow = af.getUI();
+                        filterRowMap.put(af, filterRow);
+                    }
+                    for (AtomicFilter af : change.getRemoved()) {
+                      filterRowMap.remove(af);
+                    }
+                }
+                rebuildChildren();
+            }
+        });
+
+        this.filter.active.bindBidirectional(selectedBox.selectedProperty());
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FiltersPanel.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FiltersPanel.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..41636f42d7e8414fda90926aa68c529855737048
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FiltersPanel.fxml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.util.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.paint.*?>
+
+<fx:root type="javafx.scene.layout.AnchorPane" id="AnchorPane" maxHeight="1.7976931348623157E308" maxWidth="-1.0" minHeight="-1.0" minWidth="200.0" prefHeight="-1.0" prefWidth="-1.0" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2">
+  <children>
+    <TabPane maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" prefHeight="-1.0" prefWidth="-1.0" rotateGraphic="true" side="TOP" tabClosingPolicy="UNAVAILABLE" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+      <tabs>
+        <Tab closable="false" text="Filters">
+          <content>
+            <ListView fx:id="filtersList" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" prefHeight="-1.0" prefWidth="-1.0" />
+          </content>
+          <graphic>
+            <ImageView fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true">
+              <image>
+                <Image url="@../images/funnel.png" />
+              </image>
+            </ImageView>
+          </graphic>
+        </Tab>
+        <Tab closable="false" disable="true" text="File Tree">
+          <content>
+            <AnchorPane id="Content" minHeight="0.0" minWidth="0.0" prefHeight="-1.0" prefWidth="-1.0" />
+          </content>
+        </Tab>
+      </tabs>
+    </TabPane>
+  </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FiltersPanel.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FiltersPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..2281841d21ffef236ce311738627d4bbe15dc9c4
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/FiltersPanel.java
@@ -0,0 +1,353 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.FilterSet;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.AtomicFilter;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.AttributeFilter;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaModule;
+import org.sleuthkit.autopsy.imageanalyzer.LoggedTask;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateEvent;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateListener;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableDB;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.AbstractFilter;
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.NameFilter;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupManager;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupSortBy;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.beans.PropertyChangeSupport;
+import java.net.URL;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.concurrent.Service;
+import javafx.concurrent.Task;
+import javafx.fxml.FXML;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.layout.AnchorPane;
+import javafx.util.Callback;
+import javax.swing.SortOrder;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadUtils;
+
+/**
+ * This singleton acts as the controller for the Filters. It creates filters
+ * based on values in the database, and broadcasts events when the activation
+ * state or other ui configuration of the filters changes.
+ *
+ * deprecated until we revisit filtering
+ */
+@Deprecated
+public class FiltersPanel extends AnchorPane implements FileUpdateListener {
+
+    public static final String FILTER_STATE_CHANGED = "FILTER_STATE_CHANGED";
+    @FXML
+    private ResourceBundle resources;
+    @FXML
+    private URL location;
+    @FXML
+    private ListView< AttributeFilter> filtersList;
+    private static final Logger LOGGER = Logger.getLogger(FiltersPanel.class.getName());
+    volatile private DrawableDB db;
+    final Map<DrawableAttribute, AttributeFilter> attrFilterMap = new HashMap<>();
+    private static FiltersPanel instance;
+
+    /**
+     * clear/reset state
+     */
+    public void clear() {
+        Platform.runLater(new Runnable() {
+            @Override
+            public void run() {
+                db = null;
+                filterSet.clear();
+                attrFilterMap.clear();
+            }
+        });
+    }
+    /**
+     * listen to changes in individual filters and forward to external listeners
+     * via the pcs
+     */
+    private final ChangeListener filterForwardingListener = new ChangeListener<Object>() {
+        @Override
+        public void changed(ObservableValue<? extends Object> observable, Object oldValue, Object newValue) {
+            pcs.firePropertyChange(new PropertyChangeEvent(observable, FILTER_STATE_CHANGED, oldValue, newValue));
+        }
+    };
+
+    public FilterSet getFilterSet() {
+        return filterSet;
+    }
+    /* top level filter */
+    private FilterSet filterSet = new FilterSet();
+    /**
+     * {@link Service} to (re)build filterset based on values in the database
+     */
+    private final RebuildFiltersService rebuildFiltersService = new RebuildFiltersService();
+    private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
+
+    /**
+     * register a {@link PropertyChangeListener}
+     *
+     * @param pcl
+     */
+    public void addListener(PropertyChangeListener pcl) {
+        pcs.addPropertyChangeListener(pcl);
+    }
+
+    @FXML
+    void initialize() {
+        assert filtersList != null : "fx:id=\"filtersList\" was not injected: check your FXML file 'FiltersPanel.fxml'.";
+
+        filtersList.setItems(filterSet.subFilters);
+
+        filtersList.setCellFactory(new Callback<ListView<AttributeFilter>, ListCell<AttributeFilter>>() {
+            @Override
+            public ListCell<AttributeFilter> call(ListView<AttributeFilter> p) {
+                return new FilterPaneListCell();
+            }
+        });
+        for (final DrawableAttribute attr : DrawableAttribute.getValues()) {
+            if (attr != DrawableAttribute.NAME && attr != DrawableAttribute.CREATED_TIME && attr != DrawableAttribute.MODIFIED_TIME) {
+                final AttributeFilter attrFilter = new AttributeFilter(attr);
+                filterSet.subFilters.add(attrFilter);
+                attrFilterMap.put(attr, attrFilter);
+            }
+        }
+    }
+
+    static synchronized public FiltersPanel getDefault() {
+        if (instance == null) {
+            instance = new FiltersPanel();
+        }
+        return instance;
+    }
+
+    private FiltersPanel() {
+
+        FXMLConstructor.construct(this, "FiltersPanel.fxml");
+    }
+
+    public void setDB(DrawableDB drawableDb) {
+        db = drawableDb;
+        db.addUpdatedFileListener(this);
+        rebuildFilters();
+    }
+
+    @Override
+    synchronized public void handleFileUpdate(FileUpdateEvent evt) {
+        //updateFilters(evt.getUpdatedFiles());
+    }
+
+    synchronized public void rebuildFilters() {
+        /*
+         * Platform.runLater(new Runnable() {
+         * @Override
+         * public void run() {
+         * rebuildFiltersService.restart();
+         * }
+         * });
+         * */
+    }
+
+    synchronized public void updateFilters(final Collection< DrawableFile> files) {
+        /*
+         * for (DrawableFile file : files) {
+         * for (final DrawableAttribute attr : DrawableAttribute.getValues()) {
+         * AttributeFilter attributeFilter;
+         * AbstractFilter.FilterComparison comparison = AtomicFilter.EQUALS;
+         * switch (attr.attrName) {
+         * case NAME:
+         * case PATH:
+         * case CREATED_TIME:
+         * case MODIFIED_TIME:
+         * case CATEGORY:
+         * case OBJ_ID:
+         * case ANALYZED:
+         * //fall through all attributes that don't have per value filters
+         * break;
+         * case HASHSET:
+         * comparison = AtomicFilter.CONTAINED_IN;
+         * break;
+         * default:
+         * //default is make one == filter for each value in database
+         * attributeFilter = getFilterForAttr(attr);
+         *
+         * ObservableList<Object> vals =
+         * FXCollections.singletonObservableList(file.getValueOfAttribute(attr));
+         *
+         * addFilterForAttrValues(vals, attributeFilter, comparison);
+         * }
+         * }
+         * }
+         */
+    }
+
+    synchronized private AttributeFilter getFilterForAttr(final DrawableAttribute attr) {
+        AttributeFilter attributeFilter = attrFilterMap.get(attr);
+        if (attributeFilter == null) {
+            attributeFilter = new AttributeFilter(attr);
+            attributeFilter.active.addListener(filterForwardingListener);
+        }
+
+        final AttributeFilter finalFilter = attributeFilter;
+
+//        Platform.runLater(new Runnable() {
+//            @Override
+//            public void run() {
+        if (filterSet.subFilters.contains(finalFilter) == false) {
+            filterSet.subFilters.add(finalFilter);
+        }
+//            }
+//        });
+
+        attrFilterMap.put(attr, attributeFilter);
+        return attributeFilter;
+    }
+
+    synchronized private void addFilterForAttrValues(List<? extends Object> vals, final AttributeFilter attributeFilter, final AbstractFilter.FilterComparison filterComparison) {
+        for (final Object val : vals) {
+            if (attributeFilter.containsSubFilterForValue(val) == false) {
+                final AtomicFilter filter = new AtomicFilter(attributeFilter.getAttribute(), filterComparison, val);
+                Platform.runLater(new Runnable() {
+                    @Override
+                    public void run() {
+                        attributeFilter.subFilters.add(filter);
+                    }
+                });
+
+                filter.active.addListener(filterForwardingListener);
+//                Logger.getAnonymousLogger().log(Level.INFO, "created filter " + filter);
+            }
+
+        }
+    }
+
+    /**
+     *
+     */
+    static class FilterPaneListCell extends ListCell<AttributeFilter> {
+
+        private final FilterPane filterPane;
+
+        public FilterPaneListCell() {
+            super();
+            filterPane = new FilterPane();
+        }
+
+        @Override
+        protected void updateItem(final AttributeFilter item, final boolean empty) {
+            super.updateItem(item, empty);
+            Platform.runLater(new Runnable() {
+                @Override
+                public void run() {
+                    if (empty || item == null) {
+                        setGraphic(null);
+                    } else {
+                        setGraphic(filterPane);
+                        filterPane.setFilter(item);
+                    }
+                }
+            });
+
+
+        }
+    }
+
+    private class RebuildFiltersService extends Service<Void> {
+
+        @Override
+        protected Task<Void> createTask() {
+            return new RebuildFiltersTask();
+        }
+
+        private class RebuildFiltersTask extends LoggedTask<Void> {
+
+            public RebuildFiltersTask() {
+                super("rebuilding filters", true);
+            }
+
+            @Override
+            protected Void call() throws Exception {
+                ThreadUtils.runAndWait(new Runnable() {
+                    @Override
+                    public void run() {
+                        filterSet.subFilters.clear();
+                        attrFilterMap.clear();
+                    }
+                });
+
+                LOGGER.log(Level.INFO, "rebuilding filters started");
+
+                for (final DrawableAttribute attr : DrawableAttribute.getValues()) {
+                    AbstractFilter.FilterComparison comparison = AtomicFilter.EQUALS;
+                    switch (attr.attrName) {
+                        case NAME:
+                            final AttributeFilter nameFilter = getFilterForAttr(attr);
+                            final NameFilter filter = new NameFilter();
+                            Platform.runLater(new Runnable() {
+                                @Override
+                                public void run() {
+                                    nameFilter.subFilters.add(filter);
+                                }
+                            });
+
+                            filter.active.addListener(filterForwardingListener);
+                            filter.filterValue.addListener(filterForwardingListener);
+
+                            LOGGER.log(Level.INFO, "createdfilter {0}", filter);
+                            break;
+
+                        case PATH:
+                        case CREATED_TIME:
+                        case MODIFIED_TIME:
+                        case OBJ_ID:
+                            break;
+                        case HASHSET:
+                            comparison = AtomicFilter.CONTAINED_IN;
+                            break;
+                        default:
+
+                            //default is make one == filter per attribute value in db
+                            final AttributeFilter attributeFilter = getFilterForAttr(attr);
+                            //TODO: FILE_COUNT is arbitrarty but maybe better than NONE, we can include file counts in labels in future
+                            List<? extends Object> vals = EurekaController.getDefault().getGroupManager().findValuesForAttribute(attr, GroupSortBy.FILE_COUNT, SortOrder.DESCENDING);
+                            addFilterForAttrValues(vals, attributeFilter, comparison);
+                    }
+                }
+                return null;
+            }
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AbstractFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AbstractFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..c8bf253293f884d4f75834d3c5dd3e64eecd9dc9
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AbstractFilter.java
@@ -0,0 +1,131 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import java.util.List;
+import javafx.beans.property.SimpleBooleanProperty;
+
+/**
+ *
+ * @author jonathan
+ */
+public abstract class AbstractFilter<R> {
+
+    public abstract void clear();
+    public static final AtomicFilter.FilterComparison EQUALS = new AtomicFilter.EQUALS();
+    public static final AtomicFilter.FilterComparison EQUALS_IGNORECASE = new AtomicFilter.EQUALS_IGNORECASE();
+    public static final AtomicFilter.FilterComparison NOT_EQUALS = new AtomicFilter.NOT_EQUALS();
+    public static final AtomicFilter.FilterComparison SUBSTRING = new AtomicFilter.SUBSTRING();
+    public static final AtomicFilter.FilterComparison CONTAINED_IN = new AtomicFilter.CONTAINS();
+    public final SimpleBooleanProperty active = new SimpleBooleanProperty(true);
+
+    public abstract String getDisplayName();
+
+    abstract public Boolean accept(DrawableFile df);
+
+    public boolean isActive() {
+        return active.get();
+    }
+
+    final protected static class CONTAINS extends AtomicFilter.FilterComparison<List<String>, String> {
+
+        @Override
+        public String getSqlOperator() {
+            return " in ";
+        }
+
+        @Override
+        public Boolean compare(List<String> attrVal, String filterVal) {
+            return attrVal.contains(filterVal);
+        }
+    }
+
+    final protected static class SUBSTRING extends AtomicFilter.FilterComparison<String, String> {
+
+        @Override
+        public Boolean compare(String attrVal, String filterVal) {
+            return attrVal.toLowerCase().contains(filterVal.toLowerCase());
+        }
+
+        @Override
+        public String getSqlOperator() {
+            return " like ";
+        }
+
+        @Override
+        public String toString() {
+            return "in";
+        }
+    }
+
+    final protected static class EQUALS_IGNORECASE extends AtomicFilter.FilterComparison<String, String> {
+
+        @Override
+        public Boolean compare(String attrVal, String filterVal) {
+            return attrVal.equals(filterVal);
+        }
+
+        @Override
+        public String getSqlOperator() {
+            return " == ";
+        }
+
+        @Override
+        public String toString() {
+            return "=";
+        }
+    }
+
+    final protected static class EQUALS<T> extends AtomicFilter.FilterComparison<T, T> {
+
+        @Override
+        public Boolean compare(T attrVal, T filterVal) {
+            return attrVal.equals(filterVal);
+        }
+
+        @Override
+        public String getSqlOperator() {
+            return " == ";
+        }
+
+        @Override
+        public String toString() {
+            return "=";
+        }
+    }
+
+    final protected static class NOT_EQUALS<T> extends AtomicFilter.FilterComparison<T, T> {
+
+        @Override
+        public Boolean compare(T attrVal, T filterVal) {
+            return attrVal != filterVal;
+        }
+
+        @Override
+        public String getSqlOperator() {
+            return " != ";
+        }
+
+        @Override
+        public String toString() {
+            return "≠";
+        }
+    }
+
+    /**
+     *
+     *
+     */
+    public static abstract class FilterComparison<A, F> {
+
+        private FilterComparison() {
+        }
+
+        abstract public Boolean compare(A attrVal, F filterVal);
+
+        abstract public String getSqlOperator();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AtomicFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AtomicFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..77c545e8952fcdfeaf9e21c6cfd5c8f07cf05bba
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AtomicFilter.java
@@ -0,0 +1,168 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import java.net.URL;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.ResourceBundle;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.fxml.FXML;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ChoiceBox;
+import javafx.scene.control.Label;
+
+/**
+ *
+ */
+public class AtomicFilter<A, F> extends AbstractFilter {
+
+    private DrawableAttribute<A> filterAttribute;
+
+    public DrawableAttribute<A> getFilterAttribute() {
+        return filterAttribute;
+    }
+
+    public F getFilterValue() {
+        return filterValue.get();
+    }
+
+    public FilterComparison<A, F> getFilterComparisson() {
+        return filterComparisson;
+    }
+    public SimpleObjectProperty<F> filterValue;
+    private FilterComparison<A, F> filterComparisson;
+
+    public AtomicFilter(DrawableAttribute filterAttribute, FilterComparison<A, F> filterComparisson, F filterValue) {
+        this.filterAttribute = filterAttribute;
+        this.filterValue = new SimpleObjectProperty<>(filterValue);
+        this.filterComparisson = filterComparisson;
+    }
+
+    @Override
+    public Boolean accept(DrawableFile df) {
+//        Logger.getAnonymousLogger().log(Level.INFO, getDisplayName() + " : " + filterValue + " filtered " + df.getName() + " = " + compare);
+        return isActive()
+                ? filterComparisson.compare((A) df.getValueOfAttribute(filterAttribute), filterValue.get())
+                : false;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 7;
+        hash = 79 * hash + Objects.hashCode(this.filterAttribute);
+        hash = 79 * hash + Objects.hashCode(this.filterValue);
+        hash = 79 * hash + Objects.hashCode(this.filterComparisson);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final AtomicFilter other = (AtomicFilter) obj;
+        if (this.filterAttribute != other.filterAttribute) {
+            return false;
+        }
+        if (!Objects.equals(this.filterValue, other.filterValue)) {
+            return false;
+        }
+        if (!Objects.equals(this.filterComparisson, other.filterComparisson)) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public String getDisplayName() {
+        return filterValue.get().toString();
+    }
+
+    public FilterRow getUI() {
+        return new AtomicFilterRow(this);
+    }
+
+    @Override
+    public void clear() {
+        active.set(true);
+    }
+    public static final Comparator<AtomicFilter> ALPHABETIC_COMPARATOR = new Comparator<AtomicFilter>() {
+        @Override
+        public int compare(AtomicFilter o1, AtomicFilter o2) {
+            return o1.getDisplayName().compareTo(o2.getDisplayName());
+        }
+    };
+
+    /**
+     *
+     * @author jonathan
+     */
+    public static class AtomicFilterRow<A, F> extends FilterRow<AtomicFilter<A, F>> {
+
+        @FXML
+        protected ResourceBundle resources;
+        @FXML
+        protected URL location;
+        @FXML
+        protected ChoiceBox comparisonBox;
+        @FXML
+        protected Label filterLabel;
+        @FXML
+        protected CheckBox selectedBox;
+        protected final AtomicFilter<A, F> filter;
+
+        private AtomicFilterRow(AtomicFilter filter) {
+            super();
+            this.filter = filter;
+            FXMLConstructor.construct(this, "FilterRow.fxml");
+        }
+
+        public ReadOnlyBooleanProperty getSelectedProperty() {
+            return selectedBox.selectedProperty();
+        }
+
+        public ReadOnlyObjectProperty<AtomicFilter.FilterComparison> getComparisonProperty() {
+            return comparisonBox.getSelectionModel().selectedItemProperty();
+        }
+
+        @FXML
+        void initialize() {
+            assert comparisonBox != null : "fx:id=\"comparisonBox\" was not injected: check your FXML file 'FilterRow.fxml'.";
+            assert filterLabel != null : "fx:id=\"filterLabel\" was not injected: check your FXML file 'FilterRow.fxml'.";
+            assert selectedBox != null : "fx:id=\"selectedBox\" was not injected: check your FXML file 'FilterRow.fxml'.";
+            comparisonBox.getItems().setAll(AtomicFilter.EQUALS, AtomicFilter.EQUALS_IGNORECASE);
+            switch (filter.filterAttribute.attrName) {
+                case MAKE:
+                case MODEL:
+                    comparisonBox.getSelectionModel().select(AtomicFilter.EQUALS_IGNORECASE);
+                    break;
+                default:
+                    comparisonBox.getSelectionModel().select(AtomicFilter.EQUALS);
+            }
+            final F filterValue = filter.getFilterValue();
+
+            if (filterValue == null || "".equals(filterValue)) {
+                filterLabel.setText("unknown");
+            } else {
+                filterLabel.setText(filterValue.toString());
+            }
+            selectedBox.selectedProperty().bindBidirectional(filter.active);
+        }
+
+        @Override
+        public AtomicFilter<A, F> getFilter() {
+            return filter;
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AtomicSqlFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AtomicSqlFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..7dff206d9d98a891e6eba99df1761f11da2458ca
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AtomicSqlFilter.java
@@ -0,0 +1,19 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+
+public class AtomicSqlFilter extends AtomicFilter implements SqlFilter {
+
+    public AtomicSqlFilter(DrawableAttribute filterAttribute, FilterComparison filterComparisson, Object filterValue) {
+        super(filterAttribute, filterComparisson, filterValue);
+    }
+
+    @Override
+    public String getFilterQueryString() {
+        return getFilterAttribute().attrName.name() + " " + getFilterComparisson().getSqlOperator() + " " + getFilterValue().toString();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AttributeFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AttributeFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..17534ea55b000420ae4ca930b673dcdda46d055e
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/AttributeFilter.java
@@ -0,0 +1,43 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+
+/**
+ *
+ * @author jonathan
+ */
+public class AttributeFilter<AVT, FVT> extends UnionFilter<AtomicFilter<AVT, FVT>> {
+
+    DrawableAttribute filterAttribute;
+
+    public AttributeFilter(DrawableAttribute filterAttribute) {
+        super();
+        this.filterAttribute = filterAttribute;
+    }
+
+    @Override
+    public String getDisplayName() {
+        return filterAttribute.getDisplayName();
+    }
+
+    public DrawableAttribute getAttribute() {
+        return filterAttribute;
+    }
+
+    public boolean containsSubFilterForValue(FVT val) {
+        try {
+            for (AtomicFilter sf : subFilters) {
+                if (val.equals(sf.getFilterValue())) {
+                    return true;
+                }
+            }
+        } catch (Exception e) {
+        }
+
+        return false;
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/CompoundFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/CompoundFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..2c1700747637f021bf72282e67d598d2b78af2fd
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/CompoundFilter.java
@@ -0,0 +1,28 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+/**
+ *
+ */
+public abstract class CompoundFilter<T extends AbstractFilter> extends AbstractFilter {
+
+    public ObservableList<T> subFilters;
+
+    public CompoundFilter(ObservableList< T> subFilters) {
+        this.subFilters = FXCollections.synchronizedObservableList(subFilters);
+    }
+
+    public void clear() {
+        for (T filter : subFilters) {
+            filter.clear();
+        }
+        active.set(true);
+        subFilters.clear();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterRow.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterRow.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..9d84e288f3380030223dad9645e0e4f6a06e93d2
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterRow.fxml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.util.*?>
+<?import javafx.collections.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.paint.*?>
+
+<fx:root type="javafx.scene.layout.AnchorPane" id="AnchorPane" maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" minHeight="-Infinity" minWidth="0.0" prefHeight="-1.0" prefWidth="-1.0" style="" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2">
+  <children>
+    <HBox alignment="CENTER_LEFT" fillHeight="true" prefHeight="-1.0" prefWidth="-1.0" spacing="5.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+      <children>
+        <CheckBox fx:id="selectedBox" mnemonicParsing="false" prefHeight="-1.0" prefWidth="-1.0" selected="true" text="" HBox.hgrow="NEVER" />
+        <ChoiceBox fx:id="comparisonBox" minHeight="-1.0" minWidth="0.0" prefHeight="-1.0" prefWidth="0.0" style=" -fx-background-color:&#10;        linear-gradient(to bottom, derive(-fx-base,-30%), derive(-fx-base,-60%)),&#10;        linear-gradient(to bottom, derive(-fx-base,65%) 2%, derive(-fx-base,-20%) 95%);" visible="false" HBox.hgrow="NEVER">
+          <items>
+            <FXCollections fx:factory="observableArrayList">
+              <String fx:value="Item 1" />
+              <String fx:value="Item 2" />
+              <String fx:value="Item 3" />
+            </FXCollections>
+          </items>
+        </ChoiceBox>
+        <Label fx:id="filterLabel" minWidth="50.0" text="Label" wrapText="true" HBox.hgrow="NEVER" />
+      </children>
+      <padding>
+        <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
+      </padding>
+    </HBox>
+  </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterRow.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterRow.java
new file mode 100644
index 0000000000000000000000000000000000000000..3eb13cd0ce83de90d58f364f5e97f650d8549edf
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterRow.java
@@ -0,0 +1,17 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.filtering.filters.AbstractFilter;
+import javafx.scene.layout.AnchorPane;
+
+/**
+ *
+ * @author jmillman
+ */
+public abstract class FilterRow<T extends AbstractFilter> extends AnchorPane {
+
+    public abstract T getFilter();
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterSet.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterSet.java
new file mode 100644
index 0000000000000000000000000000000000000000..88c19e4056665f888cdd6f33d627060f225fde11
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/FilterSet.java
@@ -0,0 +1,17 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+/**
+ *
+ * @author jonathan
+ */
+public class FilterSet extends IntersectionFilter<AttributeFilter> {
+
+    @Override
+    public String getDisplayName() {
+        return "Filters";
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/IntersectionFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/IntersectionFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..8e4b457885d43b0a36a9d72e9e6fcd486fc4f786
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/IntersectionFilter.java
@@ -0,0 +1,38 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+/**
+ *
+ * Intersection(And) filter
+ */
+public abstract class IntersectionFilter<T extends AbstractFilter> extends CompoundFilter<T> {
+
+    public IntersectionFilter(ObservableList< T> subFilters) {
+        super(subFilters);
+    }
+
+    public IntersectionFilter() {
+        super(FXCollections.<T>observableArrayList());
+    }
+
+    @Override
+    public Boolean accept(DrawableFile df) {
+        if (isActive()) {
+            for (T f : subFilters) {
+                if (f.isActive() && f.accept(df) == false) {
+                    return false;
+                }
+            }
+            return true;
+        } else {
+            return true;
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/NameFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/NameFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..08b0f4305efe05d485c15517e7614c6ea375429b
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/NameFilter.java
@@ -0,0 +1,70 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import java.net.URL;
+import java.util.ResourceBundle;
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.fxml.FXML;
+import javafx.scene.control.TextField;
+
+/**
+ *
+ */
+public class NameFilter extends AtomicFilter<String, String> {
+
+    public NameFilter() {
+        super(DrawableAttribute.NAME, SUBSTRING, "");
+    }
+
+    @Override
+    public FilterRow getUI() {
+        return new NameRow(this);
+    }
+
+    private void setValue(String text) {
+        filterValue.set(text);
+    }
+
+    private static class NameRow extends FilterRow<NameFilter> {
+
+        @FXML
+        private ResourceBundle resources;
+        @FXML
+        private URL location;
+        @FXML
+        private TextField textField;
+        private final NameFilter filter;
+
+        @FXML
+        void initialize() {
+            assert textField != null : "fx:id=\"textField\" was not injected: check your FXML file 'NameFilterRow.fxml'.";
+
+            textField.textProperty().addListener(new InvalidationListener() {
+                @Override
+                public void invalidated(Observable observable) {
+                    filter.setValue(textField.getText());
+                }
+            });
+
+//            //hack to make listener hear that this filter has changed
+//            filter.active.set(!filter.isActive());
+//            filter.active.set(!filter.isActive());
+        }
+
+        private NameRow(NameFilter filter) {
+            this.filter = filter;
+            FXMLConstructor.construct(this, "NameFilterRow.fxml");
+        }
+
+        @Override
+        public NameFilter getFilter() {
+            return filter;
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/NameFilterRow.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/NameFilterRow.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..a51b6c5081591960ca1390a868206e55033dcac9
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/NameFilterRow.fxml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.util.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.paint.*?>
+
+<fx:root type="javafx.scene.layout.AnchorPane" id="AnchorPane" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="200.0" prefHeight="-1.0" prefWidth="-1.0" style="" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2">
+  <children>
+    <TextField fx:id="textField" disable="false" editable="true" focusTraversable="false" maxHeight="-Infinity" maxWidth="-Infinity" minWidth="-1.0" prefColumnCount="12" prefWidth="-1.0" promptText="enter search term" style="" text="" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
+  </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/SqlFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/SqlFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..75d530a5e587e37c4c131f9bdf8eae3a9ee6fd3e
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/SqlFilter.java
@@ -0,0 +1,18 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+
+/**
+ *
+ *
+ */
+public interface SqlFilter {
+
+    public Boolean accept(DrawableFile df);
+
+    public String getFilterQueryString();
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/UnionFilter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/UnionFilter.java
new file mode 100644
index 0000000000000000000000000000000000000000..898aff3947136ef5d4c109668b88d0e8dddf1a1c
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/filtering/filters/UnionFilter.java
@@ -0,0 +1,39 @@
+/*
+ * To change this template, choose Tools | Templates
+ * and open the template in the editor.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.filtering.filters;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+
+/**
+ *
+ * Union(or) filter
+ */
+abstract public class UnionFilter<T extends AbstractFilter> extends CompoundFilter<T> {
+
+    public UnionFilter(ObservableList<T> subFilters) {
+        super(subFilters);
+    }
+
+    public UnionFilter() {
+        super(FXCollections.<T>observableArrayList());
+    }
+
+    @Override
+    public Boolean accept(DrawableFile df) {
+        if (isActive()) {
+            for (T f : subFilters) {
+                if (f.isActive() && f.accept(df) == true) {
+                    return true;
+                }
+            }
+            return false;
+        } else {
+            return true;
+        }
+
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupKey.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupKey.java
new file mode 100644
index 0000000000000000000000000000000000000000..47d367cc985e03829263809536dc4b9e7835126b
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupKey.java
@@ -0,0 +1,98 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.grouping;
+
+import java.util.Map;
+import java.util.Objects;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.datamodel.TagName;
+
+/**
+ * key identifying information of a {@link Grouping}. Used to look up groups in
+ * {@link Map}s and from the db.
+ */
+public class GroupKey<T> implements Comparable<GroupKey<Comparable<T>>> {
+
+    private final Comparable<T> val;
+
+    public Comparable<T> getValue() {
+        return val;
+    }
+
+    public DrawableAttribute<T> getAttribute() {
+        return attr;
+    }
+
+    private final DrawableAttribute<T> attr;
+
+    public GroupKey(DrawableAttribute<T> attr, Comparable<T> val) {
+        this.attr = attr;
+        this.val = val;
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 5;
+        hash = 29 * hash + Objects.hashCode(this.val);
+        hash = 29 * hash + Objects.hashCode(this.attr);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final GroupKey<T> other = (GroupKey<T>) obj;
+        if (!Objects.equals(this.val, other.val)) {
+            return false;
+        }
+        if (this.attr != other.attr) {
+            return false;
+        }
+        return true;
+    }
+
+    @Override
+    public int compareTo(GroupKey<Comparable<T>> o) {
+        if (val instanceof Comparable) {
+            return ((Comparable) val).compareTo(o.val);
+        } else {
+            return Integer.compare(val.hashCode(), o.val.hashCode());
+        }
+
+    }
+
+    public String getValueDisplayName() {
+        if (attr == DrawableAttribute.TAGS) {
+            return ((TagName) getValue()).getDisplayName();
+        } else {
+
+            return getValue().toString();
+        }
+    }
+
+    @Override
+    public String toString() {
+        return "GroupKey: " + getAttribute() + " = " + getValue();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupManager.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupManager.java
new file mode 100644
index 0000000000000000000000000000000000000000..28819748732c5142e500fff0ebffa044b48a7051
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupManager.java
@@ -0,0 +1,635 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.grouping;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.collections.transformation.SortedList;
+import javax.swing.SortOrder;
+import org.apache.commons.lang3.StringUtils;
+import org.netbeans.api.progress.ProgressHandle;
+import org.netbeans.api.progress.ProgressHandleFactory;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaModule;
+import org.sleuthkit.autopsy.imageanalyzer.LoggedTask;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableDB;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.datamodel.AbstractFile;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.BlackboardAttribute;
+import org.sleuthkit.datamodel.ContentTag;
+import org.sleuthkit.datamodel.SleuthkitCase;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Provides an abstraction layer on top of {@link  DrawableDB} ( and to some
+ * extent {@link SleuthkitCase} ) to facilitate creation, retrieval, updating,
+ * and sorting of {@link Grouping}s.
+ */
+public class GroupManager {
+
+    private static final Logger LOGGER = Logger.getLogger(GroupManager.class.getName());
+
+    private DrawableDB db;
+
+    private final EurekaController controller;
+
+    /** map from {@link GroupKey}s to {@link  Grouping}s. All groups (even not
+     * fully analyzed or not visible groups could be in this map */
+    private final Map<GroupKey, Grouping> groupMap = new HashMap<>();
+
+    /** list of all analyzed groups */
+    private final ObservableList<Grouping> analyzedGroups = FXCollections.observableArrayList();
+
+    /** list of unseen groups */
+    private final ObservableList<Grouping> unSeenGroups = FXCollections.observableArrayList();
+
+    /** sorted list of unseen groups */
+    private final SortedList<Grouping> sortedUnSeenGroups = unSeenGroups.sorted();
+
+    private ReGroupTask groupByTask;
+
+    /* --- current grouping/sorting attributes --- */
+    private volatile GroupSortBy sortBy = GroupSortBy.NONE;
+
+    private volatile DrawableAttribute groupBy = DrawableAttribute.PATH;
+
+    private volatile SortOrder sortOrder = SortOrder.ASCENDING;
+
+    public void setDB(DrawableDB db) {
+        this.db = db;
+        regroup(groupBy, sortBy, sortOrder, Boolean.TRUE);
+    }
+
+    public ObservableList<Grouping> getAnalyzedGroups() {
+        return analyzedGroups;
+    }
+
+    public SortedList<Grouping> getUnSeenGroups() {
+        return sortedUnSeenGroups;
+    }
+
+    /**
+     * construct a group manager hooked up to the given db and controller
+     *
+     * @param db
+     * @param controller
+     */
+    public GroupManager(EurekaController controller) {
+        this.controller = controller;
+    }
+
+    /**
+     * using the current groupBy set for this manager, find groupkeys for all
+     * the groups the given file is a part of
+     *
+     * @param file
+     *
+     * @returna a set of {@link GroupKey}s representing the group(s) the given
+     * file is a part of
+     */
+    public Set<GroupKey> getGroupKeysForFile(DrawableFile file) {
+        Set<GroupKey> resultSet = new HashSet();
+        final Object valueOfAttribute = file.getValueOfAttribute(groupBy);
+        List<? extends Comparable> vals;
+        if (valueOfAttribute instanceof List<?>) {
+            vals = (List<? extends Comparable>) valueOfAttribute;
+        } else {
+            vals = Collections.singletonList((Comparable) valueOfAttribute);
+        }
+        for (Comparable val : vals) {
+            final GroupKey groupKey = new GroupKey(groupBy, val);
+            resultSet.add(groupKey);
+        }
+        return resultSet;
+    }
+
+    /**
+     * using the current groupBy set for this manager, find groupkeys for all
+     * the groups the given file is a part of
+     *
+     * @param file
+     *
+     * @returna a set of {@link GroupKey}s representing the group(s) the given
+     * file is a part of
+     */
+    public Set<GroupKey> getGroupKeysForFileID(Long fileID) {
+        Set<GroupKey> resultSet = new HashSet();
+        try {
+            DrawableFile file = db.getFileFromID(fileID);
+            final Object valueOfAttribute = file.getValueOfAttribute(groupBy);
+
+            List<? extends Comparable> vals;
+            if (valueOfAttribute instanceof List<?>) {
+                vals = (List<? extends Comparable>) valueOfAttribute;
+            } else {
+                vals = Collections.singletonList((Comparable) valueOfAttribute);
+            }
+
+            for (Comparable val : vals) {
+                final GroupKey groupKey = new GroupKey(groupBy, val);
+                resultSet.add(groupKey);
+            }
+        } catch (TskCoreException ex) {
+            Logger.getLogger(GroupManager.class.getName()).log(Level.SEVERE, "failed to load file with id: " + fileID + " from database", ex);
+        }
+        return resultSet;
+    }
+
+    /**
+     * @param groupKey
+     *
+     * @return return the Grouping (if it exists) for the given GroupKey, or
+     *         null if no group exists for that key.
+     */
+    public Grouping getGroupForKey(GroupKey groupKey) {
+        synchronized (groupMap) {
+            return groupMap.get(groupKey);
+        }
+    }
+
+    synchronized public void clear() {
+
+        if (groupByTask != null) {
+            groupByTask.cancel(true);
+        }
+        sortBy = GroupSortBy.GROUP_BY_VALUE;
+        groupBy = DrawableAttribute.PATH;
+        sortOrder = SortOrder.ASCENDING;
+        Platform.runLater(() -> {
+            unSeenGroups.clear();
+            analyzedGroups.clear();
+        });
+        synchronized (groupMap) {
+            groupMap.clear();
+        }
+        db = null;
+    }
+
+    public boolean isRegrouping() {
+        if (groupByTask == null) {
+            return false;
+        }
+
+        switch (groupByTask.getState()) {
+            case READY:
+            case RUNNING:
+            case SCHEDULED:
+                return true;
+            case CANCELLED:
+            case FAILED:
+
+            case SUCCEEDED:
+            default:
+                return false;
+        }
+    }
+
+    /**
+     * make and return a group with the given key and files. If a group already
+     * existed for that key, it will be replaced.
+     *
+     * @param groupKey
+     * @param files
+     *
+     * @return
+     *
+     * TODO: check if a group already exists for that key and ... (do what?add
+     * files to it?) -jm
+     */
+    public Grouping makeGroup(GroupKey groupKey, List<Long> files) {
+        List<Long> newFiles = files == null ? new ArrayList<>() : files;
+
+        Grouping g = new Grouping(groupKey, newFiles);
+        synchronized (groupMap) {
+            groupMap.put(groupKey, g);
+        }
+        return g;
+    }
+
+    public void markGroupSeen(Grouping group) {
+        unSeenGroups.remove(group);
+        db.markGroupSeen(group.groupKey);
+    }
+
+    /**
+     * remove the given file from the group with the given key. If the group
+     * doesn't exist or doesn't already contain this file, this method is a
+     * no-op
+     *
+     * @param groupKey the value of groupKey
+     * @param fileID   the value of file
+     */
+    public synchronized void removeFromGroup(GroupKey groupKey, final Long fileID) {
+        //get grouping this file would be in
+        final Grouping group = getGroupForKey(groupKey);
+        if (group != null) {
+            group.removeFile(fileID);
+        }
+    }
+
+    public synchronized void populateAnalyzedGroup(final GroupKey groupKey, List<Long> filesInGroup) {
+        populateAnalyzedGroup(groupKey, filesInGroup, null);
+    }
+
+    /**
+     * create a group with the given GroupKey and file ids and add it to the
+     * analyzed group list.
+     *
+     * @param groupKey
+     * @param filesInGroup
+     */
+    private synchronized void populateAnalyzedGroup(final GroupKey groupKey, List<Long> filesInGroup, ReGroupTask task) {
+
+        /* if this is not part of a regroup task or it is but the task is not
+         * cancelled...
+         *
+         * this allows us to stop if a regroup task has been cancelled (e.g. the
+         * user picked a different group by attribute, while the current task
+         * was still running) */
+        if (task == null || (task.isCancelled() == false)) {
+            Grouping g = makeGroup(groupKey, filesInGroup);
+
+            final boolean groupSeen = db.isGroupSeen(groupKey);
+            Platform.runLater(() -> {
+                analyzedGroups.add(g);
+
+                if (groupSeen == false) {
+                    unSeenGroups.add(g);
+                }
+            });
+        }
+    }
+
+    /**
+     * check if the group for the given groupkey is analyzed
+     *
+     * @param groupKey
+     *
+     * @return null if this group is not analyzed or a list of file ids in this
+     *         group if they are all analyzed
+     */
+    public List<Long> checkAnalyzed(final GroupKey groupKey) {
+        try {
+            /* for attributes other than path we can't be sure a group is fully
+             * analyzed because we don't know all the files that will be a part
+             * of that group */
+            if ((groupKey.getAttribute() != DrawableAttribute.PATH) || db.isGroupAnalyzed(groupKey)) {
+                return getFileIDsInGroup(groupKey);
+            } else {
+                return null;
+            }
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.SEVERE, "failed to get files for group: " + groupKey.getAttribute().attrName.name() + " = " + groupKey.getValue(), ex);
+            return null;
+        }
+    }
+
+    /**
+     * the implementation of this should be moved to DrawableDB
+     *
+     * @param hashDbName
+     *
+     * @return
+     *
+     * @deprecated
+     */
+    @Deprecated
+    private List<Long> getFileIDsWithHashSetName(String hashDbName) {
+        List<Long> files = new ArrayList<>();
+        try {
+
+            final SleuthkitCase sleuthkitCase = EurekaController.getDefault().getSleuthKitCase();
+            String query = "SELECT obj_id FROM blackboard_attributes,blackboard_artifacts WHERE "
+                    + "attribute_type_id=" + BlackboardAttribute.ATTRIBUTE_TYPE.TSK_SET_NAME.getTypeID()
+                    + " AND blackboard_attributes.artifact_id=blackboard_artifacts.artifact_id"
+                    + " AND blackboard_attributes.value_text='" + hashDbName + "'"
+                    + " AND blackboard_artifacts.artifact_type_id=" + BlackboardArtifact.ARTIFACT_TYPE.TSK_HASHSET_HIT.getTypeID();
+
+            ResultSet rs = null;
+            try {
+                rs = sleuthkitCase.runQuery(query);
+                while (rs.next()) {
+                    long id = rs.getLong("obj_id");
+                    try {
+                        if (EurekaModule.isSupportedAndNotKnown(Case.getCurrentCase().getSleuthkitCase().getAbstractFileById(id))) {
+                            files.add(id);
+                        }
+                    } catch (TskCoreException ex) {
+                        Exceptions.printStackTrace(ex);
+                    }
+                }
+            } catch (SQLException ex) {
+                Exceptions.printStackTrace(ex);
+            } finally {
+                if (rs != null) {
+                    try {
+                        Case.getCurrentCase().getSleuthkitCase().closeRunQuery(rs);
+                    } catch (SQLException ex) {
+                        LOGGER.log(Level.WARNING, "Error closing result set after getting hashset hits", ex);
+                    }
+                }
+            }
+
+            return files;
+        } catch (IllegalStateException ex) {
+            LOGGER.log(Level.SEVERE, "Can't get the current case; there is no case open!", ex);
+            return files;
+        }
+    }
+
+    /**
+     * find the distinct values for the given column (DrawableAttribute) in the
+     * order given by sortBy and sortOrder.
+     *
+     * These values represent the groups of files.
+     *
+     * @param regroup
+     * @param sortBy
+     * @param sortOrder
+     *
+     * @return
+     */
+    public <A extends Comparable> List<A> findValuesForAttribute(DrawableAttribute<A> groupBy, GroupSortBy sortBy, SortOrder sortOrder) {
+        try {
+            List<A> values;
+            switch (groupBy.attrName) {
+                //these cases get special treatment
+                case CATEGORY:
+                    values = (List<A>) Category.valuesList();
+                    break;
+                case TAGS:
+                    values = (List<A>) Case.getCurrentCase().getServices().getTagsManager().getTagNamesInUse();
+                    break;
+                case ANALYZED:
+                    values = (List<A>) Arrays.asList(false, true);
+                    break;
+                case HASHSET:
+                    TreeSet<A> names = new TreeSet<>((Set<A>) db.getHashSetNames());
+                    values = new ArrayList<>(names);
+                    break;
+                default:
+                    //otherwise do straight db query 
+                    return db.findValuesForAttribute(groupBy, sortBy, sortOrder);
+            }
+
+            //sort in memory
+            Collections.sort(values, sortBy.getValueComparator(groupBy, sortOrder));
+
+            return values;
+        } catch (TskCoreException ex) {
+            Exceptions.printStackTrace(ex);
+            return new ArrayList<>();
+        }
+    }
+
+    /**
+     * find the distinct values of the regroup attribute in the order given by
+     * sortBy with a ascending order
+     *
+     * @param regroup
+     * @param sortBy
+     *
+     * @return
+     */
+    public <A extends Comparable> List<A> findValuesForAttribute(DrawableAttribute<A> groupBy, GroupSortBy sortBy) {
+        return findValuesForAttribute(groupBy, sortBy, SortOrder.ASCENDING);
+    }
+
+    public List<Long> getFileIDsInGroup(GroupKey groupKey) throws TskCoreException {
+        switch (groupKey.getAttribute().attrName) {
+            //these cases get special treatment
+            case CATEGORY:
+                return getFileIDsWithCategory((Category) groupKey.getValue());
+            case TAGS:
+                return getFileIDsWithTag((TagName) groupKey.getValue());
+            case HASHSET: //comment out this case to use db functionality for hashsets
+                return getFileIDsWithHashSetName((String) groupKey.getValue());
+            default:
+                //straight db query
+                return db.getFileIDsInGroup(groupKey);
+        }
+    }
+
+    // @@@ This was kind of slow in the profiler.  Maybe we should cache it.
+    public List<Long> getFileIDsWithCategory(Category category) throws TskCoreException {
+
+        try {
+            if (category == Category.ZERO) {
+
+                List<Long> files = new ArrayList<>();
+                TagName[] tns = {Category.FOUR.getTagName(), Category.THREE.getTagName(), Category.TWO.getTagName(), Category.ONE.getTagName(), Category.FIVE.getTagName()};
+                for (TagName tn : tns) {
+                    List<ContentTag> contentTags = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByTagName(tn);
+                    for (ContentTag ct : contentTags) {
+                        if (ct.getContent() instanceof AbstractFile && EurekaModule.isSupportedAndNotKnown((AbstractFile) ct.getContent())) {
+                            files.add(ct.getContent().getId());
+                        }
+                    }
+                }
+
+                return db.findAllFileIdsWhere("obj_id NOT IN (" + StringUtils.join(files, ',') + ")");
+            } else {
+
+                List<Long> files = new ArrayList<>();
+                List<ContentTag> contentTags = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByTagName(category.getTagName());
+                for (ContentTag ct : contentTags) {
+                    if (ct.getContent() instanceof AbstractFile && EurekaModule.isSupportedAndNotKnown((AbstractFile) ct.getContent())) {
+                        files.add(ct.getContent().getId());
+                    }
+                }
+
+                return files;
+            }
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.WARNING, "TSK error getting files in Category:" + category.getDisplayName(), ex);
+            throw ex;
+        }
+    }
+
+    public List<Long> getFileIDsWithTag(TagName tagName) throws TskCoreException {
+        try {
+            List<Long> files = new ArrayList<>();
+            List<ContentTag> contentTags = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByTagName(tagName);
+            for (ContentTag ct : contentTags) {
+                if (ct.getContent() instanceof AbstractFile && EurekaModule.isSupportedAndNotKnown((AbstractFile) ct.getContent())) {
+                    files.add(ct.getContent().getId());
+                }
+            }
+
+            return files;
+        } catch (TskCoreException ex) {
+            LOGGER.log(Level.WARNING, "TSK error getting files with Tag:" + tagName.getDisplayName(), ex);
+            throw ex;
+        }
+    }
+
+    public GroupSortBy getSortBy() {
+        return sortBy;
+    }
+
+    public void setSortBy(GroupSortBy sortBy) {
+        this.sortBy = sortBy;
+    }
+
+    public DrawableAttribute getGroupBy() {
+        return groupBy;
+    }
+
+    public void setGroupBy(DrawableAttribute groupBy) {
+        this.groupBy = groupBy;
+    }
+
+    public SortOrder getSortOrder() {
+        return sortOrder;
+    }
+
+    public void setSortOrder(SortOrder sortOrder) {
+        this.sortOrder = sortOrder;
+    }
+
+    /**
+     * regroup all files in the database using given {@link  DrawableAttribute}
+     * see
+     * {@link ReGroupTask} for more details.
+     *
+     * @param groupBy
+     * @param sortBy
+     * @param sortOrder
+     * @param force     true to force a full db query regroup
+     */
+    public void regroup(final DrawableAttribute groupBy, final GroupSortBy sortBy, final SortOrder sortOrder, Boolean force) {
+        //only re-query the db if the group by attribute changed or it is forced
+        if (groupBy != getGroupBy() || force == true) {
+            setGroupBy(groupBy);
+            setSortBy(sortBy);
+            setSortOrder(sortOrder);
+            if (groupByTask != null) {
+                groupByTask.cancel(true);
+            }
+            sortedUnSeenGroups.setComparator(sortBy.getGrpComparator(groupBy, sortOrder));
+            groupByTask = new ReGroupTask(groupBy, sortBy, sortOrder);
+            controller.submitBGTask(groupByTask);
+        } else {
+            // just resort the list of groups
+            setSortBy(sortBy);
+            setSortOrder(sortOrder);
+            sortedUnSeenGroups.setComparator(sortBy.getGrpComparator(groupBy, sortOrder));
+        }
+    }
+
+    /**
+     * Task to query database for files in sorted groups and build
+     * {@link Groupings} for them
+     */
+    private class ReGroupTask extends LoggedTask<Void> {
+
+        private ProgressHandle groupProgress;
+
+        private final DrawableAttribute groupBy;
+
+        private final GroupSortBy sortBy;
+
+        private final SortOrder sortOrder;
+
+        public ReGroupTask(DrawableAttribute groupBy, GroupSortBy sortBy, SortOrder sortOrder) {
+            super("regrouping files by " + groupBy.attrName.name() + " sorted by " + sortBy.name() + " in " + sortOrder.name() + " order", true);
+
+            this.groupBy = groupBy;
+            this.sortBy = sortBy;
+            this.sortOrder = sortOrder;
+        }
+
+        @Override
+        public boolean isCancelled() {
+            return super.isCancelled() || groupBy != getGroupBy() || sortBy != getSortBy() || sortOrder != getSortOrder();
+        }
+
+        @Override
+        protected Void call() throws Exception {
+
+            if (isCancelled()) {
+                return null;
+            }
+
+            groupProgress = ProgressHandleFactory.createHandle(getTaskName(), this);
+            Platform.runLater(() -> {
+                analyzedGroups.clear();
+                unSeenGroups.clear();
+            });
+            groupMap.clear();
+
+            //get a list of group key vals
+            final List<Comparable> vals = findValuesForAttribute(groupBy, sortBy, sortOrder);
+
+            groupProgress.start(vals.size());
+
+            int p = 0;
+            //for each key value
+            for (final Comparable val : vals) {
+                if (isCancelled()) {
+                    return null;//abort
+                }
+                p++;
+                updateMessage("regrouping files by " + groupBy.attrName.name() + " : " + val);
+                updateProgress(p, vals.size());
+                groupProgress.progress("regrouping files by " + groupBy.attrName.name() + " : " + val, p);
+                //check if this group is analyzed
+                final GroupKey groupKey = new GroupKey(groupBy, val);
+
+                List<Long> checkAnalyzed = checkAnalyzed(groupKey);
+                if (checkAnalyzed != null) { // != null => the group is analyzed, so add it to the ui
+                    populateAnalyzedGroup(groupKey, checkAnalyzed, ReGroupTask.this);
+                }
+            }
+            updateProgress(1, 1);
+            return null;
+        }
+
+        @Override
+        protected void done() {
+            super.done();
+            if (groupProgress != null) {
+                groupProgress.finish();
+                groupProgress = null;
+            }
+
+//            controller.checkForGroups();
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupSortBy.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupSortBy.java
new file mode 100644
index 0000000000000000000000000000000000000000..ca3a3ef792c3bb8ac5641658bb523762b58732f9
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupSortBy.java
@@ -0,0 +1,202 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.grouping;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.scene.image.Image;
+import javax.swing.SortOrder;
+import static javax.swing.SortOrder.ASCENDING;
+import static javax.swing.SortOrder.DESCENDING;
+import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import static org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute.AttributeName.TAGS;
+import org.sleuthkit.datamodel.TagName;
+
+/** enum of possible properties to sort groups by. This is the model for the
+ * drop down in {@link  EurekaToolbar} as well as each enum value having the
+ * stategy ({@link  Comparator}) for sorting the groups */
+public enum GroupSortBy implements ComparatorProvider {
+
+    /** sort the groups by the number of files in each
+     * sort the groups by the number of files in each */
+    FILE_COUNT("Group Size", true, "folder-open-image.png") {
+                @Override
+        public Comparator<Grouping> getGrpComparator(DrawableAttribute attr, final SortOrder sortOrder) {
+                    Comparator<Grouping> comparingInt = Comparator.comparingInt(Grouping::getSize);
+            return sortOrder == ASCENDING ? comparingInt : comparingInt.reversed();
+        }
+
+                @Override
+                public <A extends Comparable> Comparator<A> getValueComparator(final DrawableAttribute<A> attr, final SortOrder sortOrder) {
+                    return (A v1, A v2) -> {
+                        Grouping g1 = EurekaController.getDefault().getGroupManager().getGroupForKey(new GroupKey(attr, v1));
+                        Grouping g2 = EurekaController.getDefault().getGroupManager().getGroupForKey(new GroupKey(attr, v2));
+                        return getGrpComparator(attr, sortOrder).compare(g1, g2);
+                    };
+                }
+            },
+    /** sort
+     * the groups by the natural order of the grouping value ( eg group them by
+     * path alphabetically ) */
+    GROUP_BY_VALUE("Group Name", true, "folder-rename.png") {
+                @Override
+        public Comparator<Grouping> getGrpComparator(final DrawableAttribute attr, final SortOrder sortOrder) {
+            return (Grouping o1, Grouping o2) -> {
+
+                        final Comparable c1 = o1.groupKey.getValue();
+                        final Comparable c2 = o2.groupKey.getValue();
+                        final boolean isTags = attr.attrName == TAGS;
+
+                switch (sortOrder) {
+                    case ASCENDING:
+                        return c1.compareTo(c2);
+                    case DESCENDING:
+                        return c1.compareTo(c2) * -1;
+                    default: //unsorted
+                        return 0;
+                }
+            };
+        }
+
+        @Override
+        public <A extends Comparable> Comparator<A> getValueComparator(final DrawableAttribute<A> attr, final SortOrder sortOrder) {
+            return (A o1, A o2) -> {
+                Comparable c1;
+                Comparable c2;
+                switch (attr.attrName) {
+                    case TAGS:
+                        c1 = ((TagName) o1).getDisplayName();
+                        c2 = ((TagName) o2).getDisplayName();
+                        break;
+                    default:
+                        c1 = (Comparable) o1;
+                        c2 = (Comparable) o2;
+                }
+
+                        switch (sortOrder) {
+                            case ASCENDING:
+                                return c1.compareTo(c2);
+                            case DESCENDING:
+                                return c1.compareTo(c2) * -1;
+                            default: //unsorted
+                                return 0;
+                        }
+                    };
+                }
+            },
+    /** don't sort the groups just use what ever
+     * order they come in (ingest
+     * order) */
+    /** don't sort the groups just use what ever order they come
+     * in (ingest
+     * order) */
+    NONE("None", false, "prohibition.png") {
+                @Override
+        public Comparator<Grouping> getGrpComparator(DrawableAttribute attr, SortOrder sortOrder) {
+            return new NoOpComparator<>();
+        }
+
+                @Override
+                public <A extends Comparable> Comparator<A> getValueComparator(DrawableAttribute<A> attr, final SortOrder sortOrder) {
+                    return new NoOpComparator<>();
+                }
+            },
+    /** sort
+     * the groups by some priority metric to be determined and implemented */
+    PRIORITY("Priority", false, "hashset_hits.png") {
+                @Override
+        public Comparator<Grouping> getGrpComparator(DrawableAttribute attr, SortOrder sortOrder) {
+            return Comparator.nullsLast(Comparator.comparingDouble(Grouping::getHashHitDensity).thenComparing(Grouping::getSize).reversed());
+        }
+
+        @Override
+        public <A extends Comparable> Comparator<A> getValueComparator(DrawableAttribute<A> attr, SortOrder sortOrder) {
+            return (A v1, A v2) -> {
+                Grouping g1 = EurekaController.getDefault().getGroupManager().getGroupForKey(new GroupKey(attr, v1));
+                Grouping g2 = EurekaController.getDefault().getGroupManager().getGroupForKey(new GroupKey(attr, v2));
+
+                        return getGrpComparator(attr, sortOrder).compare(g1, g2);
+                    };
+                }
+            };
+
+    /**
+     * get a list of the values of this enum
+     *
+     * @return
+     */
+    public static ObservableList<GroupSortBy> getValues() {
+        return FXCollections.observableArrayList(Arrays.asList(values()));
+
+    }
+
+    final private String displayName;
+
+    private Image icon;
+
+    private final String imageName;
+
+    private final Boolean sortOrderEnabled;
+
+    private GroupSortBy(String displayName, Boolean sortOrderEnabled, String imagePath) {
+        this.displayName = displayName;
+        this.sortOrderEnabled = sortOrderEnabled;
+        this.imageName = imagePath;
+    }
+
+    public String getDisplayName() {
+        return displayName;
+    }
+
+    public Image getIcon() {
+        if (icon == null) {
+            if (StringUtils.isBlank(imageName) == false) {
+                this.icon = new Image("org/sleuthkit/autopsy/imageanalyzer/images/" + imageName, true);
+            }
+        }
+        return icon;
+    }
+
+    public Boolean isSortOrderEnabled() {
+        return sortOrderEnabled;
+    }
+
+    private static class NoOpComparator<A> implements Comparator<A> {
+
+        @Override
+        public int compare(A o1, A o2) {
+            return 0;
+        }
+    }
+}
+
+/** * implementers of this interface must provide a method to compare
+ * ({@link  Comparable}) values and Groupings based on an
+ * {@link DrawableAttribute} and a {@link SortOrder}
+ */
+interface ComparatorProvider {
+
+    <A extends Comparable> Comparator<A> getValueComparator(DrawableAttribute<A> attr, SortOrder sortOrder);
+
+    <A> Comparator<Grouping> getGrpComparator(DrawableAttribute<A> attr, SortOrder sortOrder);
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupViewMode.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupViewMode.java
new file mode 100644
index 0000000000000000000000000000000000000000..01e985679b477673c8f5688cc496956ac0e2865b
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupViewMode.java
@@ -0,0 +1,7 @@
+package org.sleuthkit.autopsy.imageanalyzer.grouping;
+
+public enum GroupViewMode {
+
+    TILE, SLIDE_SHOW
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupViewState.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupViewState.java
new file mode 100644
index 0000000000000000000000000000000000000000..15666e795072cbab7461193d03b17d7650b70958
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/GroupViewState.java
@@ -0,0 +1,91 @@
+
+/* Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.grouping;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ *
+ */
+public class GroupViewState {
+
+    private final Grouping group;
+
+    private final GroupViewMode mode;
+
+    private final Optional<Long> slideShowfileID;
+
+    public Grouping getGroup() {
+        return group;
+    }
+
+    public GroupViewMode getMode() {
+        return mode;
+    }
+
+    public Optional<Long> getSlideShowfileID() {
+        return slideShowfileID;
+    }
+
+    private GroupViewState(Grouping g, GroupViewMode mode, Long slideShowfileID) {
+        this.group = g;
+        this.mode = mode;
+        this.slideShowfileID = Optional.ofNullable(slideShowfileID);
+    }
+
+    public static GroupViewState tile(Grouping g) {
+        return new GroupViewState(g, GroupViewMode.TILE, null);
+    }
+
+    public static GroupViewState slideShow(Grouping g, Long fileID) {
+        return new GroupViewState(g, GroupViewMode.SLIDE_SHOW, fileID);
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 7;
+        hash = 17 * hash + Objects.hashCode(this.group);
+        hash = 17 * hash + Objects.hashCode(this.mode);
+        hash = 17 * hash + Objects.hashCode(this.slideShowfileID);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final GroupViewState other = (GroupViewState) obj;
+        if (!Objects.equals(this.group, other.group)) {
+            return false;
+        }
+        if (this.mode != other.mode) {
+            return false;
+        }
+        if (!Objects.equals(this.slideShowfileID, other.slideShowfileID)) {
+            return false;
+        }
+        return true;
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/Grouping.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/Grouping.java
new file mode 100644
index 0000000000000000000000000000000000000000..d65e73c9de376e5dd5320242619d343d3eedce8c
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/grouping/Grouping.java
@@ -0,0 +1,129 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.grouping;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import java.util.List;
+import java.util.Objects;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.datamodel.BlackboardArtifact;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Represents a set of files in a group. The UI listens to changes to the group
+ * and updates itself accordingly.
+ *
+ * This class is named Grouping and not Group to avoid confusion with
+ * {@link javafx.scene.Group} and others.
+ */
+public class Grouping {
+
+    private static final Logger LOGGER = Logger.getLogger(Grouping.class.getName());
+
+    public static final String UNKNOWN = "unknown";
+
+    final private ObservableList<Long> fileIDs = FXCollections.observableArrayList();
+
+    //cache the number of files in this groups with hashset hits
+    private int filesWithHashSetHitsCount = -1;
+
+    synchronized public ObservableList<Long> fileIds() {
+        return fileIDs;
+    }
+
+    final public GroupKey groupKey;
+
+    public Grouping(GroupKey groupKey, List<Long> filesInGroup) {
+        this.groupKey = groupKey;
+        fileIDs.setAll(filesInGroup);
+    }
+
+    synchronized public Integer getSize() {
+        return fileIDs.size();
+    }
+
+    public double getHashHitDensity() {
+        return getFilesWithHashSetHitsCount() / getSize().doubleValue();
+    }
+
+    synchronized public Integer getFilesWithHashSetHitsCount() {
+
+        if (filesWithHashSetHitsCount < 0) {
+            filesWithHashSetHitsCount = 0;
+            for (Long fileID : fileIds()) {
+                try {
+                    long artcount = EurekaController.getDefault().getSleuthKitCase().getBlackboardArtifactsCount(BlackboardArtifact.ARTIFACT_TYPE.TSK_HASHSET_HIT, fileID);
+                    if (artcount > 0) {
+                        filesWithHashSetHitsCount++;
+                    }
+                } catch (IllegalStateException | TskCoreException ex) {
+                    LOGGER.log(Level.WARNING, "could not access case during getFilesWithHashSetHitsCount()", ex);
+                    break;
+                }
+            }
+        }
+        return filesWithHashSetHitsCount;
+    }
+
+    @Override
+    public String toString() {
+        return "Grouping{ keyProp=" + groupKey + '}';
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 3;
+        hash = 53 * hash + Objects.hashCode(this.groupKey);
+        return hash;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        final Grouping other = (Grouping) obj;
+        if (!Objects.equals(this.groupKey, other.groupKey)) {
+            return false;
+        }
+        return true;
+    }
+
+    synchronized public void addFile(Long f) {
+        Platform.runLater(() -> {
+            if (fileIDs.contains(f) == false) {
+                fileIDs.add(f);
+            }
+        });
+
+    }
+
+    synchronized public void removeFile(Long f) {
+        Platform.runLater(() -> {
+            fileIDs.removeAll(f);
+        });
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/AttributeListCell.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/AttributeListCell.java
new file mode 100644
index 0000000000000000000000000000000000000000..edc9f1b03f93522b2ec94112e3fe87cb38438fd5
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/AttributeListCell.java
@@ -0,0 +1,40 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import javafx.scene.control.ListCell;
+import javafx.scene.image.ImageView;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+
+/**
+ */
+class AttributeListCell extends ListCell<DrawableAttribute<?>> {
+
+    @Override
+    protected void updateItem(DrawableAttribute<?> item, boolean empty) {
+        super.updateItem(item, empty); //To change body of generated methods, choose Tools | Templates.
+        if (item != null) {
+            setText(item.getDisplayName());
+            setGraphic(new ImageView(item.getIcon()));
+        } else {
+            setGraphic(null);
+            setText(null);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableTile.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableTile.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..8bfa03862af828ea2e182cad81920da44efa6d74
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableTile.fxml
@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+
+<fx:root maxHeight="-1.0" maxWidth="-1.0" minHeight="-Infinity" minWidth="-Infinity" opacity="1.0" prefHeight="-1.0" prefWidth="-1.0" style="-fx-background-color: linear-gradient(to bottom, derive(-fx-base,-30%), derive(-fx-base,-60%)),        linear-gradient(to bottom, derive(-fx-base,65%) 2%, derive(-fx-base,-20%) 95%); -fx-background-radius: 2;" type="javafx.scene.layout.AnchorPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+  <children>
+    <BorderPane maxHeight="-1.0" maxWidth="-1.0" minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" snapToPixel="true" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+      <bottom>
+        <BorderPane maxHeight="-Infinity" maxWidth="-1.0" minHeight="-Infinity" minWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER">
+          <center>
+            <Label id="pathLabel" fx:id="nameLabel" alignment="CENTER" contentDisplay="TEXT_ONLY" maxHeight="16.0" minHeight="16.0" minWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" text="file name" textAlignment="CENTER" wrapText="true">
+              <labelFor>
+                <ImageView fx:id="imageView" fitHeight="200.0" fitWidth="200.0" opacity="1.0" pickOnBounds="true" preserveRatio="true" style="-fx-border-radius : 5;&#10;-fx-border-width : 5;&#10;-fx-border-color : blue;" BorderPane.alignment="CENTER" />
+              </labelFor>
+            </Label>
+          </center>
+          <left>
+            <HBox maxHeight="-Infinity" prefHeight="-1.0" prefWidth="-1.0" spacing="2.0" BorderPane.alignment="CENTER_LEFT">
+              <children>
+                <ImageView fx:id="fileTypeImageView" fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true" scaleX="1.0" scaleY="1.0">
+                  <image>
+                    <Image url="@../images/video-file.png" />
+                  </image>
+                </ImageView>
+                <ImageView fx:id="hashHitImageView" fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true" style="">
+                  <image>
+                    <Image url="@../images/hashset_hits.png" />
+                  </image>
+                  <HBox.margin>
+                    <Insets bottom="1.0" left="1.0" right="1.0" top="1.0" fx:id="x1" />
+                  </HBox.margin>
+                </ImageView>
+              </children>
+              <padding>
+                <Insets bottom="2.0" right="2.0" top="2.0" />
+              </padding>
+            </HBox>
+          </left>
+          <right>
+            <ToggleButton fx:id="followUpToggle" minWidth="24.0" mnemonicParsing="false" prefWidth="24.0" selected="false" text="">
+              <graphic>
+                <ImageView id="followUpImageview" fx:id="followUpImageView" fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true">
+                  <image>
+                    <Image url="@../images/flag_gray.png" />
+                  </image>
+                </ImageView>
+              </graphic>
+            </ToggleButton>
+          </right>
+        </BorderPane>
+      </bottom>
+      <center>
+        <BorderPane fx:id="imageBorder" center="$imageView" maxHeight="-1.0" maxWidth="-1.0" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER">
+<center><ImageView fx:id="imageView" fitHeight="100.0" fitWidth="100.0" pickOnBounds="true" preserveRatio="true" BorderPane.alignment="CENTER" />
+</center></BorderPane>
+      </center>
+    </BorderPane>
+  </children>
+  <padding>
+    <Insets bottom="2.0" left="2.0" right="2.0" top="2.0" />
+  </padding>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableTile.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableTile.java
new file mode 100644
index 0000000000000000000000000000000000000000..aab1928ca9e2b747faf359072461984695d021c0
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableTile.java
@@ -0,0 +1,122 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import java.net.URL;
+import java.util.Objects;
+import java.util.ResourceBundle;
+import javafx.fxml.FXML;
+import javafx.scene.CacheHint;
+import javafx.scene.control.Control;
+import javafx.scene.effect.DropShadow;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.paint.Color;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined.ThreadType;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+
+/**
+ * GUI component that represents a single image as a tile with an icon, a label
+ * a color coded border and possibly other controls. Designed to be in a
+ * {@link GroupPane}'s TilePane or SlideShow.
+ *
+ *
+ * TODO: refactor this to extend from {@link Control}? -jm
+ */
+public class DrawableTile extends SingleDrawableViewBase implements Category.CategoryListener, TagUtils.TagListener {
+
+    private static final DropShadow LAST_SELECTED_EFFECT = new DropShadow(10, Color.BLUE);
+
+    private static final Logger LOGGER = Logger.getLogger(DrawableTile.class.getName());
+
+    /**
+     * the central ImageView that shows a thumbnail of the represented file
+     */
+    @FXML
+    @ThreadConfined(type = ThreadType.UI)
+    private ImageView imageView;
+
+    public ImageView getImageView() {
+        return imageView;
+    }
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @Override
+    protected void disposeContent() {
+        //no-op
+    }
+
+    @FXML
+    @Override
+    protected void initialize() {
+        super.initialize();
+        assert imageBorder != null : "fx:id=\"imageAnchor\" was not injected: check your FXML file 'DrawableTile.fxml'.";
+        assert imageView != null : "fx:id=\"imageView\" was not injected: check your FXML file 'DrawableTile.fxml'.";
+        assert nameLabel != null : "fx:id=\"nameLabel\" was not injected: check your FXML file 'DrawableTile.fxml'.";
+
+        //set up properties and binding
+        setCache(true);
+        setCacheHint(CacheHint.SPEED);
+        nameLabel.prefWidthProperty().bind(imageView.fitWidthProperty());
+
+        imageView.fitHeightProperty().bind(EurekaToolbar.getDefault().sizeSliderValue());
+        imageView.fitWidthProperty().bind(EurekaToolbar.getDefault().sizeSliderValue());
+
+        globalSelectionModel.lastSelectedProperty().addListener((observable, oldValue, newValue) -> {
+            setEffect(Objects.equals(newValue, fileID) ? LAST_SELECTED_EFFECT : null);
+        });
+    }
+
+    public DrawableTile(GroupPane gp) {
+        super();
+        FXMLConstructor.construct(this, "DrawableTile.fxml");
+        groupPane = gp;
+    }
+
+    @Override
+    @ThreadConfined(type = ThreadType.UI)
+    protected void clearContent() {
+        imageView.setImage(null);
+    }
+
+    @Override
+    protected Runnable getContentUpdateRunnable() {
+        Image image = file.getIcon();
+
+        return () -> {
+            imageView.setImage(image);
+        };
+    }
+
+    @Override
+    @ThreadConfined(type = ThreadType.UI)
+    protected String getLabelText() {
+        return file.getName();
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableView.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableView.java
new file mode 100644
index 0000000000000000000000000000000000000000..aec3246430474e05f0eca70bffa45b98c480c873
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/DrawableView.java
@@ -0,0 +1,93 @@
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import java.util.Collection;
+import javafx.application.Platform;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.Region;
+import javafx.scene.paint.Color;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+
+/**
+ * TODO: extract common interface out of {@link SingleImageView} and
+ * {@link MetaDataPane}
+ */
+public interface DrawableView extends Category.CategoryListener, TagUtils.TagListener {
+
+    //TODO: do this all in css? -jm
+    static final int CAT_BORDER_WIDTH = 10;
+
+    static final BorderWidths CAT_BORDER_WIDTHS = new BorderWidths(CAT_BORDER_WIDTH);
+
+    static final CornerRadii CAT_CORNER_RADII = new CornerRadii(3);
+
+    static final Border HASH_BORDER = new Border(new BorderStroke(Color.PURPLE, BorderStrokeStyle.DASHED, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    static final Border CAT1_BORDER = new Border(new BorderStroke(Category.ONE.getColor(), BorderStrokeStyle.SOLID, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    static final Border CAT2_BORDER = new Border(new BorderStroke(Category.TWO.getColor(), BorderStrokeStyle.SOLID, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    static final Border CAT3_BORDER = new Border(new BorderStroke(Category.THREE.getColor(), BorderStrokeStyle.SOLID, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    static final Border CAT4_BORDER = new Border(new BorderStroke(Category.FOUR.getColor(), BorderStrokeStyle.SOLID, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    static final Border CAT5_BORDER = new Border(new BorderStroke(Category.FIVE.getColor(), BorderStrokeStyle.SOLID, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    static final Border CAT0_BORDER = new Border(new BorderStroke(Category.ZERO.getColor(), BorderStrokeStyle.SOLID, CAT_CORNER_RADII, CAT_BORDER_WIDTHS));
+
+    Region getBorderable();
+
+    DrawableFile getFile();
+
+    void setFile(final Long fileID);
+
+    Long getFileID();
+
+    @Override
+    void handleCategoryChanged(Collection<Long> ids);
+
+    @Override
+    void handleTagsChanged(Collection<Long> ids);
+
+    default boolean hasHashHit() {
+        return getFile().getHashHitSetNames().isEmpty() == false;
+    }
+
+    static Border getCategoryBorder(Category category) {
+        switch (category) {
+            case ZERO:
+                return CAT0_BORDER;
+            case ONE:
+                return CAT1_BORDER;
+            case TWO:
+                return CAT2_BORDER;
+            case THREE:
+                return CAT3_BORDER;
+            case FOUR:
+                return CAT4_BORDER;
+            case FIVE:
+            default:
+                return CAT5_BORDER;
+        }
+    }
+
+    @ThreadConfined(type = ThreadConfined.ThreadType.ANY)
+    default Category updateCategoryBorder() {
+        final Category category = getFile().getCategory();
+        final Border border = hasHashHit() && (category == Category.ZERO)
+                              ? HASH_BORDER
+                              : DrawableView.getCategoryBorder(category);
+
+        Platform.runLater(() -> {
+            getBorderable().setBorder(border);
+        });
+        return category;
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaPanel.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..a62b9ed75b79c7cc0b7494db254dd6ad9dd2f3fd
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaPanel.java
@@ -0,0 +1,105 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.gui.navpanel.NavPanel;
+import javafx.application.Platform;
+import javafx.embed.swing.JFXPanel;
+import javafx.scene.Scene;
+import javafx.scene.control.SplitPane;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.StackPane;
+import javafx.scene.layout.VBox;
+import org.sleuthkit.autopsy.coreutils.Logger;
+
+/** JFXPanel derived class that contains Eureka GUI. */
+public class EurekaPanel extends JFXPanel {
+
+    private static final Logger LOGGER = Logger.getLogger(JFXPanel.class.getName());
+
+    private StackPane centralStack;
+
+    private EurekaController controller;
+
+    private StackPane fullUIStack;
+
+    private SplitPane splitPane;
+
+    private MetaDataPane metaDataTable;
+
+    private GroupPane groupPane;
+
+    private NavPanel navPanel;
+
+    private Scene myScene;
+
+    volatile private boolean sceneInited = false;
+
+    public EurekaPanel() {
+
+    }
+
+    @Override
+    public void addNotify() {
+
+        super.addNotify();
+        /* NOTE: why doesn't the explorer manager reflect changes as made by
+         * EurekaSelectionModel unless a 'real' explorer view is present -jm
+         *
+         * NOTE: the explorer manager was removed when we stopped using it for
+         * actions since they were diverging from autopsy, however the above
+         * question is still interesting. -jm */
+        // em = ExplorerManager.find(this);
+    }
+
+    /** initialize the embedded jfx scene */
+    public void initScene() {
+
+        if (sceneInited == false) {
+            Platform.runLater(() -> {
+                controller = EurekaController.getDefault();
+                this.navPanel = new NavPanel(controller);
+                this.groupPane = new GroupPane(controller);
+                this.metaDataTable = new MetaDataPane(controller);
+
+                VBox leftPane = new VBox();
+                leftPane.getChildren().addAll(navPanel, SummaryTablePane.getDefault());
+
+                this.splitPane = new SplitPane();
+                this.centralStack = new StackPane(splitPane);
+
+                SplitPane.setResizableWithParent(leftPane, Boolean.FALSE);
+                SplitPane.setResizableWithParent(groupPane, Boolean.TRUE);
+                SplitPane.setResizableWithParent(metaDataTable, Boolean.FALSE);
+                splitPane.getItems().addAll(leftPane, groupPane, metaDataTable);
+                splitPane.setDividerPositions(0.0, 1.0);
+
+                BorderPane borderPane = new BorderPane(centralStack, EurekaToolbar.getDefault(), null, new StatusBar(controller), null);
+
+                this.fullUIStack = new StackPane(borderPane);
+                EurekaController.getDefault().setStacks(fullUIStack, centralStack);
+                myScene = new Scene(fullUIStack);
+                setScene(myScene);
+
+                sceneInited = true;
+            });
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaToolbar.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaToolbar.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..0cee5051111f085f7b252de0eb770dec663442d5
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaToolbar.fxml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+
+<fx:root minWidth="-1.0" orientation="HORIZONTAL" prefWidth="-1.0" type="javafx.scene.control.ToolBar" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+  <items>
+    <Label text="Group By:">
+      <labelFor>
+        <ComboBox fx:id="groupByBox" editable="false" />
+      </labelFor>
+    </Label>
+    <fx:reference source="groupByBox" />
+    <Region prefHeight="-1.0" prefWidth="20.0" />
+    <Label text="Sort By:">
+      <labelFor>
+        <ComboBox fx:id="sortByBox" />
+      </labelFor>
+    </Label>
+    <HBox id="HBox" fx:id="sortControlGroup" alignment="CENTER" spacing="5.0">
+      <children>
+        <fx:reference source="sortByBox" />
+        <VBox alignment="CENTER_LEFT" prefHeight="-1.0" prefWidth="-1.0" spacing="2.0">
+          <children>
+            <RadioButton fx:id="ascRadio" contentDisplay="LEFT" mnemonicParsing="false" selected="true" text="Ascending">
+              <graphic>
+                <ImageView fitHeight="0.0" fitWidth="0.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true">
+                  <image>
+                    <Image url="@../images/arrow_up.png" />
+                  </image>
+                </ImageView>
+              </graphic>
+              <toggleGroup>
+                <ToggleGroup fx:id="orderGroup" />
+              </toggleGroup>
+            </RadioButton>
+            <RadioButton fx:id="descRadio" contentDisplay="LEFT" mnemonicParsing="false" selected="false" text="Descending" toggleGroup="$orderGroup">
+              <graphic>
+                <ImageView fitHeight="0.0" fitWidth="0.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true">
+                  <image>
+                    <Image url="@../images/arrow_down.png" />
+                  </image>
+                </ImageView>
+              </graphic>
+            </RadioButton>
+          </children>
+        </VBox>
+      </children>
+    </HBox>
+    <Separator orientation="VERTICAL" prefHeight="-1.0" prefWidth="20.0" />
+    <CheckBox fx:id="onlyAnalyzedCheckBox" allowIndeterminate="false" indeterminate="false" mnemonicParsing="false" prefWidth="16.0" selected="true" text="only analyzed groups" underline="false" visible="false" />
+    <Label contentDisplay="RIGHT" text="Apply to Selected Files:" textOverrun="ELLIPSIS" />
+    <SplitMenuButton id="tagSplitMenu" fx:id="tagSelectedMenuButton" disable="true" mnemonicParsing="false" text="Follow Up" textOverrun="ELLIPSIS">
+      <items>
+        <MenuItem mnemonicParsing="false" text="Action 1" />
+        <MenuItem mnemonicParsing="false" text="Action 2" />
+      </items>
+    </SplitMenuButton>
+    <SplitMenuButton id="catSplitMenu" fx:id="catSelectedMenuButton" disable="true" mnemonicParsing="false" text="Cat-0">
+      <items>
+        <MenuItem mnemonicParsing="false" text="Action 1" />
+        <MenuItem mnemonicParsing="false" text="Action 2" />
+      </items>
+    </SplitMenuButton>
+    <Separator orientation="VERTICAL" prefHeight="-1.0" prefWidth="20.0" />
+    <Label text="Thumbnail Size (px):">
+      <labelFor>
+        <Slider fx:id="sizeSlider" blockIncrement="100.0" majorTickUnit="100.0" max="300.0" min="100.0" minorTickCount="0" orientation="HORIZONTAL" prefHeight="-1.0" showTickLabels="true" showTickMarks="true" snapToTicks="true" value="100.0" />
+      </labelFor>
+    </Label>
+    <fx:reference source="sizeSlider" />
+  </items>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaToolbar.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaToolbar.java
new file mode 100644
index 0000000000000000000000000000000000000000..47debdf43334d01a41a20bad912c9710e3384cfc
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/EurekaToolbar.java
@@ -0,0 +1,224 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.ResourceBundle;
+import javafx.application.Platform;
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.beans.property.DoubleProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.FXCollections;
+import javafx.event.ActionEvent;
+import javafx.fxml.FXML;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.RadioButton;
+import javafx.scene.control.Slider;
+import javafx.scene.control.SplitMenuButton;
+import javafx.scene.control.ToggleGroup;
+import javafx.scene.control.ToolBar;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.HBox;
+import javax.swing.SortOrder;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.FileIDSelectionModel;
+import org.sleuthkit.autopsy.imageanalyzer.IconCache;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupSortBy;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** Controller for the Eureka ToolBar */
+public class EurekaToolbar extends ToolBar {
+
+    private static final int SIZE_SLIDER_DEFAULT = 100;
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private ComboBox<DrawableAttribute<?>> groupByBox;
+
+    @FXML
+    private CheckBox onlyAnalyzedCheckBox;
+
+    @FXML
+    private Slider sizeSlider;
+
+    @FXML
+    private ComboBox<GroupSortBy> sortByBox;
+
+    @FXML
+    private RadioButton ascRadio;
+
+    @FXML
+    private RadioButton descRadio;
+
+    @FXML
+    private ToggleGroup orderGroup;
+
+//    @FXML
+//    private ToggleButton metaDataToggle;
+    @FXML
+    private HBox sortControlGroup;
+
+    @FXML
+    private SplitMenuButton catSelectedMenuButton;
+
+    @FXML
+    private SplitMenuButton tagSelectedMenuButton;
+
+    private static EurekaToolbar instance;
+
+    private final SimpleObjectProperty<SortOrder> orderProperty = new SimpleObjectProperty<>(SortOrder.ASCENDING);
+
+    private final InvalidationListener queryInvalidationListener = (Observable o) -> {
+        if (orderGroup.getSelectedToggle() == ascRadio) {
+            orderProperty.set(SortOrder.ASCENDING);
+        } else {
+            orderProperty.set(SortOrder.DESCENDING);
+        }
+
+        EurekaController.getDefault().getGroupManager().regroup(groupByBox.getSelectionModel().getSelectedItem(), sortByBox.getSelectionModel().getSelectedItem(), getSortOrder(), false);
+    };
+
+    synchronized public SortOrder getSortOrder() {
+        return orderProperty.get();
+    }
+
+//    public ReadOnlyBooleanProperty showMetaDataProperty() {
+//        return metaDataToggle.selectedProperty();
+//    }
+    public DoubleProperty sizeSliderValue() {
+        return sizeSlider.valueProperty();
+    }
+
+    static synchronized public EurekaToolbar getDefault() {
+        if (instance == null) {
+            instance = new EurekaToolbar();
+        }
+        return instance;
+    }
+
+    @FXML
+    void initialize() {
+        assert ascRadio != null : "fx:id=\"ascRadio\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert catSelectedMenuButton != null : "fx:id=\"catSelectedMenubutton\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert descRadio != null : "fx:id=\"descRadio\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert groupByBox != null : "fx:id=\"groupByBox\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert onlyAnalyzedCheckBox != null : "fx:id=\"onlyAnalyzedCheckBox\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert orderGroup != null : "fx:id=\"orderGroup\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert sizeSlider != null : "fx:id=\"sizeSlider\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert sortByBox != null : "fx:id=\"sortByBox\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert sortControlGroup != null : "fx:id=\"sortControlGroup\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+        assert tagSelectedMenuButton != null : "fx:id=\"tagSelectedMenubutton\" was not injected: check your FXML file 'EurekaToolbar.fxml'.";
+
+        FileIDSelectionModel.getInstance().getSelected().addListener((Observable o) -> {
+            tagSelectedMenuButton.setDisable(FileIDSelectionModel.getInstance().getSelected().isEmpty());
+            catSelectedMenuButton.setDisable(FileIDSelectionModel.getInstance().getSelected().isEmpty());
+        });
+
+        tagSelectedMenuButton.setOnAction((ActionEvent t) -> {
+            try {
+                TagUtils.createSelTagMenuItem(TagUtils.getFollowUpTagName(), tagSelectedMenuButton).getOnAction().handle(t);
+            } catch (TskCoreException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        });
+
+        tagSelectedMenuButton.setGraphic(new ImageView(DrawableAttribute.TAGS.getIcon()));
+        tagSelectedMenuButton.showingProperty().addListener((ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) -> {
+            if (t1) {
+                ArrayList<MenuItem> selTagMenues = new ArrayList<>();
+                for (final TagName tn : TagUtils.getNonCategoryTagNames()) {
+                    MenuItem menuItem = TagUtils.createSelTagMenuItem(tn, tagSelectedMenuButton);
+                    selTagMenues.add(menuItem);
+                }
+                tagSelectedMenuButton.getItems().setAll(selTagMenues);
+            }
+        });
+
+        catSelectedMenuButton.setOnAction(Category.FIVE.createSelCatMenuItem(catSelectedMenuButton).getOnAction());
+        catSelectedMenuButton.setText(Category.FIVE.getDisplayName());
+        catSelectedMenuButton.setGraphic(new ImageView(DrawableAttribute.CATEGORY.getIcon()));
+        catSelectedMenuButton.showingProperty().addListener((ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) -> {
+            if (t1) {
+                ArrayList<MenuItem> categoryMenues = new ArrayList<>();
+                for (final Category cat : Category.values()) {
+                    MenuItem menuItem = cat.createSelCatMenuItem(catSelectedMenuButton);
+                    categoryMenues.add(menuItem);
+                }
+                catSelectedMenuButton.getItems().setAll(categoryMenues);
+            }
+        });
+
+        groupByBox.setItems(FXCollections.observableList(DrawableAttribute.getGroupableAttrs()));
+        groupByBox.getSelectionModel().select(DrawableAttribute.PATH);
+        groupByBox.getSelectionModel().selectedItemProperty().addListener(queryInvalidationListener);
+        groupByBox.disableProperty().bind(EurekaController.getDefault().regroupDisabled());
+        groupByBox.setCellFactory((listView) -> new AttributeListCell());
+        groupByBox.setButtonCell(new AttributeListCell());
+
+        sortByBox.setCellFactory((listView) -> new SortByListCell());
+        sortByBox.setButtonCell(new SortByListCell());
+        sortByBox.setItems(GroupSortBy.getValues());
+
+        sortByBox.getSelectionModel().selectedItemProperty().addListener(queryInvalidationListener);
+
+        sortByBox.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
+            final boolean orderEnabled = newValue == GroupSortBy.NONE || newValue == GroupSortBy.PRIORITY;
+            ascRadio.setDisable(orderEnabled);
+            descRadio.setDisable(orderEnabled);
+
+        });
+        sortByBox.getSelectionModel().select(GroupSortBy.PRIORITY);
+//        ascRadio.disableProperty().bind(sortByBox.getSelectionModel().selectedItemProperty().isEqualTo(GroupSortBy.NONE));
+//        descRadio.disableProperty().bind(sortByBox.getSelectionModel().selectedItemProperty().isEqualTo(GroupSortBy.NONE));
+
+        orderGroup.selectedToggleProperty().addListener(queryInvalidationListener);
+
+        IconCache.getDefault().iconSize.bind(sizeSlider.valueProperty());
+
+    }
+
+    public void reset() {
+        Platform.runLater(() -> {
+            groupByBox.getSelectionModel().select(DrawableAttribute.PATH);
+            sortByBox.getSelectionModel().select(GroupSortBy.NONE);
+            orderGroup.selectToggle(ascRadio);
+            sizeSlider.setValue(SIZE_SLIDER_DEFAULT);
+        });
+    }
+
+    private EurekaToolbar() {
+        FXMLConstructor.construct(this, "EurekaToolbar.fxml");
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/Fitable.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/Fitable.java
new file mode 100644
index 0000000000000000000000000000000000000000..02e846588839f2a2da06ac0a7212126bdd3dafee
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/Fitable.java
@@ -0,0 +1,47 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.DoubleProperty;
+
+/**
+ *
+ */
+public interface Fitable {
+
+    BooleanProperty preserveRatioProperty();
+
+    DoubleProperty fitHeightProperty();
+
+    DoubleProperty fitWidthProperty();
+
+    boolean isPreserveRatio();
+
+    double getFitHeight();
+
+    double getFitWidth();
+
+    void setPreserveRatio(boolean b);
+
+    void setFitHeight(double d);
+
+    void setFitWidth(double d);
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupPane.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupPane.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..6eb799970d1ef1799510e3644d3bbce3b0c4d755
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupPane.fxml
@@ -0,0 +1,136 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.text.*?>
+<?import org.controlsfx.control.*?>
+
+<fx:root type="BorderPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+   
+    <center>
+        <GridView fx:id="gridView" BorderPane.alignment="CENTER" />
+    </center>
+    <bottom>
+        <BorderPane BorderPane.alignment="CENTER">
+            <left>
+                <HBox alignment="CENTER_LEFT" spacing="5.0" BorderPane.alignment="TOP_LEFT">
+                    <children>
+                        <Label text="Group Viewing History: " />
+                        <Button fx:id="backButton" mnemonicParsing="false" text="back">
+                            <graphic>
+                                <ImageView fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true">
+                                    <image>
+                                        <Image url="@../images/arrow-180.png" />
+                                    </image>
+                                </ImageView>
+                            </graphic>
+                        </Button>
+                        <Button fx:id="forwardButton" mnemonicParsing="false" text="forward">
+                            <graphic>
+                                <ImageView fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true">
+                                    <image>
+                                        <Image url="@../images/arrow.png" />
+                                    </image>
+                                </ImageView>
+                            </graphic>
+                        </Button>
+                    </children>
+                    <BorderPane.margin>
+                        <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
+                    </BorderPane.margin>
+                </HBox>
+            </left>
+            <right>
+                <HBox alignment="CENTER_RIGHT" spacing="5.0" BorderPane.alignment="TOP_RIGHT">
+                    <children>
+                        <Button fx:id="nextButton" contentDisplay="RIGHT" mnemonicParsing="false" text="next unseen group" BorderPane.alignment="CENTER_RIGHT">
+                            <BorderPane.margin>
+                                <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
+                            </BorderPane.margin>
+                            <graphic>
+                                <ImageView fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true">
+                                    <image>
+                                        <Image url="@../images/control-double.png" />
+                                    </image>
+                                </ImageView>
+                            </graphic>
+                        </Button>
+                    </children>
+                    <BorderPane.margin>
+                        <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
+                    </BorderPane.margin>
+                </HBox>
+            </right>
+            <top>
+                <Separator prefWidth="200.0" BorderPane.alignment="CENTER">
+                    <BorderPane.margin>
+                        <Insets bottom="3.0" left="3.0" right="3.0" top="3.0" />
+                    </BorderPane.margin>
+                </Separator>
+            </top>
+        </BorderPane>
+    </bottom>
+    <top>
+        <VBox>
+            <children>
+                <Label fx:id="groupLabel" wrapText="true">
+                    <font>
+                        <Font size="14.0" />
+                    </font>
+                    <VBox.margin>
+                        <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
+                    </VBox.margin>
+                </Label>
+     
+                <ToolBar id="headToolBar" fx:id="headerToolBar" maxHeight="-Infinity" maxWidth="-1.0" minHeight="-Infinity" minWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" BorderPane.alignment="CENTER" VBox.vgrow="NEVER">
+                    <items>
+                        <Label text="Apply to Group:" />
+                        <SplitMenuButton fx:id="grpTagSplitMenu" mnemonicParsing="false" text="Follow Up">
+                            <items>
+                                <MenuItem mnemonicParsing="false" text="Action 1" />
+                                <MenuItem mnemonicParsing="false" text="Action 2" />
+                            </items>
+                        </SplitMenuButton>
+                        <SplitMenuButton fx:id="grpCatSplitMenu" mnemonicParsing="false" text="Cat-0">
+                            <items>
+                                <MenuItem mnemonicParsing="false" text="Action 1" />
+                                <MenuItem mnemonicParsing="false" text="Action 2" />
+                            </items>
+                        </SplitMenuButton>
+                        <Region fx:id="spacer" prefHeight="-1.0" prefWidth="-1.0" />
+                        <Separator prefWidth="30.0" />
+                        <SegmentedButton fx:id="segButton">
+                            <buttons>
+                                <ToggleButton id="" fx:id="tileToggle" alignment="CENTER" contentDisplay="GRAPHIC_ONLY" graphicTextGap="0.0" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="32.0" mnemonicParsing="false" prefWidth="-1.0" scaleX="1.0" selected="true" text="" textAlignment="CENTER">
+                                    <graphic>
+                                        <ImageView fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true" translateY="1.0">
+                                            <image>
+                                                <Image url="@../images/application_view_tile.png" />
+                                            </image>
+                                        </ImageView>
+                                    </graphic>
+                                </ToggleButton>
+                                <ToggleButton id="filmStripToggle" fx:id="slideShowToggle" contentDisplay="GRAPHIC_ONLY" graphicTextGap="0.0" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="32.0" mnemonicParsing="false" prefWidth="-1.0" text="">
+                                    <graphic>
+                                        <ImageView fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true" rotate="90.0" translateY="1.0">
+                                            <image>
+                                                <Image url="@../images/film.png" />
+                                            </image>
+                                        </ImageView>
+                                    </graphic>
+                                </ToggleButton>        
+                            </buttons>       
+                        </SegmentedButton>
+
+                    </items>
+                </ToolBar>
+            </children>
+        </VBox>
+    </top>
+    <padding>
+        <Insets bottom="1.0" left="1.0" right="1.0" top="1.0" />
+    </padding>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupPane.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupPane.java
new file mode 100644
index 0000000000000000000000000000000000000000..373c4d603c3899cc19ef866eaba296eb5982e15d
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupPane.java
@@ -0,0 +1,780 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import com.google.common.collect.ImmutableMap;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ResourceBundle;
+import java.util.logging.Level;
+import java.util.stream.IntStream;
+import javafx.animation.Interpolator;
+import javafx.animation.KeyFrame;
+import javafx.animation.KeyValue;
+import javafx.animation.Timeline;
+import javafx.beans.Observable;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.fxml.FXML;
+import javafx.geometry.Bounds;
+import javafx.scene.control.Button;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Label;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.ScrollBar;
+import javafx.scene.control.SplitMenuButton;
+import javafx.scene.control.ToggleButton;
+import javafx.scene.control.ToolBar;
+import javafx.scene.effect.DropShadow;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.KeyCode;
+import static javafx.scene.input.KeyCode.DIGIT0;
+import static javafx.scene.input.KeyCode.DIGIT1;
+import static javafx.scene.input.KeyCode.DIGIT2;
+import static javafx.scene.input.KeyCode.DIGIT3;
+import static javafx.scene.input.KeyCode.DIGIT4;
+import static javafx.scene.input.KeyCode.DIGIT5;
+import static javafx.scene.input.KeyCode.DOWN;
+import static javafx.scene.input.KeyCode.LEFT;
+import static javafx.scene.input.KeyCode.NUMPAD0;
+import static javafx.scene.input.KeyCode.NUMPAD1;
+import static javafx.scene.input.KeyCode.NUMPAD2;
+import static javafx.scene.input.KeyCode.NUMPAD3;
+import static javafx.scene.input.KeyCode.NUMPAD4;
+import static javafx.scene.input.KeyCode.NUMPAD5;
+import static javafx.scene.input.KeyCode.RIGHT;
+import static javafx.scene.input.KeyCode.UP;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import javafx.scene.layout.TilePane;
+import javafx.scene.paint.Color;
+import javafx.util.Duration;
+import javax.swing.Action;
+import javax.swing.SwingUtilities;
+import org.apache.commons.lang3.StringUtils;
+import org.controlsfx.control.GridCell;
+import org.controlsfx.control.GridView;
+import org.controlsfx.control.SegmentedButton;
+import org.controlsfx.control.action.ActionUtils;
+import org.openide.util.Exceptions;
+import org.openide.util.Lookup;
+import org.openide.util.actions.Presenter;
+import org.openide.windows.TopComponent;
+import org.openide.windows.WindowManager;
+import org.sleuthkit.autopsy.corecomponentinterfaces.ContextMenuActionsProvider;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.directorytree.ExtractAction;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaTopComponent;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.FileIDSelectionModel;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined.ThreadType;
+import org.sleuthkit.autopsy.imageanalyzer.actions.AddDrawableTagAction;
+import org.sleuthkit.autopsy.imageanalyzer.actions.Back;
+import org.sleuthkit.autopsy.imageanalyzer.actions.CategorizeAction;
+import org.sleuthkit.autopsy.imageanalyzer.actions.Forward;
+import org.sleuthkit.autopsy.imageanalyzer.actions.NextUnseenGroup;
+import org.sleuthkit.autopsy.imageanalyzer.actions.SwingMenuItemAdapter;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupViewMode;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupViewState;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.Grouping;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** A GroupPane displays the contents of a {@link Grouping}. It support both a
+ * {@link  TilePane} based view and a {@link  SlideShowView} view by swapping out
+ * its internal components.
+ *
+ * TODO: review for synchronization issues.
+ * TODO: Extract the The TilePane instance to a separate class analogous to
+ * the SlideShow */
+public class GroupPane extends BorderPane implements GroupView {
+
+    private static final Logger LOGGER = Logger.getLogger(GroupPane.class.getName());
+
+    private static final DropShadow DROP_SHADOW = new DropShadow(10, Color.BLUE);
+
+    private static final Timeline flashAnimation = new Timeline(new KeyFrame(Duration.millis(400), new KeyValue(DROP_SHADOW.radiusProperty(), 1, Interpolator.LINEAR)),
+                                                                new KeyFrame(Duration.millis(400), new KeyValue(DROP_SHADOW.radiusProperty(), 15, Interpolator.LINEAR))
+    );
+
+    private static final FileIDSelectionModel globalSelectionModel = FileIDSelectionModel.getInstance();
+
+    private final Back backAction;
+
+    private final Forward forwardAction;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private SplitMenuButton grpCatSplitMenu;
+
+    @FXML
+    private SplitMenuButton grpTagSplitMenu;
+
+    @FXML
+    private ToolBar headerToolBar;
+
+    @FXML
+    private SegmentedButton segButton;
+
+    private SlideShowView slideShowPane;
+
+    @FXML
+    private ToggleButton slideShowToggle;
+
+    @FXML
+    private Region spacer;
+
+    @FXML
+    private GridView<Long> gridView;
+
+    @FXML
+    private ToggleButton tileToggle;
+
+    @FXML
+    private Button nextButton;
+
+    @FXML
+    private Button backButton;
+
+    @FXML
+    private Button forwardButton;
+
+    @FXML
+    private Label groupLabel;
+
+    private final KeyboardHandler tileKeyboardNavigationHandler = new KeyboardHandler();
+
+    private final NextUnseenGroup nextGroupAction;
+
+    private final EurekaController controller;
+
+    private ContextMenu contextMenu;
+
+    private Integer selectionAnchorIndex;
+
+    /** the current GroupViewMode of this GroupPane */
+    private final SimpleObjectProperty<GroupViewMode> groupViewMode = new SimpleObjectProperty<>(GroupViewMode.TILE);
+
+    /** the grouping this pane is currently the view for */
+    private final ReadOnlyObjectWrapper<Grouping> grouping = new ReadOnlyObjectWrapper<>();
+
+    /** map from fileIDs to their assigned cells in the tile view. This is used
+     * to determine whether fileIDs are visible or are offscreen. No entry
+     * indicates the given fileID is not displayed on screenDrawableCells
+     * responsible for adding and removing themselves from this map */
+    @ThreadConfined(type = ThreadType.UI)
+    private final Map<Long, DrawableCell> cellMap = new HashMap<>();
+
+    public GroupPane(EurekaController controller) {
+        this.controller = controller;
+        nextGroupAction = new NextUnseenGroup(controller);
+        backAction = new Back(controller);
+        forwardAction = new Forward(controller);
+        FXMLConstructor.construct(this, "GroupPane.fxml");
+    }
+
+    public void activateSlideShowViewer(Long slideShowFileId) {
+        groupViewMode.set(GroupViewMode.SLIDE_SHOW);
+        gridView.removeEventHandler(KeyEvent.KEY_PRESSED, tileKeyboardNavigationHandler);
+        //make a new slideShowPane if necessary
+        if (slideShowPane == null) {
+            slideShowPane = new SlideShowView(this);
+        }
+
+        //assign last selected file or if none first file in group
+        if (slideShowFileId == null || grouping.get().fileIds().contains(slideShowFileId) == false) {
+            slideShowFileId = grouping.get().fileIds().get(0);
+        }
+
+        slideShowPane.setFile(slideShowFileId);
+        setCenter(slideShowPane);
+    }
+
+    public void activateTileViewer() {
+        groupViewMode.set(GroupViewMode.TILE);
+        gridView.addEventHandler(KeyEvent.KEY_PRESSED, tileKeyboardNavigationHandler);
+        setCenter(gridView);
+        this.scrollToFileID(globalSelectionModel.lastSelectedProperty().get());
+    }
+
+    public Grouping getGrouping() {
+        return grouping.get();
+    }
+
+    /**
+     * @return the text to display as part of the header
+     */
+    public String getHeaderString() {
+        int size = grouping.get().getSize();
+        int hashHitCount = grouping.get().getFilesWithHashSetHitsCount();
+        String groupName;
+        if (grouping.get().groupKey.getAttribute() == DrawableAttribute.TAGS) {
+            groupName = ((TagName) grouping.get().groupKey.getValue()).getDisplayName();
+        } else {
+            groupName = grouping.get().groupKey.getValue().toString();
+        }
+        return StringUtils.defaultIfBlank(groupName, Grouping.UNKNOWN) + " -- " + hashHitCount + " hash set hits / " + size + " files";
+    }
+
+    private MenuItem createGrpCatMenuItem(final Category cat) {
+        final MenuItem menuItem = new MenuItem(cat.getDisplayName(), new ImageView(DrawableAttribute.CATEGORY.getIcon()));
+        menuItem.setOnAction(new EventHandler<ActionEvent>() {
+            @Override
+            public void handle(ActionEvent t) {
+                selectAllFiles();
+                new CategorizeAction().addTag(cat.getTagName(), "");
+
+                grpCatSplitMenu.setText(cat.getDisplayName());
+                grpCatSplitMenu.setOnAction(this);
+            }
+        });
+        return menuItem;
+    }
+
+    private MenuItem createGrpTagMenuItem(final TagName tn) {
+        final MenuItem menuItem = new MenuItem(tn.getDisplayName(), new ImageView(DrawableAttribute.TAGS.getIcon()));
+        menuItem.setOnAction(new EventHandler<ActionEvent>() {
+            @Override
+            public void handle(ActionEvent t) {
+                selectAllFiles();
+                AddDrawableTagAction.getInstance().addTag(tn, "");
+
+                grpTagSplitMenu.setText(tn.getDisplayName());
+                grpTagSplitMenu.setOnAction(this);
+            }
+        });
+        return menuItem;
+    }
+
+    private void selectAllFiles() {
+        globalSelectionModel.clearAndSelectAll(getGrouping().fileIds());
+    }
+
+    /** reset the text and icons to represent the currently filtered files */
+    protected void resetHeaderString() {
+        int size = grouping.get().getSize();
+        int hashHitCount = grouping.get().getFilesWithHashSetHitsCount();
+        String groupName;
+        if (grouping.get().groupKey.getAttribute() == DrawableAttribute.TAGS) {
+            groupName = ((TagName) grouping.get().groupKey.getValue()).getDisplayName();
+        } else {
+            groupName = grouping.get().groupKey.getValue().toString();
+        }
+        groupLabel.setText(StringUtils.defaultIfBlank(groupName, Grouping.UNKNOWN) + " -- " + hashHitCount + " hash set hits / " + size + " files");
+    }
+
+    ContextMenu getContextMenu() {
+        return contextMenu;
+    }
+
+    ReadOnlyObjectProperty<Grouping> grouping() {
+        return grouping.getReadOnlyProperty();
+    }
+
+    /** called automatically during constructor by FXMLConstructor.
+     *
+     * checks that FXML loading went ok and performs additional setup */
+    @FXML
+    void initialize() {
+        assert gridView != null : "fx:id=\"tilePane\" was not injected: check your FXML file 'GroupPane.fxml'.";
+        assert grpCatSplitMenu != null : "fx:id=\"grpCatSplitMenu\" was not injected: check your FXML file 'GroupHeader.fxml'.";
+        assert grpTagSplitMenu != null : "fx:id=\"grpTagSplitMenu\" was not injected: check your FXML file 'GroupHeader.fxml'.";
+        assert headerToolBar != null : "fx:id=\"headerToolBar\" was not injected: check your FXML file 'GroupHeader.fxml'.";
+        assert segButton != null : "fx:id=\"previewList\" was not injected: check your FXML file 'GroupHeader.fxml'.";
+        assert slideShowToggle != null : "fx:id=\"segButton\" was not injected: check your FXML file 'GroupHeader.fxml'.";
+        assert tileToggle != null : "fx:id=\"tileToggle\" was not injected: check your FXML file 'GroupHeader.fxml'.";
+
+        grouping.addListener((o) -> {
+            //when the assigned group changes, reset the scroll ar to the top
+            getScrollBar().ifPresent((scrollBar) -> {
+                scrollBar.setValue(0);
+            });
+            //and assign fileIDs to gridView
+            gridView.setItems(grouping.get().fileIds());
+        });
+
+        //configure flashing glow animation on next unseen group button
+        flashAnimation.setCycleCount(Timeline.INDEFINITE);
+        flashAnimation.setAutoReverse(true);
+
+        //configure gridView cell properties
+        gridView.cellHeightProperty().bind(EurekaToolbar.getDefault().sizeSliderValue().add(75));
+        gridView.cellWidthProperty().bind(EurekaToolbar.getDefault().sizeSliderValue().add(75));
+        gridView.setCellFactory((GridView<Long> param) -> new DrawableCell());
+
+        //configure toolbar properties
+        HBox.setHgrow(spacer, Priority.ALWAYS);
+        spacer.setMinWidth(Region.USE_PREF_SIZE);
+
+        ArrayList<MenuItem> grpTagMenues = new ArrayList<>();
+        for (final TagName tn : TagUtils.getNonCategoryTagNames()) {
+            MenuItem menuItem = createGrpTagMenuItem(tn);
+            grpTagMenues.add(menuItem);
+        }
+        try {
+            grpTagSplitMenu.setText(TagUtils.getFollowUpTagName().getDisplayName());
+            grpTagSplitMenu.setOnAction(createGrpTagMenuItem(TagUtils.getFollowUpTagName()).getOnAction());
+        } catch (TskCoreException tskCoreException) {
+            LOGGER.log(Level.WARNING, "failed to load FollowUpTagName", tskCoreException.getLocalizedMessage());
+            Exceptions.printStackTrace(tskCoreException);
+        }
+        grpTagSplitMenu.setGraphic(new ImageView(DrawableAttribute.TAGS.getIcon()));
+        grpTagSplitMenu.getItems().setAll(grpTagMenues);
+
+        ArrayList<MenuItem> grpCategoryMenues = new ArrayList<>();
+        for (final Category cat : Category.values()) {
+            MenuItem menuItem = createGrpCatMenuItem(cat);
+            grpCategoryMenues.add(menuItem);
+        }
+        grpCatSplitMenu.setText(Category.FIVE.getDisplayName());
+        grpCatSplitMenu.setGraphic(new ImageView(DrawableAttribute.CATEGORY.getIcon()));
+        grpCatSplitMenu.getItems().setAll(grpCategoryMenues);
+        grpCatSplitMenu.setOnAction(createGrpCatMenuItem(Category.FIVE).getOnAction());
+
+        Runnable syncMode = () -> {
+            switch (groupViewMode.get()) {
+                case SLIDE_SHOW:
+                    slideShowToggle.setSelected(true);
+                    break;
+                case TILE:
+                    tileToggle.setSelected(true);
+                    break;
+            }
+        };
+        syncMode.run();
+        //make togle states match view state
+        groupViewMode.addListener((o) -> {
+            syncMode.run();
+        });
+
+        slideShowToggle.toggleGroupProperty().addListener((o) -> {
+            slideShowToggle.getToggleGroup().selectedToggleProperty().addListener((observable, oldToggle, newToggle) -> {
+                if (newToggle == null) {
+                    oldToggle.setSelected(true);
+                }
+            });
+        });
+
+        //listen to toggles and update view state
+        slideShowToggle.setOnAction((ActionEvent t) -> {
+            activateSlideShowViewer(globalSelectionModel.lastSelectedProperty().get());
+        });
+
+        tileToggle.setOnAction((ActionEvent t) -> {
+            activateTileViewer();
+        });
+
+        controller.viewState().addListener((ObservableValue<? extends GroupViewState> observable, GroupViewState oldValue, GroupViewState newValue) -> {
+            setViewState(newValue);
+        });
+
+        gridView.addEventHandler(KeyEvent.KEY_PRESSED, tileKeyboardNavigationHandler);
+        gridView.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler<MouseEvent>() {
+
+            private ContextMenu buildContextMenu() {
+                ArrayList<MenuItem> menuItems = new ArrayList<>();
+
+                menuItems.add(CategorizeAction.getPopupMenu());
+
+                menuItems.add(AddDrawableTagAction.getInstance().getPopupMenu());
+
+                Collection<? extends ContextMenuActionsProvider> menuProviders = Lookup.getDefault().lookupAll(ContextMenuActionsProvider.class);
+
+                for (ContextMenuActionsProvider provider : menuProviders) {
+
+                    for (final Action act : provider.getActions()) {
+
+                        if (act instanceof Presenter.Popup) {
+                            Presenter.Popup aact = (Presenter.Popup) act;
+
+                            menuItems.add(SwingMenuItemAdapter.create(aact.getPopupPresenter()));
+                        }
+                    }
+                }
+                final MenuItem extractMenuItem = new MenuItem("Extract File(s)");
+                extractMenuItem.setOnAction((ActionEvent t) -> {
+                    SwingUtilities.invokeLater(() -> {
+                        TopComponent etc = WindowManager.getDefault().findTopComponent(EurekaTopComponent.PREFERRED_ID);
+                        ExtractAction.getInstance().actionPerformed(new java.awt.event.ActionEvent(etc, 0, null));
+                    });
+                });
+                menuItems.add(extractMenuItem);
+
+                ContextMenu contextMenu = new ContextMenu(menuItems.toArray(new MenuItem[]{}));
+                contextMenu.setAutoHide(true);
+                return contextMenu;
+            }
+
+            @Override
+            public void handle(MouseEvent t) {
+                switch (t.getButton()) {
+                    case PRIMARY:
+                        if (t.getClickCount() == 1) {
+                            globalSelectionModel.clearSelection();
+                            if (contextMenu != null) {
+                                contextMenu.hide();
+                            }
+                        }
+                        t.consume();
+                        break;
+                    case SECONDARY:
+                        if (t.getClickCount() == 1) {
+                            selectAllFiles();
+                        }
+                        if (contextMenu == null) {
+                            contextMenu = buildContextMenu();
+                        }
+
+                        contextMenu.hide();
+                        contextMenu.show(GroupPane.this, t.getScreenX(), t.getScreenY());
+                        t.consume();
+                        break;
+                }
+            }
+        });
+
+        ActionUtils.configureButton(nextGroupAction, nextButton);
+        final EventHandler<ActionEvent> onAction = nextButton.getOnAction();
+        nextButton.setOnAction((ActionEvent event) -> {
+            flashAnimation.stop();
+            nextButton.setEffect(null);
+            onAction.handle(event);
+        });
+
+        ActionUtils.configureButton(forwardAction, forwardButton);
+        ActionUtils.configureButton(backAction, backButton);
+
+        nextGroupAction.disabledProperty().addListener((ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) -> {
+            nextButton.setEffect(newValue ? null : DROP_SHADOW);
+            if (newValue == false) {
+                flashAnimation.play();
+            } else {
+                flashAnimation.stop();
+            }
+        });
+
+        //listen to tile selection and make sure it is visible in scroll area
+        //TODO: make sure we are testing complete visability not just bounsd intersection
+        globalSelectionModel.lastSelectedProperty().addListener((observable, oldFileID, newFileId) -> {
+            if (groupViewMode.get() == GroupViewMode.SLIDE_SHOW) {
+                slideShowPane.setFile(newFileId);
+            } else {
+
+                scrollToFileID(newFileId);
+            }
+        });
+
+        setViewState(controller.viewState().get());
+    }
+
+    @ThreadConfined(type = ThreadType.UI)
+    private void scrollToFileID(final Long newFileID) {
+        if (newFileID == null) {
+            //scrolling to no file doesn't make sense, so abort.
+            return;
+        }
+
+        int selectedIndex = grouping.get().fileIds().indexOf(newFileID);
+
+        if (selectedIndex == -1) {
+            //somehow we got passed a file id that isn't in the curent group.
+            //this should never happen, but if it does everything is going to fail, so abort.
+            return;
+        }
+
+        Optional<ScrollBar> scrollBarOptional = getScrollBar();
+        scrollBarOptional.ifPresent((ScrollBar scrollBar) -> {
+            DrawableCell cell = cellMap.get(newFileID);
+
+            //while there is no tile/cell for the given id, scroll based on index in group
+            while (cell == null) {
+                Integer minIndex = cellMap.keySet().stream()
+                        .map(grouping.get().fileIds()::indexOf)
+                        .min(Integer::compare).get();
+                Integer maxIndex = cellMap.keySet().stream()
+                        .map(grouping.get().fileIds()::indexOf)
+                        .max(Integer::compare).get();
+
+                if (selectedIndex < minIndex) {
+                    scrollBar.decrement();
+                } else if (selectedIndex > maxIndex) {
+                    scrollBar.increment();
+                } else {
+                    //sometimes the cellMap isn't up to date, so move the position arbitrarily to update the cellMap
+                    //TODO: this is clunky and slow, find a better way to do this
+                    scrollBar.adjustValue(.5);
+                }
+                cell = cellMap.get(newFileID);
+
+            }
+
+            final Bounds gridViewBounds = gridView.localToScene(gridView.getBoundsInLocal());
+
+            Bounds tileBounds = cell.localToScene(cell.getBoundsInLocal());
+
+            //while the cell is not within the visisble bounds of the gridview, scroll based on screen coordinates
+            int i = 0;
+
+            while (gridViewBounds.contains(tileBounds)
+                    == false && (i++ < 100)) {
+
+                if (tileBounds.getMinY() < gridViewBounds.getMinY()) {
+                    scrollBar.decrement();
+                } else if (tileBounds.getMaxY() > gridViewBounds.getMaxY()) {
+                    scrollBar.increment();
+                }
+                tileBounds = cell.localToScene(cell.getBoundsInLocal());
+            }
+        });
+    }
+
+    /**
+     * assigns a grouping for this pane to represent and initializes grouping
+     * specific properties and listeners
+     *
+     * @param grouping the new grouping assigned to this group
+     */
+    void setViewState(GroupViewState viewState) {
+        if (viewState == null) {
+            setCenter(null);
+            groupLabel.setText(null);
+        } else {
+            if (this.grouping.get() != viewState.getGroup()) {
+                this.grouping.set(viewState.getGroup());
+                //set the embeded header
+                resetHeaderString();
+                grouping.get().fileIds().addListener((Observable o) -> {
+                    resetHeaderString();
+                });
+
+            }
+
+            if (viewState.getMode() == GroupViewMode.TILE) {
+                activateTileViewer();
+            } else {
+                activateSlideShowViewer(viewState.getSlideShowfileID().orElse(null));
+
+            }
+        }
+    }
+
+    private class DrawableCell extends GridCell<Long> {
+
+        private final DrawableTile tile = new DrawableTile(GroupPane.this);
+
+        public DrawableCell() {
+            itemProperty().addListener((ObservableValue<? extends Long> observable, Long oldValue, Long newValue) -> {
+                if (oldValue != null) {
+                    cellMap.remove(oldValue, DrawableCell.this);
+                }
+                if (newValue != null) {
+                    cellMap.put(newValue, DrawableCell.this);
+                }
+            });
+
+            setGraphic(tile);
+        }
+
+        @Override
+        protected void updateItem(Long item, boolean empty) {
+            super.updateItem(item, empty);
+            tile.setFile(item);
+        }
+    }
+
+    /**
+     * implements the key handler for tile navigation ( up, down , left, right
+     * arrows)
+     */
+    private class KeyboardHandler implements EventHandler<KeyEvent> {
+
+        @Override
+        public void handle(KeyEvent t) {
+
+            if (t.getEventType() == KeyEvent.KEY_PRESSED) {
+                if (t.getCode() == KeyCode.SHIFT) {
+                    if (selectionAnchorIndex == null) {
+                        selectionAnchorIndex = grouping.get().fileIds().indexOf(globalSelectionModel.lastSelectedProperty().get());
+                    }
+                }
+                if (Arrays.asList(KeyCode.UP, KeyCode.DOWN, KeyCode.LEFT, KeyCode.RIGHT).contains(t.getCode())) {
+                    handleArrows(t);
+                }
+                if (t.getCode() == KeyCode.PAGE_DOWN) {
+                    getScrollBar().ifPresent((scrollBar) -> {
+                        scrollBar.adjustValue(1);
+                    });
+                }
+
+                if (t.getCode() == KeyCode.PAGE_UP) {
+                    getScrollBar().ifPresent((scrollBar) -> {
+                        scrollBar.adjustValue(0);
+                    });
+                }
+                if (t.getCode() == KeyCode.ENTER) {
+                    nextGroupAction.handle(null);
+                }
+
+                if (t.isAltDown()) {
+                    switch (t.getCode()) {
+                        case NUMPAD0:
+                        case DIGIT0:
+                            selectAllFiles();
+                            new CategorizeAction().addTag(Category.FIVE.getTagName(), "");
+                            break;
+                        case NUMPAD1:
+                        case DIGIT1:
+                            selectAllFiles();
+                            new CategorizeAction().addTag(Category.ONE.getTagName(), "");
+                            break;
+                        case NUMPAD2:
+                        case DIGIT2:
+                            selectAllFiles();
+                            new CategorizeAction().addTag(Category.TWO.getTagName(), "");
+                            break;
+                        case NUMPAD3:
+                        case DIGIT3:
+                            selectAllFiles();
+                            new CategorizeAction().addTag(Category.THREE.getTagName(), "");
+                            break;
+                        case NUMPAD4:
+                        case DIGIT4:
+                            selectAllFiles();
+                            new CategorizeAction().addTag(Category.FOUR.getTagName(), "");
+                            break;
+                        case NUMPAD5:
+                        case DIGIT5:
+                            selectAllFiles();
+                            new CategorizeAction().addTag(Category.ZERO.getTagName(), "");
+                            break;
+                    }
+                } else {
+
+                    switch (t.getCode()) {
+                        case NUMPAD0:
+                        case DIGIT0:
+                            new CategorizeAction().addTag(Category.ZERO.getTagName(), "");
+                            break;
+                        case NUMPAD1:
+                        case DIGIT1:
+                            new CategorizeAction().addTag(Category.ONE.getTagName(), "");
+                            break;
+                        case NUMPAD2:
+                        case DIGIT2:
+                            new CategorizeAction().addTag(Category.TWO.getTagName(), "");
+                            break;
+                        case NUMPAD3:
+                        case DIGIT3:
+                            new CategorizeAction().addTag(Category.THREE.getTagName(), "");
+                            break;
+                        case NUMPAD4:
+                        case DIGIT4:
+                            new CategorizeAction().addTag(Category.FOUR.getTagName(), "");
+                            break;
+                        case NUMPAD5:
+                        case DIGIT5:
+                            new CategorizeAction().addTag(Category.FIVE.getTagName(), "");
+                            break;
+                    }
+                }
+            }
+
+            t.consume();
+        }
+
+        private Integer getPrefColumns() {
+            return Math.max((int) Math.floor((gridView.getWidth() - 18) / (gridView.getCellWidth() + gridView.getHorizontalCellSpacing() * 2)), 1);
+        }
+
+        private void handleArrows(KeyEvent t) {
+            Long lastSelectFileId = globalSelectionModel.lastSelectedProperty().get();
+
+            int lastSelectedIndex = lastSelectFileId != null
+                                    ? grouping.get().fileIds().indexOf(lastSelectFileId)
+                                    : Optional.ofNullable(selectionAnchorIndex).orElse(0);
+
+            final Integer columns = getPrefColumns();
+
+            final Map<KeyCode, Integer> tileIndexMap = ImmutableMap.of(UP, -columns, DOWN, columns, LEFT, -1, RIGHT, 1);
+
+            // implement proper keyboard based multiselect
+            int indexOfToBeSelectedTile = lastSelectedIndex + tileIndexMap.get(t.getCode());
+            final int size = grouping.get().fileIds().size();
+            if (0 > indexOfToBeSelectedTile) {
+                //don't select past begining of group
+            } else if (0 <= indexOfToBeSelectedTile && indexOfToBeSelectedTile < size) {
+                //normal selection within group
+                makeSelection(t.isShiftDown(), grouping.get().fileIds().get(indexOfToBeSelectedTile));
+            } else if (indexOfToBeSelectedTile <= size - 1 + columns - (size % columns)) {
+                //selection last item if selection is empty space at end of group
+                makeSelection(t.isShiftDown(), grouping.get().fileIds().get(size - 1));
+            } else {
+                //don't select past end of group
+            }
+        }
+    }
+
+    private Optional<ScrollBar> getScrollBar() {
+        if (gridView == null || gridView.getSkin() == null) {
+            return Optional.empty();
+        }
+        return Optional.ofNullable((ScrollBar) gridView.getSkin().getNode().lookup(".scroll-bar"));
+    }
+
+    void makeSelection(Boolean shiftDown, Long newFileID) {
+
+        if (shiftDown) {
+            //TODO: do more hear to implement slicker multiselect
+            int endIndex = grouping.get().fileIds().indexOf(newFileID);
+            int startIndex = IntStream.of(grouping.get().fileIds().size(), selectionAnchorIndex, endIndex).min().getAsInt();
+            endIndex = IntStream.of(0, selectionAnchorIndex, endIndex).max().getAsInt();
+            List<Long> subList = grouping.get().fileIds().subList(startIndex, endIndex + 1);
+
+            globalSelectionModel.clearAndSelectAll(subList.toArray(new Long[subList.size()]));
+            globalSelectionModel.select(newFileID);
+        } else {
+            selectionAnchorIndex = null;
+            globalSelectionModel.clearAndSelect(newFileID);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupView.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupView.java
new file mode 100644
index 0000000000000000000000000000000000000000..456d02970266e7ae269103059a046dfde1691ba4
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/GroupView.java
@@ -0,0 +1,8 @@
+
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+
+
+public interface GroupView {
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MediaControl.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MediaControl.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..c872afc98ed1a3cbdb5588f773644cc3156698ca
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MediaControl.fxml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.util.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.paint.*?>
+
+<fx:root type="javafx.scene.layout.BorderPane" id="BorderPane" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2">
+  <bottom>
+    <HBox fillHeight="true" prefHeight="-1.0" prefWidth="-1.0" style="-fx-background-color:&#10;        linear-gradient(to bottom, derive(-fx-base,-30%), derive(-fx-base,-60%)),&#10;        linear-gradient(to bottom, derive(-fx-base,65%) 2%, derive(-fx-base,-20%) 95%);&#10;">
+      <children>
+        <Button id="controlbutton" fx:id="controlButton" contentDisplay="TEXT_ONLY" mnemonicParsing="false" prefHeight="-1.0" prefWidth="-1.0" text="&gt;" HBox.hgrow="NEVER" />
+        <Region prefHeight="16.0" prefWidth="16.0" HBox.hgrow="NEVER" />
+        <Label text="Time:" HBox.hgrow="NEVER" />
+        <Slider fx:id="timeSlider" prefHeight="21.0" prefWidth="315.0" HBox.hgrow="ALWAYS" />
+        <Label id="timeLable" fx:id="timeLabel" text="Label" HBox.hgrow="NEVER" />
+        <Region prefHeight="16.0" prefWidth="16.0" HBox.hgrow="NEVER" />
+        <Label text="Volume:" visible="false" HBox.hgrow="NEVER" />
+        <Slider fx:id="volumeSlider" prefHeight="21.0" prefWidth="100.0" visible="false" HBox.hgrow="SOMETIMES" />
+      </children>
+      <padding>
+        <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
+      </padding>
+    </HBox>
+  </bottom>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MediaControl.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MediaControl.java
new file mode 100644
index 0000000000000000000000000000000000000000..a822acb00a2055b178f2c3462f623b8754648a75
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MediaControl.java
@@ -0,0 +1,300 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import java.net.URL;
+import java.util.ResourceBundle;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.beans.property.BooleanProperty;
+import javafx.beans.property.DoubleProperty;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.fxml.FXML;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.Slider;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.Region;
+import javafx.scene.media.MediaPlayer;
+import javafx.scene.media.MediaPlayer.Status;
+import javafx.scene.media.MediaView;
+import javafx.util.Duration;
+import org.sleuthkit.autopsy.coreutils.Logger;
+
+public class MediaControl extends BorderPane implements Fitable {
+
+    private MediaPlayer mp;
+
+    private MediaView mediaView;
+
+    private final boolean repeat = false;
+
+    private boolean stopRequested = false;
+
+    private boolean atEndOfMedia = false;
+
+    private Duration duration;
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private Button controlButton;
+
+    @FXML
+    private Label timeLabel;
+
+    @FXML
+    private Slider timeSlider;
+
+    @FXML
+    private Slider volumeSlider;
+
+    @FXML
+    void initialize() {
+        assert controlButton != null : "fx:id=\"controlButton\" was not injected: check your FXML file 'MediaControl.fxml'.";
+        assert timeLabel != null : "fx:id=\"timeLabel\" was not injected: check your FXML file 'MediaControl.fxml'.";
+        assert timeSlider != null : "fx:id=\"timeSlider\" was not injected: check your FXML file 'MediaControl.fxml'.";
+        assert volumeSlider != null : "fx:id=\"volumeSlider\" was not injected: check your FXML file 'MediaControl.fxml'.";
+
+        mediaView = new MediaView(mp);
+        mediaView.setPreserveRatio(true);
+        mediaView.fitHeightProperty().bind(this.heightProperty().subtract(50));
+        mediaView.fitWidthProperty().bind(this.widthProperty());
+        setCenter(mediaView);
+
+        controlButton.setOnAction(new EventHandler<ActionEvent>() {
+            public void handle(ActionEvent e) {
+                try {
+                    Status status = mp.getStatus();
+
+                    if (status == Status.UNKNOWN || status == Status.HALTED) {
+                        // don't do anything in these states
+                        return;
+                    }
+
+                    if (status == Status.PAUSED
+                            || status == Status.READY
+                            || status == Status.STOPPED) {
+                        // rewind the movie if we're sitting at the end
+                        if (atEndOfMedia) {
+                            mp.seek(mp.getStartTime());
+                            atEndOfMedia = false;
+                        }
+                        mp.play();
+                    } else {
+                        mp.pause();
+                    }
+                } catch (Exception ex) {
+                    Logger.getAnonymousLogger().log(Level.SEVERE, "message", ex);
+                }
+            }
+        });
+        mp.currentTimeProperty().addListener(new InvalidationListener() {
+            public void invalidated(Observable ov) {
+                updateValues();
+            }
+        });
+
+        mp.setOnPlaying(new Runnable() {
+            public void run() {
+                if (stopRequested) {
+                    mp.pause();
+                    stopRequested = false;
+                } else {
+                    controlButton.setText("||");
+                }
+            }
+        });
+
+        mp.setOnPaused(new Runnable() {
+            public void run() {
+                System.out.println("onPaused");
+                controlButton.setText(">");
+            }
+        });
+
+        mp.setOnReady(new Runnable() {
+            public void run() {
+                duration = mp.getMedia().getDuration();
+                updateValues();
+            }
+        });
+
+        mp.setCycleCount(repeat ? MediaPlayer.INDEFINITE : 1);
+        mp.setOnEndOfMedia(new Runnable() {
+            public void run() {
+                if (!repeat) {
+                    controlButton.setText(">");
+                    stopRequested = true;
+                    atEndOfMedia = true;
+                }
+            }
+        });
+
+        // Add time slider
+        timeSlider.setMinWidth(50);
+        timeSlider.setMaxWidth(Double.MAX_VALUE);
+        timeSlider.valueProperty().addListener(new InvalidationListener() {
+            public void invalidated(Observable ov) {
+                if (timeSlider.isValueChanging()) {
+                    // multiply duration by percentage calculated by slider position
+                    mp.seek(duration.multiply(timeSlider.getValue() / 100.0));
+                }
+            }
+        });
+
+        // Add Volume slider
+        volumeSlider.setPrefWidth(70);
+        volumeSlider.setMaxWidth(Region.USE_PREF_SIZE);
+        volumeSlider.setMinWidth(30);
+        volumeSlider.valueProperty().addListener(new InvalidationListener() {
+            public void invalidated(Observable ov) {
+                if (volumeSlider.isValueChanging()) {
+                    mp.setVolume(volumeSlider.getValue() / 100.0);
+                }
+            }
+        });
+
+    }
+
+    public MediaControl(final MediaPlayer mp) {
+        this.mp = mp;
+
+        FXMLConstructor.construct(this, "MediaControl.fxml");
+    }
+
+    protected void updateValues() {
+        if (timeLabel != null && timeSlider != null && volumeSlider != null) {
+            Platform.runLater(new Runnable() {
+                public void run() {
+                    Duration currentTime = mp.getCurrentTime();
+                    timeLabel.setText(formatTime(currentTime, duration));
+                    timeSlider.setDisable(duration.isUnknown());
+                    if (!timeSlider.isDisabled()
+                            && duration.greaterThan(Duration.ZERO)
+                            && !timeSlider.isValueChanging()) {
+                        timeSlider.setValue(currentTime.divide(duration).toMillis()
+                                * 100.0);
+                    }
+                    if (!volumeSlider.isValueChanging()) {
+                        volumeSlider.setValue((int) Math.round(mp.getVolume()
+                                * 100));
+                    }
+                }
+            });
+        }
+    }
+
+    private static String formatTime(Duration elapsed, Duration duration) {
+        int intElapsed = (int) Math.floor(elapsed.toSeconds());
+        int elapsedHours = intElapsed / (60 * 60);
+        if (elapsedHours > 0) {
+            intElapsed -= elapsedHours * 60 * 60;
+        }
+        int elapsedMinutes = intElapsed / 60;
+        int elapsedSeconds = intElapsed - elapsedHours * 60 * 60
+                - elapsedMinutes * 60;
+
+        if (duration.greaterThan(Duration.ZERO)) {
+            int intDuration = (int) Math.floor(duration.toSeconds());
+            int durationHours = intDuration / (60 * 60);
+            if (durationHours > 0) {
+                intDuration -= durationHours * 60 * 60;
+            }
+            int durationMinutes = intDuration / 60;
+            int durationSeconds = intDuration - durationHours * 60 * 60
+                    - durationMinutes * 60;
+            if (durationHours > 0) {
+                return String.format("%d:%02d:%02d/%d:%02d:%02d",
+                                     elapsedHours, elapsedMinutes, elapsedSeconds,
+                                     durationHours, durationMinutes, durationSeconds);
+            } else {
+                return String.format("%02d:%02d/%02d:%02d",
+                                     elapsedMinutes, elapsedSeconds, durationMinutes,
+                                     durationSeconds);
+            }
+        } else {
+            if (elapsedHours > 0) {
+                return String.format("%d:%02d:%02d", elapsedHours,
+                                     elapsedMinutes, elapsedSeconds);
+            } else {
+                return String.format("%02d:%02d", elapsedMinutes,
+                                     elapsedSeconds);
+            }
+        }
+    }
+
+    @Override
+    public final void setFitWidth(double d) {
+        mediaView.setFitWidth(d);
+    }
+
+    @Override
+    public final double getFitWidth() {
+        return mediaView.getFitWidth();
+    }
+
+    @Override
+    public final DoubleProperty fitWidthProperty() {
+        return mediaView.fitWidthProperty();
+    }
+
+    @Override
+    public final void setFitHeight(double d) {
+        mediaView.setFitHeight(d);
+    }
+
+    @Override
+    public final double getFitHeight() {
+        return mediaView.getFitHeight();
+    }
+
+    @Override
+    public final DoubleProperty fitHeightProperty() {
+        return mediaView.fitHeightProperty();
+    }
+
+    public void stopVideo() {
+        mp.stop();
+    }
+
+    @Override
+    public boolean isPreserveRatio() {
+        return mediaView.isPreserveRatio();
+    }
+
+    @Override
+    public void setPreserveRatio(boolean b) {
+        mediaView.setPreserveRatio(b);
+    }
+
+    @Override
+    public BooleanProperty preserveRatioProperty() {
+        return mediaView.preserveRatioProperty();
+    }
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MetaDataPane.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MetaDataPane.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..195600d74d320e49409dedf8e79a650b88be5a42
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MetaDataPane.fxml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+
+<fx:root id="AnchorPane" type="javafx.scene.layout.AnchorPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+  <children>
+    <TitledPane animated="false" collapsible="false" expanded="true" text="Details" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+      <content>
+        <AnchorPane id="Content">
+          <children>
+            <VBox alignment="TOP_CENTER" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+              <children>
+                <BorderPane id="imageAnchor" fx:id="imageBorder" maxHeight="-Infinity" maxWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" VBox.vgrow="NEVER">
+                  <center>
+                    <ImageView fx:id="imageView" fitHeight="200.0" fitWidth="200.0" pickOnBounds="true" preserveRatio="true" BorderPane.alignment="CENTER" />
+                  </center>
+                </BorderPane>
+                <TableView fx:id="tableView" editable="false" tableMenuButtonVisible="false" VBox.vgrow="ALWAYS">
+                  <columns>
+                    <TableColumn fx:id="attributeColumn" editable="false" prefWidth="100.0" text="Attribute" />
+                    <TableColumn fx:id="valueColumn" editable="false" prefWidth="100.0" text="Value" />
+                  </columns>
+                </TableView>
+              </children>
+            </VBox>
+          </children>
+        </AnchorPane>
+      </content>
+    </TitledPane>
+  </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MetaDataPane.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MetaDataPane.java
new file mode 100644
index 0000000000000000000000000000000000000000..e47468c42ef65dd3dc2dcd7677eb9d3ba9dc14c5
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/MetaDataPane.java
@@ -0,0 +1,260 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.Collection;
+import java.util.ResourceBundle;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.control.Label;
+import javafx.scene.control.TableCell;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.layout.AnchorPane;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.Region;
+import static javafx.scene.layout.Region.USE_COMPUTED_SIZE;
+import javafx.scene.text.Text;
+import javafx.util.Callback;
+import javafx.util.Pair;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** */
+public class MetaDataPane extends AnchorPane implements Category.CategoryListener, TagUtils.TagListener, DrawableView {
+
+    private static final Logger LOGGER = Logger.getLogger(MetaDataPane.class.getName());
+
+    private final EurekaController controller;
+
+    private Long fileID;
+
+    @FXML
+    private ImageView imageView;
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private TableColumn<Pair<DrawableAttribute, ? extends Object>, DrawableAttribute> attributeColumn;
+
+    @FXML
+    private TableView<Pair<DrawableAttribute, ? extends Object>> tableView;
+
+    @FXML
+    private TableColumn<Pair<DrawableAttribute, ? extends Object>, String> valueColumn;
+
+    @FXML
+    private BorderPane imageBorder;
+
+    private DrawableFile file;
+
+    @Override
+    public Long getFileID() {
+        return fileID;
+    }
+
+    @FXML
+    void initialize() {
+        assert attributeColumn != null : "fx:id=\"attributeColumn\" was not injected: check your FXML file 'MetaDataPane.fxml'.";
+        assert imageView != null : "fx:id=\"imageView\" was not injected: check your FXML file 'MetaDataPane.fxml'.";
+        assert tableView != null : "fx:id=\"tableView\" was not injected: check your FXML file 'MetaDataPane.fxml'.";
+        assert valueColumn != null : "fx:id=\"valueColumn\" was not injected: check your FXML file 'MetaDataPane.fxml'.";
+        TagUtils.registerListener(this);
+        Category.registerListener(this);
+
+        tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
+        tableView.setPlaceholder(new Label("Select a file to show its details here."));
+
+        attributeColumn.setCellValueFactory((TableColumn.CellDataFeatures<Pair<DrawableAttribute, ?>, DrawableAttribute> param) -> new SimpleObjectProperty<>(param.getValue().getKey()));
+        attributeColumn.setCellFactory(new Callback<TableColumn<Pair<DrawableAttribute, ? extends Object>, DrawableAttribute>, TableCell<Pair<DrawableAttribute, ? extends Object>, DrawableAttribute>>() {
+
+            @Override
+            public TableCell<Pair<DrawableAttribute, ? extends Object>, DrawableAttribute> call(TableColumn<Pair<DrawableAttribute, ? extends Object>, DrawableAttribute> param) {
+                return new TableCell<Pair<DrawableAttribute, ? extends Object>, DrawableAttribute>() {
+
+                    @Override
+                    protected void updateItem(DrawableAttribute item, boolean empty) {
+                        super.updateItem(item, empty); //To change body of generated methods, choose Tools | Templates.
+                        if (item != null) {
+                            setText(item.getDisplayName());
+                            setGraphic(new ImageView(item.getIcon()));
+                        } else {
+                            setGraphic(null);
+                            setText(null);
+                        }
+                    }
+                };
+            }
+        });
+
+        attributeColumn.setPrefWidth(USE_COMPUTED_SIZE);
+
+        valueColumn.setCellValueFactory((TableColumn.CellDataFeatures<Pair<DrawableAttribute, ?>, String> p) -> {
+            if (p.getValue().getKey() == DrawableAttribute.TAGS) {
+                return new SimpleStringProperty(TagUtils.collectionToString((Collection<TagName>) p.getValue().getValue()));
+            } else {
+                return new SimpleStringProperty(p.getValue().getValue().toString());
+            }
+        });
+        valueColumn.setPrefWidth(USE_COMPUTED_SIZE);
+        valueColumn.setCellFactory(new Callback<TableColumn<Pair<DrawableAttribute, ? extends Object>, String>, TableCell<Pair<DrawableAttribute, ? extends Object>, String>>() {
+            @Override
+            public TableCell<Pair<DrawableAttribute, ? extends Object>, String> call(TableColumn<Pair<DrawableAttribute, ? extends Object>, String> p) {
+                return new TableCell<Pair<DrawableAttribute, ? extends Object>, String>() {
+
+                    @Override
+                    public void updateItem(String item, boolean empty) {
+                        super.updateItem(item, empty);
+
+                        if (!isEmpty()) {
+                            Text text = new Text(item);
+                            text.wrappingWidthProperty().bind(getTableColumn().widthProperty());
+                            setGraphic(text);
+                        } else {
+                            setGraphic(null);
+                        }
+                    }
+                };
+            }
+        });
+
+        tableView.getColumns().setAll(attributeColumn, valueColumn);
+
+        //listen for selection change
+        controller.getSelectionModel().lastSelectedProperty().addListener((observable, oldFileID, newFileID) -> {
+            setFile(newFileID);
+        });
+
+//        MetaDataPane.this.visibleProperty().bind(controller.getMetaDataCollapsed().not());
+//        MetaDataPane.this.managedProperty().bind(controller.getMetaDataCollapsed().not());
+    }
+
+    @Override
+    public DrawableFile getFile() {
+        if (fileID != null) {
+            if (file == null || file.getId() != fileID) {
+                try {
+                    file = controller.getFileFromId(fileID);
+                } catch (TskCoreException ex) {
+                    LOGGER.log(Level.WARNING, "failed to get DrawableFile for obj_id" + fileID, ex);
+                    return null;
+                }
+            }
+        } else {
+            return null;
+        }
+        return file;
+    }
+
+    @Override
+    public void setFile(Long fileID) {
+        this.fileID = fileID;
+
+        if (fileID == null) {
+
+            Platform.runLater(() -> {
+                imageView.setImage(null);
+                tableView.getItems().clear();
+                getBorderable().setBorder(null);
+
+            });
+        } else {
+            try {
+                file = controller.getFileFromId(fileID);
+                updateUI();
+
+                file.categoryProperty().addListener(new ChangeListener<Category>() {
+                    @Override
+                    public void changed(ObservableValue<? extends Category> ov, Category t, final Category t1) {
+                        updateUI();
+                    }
+                });
+            } catch (TskCoreException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        }
+    }
+
+    public MetaDataPane(EurekaController controller) {
+        this.controller = controller;
+
+        FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("MetaDataPane.fxml"));
+        fxmlLoader.setRoot(this);
+        fxmlLoader.setController(this);
+
+        try {
+            fxmlLoader.load();
+        } catch (IOException exception) {
+            throw new RuntimeException(exception);
+        }
+    }
+
+    public void updateUI() {
+        final Image icon = getFile().getIcon();
+        final ObservableList attributesList = getFile().getAttributesList();
+
+        Platform.runLater(() -> {
+            imageView.setImage(icon);
+            tableView.getItems().setAll(attributesList);
+        });
+
+        updateCategoryBorder();
+    }
+
+    @Override
+    public Region getBorderable() {
+        return imageBorder;
+    }
+
+    @Override
+    public void handleCategoryChanged(Collection<Long> ids) {
+        if (getFile() != null && ids.contains(getFileID())) {
+            updateUI();
+        }
+    }
+
+    @Override
+    public void handleTagsChanged(Collection<Long> ids) {
+        if (getFile() != null && ids.contains(getFileID())) {
+            updateUI();
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/NoGroupsDialog.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/NoGroupsDialog.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..0f1e8beb113d9021b50a5d3a85c3146eb2ab39f0
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/NoGroupsDialog.fxml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.effect.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.text.*?>
+
+<fx:root maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" style="-fx-background-color: white;" type="GridPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+    <columnConstraints>
+        <ColumnConstraints halignment="CENTER" hgrow="NEVER" maxWidth="53.0" minWidth="10.0" prefWidth="53.0" />
+        <ColumnConstraints hgrow="NEVER" minWidth="10.0" />
+    </columnConstraints>
+    <rowConstraints>
+        <RowConstraints minHeight="10.0" vgrow="SOMETIMES" />
+    </rowConstraints>
+    <children>
+        <BorderPane fx:id="graphicBorder" GridPane.halignment="CENTER" GridPane.valignment="CENTER">
+            <center>
+                <ImageView fitHeight="32.0" fitWidth="32.0" pickOnBounds="true" preserveRatio="true" BorderPane.alignment="CENTER" GridPane.halignment="CENTER" GridPane.rowSpan="2" GridPane.valignment="CENTER">
+                    <image>
+                        <Image url="@../images/information.png" />
+                    </image>
+                    <GridPane.margin>
+                        <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
+                    </GridPane.margin>
+                </ImageView>
+            </center>
+        </BorderPane>
+        <Label fx:id="messageLabel" prefWidth="300.0" text="There are no events visible with the current zoom / filter settings.  Adjust the settings or:" wrapText="true" GridPane.columnIndex="1">
+            <GridPane.margin>
+                <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
+            </GridPane.margin>
+            <font>
+                <Font size="14.0" />
+            </font>
+        </Label>
+    </children>
+    <effect>
+        <DropShadow />
+    </effect>
+<padding>
+<Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
+</padding>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/NoGroupsDialog.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/NoGroupsDialog.java
new file mode 100644
index 0000000000000000000000000000000000000000..77bd00cc1f4ee940113203d9a7221a8b57ff1e5f
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/NoGroupsDialog.java
@@ -0,0 +1,69 @@
+
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import java.net.URL;
+import java.util.ResourceBundle;
+import javafx.fxml.FXML;
+import javafx.scene.Node;
+import javafx.scene.control.Label;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.GridPane;
+
+/**
+ *
+ */
+public class NoGroupsDialog extends GridPane {
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private BorderPane graphicBorder;
+
+    @FXML
+    private Label messageLabel;
+
+    @FXML
+    void initialize() {
+        assert graphicBorder != null : "fx:id=\"graphicBorder\" was not injected: check your FXML file 'NoGroupsDialog.fxml'.";
+        assert messageLabel != null : "fx:id=\"messageLabel\" was not injected: check your FXML file 'NoGroupsDialog.fxml'.";
+
+    }
+
+    private NoGroupsDialog() {
+        FXMLConstructor.construct(this, "NoGroupsDialog.fxml");
+    }
+
+    public NoGroupsDialog(String message) {
+        this();
+        messageLabel.setText(message);
+    }
+
+    public NoGroupsDialog(String message, Node graphic) {
+        this();
+        messageLabel.setText(message);
+        graphicBorder.setCenter(graphic);
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SingleDrawableViewBase.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SingleDrawableViewBase.java
new file mode 100644
index 0000000000000000000000000000000000000000..eb62925114e53840074575767227c0ac664421c2
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SingleDrawableViewBase.java
@@ -0,0 +1,399 @@
+
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaTopComponent;
+import org.sleuthkit.autopsy.imageanalyzer.FileIDSelectionModel;
+import org.sleuthkit.autopsy.imageanalyzer.FileUpdateEvent;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined.ThreadType;
+import org.sleuthkit.autopsy.imageanalyzer.actions.AddDrawableTagAction;
+import org.sleuthkit.autopsy.imageanalyzer.actions.CategorizeAction;
+import org.sleuthkit.autopsy.imageanalyzer.actions.SwingMenuItemAdapter;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.beans.Observable;
+import javafx.event.ActionEvent;
+import javafx.event.EventHandler;
+import javafx.fxml.FXML;
+import javafx.scene.control.ContextMenu;
+import javafx.scene.control.Label;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.ToggleButton;
+import javafx.scene.control.Tooltip;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import javafx.scene.input.MouseEvent;
+import javafx.scene.layout.AnchorPane;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.Region;
+import javafx.scene.paint.Color;
+import javax.swing.Action;
+import javax.swing.SwingUtilities;
+import org.openide.util.Exceptions;
+import org.openide.util.Lookup;
+import org.openide.util.actions.Presenter;
+import org.openide.windows.TopComponent;
+import org.openide.windows.WindowManager;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.autopsy.corecomponentinterfaces.ContextMenuActionsProvider;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.datamodel.FileNode;
+import org.sleuthkit.autopsy.directorytree.DirectoryTreeTopComponent;
+import org.sleuthkit.autopsy.directorytree.ExternalViewerAction;
+import org.sleuthkit.autopsy.directorytree.ExtractAction;
+import org.sleuthkit.autopsy.directorytree.NewWindowViewAction;
+import org.sleuthkit.datamodel.ContentTag;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** An abstract base class for {@link DrawableTile} and {@link SlideShowView},
+ * since they share a similar node tree and many behaviors, other implementers
+ * of {@link  DrawableView}s should implement the interface directly
+ * */
+public abstract class SingleDrawableViewBase extends AnchorPane implements DrawableView {
+
+    private static final Logger LOGGER = Logger.getLogger(SingleDrawableViewBase.class.getName());
+
+    private static final Border UNSELECTED_ORDER = new Border(new BorderStroke(Color.GRAY, BorderStrokeStyle.SOLID, new CornerRadii(2), new BorderWidths(3)));
+
+    private static final Border SELECTED_BORDER = new Border(new BorderStroke(Color.BLUE, BorderStrokeStyle.SOLID, new CornerRadii(2), new BorderWidths(3)));
+
+    //TODO: should this stuff be done in CSS? -jm
+    protected static final Image videoIcon = new Image("org/sleuthkit/autopsy/imageanalyzer/images/video-file.png");
+
+    protected static final Image hashHitIcon = new Image("org/sleuthkit/autopsy/imageanalyzer/images/hashset_hits.png");
+
+    protected static final Image followUpIcon = new Image("org/sleuthkit/autopsy/imageanalyzer/images/flag_red.png");
+
+    protected static final Image followUpGray = new Image("org/sleuthkit/autopsy/imageanalyzer/images/flag_gray.png");
+
+    protected static final FileIDSelectionModel globalSelectionModel = FileIDSelectionModel.getInstance();
+
+    /**
+     * displays the icon representing video files
+     */
+    @FXML
+    protected ImageView fileTypeImageView;
+
+    /**
+     * displays the icon representing hash hits
+     */
+    @FXML
+    protected ImageView hashHitImageView;
+
+    /**
+     * displays the icon representing follow up tag
+     */
+    @FXML
+    protected ImageView followUpImageView;
+
+    @FXML
+    protected ToggleButton followUpToggle;
+
+    /**
+     * the label that shows the name of the represented file
+     */
+    @FXML
+    protected Label nameLabel;
+
+    @FXML
+    protected BorderPane imageBorder;
+
+    static private ContextMenu contextMenu;
+
+    protected DrawableFile file;
+
+    protected Long fileID;
+
+    /** the groupPane this {@link SingleDrawableViewBase} is embedded in */
+    protected GroupPane groupPane;
+
+    protected SingleDrawableViewBase() {
+
+        globalSelectionModel.getSelected().addListener((Observable observable) -> {
+            updateSelectionState();
+        });
+
+        //set up mouse listener
+        //TODO: split this between DrawableTile and SingleDrawableViewBase
+        addEventFilter(MouseEvent.MOUSE_CLICKED, new EventHandler<MouseEvent>() {
+
+            @Override
+            public void handle(MouseEvent t) {
+
+                switch (t.getButton()) {
+                    case PRIMARY:
+                        if (t.getClickCount() == 1) {
+                            if (t.isControlDown()) {
+                                globalSelectionModel.toggleSelection(fileID);
+                            } else {
+                                groupPane.makeSelection(t.isShiftDown(), fileID);
+                            }
+                        } else if (t.getClickCount() > 1) {
+                            groupPane.activateSlideShowViewer(fileID);
+                        }
+                        break;
+                    case SECONDARY:
+
+                        if (t.getClickCount() == 1) {
+                            if (globalSelectionModel.isSelected(fileID) == false) {
+                                groupPane.makeSelection(false, fileID);
+                            }
+                        }
+
+                        if (contextMenu != null) {
+                            contextMenu.hide();
+                        }
+                        final ContextMenu groupContextMenu = groupPane.getContextMenu();
+                        if (groupContextMenu != null) {
+                            groupContextMenu.hide();
+                        }
+                        contextMenu = buildContextMenu();
+                        contextMenu.show(SingleDrawableViewBase.this, t.getScreenX(), t.getScreenY());
+
+                        break;
+                }
+                t.consume();
+            }
+
+            private ContextMenu buildContextMenu() {
+                final ArrayList<MenuItem> menuItems = new ArrayList<>();
+
+                menuItems.add(CategorizeAction.getPopupMenu());
+
+                menuItems.add(AddDrawableTagAction.getInstance().getPopupMenu());
+
+                final MenuItem extractMenuItem = new MenuItem("Extract File(s)");
+                extractMenuItem.setOnAction((ActionEvent t) -> {
+                    SwingUtilities.invokeLater(() -> {
+                        TopComponent etc = WindowManager.getDefault().findTopComponent(EurekaTopComponent.PREFERRED_ID);
+                        ExtractAction.getInstance().actionPerformed(new java.awt.event.ActionEvent(etc, 0, null));
+                    });
+                });
+                menuItems.add(extractMenuItem);
+
+                MenuItem contentViewer = new MenuItem("Show Content Viewer");
+                contentViewer.setOnAction((ActionEvent t) -> {
+                    SwingUtilities.invokeLater(() -> {
+                        new NewWindowViewAction("Show Content Viewer", new FileNode(getFile().getAbstractFile())).actionPerformed(null);
+                    });
+                });
+                menuItems.add(contentViewer);
+
+                MenuItem externalViewer = new MenuItem("Open in External Viewer");
+                final ExternalViewerAction externalViewerAction = new ExternalViewerAction("Open in External Viewer", new FileNode(getFile().getAbstractFile()));
+
+                externalViewer.setDisable(externalViewerAction.isEnabled() == false);
+                externalViewer.setOnAction((ActionEvent t) -> {
+                    SwingUtilities.invokeLater(() -> {
+                        externalViewerAction.actionPerformed(null);
+                    });
+                });
+                menuItems.add(externalViewer);
+
+                Collection<? extends ContextMenuActionsProvider> menuProviders = Lookup.getDefault().lookupAll(ContextMenuActionsProvider.class);
+
+                for (ContextMenuActionsProvider provider : menuProviders) {
+                    for (final Action act : provider.getActions()) {
+                        if (act instanceof Presenter.Popup) {
+                            Presenter.Popup aact = (Presenter.Popup) act;
+                            menuItems.add(SwingMenuItemAdapter.create(aact.getPopupPresenter()));
+                        }
+                    }
+                }
+
+                ContextMenu contextMenu = new ContextMenu(menuItems.toArray(new MenuItem[]{}));
+                contextMenu.setAutoHide(true);
+                return contextMenu;
+            }
+        });
+    }
+
+    @ThreadConfined(type = ThreadType.UI)
+    protected abstract void clearContent();
+
+    protected abstract void disposeContent();
+
+    protected abstract Runnable getContentUpdateRunnable();
+
+    protected abstract String getLabelText();
+
+    protected void initialize() {
+        followUpToggle.setOnAction((ActionEvent t) -> {
+            if (followUpToggle.isSelected() == true) {
+                globalSelectionModel.clearAndSelect(fileID);
+                try {
+                    AddDrawableTagAction.getInstance().addTag(TagUtils.getFollowUpTagName(), "");
+                } catch (TskCoreException ex) {
+                    Exceptions.printStackTrace(ex);
+                }
+            } else {
+
+                //TODO: convert this to an action!
+                List<ContentTag> contentTagsByContent;
+                try {
+                    contentTagsByContent = Case.getCurrentCase().getServices().getTagsManager().getContentTagsByContent(getFile());
+                    for (ContentTag ct : contentTagsByContent) {
+                        if (ct.getName().getDisplayName().equals(TagUtils.getFollowUpTagName().getDisplayName())) {
+                            Case.getCurrentCase().getServices().getTagsManager().deleteContentTag(ct);
+                            SwingUtilities.invokeLater(() -> DirectoryTreeTopComponent.findInstance().refreshContentTreeSafe());
+                        }
+                    }
+                    EurekaController.getDefault().handleFileUpdate(new FileUpdateEvent(Collections.singleton(fileID), DrawableAttribute.TAGS));
+                } catch (TskCoreException ex) {
+                    Exceptions.printStackTrace(ex);
+                }
+            }
+        });
+    }
+
+    @Override
+    public DrawableFile getFile() {
+        if (fileID != null) {
+            if (file == null || file.getId() != fileID) {
+                try {
+                    file = EurekaController.getDefault().getFileFromId(fileID);
+                } catch (TskCoreException ex) {
+                    LOGGER.log(Level.WARNING, "failed to get DrawableFile for obj_id" + fileID, ex);
+                    return null;
+                }
+            }
+        } else {
+            return null;
+        }
+        return file;
+    }
+
+    protected boolean hasFollowUp() throws TskCoreException {
+        String followUpTagName = TagUtils.getFollowUpTagName().getDisplayName();
+        Collection<TagName> tagNames = (Collection<TagName>) getFile().getValueOfAttribute(DrawableAttribute.TAGS);
+        return tagNames.stream().anyMatch((tn) -> tn.getDisplayName().equals(followUpTagName));
+    }
+
+    protected void updateUI(final boolean isVideo, final boolean hasHashSetHits, final String text) {
+        if (isVideo) {
+            fileTypeImageView.setImage(videoIcon);
+        } else {
+            fileTypeImageView.setImage(null);
+        }
+
+        if (hasHashSetHits) {
+            hashHitImageView.setImage(hashHitIcon);
+        } else {
+            hashHitImageView.setImage(null);
+        }
+
+        nameLabel.setText(text);
+        nameLabel.setTooltip(new Tooltip(text));
+    }
+
+    @Override
+    public Long getFileID() {
+        return fileID;
+    }
+
+    @Override
+    public void handleTagsChanged(Collection<Long> ids) {
+        if (fileID != null && ids.contains(fileID)) {
+            updateFollowUpIcon();
+        }
+    }
+
+    protected void updateFollowUpIcon() {
+        if (file != null) {
+            try {
+                boolean hasFollowUp = hasFollowUp();
+                Platform.runLater(() -> {
+                    followUpImageView.setImage(hasFollowUp ? followUpIcon : followUpGray);
+                    followUpToggle.setSelected(hasFollowUp);
+                });
+            } catch (TskCoreException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        }
+    }
+
+    @Override
+    public void setFile(final Long fileID) {
+        if (Objects.equals(fileID, this.fileID) == false) {
+            this.fileID = fileID;
+            disposeContent();
+            if (this.fileID == null) {
+                Category.unregisterListener(this);
+                TagUtils.unregisterListener(this);
+                file = null;
+                Platform.runLater(() -> {
+                    clearContent();
+                });
+            } else {
+                Category.registerListener(this);
+                TagUtils.registerListener(this);
+
+                getFile();
+                updateSelectionState();
+                updateCategoryBorder();
+                updateFollowUpIcon();
+                final String text = getLabelText();
+                final boolean isVideo = file.isVideo();
+                final boolean hasHashSetHits = hasHashHit();
+                Platform.runLater(() -> {
+                    updateUI(isVideo, hasHashSetHits, text);
+                });
+
+                Platform.runLater(getContentUpdateRunnable());
+            }
+        }
+    }
+
+    private void updateSelectionState() {
+        final boolean selected = globalSelectionModel.isSelected(fileID);
+        Platform.runLater(() -> {
+            SingleDrawableViewBase.this.setBorder(selected ? SELECTED_BORDER : UNSELECTED_ORDER);
+        });
+    }
+
+    @Override
+    public Region getBorderable() {
+        return imageBorder;
+    }
+
+    @Override
+    public void handleCategoryChanged(Collection<Long> ids) {
+        if (ids.contains(fileID)) {
+            updateCategoryBorder();
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SlideShow.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SlideShow.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..2f2c783472d6ef125b8222b7f1d67220772372fd
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SlideShow.fxml
@@ -0,0 +1,151 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.net.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+<?import org.controlsfx.control.*?>
+<?scenebuilder-stylesheet GroupPane.css?>
+
+<fx:root type="AnchorPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+    <children>
+        <HBox AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+            <children>
+                <HBox maxWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER">
+                    <children>
+                        <Button fx:id="leftButton" contentDisplay="GRAPHIC_ONLY" maxHeight="1.7976931348623157E308" mnemonicParsing="false" text="" HBox.hgrow="NEVER">
+                            <graphic>
+                                <ImageView fitHeight="128.0" fitWidth="32.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="false" rotate="180.0" scaleX="1.0" translateX="0.0">
+                                    <image>
+                                        <Image url="@../images/right_arrow_128.png" />
+                                    </image>
+                                </ImageView>
+                            </graphic>
+                        </Button>
+                    </children>
+                </HBox>
+                <BorderPane fx:id="internalBorderPane" maxWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER" HBox.hgrow="ALWAYS">
+                    <top>
+                        <ToolBar fx:id="toolBar" maxHeight="-Infinity">
+                            <items>
+                                <Region fx:id="spring" prefHeight="-1.0" prefWidth="-1.0" />
+                                <Label text="Apply to File:" />
+                                <SplitMenuButton id="tagSplitMenu" fx:id="tagSplitButton" disable="false" mnemonicParsing="false" text="Follow Up" textOverrun="ELLIPSIS">
+                                    <items>
+                                        <MenuItem mnemonicParsing="false" text="Action 1" />
+                                        <MenuItem mnemonicParsing="false" text="Action 2" />
+                                    </items>
+                                </SplitMenuButton>
+                                <Region prefHeight="-1.0" prefWidth="10.0" />
+                                <Label text="Category:">
+                                    <graphic>
+                                        <ImageView fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true">
+                                            <image>
+                                                <Image url="@../images/category-icon.png" />
+                                            </image>
+                                        </ImageView>
+                                    </graphic>
+                                </Label>
+                                <HBox id="HBox" alignment="CENTER" spacing="0.0" styleClass="segmented-button-bar">
+                                    <children>
+                                       
+                                        <Region prefHeight="-1.0" prefWidth="5.0" />
+                                        <org.controlsfx.control.SegmentedButton>
+                                            <buttons>
+                                                <ToggleButton fx:id="cat0Toggle" mnemonicParsing="false" text="0" HBox.hgrow="ALWAYS" />
+                                                <ToggleButton fx:id="cat1Toggle" mnemonicParsing="false" style="" styleClass="button" text="1" HBox.hgrow="ALWAYS" />
+                                                <ToggleButton id="Cat2Toggle" fx:id="cat2Toggle" mnemonicParsing="false" styleClass="button" text="2" HBox.hgrow="ALWAYS" />
+                                                <ToggleButton fx:id="cat3Toggle" mnemonicParsing="false" styleClass="button" text="3" HBox.hgrow="ALWAYS" />
+                                                <ToggleButton fx:id="cat4Toggle" mnemonicParsing="false" styleClass="button" text="4" HBox.hgrow="ALWAYS" />
+                                                <ToggleButton fx:id="cat5Toggle" mnemonicParsing="false" text="5" toggleGroup="$cat" HBox.hgrow="ALWAYS" />                                          
+                                            </buttons>
+                                        
+                                        </org.controlsfx.control.SegmentedButton>
+                                        <Region prefHeight="-1.0" prefWidth="5.0" />
+                                      
+                                    </children>
+                                    <stylesheets>
+                                        <URL value="@GroupPane.css" />
+                                    </stylesheets>
+                                </HBox>
+                            </items>
+                        </ToolBar>
+                    </top>
+                    <center>
+                        <AnchorPane maxHeight="-1.0" maxWidth="-1.0" minHeight="-Infinity" minWidth="-Infinity" opacity="1.0" prefHeight="-1.0" prefWidth="-1.0" style="-fx-border-width: 1; -fx-border-color: darkgray; -fx-border-radius: 2; -fx-background-color: linear-gradient(to bottom, derive(-fx-base,-30%), derive(-fx-base,-60%)),        linear-gradient(to bottom, derive(-fx-base,65%) 2%, derive(-fx-base,-20%) 95%); -fx-background-radius: 2;" BorderPane.alignment="CENTER">
+                            <children>
+                                <BorderPane maxHeight="-1.0" maxWidth="-1.0" minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" snapToPixel="true" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+                                    <bottom>
+                                        <BorderPane fx:id="footer" maxHeight="-Infinity" maxWidth="-1.0" minHeight="-Infinity" minWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER">
+                                            <center>
+                                                <Label id="pathLabel" fx:id="nameLabel" alignment="CENTER" contentDisplay="TEXT_ONLY" maxHeight="16.0" minHeight="16.0" minWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" text="file name" textAlignment="CENTER" wrapText="true">
+                                                    <labelFor>
+                                                        <ImageView fitHeight="200.0" fitWidth="200.0" opacity="1.0" pickOnBounds="true" preserveRatio="true" style="-fx-border-radius : 5;&#10;-fx-border-width : 5;&#10;-fx-border-color : blue;" BorderPane.alignment="CENTER" />
+                                                    </labelFor>
+                                                </Label>
+                                            </center>
+                                            <left>
+                                                <HBox maxHeight="-Infinity" prefHeight="-1.0" prefWidth="-1.0" spacing="2.0" BorderPane.alignment="CENTER_LEFT">
+                                                    <children>
+                                                        <ImageView fx:id="fileTypeImageView" fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true" scaleX="1.0" scaleY="1.0">
+                                                            <image>
+                                                                <Image url="@../images/video-file.png" />
+                                                            </image>
+                                                        </ImageView>
+                                                        <ImageView fx:id="hashHitImageView" fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true" style="">
+                                                            <image>
+                                                                <Image url="@../images/hashset_hits.png" />
+                                                            </image>
+                                                            <HBox.margin>
+                                                                <Insets bottom="1.0" left="1.0" right="1.0" top="1.0" />
+                                                            </HBox.margin>
+                                                        </ImageView>
+                                                    </children>
+                                                    <padding>
+                                                        <Insets bottom="2.0" right="2.0" top="2.0" />
+                                                    </padding>
+                                                </HBox>
+                                            </left>
+                                            <right>
+                                                <ToggleButton fx:id="followUpToggle" minWidth="24.0" mnemonicParsing="false" prefWidth="24.0" selected="false" text="">
+                                                    <graphic>
+                                                        <ImageView id="followUpImageview" fx:id="followUpImageView" fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true">
+                                                            <image>
+                                                                <Image url="@../images/flag_gray.png" />
+                                                            </image>
+                                                        </ImageView>
+                                                    </graphic>
+                                                </ToggleButton>
+                                            </right>
+                                        </BorderPane>
+                                    </bottom>
+                                    <center>
+                                        <BorderPane fx:id="imageBorder" center="$imageView" maxHeight="-1.0" prefHeight="-1.0" prefWidth="-1.0" style="-fx-border-color: lightgray;&#10;-fx-border-width:10;&#10;-fx-border-radius:2;" BorderPane.alignment="CENTER" />
+                                    </center>
+                                </BorderPane>
+                            </children>
+                            <padding>
+                                <Insets bottom="2.0" left="2.0" right="2.0" top="2.0" />
+                            </padding>
+                        </AnchorPane>
+                    </center>
+                </BorderPane>
+                <HBox maxWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER">
+                    <children>
+                        <Button fx:id="rightButton" contentDisplay="GRAPHIC_ONLY" graphicTextGap="0.0" maxHeight="1.7976931348623157E308" mnemonicParsing="false" prefHeight="-1.0" prefWidth="-1.0" text="" HBox.hgrow="NEVER">
+                            <graphic>
+                                <ImageView fitHeight="128.0" fitWidth="32.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="false" scaleX="1.0" smooth="true" translateX="0.0">
+                                    <image>
+                                        <Image url="@../images/right_arrow_128.png" />
+                                    </image>
+                                </ImageView>
+                            </graphic>
+                        </Button>
+                    </children>
+                </HBox>
+            </children>
+        </HBox>
+    </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SlideShowView.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SlideShowView.java
new file mode 100644
index 0000000000000000000000000000000000000000..635737a0d8e0b0a5fb652d36c141666100c9a25c
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SlideShowView.java
@@ -0,0 +1,363 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import java.util.ArrayList;
+import javafx.application.Platform;
+import javafx.beans.Observable;
+import javafx.beans.value.ChangeListener;
+import javafx.beans.value.ObservableValue;
+import javafx.event.ActionEvent;
+import javafx.fxml.FXML;
+import javafx.geometry.HorizontalDirection;
+import javafx.scene.Node;
+import javafx.scene.control.Button;
+import javafx.scene.control.MenuItem;
+import javafx.scene.control.SplitMenuButton;
+import javafx.scene.control.ToggleButton;
+import javafx.scene.control.ToolBar;
+import javafx.scene.image.ImageView;
+import static javafx.scene.input.KeyCode.LEFT;
+import static javafx.scene.input.KeyCode.RIGHT;
+import javafx.scene.input.KeyEvent;
+import javafx.scene.layout.Border;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.BorderStroke;
+import javafx.scene.layout.BorderStrokeStyle;
+import javafx.scene.layout.BorderWidths;
+import javafx.scene.layout.CornerRadii;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.Region;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.coreutils.Logger;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.FileIDSelectionModel;
+import org.sleuthkit.autopsy.imageanalyzer.TagUtils;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadConfined.ThreadType;
+import org.sleuthkit.autopsy.imageanalyzer.ThreadUtils;
+import org.sleuthkit.autopsy.imageanalyzer.actions.CategorizeAction;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** Displays the files of a group one at a time. Designed to be embedded in a
+ * GroupPane.
+ * TODO: Extract a subclass for video files in slideshow mode-jm
+ * TODO: reduce coupling to GroupPane */
+public class SlideShowView extends SingleDrawableViewBase implements TagUtils.TagListener, Category.CategoryListener {
+
+    private static final Logger LOGGER = Logger.getLogger(SlideShowView.class.getName());
+
+    @FXML
+    private ToggleButton cat0Toggle;
+
+    @FXML
+    private ToggleButton cat2Toggle;
+
+    @FXML
+    private SplitMenuButton tagSplitButton;
+
+    @FXML
+    private ToggleButton cat3Toggle;
+
+    @FXML
+    private Region spring;
+
+    @FXML
+    private Button leftButton;
+
+    @FXML
+    private ToggleButton cat4Toggle;
+
+    @FXML
+    private ToggleButton cat5Toggle;
+
+    @FXML
+    private ToggleButton cat1Toggle;
+
+    @FXML
+    private Button rightButton;
+
+    @FXML
+    private ToolBar toolBar;
+
+    @FXML
+    private BorderPane footer;
+
+    @FXML
+    @Override
+    protected void initialize() {
+        super.initialize();
+        assert cat0Toggle != null : "fx:id=\"cat0Toggle\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert cat1Toggle != null : "fx:id=\"cat1Toggle\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert cat2Toggle != null : "fx:id=\"cat2Toggle\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert cat3Toggle != null : "fx:id=\"cat3Toggle\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert cat4Toggle != null : "fx:id=\"cat4Toggle\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert cat5Toggle != null : "fx:id=\"cat5Toggle\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert leftButton != null : "fx:id=\"leftButton\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert rightButton != null : "fx:id=\"rightButton\" was not injected: check your FXML file 'SlideShow.fxml'.";
+        assert tagSplitButton != null : "fx:id=\"tagSplitButton\" was not injected: check your FXML file 'SlideShow.fxml'.";
+
+        Platform.runLater(() -> {
+            HBox.setHgrow(spring, Priority.ALWAYS);
+            spring.setMinWidth(Region.USE_PREF_SIZE);
+        });
+
+        tagSplitButton.setOnAction((ActionEvent t) -> {
+            try {
+                TagUtils.createSelTagMenuItem(TagUtils.getFollowUpTagName(), tagSplitButton).getOnAction().handle(t);
+            } catch (TskCoreException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        });
+
+        tagSplitButton.setGraphic(new ImageView(DrawableAttribute.TAGS.getIcon()));
+        tagSplitButton.showingProperty().addListener((ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) -> {
+            if (t1) {
+                ArrayList<MenuItem> selTagMenues = new ArrayList<>();
+                for (final TagName tn : TagUtils.getNonCategoryTagNames()) {
+                    MenuItem menuItem = TagUtils.createSelTagMenuItem(tn, tagSplitButton);
+                    selTagMenues.add(menuItem);
+                }
+                tagSplitButton.getItems().setAll(selTagMenues);
+            }
+        });
+        cat0Toggle.setBorder(new Border(new BorderStroke(Category.ZERO.getColor(), BorderStrokeStyle.SOLID, new CornerRadii(1), new BorderWidths(1))));
+        cat1Toggle.setBorder(new Border(new BorderStroke(Category.ONE.getColor(), BorderStrokeStyle.SOLID, new CornerRadii(1), new BorderWidths(1))));
+        cat2Toggle.setBorder(new Border(new BorderStroke(Category.TWO.getColor(), BorderStrokeStyle.SOLID, new CornerRadii(1), new BorderWidths(1))));
+        cat3Toggle.setBorder(new Border(new BorderStroke(Category.THREE.getColor(), BorderStrokeStyle.SOLID, new CornerRadii(1), new BorderWidths(1))));
+        cat4Toggle.setBorder(new Border(new BorderStroke(Category.FOUR.getColor(), BorderStrokeStyle.SOLID, new CornerRadii(1), new BorderWidths(1))));
+        cat5Toggle.setBorder(new Border(new BorderStroke(Category.FIVE.getColor(), BorderStrokeStyle.SOLID, new CornerRadii(1), new BorderWidths(1))));
+
+        cat0Toggle.selectedProperty().addListener(new CategorizeToggleHandler(Category.ZERO));
+        cat1Toggle.selectedProperty().addListener(new CategorizeToggleHandler(Category.ONE));
+        cat2Toggle.selectedProperty().addListener(new CategorizeToggleHandler(Category.TWO));
+        cat3Toggle.selectedProperty().addListener(new CategorizeToggleHandler(Category.THREE));
+        cat4Toggle.selectedProperty().addListener(new CategorizeToggleHandler(Category.FOUR));
+        cat5Toggle.selectedProperty().addListener(new CategorizeToggleHandler(Category.FIVE));
+
+        cat0Toggle.toggleGroupProperty().addListener((o, oldGroup, newGroup) -> {
+            newGroup.selectedToggleProperty().addListener((ov, oldToggle, newToggle) -> {
+                if (newToggle == null) {
+                    oldToggle.setSelected(true);
+                }
+            });
+        });
+
+        leftButton.setOnAction((ActionEvent t) -> {
+            cycleSlideShowImage(HorizontalDirection.LEFT);
+        });
+        rightButton.setOnAction((ActionEvent t) -> {
+            cycleSlideShowImage(HorizontalDirection.RIGHT);
+        });
+
+        //set up key listener equivalents of buttons
+        addEventFilter(KeyEvent.KEY_PRESSED, (KeyEvent t) -> {
+
+            if (t.getEventType() == KeyEvent.KEY_PRESSED) {
+                switch (t.getCode()) {
+                    case LEFT:
+                        cycleSlideShowImage(HorizontalDirection.LEFT);
+                        break;
+                    case RIGHT:
+                        cycleSlideShowImage(HorizontalDirection.RIGHT);
+                        break;
+
+                    //TODO: find a way to share these with grouppane/tileview... (ActionMap?)
+                    case NUMPAD0:
+                    case DIGIT0:
+                        new CategorizeAction().addTag(Category.ZERO.getTagName(), "");
+                        break;
+                    case NUMPAD1:
+                    case DIGIT1:
+                        new CategorizeAction().addTag(Category.ONE.getTagName(), "");
+                        break;
+                    case NUMPAD2:
+                    case DIGIT2:
+                        new CategorizeAction().addTag(Category.TWO.getTagName(), "");
+                        break;
+                    case NUMPAD3:
+                    case DIGIT3:
+                        new CategorizeAction().addTag(Category.THREE.getTagName(), "");
+                        break;
+                    case NUMPAD4:
+                    case DIGIT4:
+                        new CategorizeAction().addTag(Category.FOUR.getTagName(), "");
+                        break;
+                    case NUMPAD5:
+                    case DIGIT5:
+                        new CategorizeAction().addTag(Category.FIVE.getTagName(), "");
+                        break;
+                }
+                t.consume();
+            }
+        });
+
+        syncButtonVisibility();
+
+        groupPane.grouping()
+                .addListener((Observable observable) -> {
+                    syncButtonVisibility();
+                    groupPane.getGrouping().fileIds().addListener((Observable observable1) -> {
+                        syncButtonVisibility();
+                    });
+                }
+                );
+    }
+
+    @ThreadConfined(type = ThreadType.ANY)
+    private void syncButtonVisibility() {
+        final boolean hasMultipleFiles = groupPane.getGrouping().fileIds().size() > 1;
+        Platform.runLater(() -> {
+            rightButton.setVisible(hasMultipleFiles);
+            leftButton.setVisible(hasMultipleFiles);
+            rightButton.setManaged(hasMultipleFiles);
+            leftButton.setManaged(hasMultipleFiles);
+        });
+    }
+
+    SlideShowView(GroupPane gp) {
+        super();
+        groupPane = gp;
+        FXMLConstructor.construct(this, "SlideShow.fxml");
+
+    }
+
+    @ThreadConfined(type = ThreadType.UI)
+    public void stopVideo() {
+        if (imageBorder.getCenter() instanceof MediaControl) {
+            ((MediaControl) imageBorder.getCenter()).stopVideo();
+        }
+    }
+
+    @Override
+    public void setFile(final Long fileID) {
+        super.setFile(fileID);
+        if (this.fileID != null) {
+            groupPane.makeSelection(false, this.fileID);
+        }
+    }
+
+    @Override
+    protected void disposeContent() {
+        stopVideo();
+    }
+
+    @Override
+    @ThreadConfined(type = ThreadType.UI)
+    protected void clearContent() {
+        imageBorder.setCenter(null);
+    }
+
+    @Override
+    protected Runnable getContentUpdateRunnable() {
+        Node newCenterNode = file.getFullsizeDisplayNode();
+        if (newCenterNode instanceof Fitable) {
+            Fitable fitable = (Fitable) newCenterNode;
+            fitable.setPreserveRatio(true);
+            //JMTODO: this math is hack! fix it -jm
+            fitable.fitWidthProperty().bind(imageBorder.widthProperty().subtract(CAT_BORDER_WIDTH * 2));
+            fitable.fitHeightProperty().bind(this.heightProperty().subtract(CAT_BORDER_WIDTH * 4).subtract(footer.heightProperty()).subtract(toolBar.heightProperty()));
+        }
+        return () -> {
+            imageBorder.setCenter(newCenterNode);
+        };
+    }
+
+    @Override
+    protected String getLabelText() {
+        return file.getName() + " " + getSupplementalText();
+    }
+
+    private void cycleSlideShowImage(HorizontalDirection d) {
+
+        stopVideo();
+        if (fileID != null) {
+            int index = groupPane.getGrouping().fileIds().indexOf(fileID);
+            final int size = groupPane.getGrouping().fileIds().size();
+            index = (index + ((d == HorizontalDirection.LEFT) ? -1 : 1)) % size;
+            if (index < 0) {
+                index += size;
+            }
+            setFile(groupPane.getGrouping().fileIds().get(index));
+
+        } else {
+            setFile(groupPane.getGrouping().fileIds().get(0));
+        }
+
+    }
+
+    /** @return supplemental text to include in the label, specifically:
+     *          "image x of y" */
+    private String getSupplementalText() {
+        return " ( " + (groupPane.getGrouping().fileIds().indexOf(fileID) + 1) + " of " + groupPane.getGrouping().fileIds().size() + " in group )";
+    }
+
+    @Override
+    @ThreadConfined(type = ThreadType.ANY)
+    public Category updateCategoryBorder() {
+        final Category category = super.updateCategoryBorder();
+        ToggleButton toggleForCategory = getToggleForCategory(category);
+
+        ThreadUtils.runNowOrLater(() -> {
+            toggleForCategory.setSelected(true);
+        });
+        return category;
+    }
+
+    private ToggleButton getToggleForCategory(Category category) {
+        switch (category) {
+            case ZERO:
+                return cat0Toggle;
+            case ONE:
+                return cat1Toggle;
+            case TWO:
+                return cat2Toggle;
+            case THREE:
+                return cat3Toggle;
+            case FOUR:
+                return cat4Toggle;
+            case FIVE:
+                return cat5Toggle;
+            default:
+                throw new IllegalArgumentException(category.name());
+        }
+    }
+
+    private class CategorizeToggleHandler implements ChangeListener<Boolean> {
+
+        private final Category cat;
+
+        public CategorizeToggleHandler(Category cat) {
+            this.cat = cat;
+        }
+
+        @Override
+        public void changed(ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) {
+
+            if (t1) {
+                FileIDSelectionModel.getInstance().clearAndSelect(fileID);
+                new CategorizeAction().addTag(cat.getTagName(), "");
+            }
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SortByListCell.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SortByListCell.java
new file mode 100644
index 0000000000000000000000000000000000000000..5913fbae22b1f798781eb8aabc49e519c27d204d
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SortByListCell.java
@@ -0,0 +1,38 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import javafx.scene.control.ListCell;
+import javafx.scene.image.ImageView;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupSortBy;
+
+public class SortByListCell extends ListCell<GroupSortBy> {
+    
+    @Override
+    protected void updateItem(GroupSortBy t, boolean bln) {
+        super.updateItem(t, bln);
+        if (t != null) {
+            setText(t.getDisplayName());
+            setGraphic(new ImageView(t.getIcon()));
+        } else {
+            setText(null);
+            setGraphic(null);
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/StatusBar.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/StatusBar.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..5f8a0615852e8f21b95d76129047143776a3b5c5
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/StatusBar.fxml
@@ -0,0 +1,61 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.geometry.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+
+<fx:root id="AnchorPane" maxHeight="-Infinity" maxWidth="1.7976931348623157E308" minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" type="javafx.scene.layout.AnchorPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+  <children>
+    <BorderPane minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+      <right>
+        <HBox alignment="CENTER_RIGHT" prefHeight="-1.0" prefWidth="-1.0" BorderPane.alignment="CENTER_RIGHT">
+          <children>
+            <StackPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" HBox.hgrow="NEVER">
+              <children>
+                <ProgressBar id="progBar" fx:id="fileTaskProgresBar" focusTraversable="false" maxHeight="-1.0" maxWidth="1.7976931348623157E308" minHeight="-Infinity" minWidth="-1.0" prefHeight="24.0" prefWidth="-1.0" progress="0.0" visible="true" />
+                <Label id="fileUpdateLabel" fx:id="fileUpdateTaskLabel" alignment="CENTER" contentDisplay="CENTER" graphicTextGap="0.0" labelFor="$fileTaskProgresBar" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefWidth="-1.0" text="0 File Update Tasks" StackPane.alignment="CENTER" />
+              </children>
+            </StackPane>
+            <StackPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" HBox.hgrow="NEVER">
+              <children>
+                <ProgressBar fx:id="bgTaskProgressBar" maxHeight="-1.0" maxWidth="-1.0" minHeight="-Infinity" minWidth="-1.0" prefHeight="24.0" prefWidth="-1.0" progress="0.0" StackPane.alignment="CENTER" />
+                <Label fx:id="bgTaskLabel" alignment="CENTER" cache="false" contentDisplay="CENTER" disable="false" focusTraversable="false" labelFor="$uiTaskProgressBar" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" text="0 BG Tasks" StackPane.alignment="CENTER" />
+              </children>
+<HBox.margin>
+<Insets right="5.0" />
+</HBox.margin>
+            </StackPane>
+          </children>
+<BorderPane.margin>
+<Insets left="10.0" />
+</BorderPane.margin>
+        </HBox>
+      </right>
+<center>
+<HBox>
+<children>
+        <Label fx:id="statusLabel" maxWidth="-Infinity" minWidth="-Infinity" wrapText="true" BorderPane.alignment="CENTER" HBox.hgrow="ALWAYS">
+<BorderPane.margin>
+<Insets left="10.0" right="10.0" />
+</BorderPane.margin>
+<HBox.margin>
+<Insets left="10.0" right="10.0" />
+</HBox.margin></Label>
+</children>
+</HBox>
+</center>
+<left><Label fx:id="staleLabel" text="Some data may be out of date.  Enable listening to ingest to update." BorderPane.alignment="CENTER">
+<graphic><ImageView fitHeight="16.0" fitWidth="16.0" pickOnBounds="true" preserveRatio="true">
+<image>
+<Image url="@../images/information.png" />
+</image></ImageView>
+</graphic>
+<BorderPane.margin>
+<Insets bottom="5.0" left="5.0" right="10.0" top="5.0" />
+</BorderPane.margin></Label>
+</left>
+    </BorderPane>
+  </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/StatusBar.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/StatusBar.java
new file mode 100644
index 0000000000000000000000000000000000000000..e3452e12dd9264a8c52289917ce283e94cdf3156
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/StatusBar.java
@@ -0,0 +1,113 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import java.io.IOException;
+import java.net.URL;
+import java.util.ResourceBundle;
+import javafx.application.Platform;
+import javafx.fxml.FXML;
+import javafx.fxml.FXMLLoader;
+import javafx.scene.control.Label;
+import javafx.scene.control.ProgressBar;
+import javafx.scene.control.Tooltip;
+import javafx.scene.layout.AnchorPane;
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+
+/**
+ *
+ */
+public class StatusBar extends AnchorPane {
+
+    private final EurekaController controller;
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private ProgressBar fileTaskProgresBar;
+
+    @FXML
+    private Label fileUpdateTaskLabel;
+
+    @FXML
+    private Label statusLabel;
+
+    @FXML
+    private Label bgTaskLabel;
+
+    @FXML
+    private Label staleLabel;
+
+    @FXML
+    private ProgressBar bgTaskProgressBar;
+
+    @FXML
+    void initialize() {
+        assert fileTaskProgresBar != null : "fx:id=\"fileTaskProgresBar\" was not injected: check your FXML file 'StatusBar.fxml'.";
+        assert fileUpdateTaskLabel != null : "fx:id=\"fileUpdateTaskLabel\" was not injected: check your FXML file 'StatusBar.fxml'.";
+        assert statusLabel != null : "fx:id=\"statusLabel\" was not injected: check your FXML file 'StatusBar.fxml'.";
+        assert bgTaskLabel != null : "fx:id=\"uiTaskLabel\" was not injected: check your FXML file 'StatusBar.fxml'.";
+        assert bgTaskProgressBar != null : "fx:id=\"uiTaskProgressBar\" was not injected: check your FXML file 'StatusBar.fxml'.";
+
+        controller.getFileUpdateQueueSizeProperty().addListener((ov, oldSize, newSize) -> {
+            Platform.runLater(() -> {
+                fileUpdateTaskLabel.setText(newSize.toString() + " File Update Tasks");
+                fileTaskProgresBar.setProgress((double) (newSize.intValue() > 0 ? -1 : 0));
+            });
+        });
+
+        controller.bgTaskQueueSizeProperty().addListener((ov, oldSize, newSize) -> {
+            Platform.runLater(() -> {
+                bgTaskLabel.setText(newSize.toString() + " BG Tasks");
+                bgTaskProgressBar.setProgress((double) (newSize.intValue() > 0 ? -1 : 0));
+            });
+        });
+
+        staleLabel.setTooltip(new Tooltip("Some data may be out of date.  Enable listening to ingest in Tools | Options | Image /Video Analyzer , after ingest is complete to update."));
+        staleLabel.visibleProperty().bind(controller.stale());
+    }
+
+    StatusBar(EurekaController controller) {
+        this.controller = controller;
+        FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("StatusBar.fxml"));
+        fxmlLoader.setRoot(this);
+        fxmlLoader.setController(this);
+
+        try {
+            fxmlLoader.load();
+        } catch (IOException exception) {
+            throw new RuntimeException(exception);
+        }
+
+    }
+
+    public void setLabelText(final String newText) {
+        Platform.runLater(() -> {
+            statusLabel.setText(newText);
+        });
+    }
+
+    public String getLabeltext() {
+        return statusLabel.getText();
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SummaryTablePane.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SummaryTablePane.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..28f59bf9087d3f9d4f8f2dd73003d0153306973a
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SummaryTablePane.fxml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import java.util.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.layout.*?>
+<?import javafx.scene.paint.*?>
+
+<fx:root type="javafx.scene.layout.AnchorPane" id="AnchorPane" maxHeight="-Infinity" maxWidth="-1.0" minHeight="-Infinity" minWidth="-Infinity" prefHeight="-1.0" prefWidth="-1.0" xmlns:fx="http://javafx.com/fxml/1" xmlns="http://javafx.com/javafx/2.2">
+  <children>
+    <TableView id="summaryTable" fx:id="tableView" prefHeight="-1.0" prefWidth="-1.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+      <columns>
+        <TableColumn prefWidth="75.0" text="Category" fx:id="catColumn" />
+        <TableColumn prefWidth="75.0" text="# Files" fx:id="countColumn" />
+      </columns>
+    </TableView>
+  </children>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SummaryTablePane.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SummaryTablePane.java
new file mode 100644
index 0000000000000000000000000000000000000000..81c034151cb861610b365e636d9fb257dfdc4ebe
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/SummaryTablePane.java
@@ -0,0 +1,117 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.Category;
+import java.net.URL;
+import java.util.Collection;
+import java.util.ResourceBundle;
+import javafx.application.Platform;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.collections.FXCollections;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.layout.AnchorPane;
+import javafx.scene.layout.Priority;
+import static javafx.scene.layout.Region.USE_COMPUTED_SIZE;
+import javafx.scene.layout.VBox;
+import javafx.util.Pair;
+import org.openide.util.Exceptions;
+import org.sleuthkit.autopsy.casemodule.Case;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/** Displays summary statistics (counts) for each group */
+public class SummaryTablePane extends AnchorPane implements Category.CategoryListener {
+
+    private static SummaryTablePane instance;
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    @FXML
+    private TableColumn<Pair<Category, Integer>, String> catColumn;
+
+    @FXML
+    private TableColumn<Pair<Category, Integer>, Integer> countColumn;
+
+    @FXML
+    private TableView<Pair<Category, Integer>> tableView;
+
+    @FXML
+    void initialize() {
+        assert catColumn != null : "fx:id=\"catColumn\" was not injected: check your FXML file 'SummaryTablePane.fxml'.";
+        assert countColumn != null : "fx:id=\"countColumn\" was not injected: check your FXML file 'SummaryTablePane.fxml'.";
+        assert tableView != null : "fx:id=\"tableView\" was not injected: check your FXML file 'SummaryTablePane.fxml'.";
+
+        //set some properties related to layout/resizing
+        VBox.setVgrow(this, Priority.NEVER);
+        tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
+        tableView.prefHeightProperty().set(7 * 25);
+
+        //set up columns
+        catColumn.setCellValueFactory((TableColumn.CellDataFeatures<Pair<Category, Integer>, String> p) -> new SimpleObjectProperty<>(p.getValue().getKey().getDisplayName()));
+        catColumn.setPrefWidth(USE_COMPUTED_SIZE);
+
+        countColumn.setCellValueFactory((TableColumn.CellDataFeatures<Pair<Category, Integer>, Integer> p) -> new SimpleObjectProperty<>(p.getValue().getValue()));
+        countColumn.setPrefWidth(USE_COMPUTED_SIZE);
+
+        tableView.getColumns().setAll(catColumn, countColumn);
+
+        //register for category events
+        Category.registerListener(this);
+    }
+
+    private SummaryTablePane() {
+        FXMLConstructor.construct(this, "SummaryTablePane.fxml");
+    }
+
+    public static synchronized SummaryTablePane getDefault() {
+        if (instance == null) {
+            instance = new SummaryTablePane();
+        }
+
+        return instance;
+    }
+
+    /** listen to Category updates and rebuild the table */
+    @Override
+    public void handleCategoryChanged(Collection<Long> ids) {
+        if (Case.isCaseOpen()) {
+            final ObservableList<Pair<Category, Integer>> data = FXCollections.observableArrayList();
+
+            for (Category cat : Category.values()) {
+                try {
+                    data.add(new Pair<>(cat, EurekaController.getDefault().getGroupManager().getFileIDsWithCategory(cat).size()));
+                } catch (TskCoreException ex) {
+                    Exceptions.printStackTrace(ex);
+                }
+            }
+            Platform.runLater(() -> {
+                tableView.setItems(data);
+            });
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/GroupTreeCell.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/GroupTreeCell.java
new file mode 100644
index 0000000000000000000000000000000000000000..f913978abf24d7fda0edfda06dfd448249994e32
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/GroupTreeCell.java
@@ -0,0 +1,95 @@
+package org.sleuthkit.autopsy.imageanalyzer.gui.navpanel;
+
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.Grouping;
+import javafx.application.Platform;
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.scene.Node;
+import javafx.scene.control.OverrunStyle;
+import javafx.scene.control.Tooltip;
+import javafx.scene.control.TreeCell;
+import javafx.scene.image.Image;
+import javafx.scene.image.ImageView;
+import org.apache.commons.lang3.StringUtils;
+
+/** A {@link Node} in the tree that listens to its associated group. Manages
+ * visual representation of TreeNode in Tree. Listens to properties of group
+ * that don't impact hierarchy and updates ui to reflect them */
+class GroupTreeCell extends TreeCell<TreeNode> {
+
+    /** icon to use if this cell's TreeNode doesn't represent a group but just a
+     * folder(with no DrawableFiles) in the hierarchy. */
+    private static final Image EMPTY_FOLDER_ICON = new Image("org/sleuthkit/autopsy/imageanalyzer/images/folder.png");
+
+    public GroupTreeCell() {
+        //adjust indent, default is 10 which uses up a lot of space.
+        setStyle("-fx-indent:5;");
+        //since end of path is probably more interesting put ellipsis at front
+        setTextOverrun(OverrunStyle.LEADING_ELLIPSIS);
+    }
+
+    @Override
+    synchronized protected void updateItem(final TreeNode tNode, boolean empty) {
+        super.updateItem(tNode, empty);
+        prefWidthProperty().bind(getTreeView().widthProperty().subtract(15));
+
+        if (tNode != null) {
+            final String name = StringUtils.defaultIfBlank(tNode.getPath(), Grouping.UNKNOWN);
+            setTooltip(new Tooltip(name));
+
+            if (tNode.getGroup() == null) {
+                setText(name);
+                setGraphic(new ImageView(EMPTY_FOLDER_ICON));
+            } else {
+                //this TreeNode has a group so append counts to name ...
+                setText(name + " (" + getNumerator() + getDenominator() + ")");
+
+
+                //if number of files in this group changes (eg file is recategorized), update counts
+                tNode.getGroup().fileIds().addListener(new InvalidationListener() {
+                    @Override
+                    public void invalidated(Observable o) {
+                        Platform.runLater(new Runnable() {
+                            @Override
+                            public void run() {
+                                setText(name + " (" + getNumerator() + getDenominator() + ")");
+                            }
+                        });
+                    }
+                });
+
+                //... and use icon corresponding to group type
+                setGraphic(new ImageView(tNode.getGroup().groupKey.getAttribute().getIcon()));
+            }
+        } else {
+            setTooltip(null);
+            setText(null);
+            setGraphic(null);
+        }
+    }
+
+    /** @return the Numerator of the count to append to the group name = number
+     *          of hashset hits + "/" */
+    synchronized private String getNumerator() {
+        try {
+            final String numerator = (getItem().getGroup().groupKey.getAttribute() != DrawableAttribute.HASHSET)
+                    ? getItem().getGroup().getFilesWithHashSetHitsCount() + "/"
+                    : "";
+            return numerator;
+        } catch (NullPointerException ex) {
+            return "";
+        }
+
+    }
+
+    /** @return the Denominator of the count to append to the group name =
+     *          number of files in group */
+    synchronized private Integer getDenominator() {
+        try {
+            return getItem().getGroup().getSize();
+        } catch (NullPointerException ex) {
+            return 0;
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/GroupTreeItem.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/GroupTreeItem.java
new file mode 100644
index 0000000000000000000000000000000000000000..7dfcc5d47d63b01100988bbeb765efd068b3c882
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/GroupTreeItem.java
@@ -0,0 +1,252 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui.navpanel;
+
+import org.sleuthkit.autopsy.imageanalyzer.grouping.Grouping;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import javafx.application.Platform;
+import javafx.collections.FXCollections;
+import javafx.event.EventType;
+import javafx.scene.control.TreeItem;
+import org.apache.commons.lang3.StringUtils;
+import org.sleuthkit.autopsy.coreutils.Logger;
+
+/** A node in the nav/hash tree. Manages inserts and removals. Has parents
+ * and children. Does not have graphical properties these are configured in
+ * {@link GroupTreeCell}. Each GroupTreeItem has a TreeNode which has a path
+ * segment and may or may not have a group */
+class GroupTreeItem extends TreeItem<TreeNode> implements Comparable<GroupTreeItem> {
+
+    /** maps a path segment to the child item of this item with that path
+     * segment */
+    private Map<String, GroupTreeItem> childMap = new HashMap<>();
+    /** the comparator if any used to sort the children of this item */
+    private TreeNodeComparators comp;
+
+    public GroupTreeItem(String t, Grouping g, TreeNodeComparators comp) {
+        super(new TreeNode(t, g));
+        this.comp = comp;
+    }
+
+    /** Returns the full absolute path of this level in the tree
+     *
+     * @return the full absolute path of this level in the tree */
+    public String getAbsolutePath() {
+        if (getParent() != null) {
+            return ((GroupTreeItem) getParent()).getAbsolutePath() + getValue().getPath() + "/";
+        } else {
+            return getValue().getPath() + "/";
+        }
+    }
+
+    /** Recursive method to add a grouping at a given path.
+     *
+     * @param path Full path (or subset not yet added) to add
+     * @param g    Group to add
+     * @param tree True if it is part of a tree (versus a list)
+     */
+    void insert(String path, Grouping g, Boolean tree) {
+        if (tree) {
+            String cleanPath = StringUtils.stripStart(path, "/");
+
+            // get the first token
+            String prefix = StringUtils.substringBefore(cleanPath, "/");
+
+            // Are we at the end of the recursion?
+            if ("".equals(prefix)) {
+                getValue().setGroup(g);
+            } else {
+                GroupTreeItem prefixTreeItem = childMap.get(prefix);
+                if (prefixTreeItem == null) {
+                    final GroupTreeItem newTreeItem = new GroupTreeItem(prefix, null, comp);
+
+                    prefixTreeItem = newTreeItem;
+                    childMap.put(prefix, prefixTreeItem);
+                    Platform.runLater(new Runnable() {
+                        @Override
+                        public void run() {
+                            synchronized (getChildren()) {
+                                getChildren().add(newTreeItem);
+                            }
+                        }
+                    });
+
+                }
+
+                // recursively go into the path
+                prefixTreeItem.insert(StringUtils.stripStart(cleanPath, prefix), g, tree);
+            }
+        } else {
+            GroupTreeItem treeItem = childMap.get(path);
+            if (treeItem == null) {
+                final GroupTreeItem newTreeItem = new GroupTreeItem(path, g, comp);
+                newTreeItem.setExpanded(true);
+                childMap.put(path, newTreeItem);
+
+                Platform.runLater(new Runnable() {
+                    @Override
+                    public void run() {
+                        synchronized (getChildren()) {
+                            getChildren().add(newTreeItem);
+                            if (comp != null) {
+                                FXCollections.sort(getChildren(), comp);
+                            }
+                        }
+                    }
+                });
+
+            }
+        }
+    }
+
+    /** Recursive method to add a grouping at a given path.
+     *
+     * @param path Full path (or subset not yet added) to add
+     * @param g    Group to add
+     * @param tree True if it is part of a tree (versus a list)
+     */
+    void insert(List<String> path, Grouping g, Boolean tree) {
+        if (tree) {
+            // Are we at the end of the recursion?
+            if (path.isEmpty()) {
+                getValue().setGroup(g);
+            } else {
+                String prefix = path.get(0);
+
+                GroupTreeItem prefixTreeItem = childMap.get(prefix);
+                if (prefixTreeItem == null) {
+                    final GroupTreeItem newTreeItem = new GroupTreeItem(prefix, null, comp);
+
+                    prefixTreeItem = newTreeItem;
+                    childMap.put(prefix, prefixTreeItem);
+
+                    Platform.runLater(new Runnable() {
+                        @Override
+                        public void run() {
+                            synchronized (getChildren()) {
+                                getChildren().add(newTreeItem);
+                            }
+                        }
+                    });
+
+                }
+
+                // recursively go into the path
+                prefixTreeItem.insert(path.subList(1, path.size()), g, tree);
+            }
+        } else {
+            //flat list
+            GroupTreeItem treeItem = childMap.get(StringUtils.join(path, "/"));
+            if (treeItem == null) {
+                final GroupTreeItem newTreeItem = new GroupTreeItem(StringUtils.join(path, "/"), g, comp);
+                newTreeItem.setExpanded(true);
+                childMap.put(path.get(0), newTreeItem);
+
+                Platform.runLater(new Runnable() {
+                    @Override
+                    public void run() {
+                        synchronized (getChildren()) {
+                            getChildren().add(newTreeItem);
+                            if (comp != null) {
+                                FXCollections.sort(getChildren(), comp);
+                            }
+                        }
+                    }
+                });
+            }
+        }
+    }
+
+    @Override
+    public int compareTo(GroupTreeItem o) {
+        return comp.compare(this, o);
+    }
+
+    static GroupTreeItem getTreeItemForGroup(GroupTreeItem root, Grouping grouping) {
+        if (root.getValue().getGroup() == grouping) {
+            return root;
+        } else {
+            synchronized (root.getChildren()) {
+                for (TreeItem<TreeNode> child : root.getChildren()) {
+                    final GroupTreeItem childGTI = (GroupTreeItem) child;
+
+                    GroupTreeItem val = getTreeItemForGroup(childGTI, grouping);
+                    if (val != null) {
+                        return val;
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    GroupTreeItem getTreeItemForPath(List<String> path) {
+        // end of recursion
+        if (path.isEmpty()) {
+            return this;
+        } else {
+            synchronized (getChildren()) {
+                String prefix = path.get(0);
+
+                GroupTreeItem prefixTreeItem = childMap.get(prefix);
+                if (prefixTreeItem == null) {
+                    // @@@ ERROR;
+                    return null;
+                }
+
+                // recursively go into the path
+                return prefixTreeItem.getTreeItemForPath(path.subList(1, path.size()));
+            }
+        }
+    }
+
+    void removeFromParent() {
+        final GroupTreeItem parent = (GroupTreeItem) getParent();
+        if (parent != null) {
+            parent.childMap.remove(getValue().getPath());
+
+            Platform.runLater(new Runnable() {
+                @Override
+                public void run() {
+                    synchronized (parent.getChildren()) {
+                        parent.getChildren().removeAll(GroupTreeItem.this);
+                    }
+                }
+            });
+
+            if (parent.childMap.isEmpty()) {
+                parent.removeFromParent();
+            }
+        }
+    }
+
+    void resortChildren(TreeNodeComparators newComp) {
+        this.comp = newComp;
+        synchronized (getChildren()) {
+            FXCollections.sort(getChildren(), comp);
+        }
+        for (GroupTreeItem ti : childMap.values()) {
+            ti.resortChildren(comp);
+        }
+    }
+}
\ No newline at end of file
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/NavPanel.fxml b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/NavPanel.fxml
new file mode 100644
index 0000000000000000000000000000000000000000..100d08afa90711b89d1fca3dae486a06bd739b1a
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/NavPanel.fxml
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import java.lang.*?>
+<?import javafx.collections.*?>
+<?import javafx.scene.control.*?>
+<?import javafx.scene.image.*?>
+<?import javafx.scene.layout.*?>
+
+<fx:root id="BorderPane" maxHeight="-1.0" maxWidth="-1.0" minHeight="-1.0" minWidth="200.0" prefHeight="-1.0" prefWidth="-1.0" type="javafx.scene.layout.BorderPane" xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
+  <center>
+    <TabPane fx:id="navTabPane" prefHeight="-1.0" prefWidth="-1.0" tabClosingPolicy="UNAVAILABLE">
+      <tabs>
+        <Tab fx:id="navTab" text="Contents">
+          <content>
+            <AnchorPane prefHeight="-1.0" prefWidth="-1.0">
+              <children>
+                <TreeView fx:id="navTree" minWidth="-1.0" prefHeight="-1.0" prefWidth="-1.0" style="" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
+              </children>
+            </AnchorPane>
+          </content>
+          <graphic>
+            <ImageView fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true">
+              <image>
+                <Image url="@../../images/folder-tree.png" />
+              </image>
+            </ImageView>
+          </graphic>
+        </Tab>
+        <Tab fx:id="hashTab" text="Hash Hits">
+          <content>
+            <AnchorPane id="Content" minHeight="0.0" minWidth="0.0" prefHeight="-1.0" prefWidth="-1.0">
+              <children>
+                <BorderPane prefHeight="-1.0" prefWidth="-1.0" AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0">
+                  <center>
+                    <TreeView fx:id="hashTree" prefHeight="-1.0" prefWidth="-1.0" />
+                  </center>
+                  <top>
+                    <ToolBar>
+                      <items>
+                        <Label text="Sort By:" />
+                        <ComboBox fx:id="sortByBox">
+                          <items>
+                            <FXCollections fx:factory="observableArrayList">
+                              <String fx:value="Item 1" />
+                              <String fx:value="Item 2" />
+                              <String fx:value="Item 3" />
+                            </FXCollections>
+                          </items>
+                        </ComboBox>
+                      </items>
+                    </ToolBar>
+                  </top>
+                </BorderPane>
+              </children>
+            </AnchorPane>
+          </content>
+          <graphic>
+            <ImageView fitHeight="16.0" fitWidth="16.0" mouseTransparent="true" pickOnBounds="true" preserveRatio="true">
+              <image>
+                <Image url="@../../images/hashset_hits.png" />
+              </image>
+            </ImageView>
+          </graphic>
+        </Tab>
+      </tabs>
+    </TabPane>
+  </center>
+</fx:root>
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/NavPanel.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/NavPanel.java
new file mode 100644
index 0000000000000000000000000000000000000000..a65766ae0c632af339a9470dae1a2b31d8df20fd
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/NavPanel.java
@@ -0,0 +1,375 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui.navpanel;
+
+import org.sleuthkit.autopsy.imageanalyzer.EurekaController;
+import org.sleuthkit.autopsy.imageanalyzer.FXMLConstructor;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableFile;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute;
+import org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute.AttributeName;
+import static org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute.AttributeName.PATH;
+import static org.sleuthkit.autopsy.imageanalyzer.datamodel.DrawableAttribute.AttributeName.TAGS;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.Grouping;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupKey;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupSortBy;
+import org.sleuthkit.autopsy.imageanalyzer.grouping.GroupViewState;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.ResourceBundle;
+import javafx.application.Platform;
+import javafx.beans.InvalidationListener;
+import javafx.beans.Observable;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.value.ObservableValue;
+import javafx.collections.FXCollections;
+import javafx.collections.ListChangeListener;
+import javafx.collections.ObservableList;
+import javafx.fxml.FXML;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.ListView;
+import javafx.scene.control.SelectionMode;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+import javafx.scene.control.TreeItem;
+import javafx.scene.control.TreeView;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+import org.apache.commons.lang3.StringUtils;
+import org.openide.util.Exceptions;
+import org.sleuthkit.datamodel.TagName;
+import org.sleuthkit.datamodel.TskCoreException;
+
+/**
+ * Display two trees. one shows all folders (groups) and calls out folders with
+ * images. the user can select folders with images to see them in the main
+ * GroupListPane The other shows folders with hash set hits.
+ */
+public class NavPanel extends BorderPane {
+
+    @FXML
+    private ResourceBundle resources;
+
+    @FXML
+    private URL location;
+
+    /**
+     * TreeView for folders with hash hits
+     */
+    @FXML
+    private TreeView<TreeNode> hashTree;
+
+    @FXML
+    private TabPane navTabPane;
+
+    /**
+     * TreeView for all folders
+     */
+    @FXML
+    private TreeView<TreeNode> navTree;
+
+    @FXML
+    private Tab hashTab;
+
+    @FXML
+    private Tab navTab;
+
+    @FXML
+    private ComboBox<TreeNodeComparators> sortByBox;
+
+    /** contains the 'active tree' */
+    private final SimpleObjectProperty<TreeView<TreeNode>> activeTreeProperty = new SimpleObjectProperty<>();
+
+    private GroupTreeItem navTreeRoot;
+
+    private GroupTreeItem hashTreeRoot;
+
+    public NavPanel(EurekaController controller) {
+        this.controller = controller;
+        FXMLConstructor.construct(this, "NavPanel.fxml");
+    }
+
+    @FXML
+    void initialize() {
+        assert hashTab != null : "fx:id=\"hashTab\" was not injected: check your FXML file 'NavPanel.fxml'.";
+        assert hashTree != null : "fx:id=\"hashTree\" was not injected: check your FXML file 'NavPanel.fxml'.";
+        assert navTab != null : "fx:id=\"navTab\" was not injected: check your FXML file 'NavPanel.fxml'.";
+        assert navTabPane != null : "fx:id=\"navTabPane\" was not injected: check your FXML file 'NavPanel.fxml'.";
+        assert navTree != null : "fx:id=\"navTree\" was not injected: check your FXML file 'NavPanel.fxml'.";
+        assert sortByBox != null : "fx:id=\"sortByBox\" was not injected: check your FXML file 'NavPanel.fxml'.";
+
+        VBox.setVgrow(this, Priority.SOMETIMES);
+
+        navTree.setShowRoot(false);
+        hashTree.setShowRoot(false);
+
+        navTree.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
+        hashTree.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
+
+        sortByBox.setCellFactory((ListView<TreeNodeComparators> p) -> new TreeNodeComparators.ComparatorListCell());
+
+        sortByBox.setButtonCell(new TreeNodeComparators.ComparatorListCell());
+        sortByBox.setItems(FXCollections.observableArrayList(FXCollections.observableArrayList(TreeNodeComparators.values())));
+        sortByBox.getSelectionModel().select(TreeNodeComparators.HIT_COUNT);
+        sortByBox.getSelectionModel().selectedItemProperty().addListener((Observable o) -> {
+            resortHashTree();
+        });
+
+        navTree.setCellFactory((TreeView<TreeNode> p) -> new GroupTreeCell());
+
+        hashTree.setCellFactory((TreeView<TreeNode> p) -> new GroupTreeCell());
+
+        activeTreeProperty.addListener(new InvalidationListener() {
+            private void setSelectedGroupProperty() {
+                final TreeItem<TreeNode> selectedItem = activeTreeProperty.get().getSelectionModel().getSelectedItem();
+                if (selectedItem != null && selectedItem.getValue() != null && selectedItem.getValue().getGroup() != null) {
+                    controller.pushGroup(GroupViewState.tile(selectedItem.getValue().getGroup()), false);
+                } else {
+//                    selectedGroupProperty.set(selectedItem.getValue().getGroup());
+                }
+            }
+
+            @Override
+            public void invalidated(Observable o) {
+                setSelectedGroupProperty();
+                activeTreeProperty.get().getSelectionModel().selectedItemProperty().addListener((Observable o1) -> {
+                    setSelectedGroupProperty();
+                });
+            }
+        });
+
+        this.activeTreeProperty.set(navTree);
+
+        navTabPane.getSelectionModel().selectedItemProperty().addListener((ObservableValue<? extends Tab> ov, Tab t, Tab t1) -> {
+            if (t1 == hashTab) {
+                activeTreeProperty.set(hashTree);
+            } else if (t1 == navTab) {
+                activeTreeProperty.set(navTree);
+            }
+        });
+
+        initHashTree();
+        initNavTree();
+
+        controller.getGroupManager().getAnalyzedGroups().addListener((ListChangeListener.Change<? extends Grouping> change) -> {
+            while (change.next()) {
+                for (Grouping g : change.getAddedSubList()) {
+                    insertIntoNavTree(g);
+                    if (g.getFilesWithHashSetHitsCount() > 0) {
+                        insertIntoHashTree(g);
+                    }
+                }
+                for (Grouping g : change.getRemoved()) {
+                    removeFromNavTree(g);
+                    removeFromHashTree(g);
+                }
+            }
+        });
+
+        controller.viewState().addListener((ObservableValue<? extends GroupViewState> observable, GroupViewState oldValue, GroupViewState newValue) -> {
+            if (newValue != null && newValue.getGroup() != null) {
+                setFocusedGroup(newValue.getGroup());
+            }
+        });
+        parentProperty().addListener(new RebuildTreeListener());
+
+    }
+
+    private final EurekaController controller;
+
+    synchronized private void rebuildNavTree() {
+        navTreeRoot = new GroupTreeItem("", null, sortByBox.getSelectionModel().selectedItemProperty().get());
+
+        ObservableList<Grouping> groups = controller.getGroupManager().getAnalyzedGroups();
+
+        for (Grouping g : groups) {
+            insertIntoNavTree(g);
+        }
+
+        Platform.runLater(() -> {
+            navTree.setRoot(navTreeRoot);
+            navTreeRoot.setExpanded(true);
+        });
+    }
+
+    synchronized private void resortHashTree() {
+        hashTreeRoot.resortChildren(sortByBox.getSelectionModel().getSelectedItem());
+    }
+
+    synchronized private void rebuildHashTree() {
+        hashTreeRoot = new GroupTreeItem("", null, sortByBox.getSelectionModel().getSelectedItem());
+        //TODO: can we do this as db query?
+        List<String> hashSetNames = controller.getGroupManager().findValuesForAttribute(DrawableAttribute.HASHSET, GroupSortBy.NONE);
+        for (String name : hashSetNames) {
+            try {
+                List<Long> fileIDsInGroup = controller.getGroupManager().getFileIDsInGroup(new GroupKey(DrawableAttribute.HASHSET, name));
+
+                for (Long fileId : fileIDsInGroup) {
+
+                    DrawableFile file = controller.getFileFromId(fileId);
+                    Collection<GroupKey> groupKeysForFile;
+                    if (controller.getGroupManager().getGroupBy() == DrawableAttribute.TAGS) {
+                        Collection<TagName> tagNames = (Collection<TagName>) file.getValueOfAttribute(DrawableAttribute.TAGS);
+                        groupKeysForFile = new ArrayList<>();
+                        for (TagName tn : tagNames) {
+                            groupKeysForFile.add(new GroupKey(DrawableAttribute.TAGS, tn));
+                        }
+                    } else {
+                        groupKeysForFile = controller.getGroupManager().getGroupKeysForFile(file);
+                    }
+
+                    for (GroupKey k : groupKeysForFile) {
+                        final Grouping groupForKey = controller.getGroupManager().getGroupForKey(k);
+                        if (groupForKey != null) {
+                            insertIntoHashTree(groupForKey);
+                        }
+                    }
+                }
+            } catch (TskCoreException ex) {
+                Exceptions.printStackTrace(ex);
+            }
+        }
+        Platform.runLater(() -> {
+            hashTree.setRoot(hashTreeRoot);
+            hashTreeRoot.setExpanded(true);
+        });
+
+    }
+
+    private void insertIntoHashTree(Grouping g) {
+        initHashTree();
+        hashTreeRoot.insert(g.groupKey.getValueDisplayName(), g, false);
+    }
+
+    /**
+     * Set the tree to the passed in group
+     *
+     * @param grouping
+     */
+    public void setFocusedGroup(Grouping grouping) {
+
+        List<String> path = groupingToPath(grouping);
+
+        final GroupTreeItem treeItemForGroup = ((GroupTreeItem) activeTreeProperty.get().getRoot()).getTreeItemForPath(path);
+
+        if (treeItemForGroup != null) {
+            Platform.runLater(() -> {
+                TreeItem ti = treeItemForGroup;
+                while (ti != null) {
+                    ti.setExpanded(true);
+                    ti = ti.getParent();
+                }
+                int row = activeTreeProperty.get().getRow(treeItemForGroup);
+                if (row != -1) {
+                    activeTreeProperty.get().getSelectionModel().select(treeItemForGroup);
+                    activeTreeProperty.get().scrollTo(row);
+
+                }
+            });
+        }
+    }
+
+    private static List<String> groupingToPath(Grouping g) {
+
+        AttributeName attrName = g.groupKey.getAttribute().attrName;
+        String path = null;
+        switch (attrName) {
+            case PATH:
+                path = ((String) g.groupKey.getValue());
+                break;
+            case TAGS:
+                path = ((TagName) g.groupKey.getValue()).getDisplayName();
+            default:
+                if (path == null) {
+                    path = g.groupKey.getValue().toString();
+                }
+        }
+
+        String cleanPath = StringUtils.stripStart(path, "/");
+        String[] tokens = cleanPath.split("/");
+
+        return Arrays.asList(tokens);
+    }
+
+    private void insertIntoNavTree(Grouping g) {
+        initNavTree();
+        List<String> path = groupingToPath(g);
+
+        AttributeName attrName = g.groupKey.getAttribute().attrName;
+        switch (attrName) {
+            case PATH:
+                navTreeRoot.insert(path, g, true);
+                break;
+            default:
+                navTreeRoot.insert(path, g, false);
+                break;
+        }
+    }
+
+    private void removeFromNavTree(Grouping g) {
+        initNavTree();
+        final GroupTreeItem treeItemForGroup = GroupTreeItem.getTreeItemForGroup(navTreeRoot, g);
+        if (treeItemForGroup != null) {
+            treeItemForGroup.removeFromParent();
+        }
+    }
+
+    private void removeFromHashTree(Grouping g) {
+        initHashTree();
+        final GroupTreeItem treeItemForGroup = GroupTreeItem.getTreeItemForGroup(hashTreeRoot, g);
+        if (treeItemForGroup != null) {
+            treeItemForGroup.removeFromParent();
+        }
+    }
+
+    private void initNavTree() {
+        if (navTreeRoot == null) {
+            navTreeRoot = new GroupTreeItem("", null, null);
+
+            Platform.runLater(() -> {
+                navTree.setRoot(navTreeRoot);
+                navTreeRoot.setExpanded(true);
+            });
+        }
+    }
+
+    private void initHashTree() {
+        if (hashTreeRoot == null) {
+            hashTreeRoot = new GroupTreeItem("", null, null);
+
+            Platform.runLater(() -> {
+                hashTree.setRoot(hashTreeRoot);
+                hashTreeRoot.setExpanded(true);
+            });
+
+        }
+    }
+
+    private class RebuildTreeListener implements InvalidationListener {
+
+        @Override
+        public void invalidated(Observable o) {
+            rebuildHashTree();
+            rebuildNavTree();
+        }
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/TreeNode.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/TreeNode.java
new file mode 100644
index 0000000000000000000000000000000000000000..6b9919dc84ca475e5f8ad537f31aa3a0fe421075
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/TreeNode.java
@@ -0,0 +1,47 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui.navpanel;
+
+import org.sleuthkit.autopsy.imageanalyzer.grouping.Grouping;
+
+/**
+ *
+ */
+ class TreeNode {
+
+   private  String path;
+   private Grouping group;
+
+    public String getPath() {
+        return path;
+    }
+
+    public Grouping getGroup() {
+        return group;
+    }
+
+    public TreeNode(String path, Grouping group) {
+        this.path = path;
+        this.group = group;
+    }
+
+    void setGroup(Grouping g) {
+    group = g;
+    }
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/TreeNodeComparators.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/TreeNodeComparators.java
new file mode 100644
index 0000000000000000000000000000000000000000..bab4f5c6691e14c30db39ae74afc0b7ee15c7937
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/gui/navpanel/TreeNodeComparators.java
@@ -0,0 +1,94 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.gui.navpanel;
+
+import java.util.Comparator;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.TreeItem;
+
+/**
+ *
+ */
+enum TreeNodeComparators implements Comparator<TreeItem<TreeNode>>, NonNullCompareable {
+
+    ALPHABETICAL("Group Name") {
+        @Override
+        public int nonNullCompare(TreeItem<TreeNode> o1, TreeItem<TreeNode> o2) {
+
+            return o1.getValue().getGroup().groupKey.getValue().toString().compareTo(o2.getValue().getGroup().groupKey.getValue().toString());
+        }
+    }, HIT_COUNT("Hit Count") {
+        @Override
+        public int nonNullCompare(TreeItem<TreeNode> o1, TreeItem<TreeNode> o2) {
+
+            return -Integer.compare(o1.getValue().getGroup().getFilesWithHashSetHitsCount(), o2.getValue().getGroup().getFilesWithHashSetHitsCount());
+        }
+    }, FILE_COUNT("Group Size") {
+        @Override
+        public int nonNullCompare(TreeItem<TreeNode> o1, TreeItem<TreeNode> o2) {
+
+            return -Integer.compare(o1.getValue().getGroup().getSize(), o2.getValue().getGroup().getSize());
+        }
+    }, HIT_FILE_RATIO("Hit Density") {
+        @Override
+        public int nonNullCompare(TreeItem<TreeNode> o1, TreeItem<TreeNode> o2) {
+
+            return -Double.compare(o1.getValue().getGroup().getFilesWithHashSetHitsCount() / o1.getValue().getGroup().getSize().doubleValue(),
+                                   o2.getValue().getGroup().getFilesWithHashSetHitsCount() / o2.getValue().getGroup().getSize().doubleValue());
+        }
+    };
+
+    @Override
+    public int compare(TreeItem<TreeNode> o1, TreeItem<TreeNode> o2) {
+        if (o1.getValue().getGroup() == null) {
+            if (o1.getValue().getGroup() == null) {
+                return 0;
+            }
+            return 1;
+        } else if (o2.getValue().getGroup() == null) {
+            return -1;
+        }
+        return nonNullCompare(o1, o2);
+    }
+    final private String displayName;
+
+    public String getDisplayName() {
+        return displayName;
+    }
+
+    private TreeNodeComparators(String displayName) {
+        this.displayName = displayName;
+    }
+
+    public static class ComparatorListCell extends ListCell<TreeNodeComparators> {
+
+        @Override
+        protected void updateItem(TreeNodeComparators t, boolean bln) {
+            super.updateItem(t, bln);
+            if (t != null) {
+                setText(t.getDisplayName());
+            }
+        }
+    }
+}
+
+interface NonNullCompareable {
+
+    int nonNullCompare(TreeItem<TreeNode> o1, TreeItem<TreeNode> o2);
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/Clapperboard.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/Clapperboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..134ec3828be1031442406547f035af1e2c781538
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/Clapperboard.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/Folder-icon.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/Folder-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..77fb7695deb151621129a32a9d1b377851a65359
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/Folder-icon.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/TriangleDown.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/TriangleDown.png
new file mode 100644
index 0000000000000000000000000000000000000000..ea290a20c72e077e8e5e39770c2da518ef26206e
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/TriangleDown.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/application_view_tile.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/application_view_tile.png
new file mode 100644
index 0000000000000000000000000000000000000000..3bc0bd32fceb21d70368f7842a00a53d6369ba48
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/application_view_tile.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-090.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-090.png
new file mode 100644
index 0000000000000000000000000000000000000000..f62345e491b0af46cf10f59d46467c257d5be8d9
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-090.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-180.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-180.png
new file mode 100644
index 0000000000000000000000000000000000000000..4d2aa3ccb2d59ecf6cc1db2006bc330a7b32423c
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-180.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-270.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-270.png
new file mode 100644
index 0000000000000000000000000000000000000000..56e9a63d6233853be9c14ec522652611d7b9056c
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-270.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-circle-double-135.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-circle-double-135.png
new file mode 100644
index 0000000000000000000000000000000000000000..06f1ee390a69aede315c33ab6ba8c085bcdd003e
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-circle-double-135.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-circle-double.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-circle-double.png
new file mode 100644
index 0000000000000000000000000000000000000000..1c5605506772be9ca53f69e424c4a37a98375c02
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-circle-double.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-resize-090.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-resize-090.png
new file mode 100644
index 0000000000000000000000000000000000000000..dcfa5ab5364f6b4d0093a20d453df6e06bb8f06c
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-resize-090.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-resize.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-resize.png
new file mode 100644
index 0000000000000000000000000000000000000000..a41414154515ed02b66ec3508436849ea927427b
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow-resize.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow.png
new file mode 100644
index 0000000000000000000000000000000000000000..12077d33242acb0933fb8a1eb1e9b283173bf3b6
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow_down.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow_down.png
new file mode 100644
index 0000000000000000000000000000000000000000..2c4e279377bf348f9cf53894e76bb673ccf067bd
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow_down.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow_up.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow_up.png
new file mode 100644
index 0000000000000000000000000000000000000000..1ebb193243780b8eb1919a51ef27c2a0d36ccec2
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/arrow_up.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/border-bottom-double.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/border-bottom-double.png
new file mode 100644
index 0000000000000000000000000000000000000000..367ab664eb9283f66f5e33c83dff15411a02c29b
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/border-bottom-double.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/border-top-bottom-double.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/border-top-bottom-double.png
new file mode 100644
index 0000000000000000000000000000000000000000..7cdfad4ae58a8b48ff96a5a26099fa03bbfec344
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/border-top-bottom-double.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/bullet_arrow_down.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/bullet_arrow_down.png
new file mode 100644
index 0000000000000000000000000000000000000000..9b23c06d7b4f4689dc8c9fd4e9d4d1f199fe376f
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/bullet_arrow_down.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/camera.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/camera.png
new file mode 100644
index 0000000000000000000000000000000000000000..8536d1a795888d8d396ac4211b639c6395dd08e6
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/camera.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/category-icon.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/category-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..1a3f6a24aebe4cb403d45887605ed82a17fbfa19
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/category-icon.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--minus.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--minus.png
new file mode 100644
index 0000000000000000000000000000000000000000..0a07b14b24d23b8b7443802447655722bd376325
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--minus.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--pencil.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--pencil.png
new file mode 100644
index 0000000000000000000000000000000000000000..6b89ab946efb92f31946a7c1f5edd7fd3ccdd674
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--pencil.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--plus.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--plus.png
new file mode 100644
index 0000000000000000000000000000000000000000..b3e5e329950eb2caaf33eb8cc39eab41980df824
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/clock--plus.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-000-small.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-000-small.png
new file mode 100644
index 0000000000000000000000000000000000000000..826e3d7f03cfa0c62cf7c121b53e86ee2438d5b1
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-000-small.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-090-small.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-090-small.png
new file mode 100644
index 0000000000000000000000000000000000000000..da6e413da1aaf7468f6dfca3ac1fa5e04a5ba7a0
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-090-small.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-090.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-090.png
new file mode 100644
index 0000000000000000000000000000000000000000..f4db3cfd2592f9d0408792510132945b86978b6e
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-090.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-180-small.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-180-small.png
new file mode 100644
index 0000000000000000000000000000000000000000..e4e0830f67db1b0bc85b2e6768aea911e1dc9ae9
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-180-small.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-180.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-180.png
new file mode 100644
index 0000000000000000000000000000000000000000..6cb4ed8cb59ea4c3ece677295f99a105c98fb9dd
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-180.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-270-small.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-270-small.png
new file mode 100644
index 0000000000000000000000000000000000000000..ab4753fbfd58cb3d7adafefbdde15d516826d9f1
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-270-small.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-270.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-270.png
new file mode 100644
index 0000000000000000000000000000000000000000..8232606b0fbcac9ae9314f0502db46238a2a2a92
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double-270.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double.png
new file mode 100644
index 0000000000000000000000000000000000000000..4197fb468badbb0a39a578739a99ab817d3bb9e8
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/control-double.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/film.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/film.png
new file mode 100644
index 0000000000000000000000000000000000000000..31fa687ecd6474f13007b3b0a903ed5ee937a6b6
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/film.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/flag_gray.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/flag_gray.png
new file mode 100644
index 0000000000000000000000000000000000000000..1888f8da96ea4b145de240a906f985540bcf8037
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/flag_gray.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/flag_red.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/flag_red.png
new file mode 100644
index 0000000000000000000000000000000000000000..e8a602da7b17a323b2c9afe3d8aac62cb717a0b8
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/flag_red.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-open-image.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-open-image.png
new file mode 100644
index 0000000000000000000000000000000000000000..6b21e1abbb7ba2fc1e13cea8ab799f3beedc3ac0
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-open-image.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-rename.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-rename.png
new file mode 100644
index 0000000000000000000000000000000000000000..0ae261d4d171cdfa71687b24e85c54129e5c187f
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-rename.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-tree.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-tree.png
new file mode 100644
index 0000000000000000000000000000000000000000..6812f52669d508ffcc1623b1e027f8463bd00d69
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder-tree.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder.png
new file mode 100644
index 0000000000000000000000000000000000000000..784e8fa48234f4f64b6922a6758f254ee0ca08ec
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder_picture.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder_picture.png
new file mode 100644
index 0000000000000000000000000000000000000000..052b33638eaa0f870a255bfdd5df5b79fb01a89e
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folder_picture.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folders-path.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folders-path.png
new file mode 100644
index 0000000000000000000000000000000000000000..ea9cefe849c36a569ba9891c9e207e4d7e521d94
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/folders-path.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/funnel.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/funnel.png
new file mode 100644
index 0000000000000000000000000000000000000000..1f69604528f29ca95e3b124de2849067797d839f
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/funnel.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/group.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/group.png
new file mode 100644
index 0000000000000000000000000000000000000000..7fb4e1f1e1cd6ee67d33ffd24f09ddd5c3478bec
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/group.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/hashset_hits.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/hashset_hits.png
new file mode 100644
index 0000000000000000000000000000000000000000..f1caff1f30333a7d3dc5ab278472b433a2e6f165
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/hashset_hits.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/information.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/information.png
new file mode 100644
index 0000000000000000000000000000000000000000..5d353a191098dee1e1e8f4bdf99c7f4bccb0f292
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/information.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/lightbulb.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/lightbulb.png
new file mode 100644
index 0000000000000000000000000000000000000000..d22fde8ba46eabd4335e4fa88077e80f96b92d62
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/lightbulb.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/page_white_stack.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/page_white_stack.png
new file mode 100644
index 0000000000000000000000000000000000000000..44084add79b9a0fc3354d16bbd4b4b5ff8095da7
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/page_white_stack.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/prohibition.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/prohibition.png
new file mode 100644
index 0000000000000000000000000000000000000000..18f151071ad328b247966fe9d743b19e6827e430
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/prohibition.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/right arrow.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/right arrow.png
new file mode 100644
index 0000000000000000000000000000000000000000..2e304ad84c772659762b15ee5b02e08254d482ca
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/right arrow.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/right_arrow_128.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/right_arrow_128.png
new file mode 100644
index 0000000000000000000000000000000000000000..2c88990a852fb5a54087c28ae2f5b9285912dce5
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/right_arrow_128.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/shape_group.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/shape_group.png
new file mode 100644
index 0000000000000000000000000000000000000000..bb2ff516d35dc9a92ed6ffdc79595d61513e65e3
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/shape_group.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/slide.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/slide.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f1ae4496e4f874742133d42941b2301d23fb95c
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/slide.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/tag_red.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/tag_red.png
new file mode 100644
index 0000000000000000000000000000000000000000..6ebb37d25f58c68246d8ad6a015295dfa5367870
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/tag_red.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/video-file.png b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/video-file.png
new file mode 100644
index 0000000000000000000000000000000000000000..c290609f59048e61cb28af42c4500709e342f437
Binary files /dev/null and b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/images/video-file.png differ
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/license-eureka.txt b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/license-eureka.txt
new file mode 100644
index 0000000000000000000000000000000000000000..fb3e1b684a3502c1045b0aa4b372b145a785f4a1
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/license-eureka.txt
@@ -0,0 +1,18 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013-14 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
\ No newline at end of file
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/progress/ProgressAdapter.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/progress/ProgressAdapter.java
new file mode 100644
index 0000000000000000000000000000000000000000..80ccece647f086ca664cf58e9921daaedec783fa
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/progress/ProgressAdapter.java
@@ -0,0 +1,43 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.progress;
+
+import javafx.beans.property.ReadOnlyDoubleProperty;
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyStringProperty;
+import javafx.concurrent.Worker;
+
+/**
+ *
+ */
+public interface ProgressAdapter {
+
+    public double getProgress();
+
+    public ReadOnlyDoubleProperty progressProperty();
+
+    public String getMessage();
+
+    public ReadOnlyStringProperty messageProperty();
+
+    public Worker.State getState();
+
+    public ReadOnlyObjectProperty<Worker.State> stateProperty();
+
+}
diff --git a/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/progress/ProgressAdapterBase.java b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/progress/ProgressAdapterBase.java
new file mode 100644
index 0000000000000000000000000000000000000000..d0fe1be6e2606d9b053db982e9ef072ae1b9da23
--- /dev/null
+++ b/ImageAnalyzer/src/org/sleuthkit/autopsy/imageanalyzer/progress/ProgressAdapterBase.java
@@ -0,0 +1,83 @@
+/*
+ * Autopsy Forensic Browser
+ *
+ * Copyright 2013 Basis Technology Corp.
+ * Contact: carrier <at> sleuthkit <dot> org
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sleuthkit.autopsy.imageanalyzer.progress;
+
+import javafx.beans.property.ReadOnlyObjectProperty;
+import javafx.beans.property.ReadOnlyObjectWrapper;
+import javafx.beans.property.SimpleDoubleProperty;
+import javafx.beans.property.SimpleObjectProperty;
+import javafx.beans.property.SimpleStringProperty;
+import javafx.concurrent.Worker;
+
+/**
+ *
+ */
+public abstract class ProgressAdapterBase implements ProgressAdapter {
+
+    @Override
+    public double getProgress() {
+        return progress.get();
+    }
+
+    public final void updateProgress(Double workDone) {
+        this.progress.set(workDone);
+    }
+
+    @Override
+    public String getMessage() {
+        return message.get();
+    }
+
+    public final void updateMessage(String Status) {
+        this.message.set(Status);
+    }
+
+    SimpleObjectProperty<Worker.State> state = new SimpleObjectProperty(Worker.State.READY);
+
+    SimpleDoubleProperty progress = new SimpleDoubleProperty(this, "pregress");
+
+    SimpleStringProperty message = new SimpleStringProperty(this, "status");
+
+    @Override
+    public SimpleDoubleProperty progressProperty() {
+        return progress;
+    }
+
+    @Override
+    public SimpleStringProperty messageProperty() {
+        return message;
+    }
+
+    @Override
+    public Worker.State getState() {
+        return state.get();
+    }
+
+    protected void updateState(Worker.State newState) {
+        state.set(newState);
+    }
+
+    @Override
+    public ReadOnlyObjectProperty<Worker.State> stateProperty() {
+        return new ReadOnlyObjectWrapper<>(state.get());
+    }
+
+    public ProgressAdapterBase() {
+    }
+}