Repository: debrief/pepys-import (language: python)

Issue:
Add MacOS developer setup guide
## 🧰 Issue
Fixes #925

## 🚀 Overview: 
Adds MacOS developer setup guide

## 🤔 Reason: 
Add guide for MacOS developers

## 🔨Work carried out:

- [x] Update developer setup guide
- [x] Tests pass

## Confirmations

- [x] I have chosen reviewers for my PR.
- [x] I have chosen an appropriate label for the PR, adding `interactive_review` if reviewers will need to see UI
- [x] I have extended/updated the documentation in `\docs` folder
- [x] Any database content changes (Create, Edit, Delete) are recorded in the Log/Changes tables
- [x] Any database schema changes are implemented via `alembic revision` [transitions](https://pepys-import.readthedocs.io/en/latest/database_migration.html#how-to-use-it-for-developers)
- [x] I have completed the mandatory sections of this document.
- [x] I have deleted any unused sections.

## 📝 Developer Notes:
@IanMayo Could you test these instructions? They worked for me on a clean MacBook, but it is possible I've missed a step somewhere.


Discussion / hints:

Fix patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 31c2c99fc..5717e62c2 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,5 @@
 # [Choice] Python version: 3, 3.9, 3.8, 3.7, 3.6
-ARG VARIANT="3.8"
+ARG VARIANT="3.9"
 FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
 
 # [Optional] Uncomment this section to install additional OS packages.
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index a7e33363c..cbd70a8e1 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -5,7 +5,7 @@
 		"context": "..",
 		"args": {
 			// Update 'VARIANT' to pick a Python version: 3, 3.6, 3.7, 3.8, 3.9
-			"VARIANT": "3.8",
+			"VARIANT": "3.9",
 			// Options
 			"INSTALL_NODE": "false"
 		}
diff --git a/.isort.cfg b/.isort.cfg
index 3404c5f80..1295ffb1d 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -4,4 +4,4 @@ include_trailing_comma = true
 force_grid_wrap = 0
 use_parentheses = true
 line_length = 100
-known_third_party =alembic,dateutil,flask,flask_cachebuster,flask_cors,freezegun,geoalchemy2,geopy,halo,iterfzf,loguru,pexpect,pg8000,pint,prompt_toolkit,psycopg2,pyfiglet,pygments,pytest,setuptools,shapely,sqlalchemy,sqlalchemy_utils,tabulate,testing,tqdm,twisted,waitress
\ No newline at end of file
+known_third_party =alembic,dateutil,flask,flask_cachebuster,flask_cors,freezegun,geoalchemy2,geopy,halo,iterfzf,loguru,lxml,pexpect,pg8000,pint,prompt_toolkit,psycopg2,pyfiglet,pygments,pytest,setuptools,shapely,sqlalchemy,sqlalchemy_utils,tabulate,testing,tqdm,twisted,waitress
\ No newline at end of file
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..665636c5c
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,12 @@
+version: 2
+
+# Build from the docs/ directory with Sphinx
+sphinx:
+  configuration: docs/conf.py
+
+# Explicitly set the version of Python and its requirements
+python:
+  version: 3.7
+  install:
+    - requirements: requirements.txt
+    - requirements: requirements_dev.txt
\ No newline at end of file
diff --git a/DeveloperGuide.rst b/DeveloperGuide.rst
index 7c0aa87e0..f3ff90737 100644
--- a/DeveloperGuide.rst
+++ b/DeveloperGuide.rst
@@ -88,10 +88,31 @@ Run the following command::
 Mac OS X
 ^^^^^^^^
 
-**Note:** These instructions are not complete due to not having a bare OS X machine to test on. However,
-they should give you some hints as to how to get an OS X machine set up to develop pepys-import.
+The easiest way to get the relevant libraries working on a M1 MacOS machine is to use the conda Python package manager which will install from the
+packages provided by conda-forge. This is different to the standard pip/virtualenv process, but works quite nicely alongside it.
+The conda package manager can be quite slow, so the instructions show how to install mamba instead, which is a faster fork.
 
-**TODO - Complete instructions here**
+These instructions are for Macs with a M1 (ARM) processor. A similar approach will work for x86 (Intel) Macs, but you will need
+to ensure that you install the Intel version of miniconda.
+
+1. Install Postgres.app, available from `here <https://postgresapp.com/>`_, which is a self-contained installation of Postgres and PostGIS for MacOS.
+
+2. Once installed, add the Postgres bin directory to the PATH variable in your proefile. The bin directory will be _inside_ the Postgres.app file you've just installed, and the path will be something
+   like :code:`/Applications/Postgres.app/Contents/Versions/14/bin`.
+
+3. Install the ARM version of mambaforge, which is available `here <https://github.com/conda-forge/miniforge/releases/latest/download/Mambaforge-MacOSX-arm64.sh>`_, following 
+    all the instructions to the end (including setting up integration with your shell)
+
+4. Create a new environment (like conda's equivalent of a virtualenv) by running: ``mamba create -n pepys python=3.9 libspatialite psycopg2``. This will
+   install Python 3.9 and libspatialite in a new environment called ``pepys``.
+
+5. Activate the environment by running ``mamba activate pepys``
+
+6. Install the other Pepys dependencies by running ``pip install -r requirements.txt -r requirements_dev.txt``
+
+7. Install the Pepys package itself in 'editable mode' by running ``pip install -e .``
+
+8.  **Important:** Skip to the :ref:`Run the unit tests` section without following the intermediate steps of the guide.
 
 
 Windows
diff --git a/HISTORY.rst b/HISTORY.rst
index d9ff38c47..0ac339cdd 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,16 @@ History
 =======
 
 
+0.0.33 (2021-11-29)
+-------------------
+
+* Support for Unknown Platforms in bulk import
+* Add validation in Tasks UI for start time before end time
+* Show warning if database requires migration
+* Introduce updated platform icons in timeline
+* Allow filtered export of measurement data
+
+
 0.0.31 (2021-10-12)
 -------------------
 
diff --git a/create_deployment.ps1 b/create_deployment.ps1
index cc830bcc5..cf5521392 100644
--- a/create_deployment.ps1
+++ b/create_deployment.ps1
@@ -2,7 +2,7 @@ Write-Output "INFO: Starting to create Pepys deployment"
 
 # Download embedded Python distribution
 try {
-    $url = 'https://www.python.org/ftp/python/3.8.6/python-3.8.6-embed-amd64.zip'
+    $url = 'https://www.python.org/ftp/python/3.9.8/python-3.9.8-embed-amd64.zip'
     (New-Object System.Net.WebClient).DownloadFile($url,  "$PWD\python.zip")
 }
 catch {
@@ -133,8 +133,8 @@ try {
     # the directory above the python folder (with pepys-import in it). This creates a ._pth file which
     # Python uses as it's *only* source for generating sys.path (ie. it does NOT take into account
     # environment variables such as PYTHONPATH)
-    Set-Content -Encoding ascii .\python\python38._pth @"
-python38.zip
+    Set-Content -Encoding ascii .\python\python39._pth @"
+python39.zip
 .
 Lib\site-packages
 ..
diff --git a/docs/conf.py b/docs/conf.py
index b7686e6e5..76c6268d1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -33,6 +33,7 @@
     "sphinx.ext.autodoc",
     "sphinx.ext.viewcode",
     "sphinx.ext.todo",
+    "sphinx.ext.autosectionlabel",
 ]
 
 # Add any paths that contain templates here, relative to this directory.
diff --git a/importers/jchat_importer.py b/importers/jchat_importer.py
new file mode 100644
index 000000000..c08d7952c
--- /dev/null
+++ b/importers/jchat_importer.py
@@ -0,0 +1,287 @@
+import os
+import re
+from datetime import datetime
+
+from dateutil.tz import tzoffset
+from lxml import etree, html
+from tqdm import tqdm
+
+from pepys_import.core.validators import constants
+from pepys_import.file.highlighter.xml_parser import parse
+from pepys_import.file.importer import Importer
+from pepys_import.utils.timezone_utils import TIMEZONE_MAPPINGS
+
+# In future, we may be able to replace these with actual version numbers
+JCHAT_LEGACY = 1
+JCHAT_MODERN = 2
+
+
+class JChatImporter(Importer):
+    """Imports JChat messages"""
+
+    def __init__(self):
+        super().__init__(
+            name="JChat Format Importer",
+            validation_level=constants.BASIC_LEVEL,
+            short_name="JChat Importer",
+            datafile_type="JChat",
+        )
+        self.year = datetime.now().year
+        self.month = datetime.now().month
+        self.last_days = 0
+
+    def can_load_this_type(self, suffix):
+        # The sample data that we have includes some files with .html but
+        # several files have no extension and have a . in the name
+        # so we can't use the file extension
+        return True
+
+    def can_load_this_filename(self, filename):
+        return True
+
+    def can_load_this_header(self, header):
+        if header:
+            return header.startswith("<html>")
+        return False
+
+    def can_load_this_file(self, file_contents):
+        if len(file_contents) < 8:  # Enough to cover the header
+            return False
+        # Check the HTML header
+        is_valid = "html" in file_contents[0]
+        is_valid = is_valid and "head" in file_contents[1]
+        is_valid = is_valid and 'style type="text/css"' in file_contents[2]
+        # Only going to check for msgcontent in case there are other styles
+        is_valid = is_valid and "msgcontent" in "".join(file_contents[3:8])
+        return is_valid
+
+    def _load_this_file(self, data_store, path, file_object, datafile, change_id):
+        # JChat HTML is machine generated but <br> and <hr> tags are not valid
+        # XML and appear in several of the example files, so load & correct to xml
+        with open(path) as original:
+            original_doc = original.read()
+            simplified_doc = html.fromstring(self.simplify_jchat_html(original_doc))
+
+        xhtml_path = f"{path}.xhtml"
+        with open(xhtml_path, "wb") as corrected:
+            corrected.write(etree.tostring(simplified_doc))
+
+        file_object.reinitialise(xhtml_path, datafile)
+        try:
+            doc = parse(xhtml_path, highlighted_file=file_object)
+        except Exception as e:
+            self.errors.append(
+                {
+                    self.error_type: f'Invalid JChat file at {path}\nError from parsing was "{str(e)}"'
+                }
+            )
+            print(f"Error {e}")
+            return
+
+        self.quad_platform_cache = {}
+        self.year = int(
+            data_store.ask_for_missing_info(
+                "Which year was this file generated (YYYY)?",
+                default_value=self.year,
+                min_value=1990,
+                allow_empty=True,
+            )
+        )
+        self.month = int(
+            data_store.ask_for_missing_info(
+                "Which month was this file generated (MM)?",
+                default_value=self.month,
+                min_value=1,
+                max_value=12,
+                allow_empty=True,
+            )
+        )
+
+        # Each chat message is wrapped in a <div> tag
+        for div in tqdm(doc.findall(".//{*}div")):
+            self._read_message_div(div, data_store, datafile, change_id)
+
+        try:
+            # Delete the extra file
+            os.remove(xhtml_path)
+        except IOError:
+            self.errors.append(
+                {
+                    self.error_type: f'Unable to remove intermediate file at {xhtml_path}\nPlease delete this file manually."'
+                }
+            )
+
+    def _read_message_div(self, div, data_store, datafile, change_id):
+        """Reads the key parts of the JChat message from the data provided
+        :param div: The div tag from the JChat message to parse
+        :param data_store: The data store we're putting this message into
+        :param datafile: The data file we're extracting from
+        :param change_id: The change ID of this import
+        """
+        try:
+            # Older format uses id=
+            # Newer format uses msgid=
+            msg_id = div.attrib["msgid"]  # Grabbing ID to help with error reporting
+            version = JCHAT_MODERN
+        except KeyError:
+            try:
+                msg_id = div.attrib["id"]
+                version = JCHAT_LEGACY
+            except KeyError:
+                # Ignore any non-comment messages (e.g. connect/disconnect)
+                return
+
+        time_element = div.find("{*}tt/font")
+
+        # Sample data included some "Marker" messages with the id="marker"
+        if str.upper(msg_id) == "MARKER":
+            return  # Ignore these messages
+
+        if time_element is None:
+            self.errors.append(
+                {self.error_type: f"Unable to read message {msg_id}. No timestamp provided"}
+            )
+            return
+
+        time_string = time_element.text.strip("[").strip("]")
+        timestamp = self.parse_timestamp(time_string, msg_id)
+        time_element.record(self.name, "timestamp", timestamp)
+
+        if version == JCHAT_LEGACY:
+            platform_element = div.find("{*}b/a/font")
+        else:  # version == JCHAT_MODERN
+            platform_element = div.find("{*}b/font/a")
+
+        if platform_element is None:
+            self.errors.append(
+                {self.error_type: f"Unable to read message {msg_id}. No platform provided"}
+            )
+            return
+        platform_quad = platform_element.text[0:4]
+        platform_element.record(self.name, "platform", platform_quad)
+        # Match on quadgraphs
+        platform = self.get_cached_platform_from_quad(data_store, platform_quad, change_id)
+
+        msg_content_element = [element for element in div.iterfind("font")]
+
+        if not msg_content_element:
+            self.errors.append(
+                {self.error_type: f"Unable to read message {msg_id}. No message provided"}
+            )
+            return
+        msg_content = self.parse_message_content(msg_content_element)
+        if not msg_content:
+            self.errors.append({self.error_type: f"Unable to parse JChat message {msg_id}."})
+            return
+        msg_content_element[0].record(self.name, "Content", msg_content)
+
+        datafile.create_comment(
+            data_store=data_store,
+            platform=platform,
+            timestamp=timestamp,
+            comment=msg_content,
+            comment_type=data_store.add_to_comment_types("JChat", change_id),
+            parser_name=self.short_name,
+        )
+
+        datafile.flush_extracted_tokens()
+
+    def roll_month_year(self):
+        """Rolls the current month/year over to the next one"""
+        self.month += 1
+        if self.month > 12:
+            self.month = 1
+            self.year += 1
+
+    def parse_timestamp(self, timestamp_str, msg_id):
+        """Parses the JChat timestamp passed in for the given message
+        :param timestamp_str: The DDHHmmssZ formatted date time string
+        :param msg_id: The ID of the message whose timestamp is being parsed
+        :return: The parsed datetime representation of the timestamp
+            OR False if unable to parse the timestamp
+        :rtype: datetime | bool
+        """
+        if len(timestamp_str) != 9:
+            self.errors.append(
+                {self.error_type: f"Invalid JChat timestamp {timestamp_str} at {msg_id}"}
+            )
+            return False
+        # Parse days separately to make sure we have the right month/year
+        days = int(timestamp_str[0:2])
+        if days < self.last_days:
+            self.roll_month_year()
+        self.last_days = days
+
+        day_and_time = timestamp_str[0:8]
+        timezone_offset = TIMEZONE_MAPPINGS[timestamp_str[8]]
+
+        timezone_aware_timestamp = datetime.strptime(
+            f"{self.year}{self.month:02d}{day_and_time}{timezone_offset}", "%Y%m%d%H%M%S%z"
+        )
+        # Give back the time in UTC for storing
+        return timezone_aware_timestamp.astimezone(tzoffset("UTC", 0))
+
+    def get_cached_platform_from_quad(self, data_store, quadgraph, change_id):
+        """Attempts to get a platform from the quadgraph
+        :param data_store: The data store that we want to get the platform from
+        :param quadgraph: The quadgraph to get a platform for
+        :param change_id: The change ID should we need to make a new platform
+        :return: The platform for this quadgraph
+        :rtype: Platform
+        """
+        # look in the cache first
+        platform_name = self.quad_platform_cache.get(quadgraph)
+        if platform_name is None:
+            # Otherwise we need to check whether there is actually a platform with this quad
+            platform_name = data_store.get_platform_name_from_quad(quadgraph)
+            if platform_name is None:
+                platform_name = quadgraph
+
+        # Grab the right platform
+        platform = self.get_cached_platform(
+            data_store, platform_name=platform_name, quadgraph=quadgraph, change_id=change_id
+        )
+        # Got a platform name, so now cache it
+        self.quad_platform_cache[quadgraph] = platform_name
+
+        return platform
+
+    def parse_message_content(self, msg_content_element):
+        """Takes the content of a JChat message and parses it to a string
+        :param msg_content_element: The XML element with the message in it
+        :ptype msg_content_element: Element
+        :return: The message content text
+        :rtype: String
+        """
+        if not msg_content_element:
+            return None
+        msg_content = " ".join([text for text in msg_content_element[0].itertext()])
+        msg_content = re.sub(" +", " ", msg_content.replace("\n", "").strip())
+        try:
+            cp1252_content = msg_content.encode("cp1252", "ignore").decode("cp1252", "ignore")
+            return cp1252_content
+        except UnicodeError:
+            return None
+
+    @staticmethod
+    def simplify_jchat_html(jchat_html_string):
+        """Strips tags that are common in jchat files but get in the way of parsing
+        :param jchat_html_string: The jchat HTML
+        :ptype jchat_html_string: String
+        :returns: The original string without the awkward tags"""
+        # The (tag to remove, string to replace it with)
+        tags_to_remove = [
+            ("<i>", ""),
+            ("</i>", ""),
+            ("<br>", " "),
+            ("<br/>", " "),
+            ("</br>", ""),  # Avoid double space
+            ('<span class="msgcontent">', ""),
+            ("<span>", ""),
+            ("</span>", ""),
+        ]
+        simple_html_string = jchat_html_string
+        for tag, replacement in tags_to_remove:
+            simple_html_string = simple_html_string.replace(tag, replacement)
+
+        return simple_html_string
diff --git a/migrations/postgres_versions/2021-11-10_2bc85b76a38b_extend_reason_field_to_unlimited_length.py b/migrations/postgres_versions/2021-11-10_2bc85b76a38b_extend_reason_field_to_unlimited_length.py
new file mode 100644
index 000000000..b007c60ab
--- /dev/null
+++ b/migrations/postgres_versions/2021-11-10_2bc85b76a38b_extend_reason_field_to_unlimited_length.py
@@ -0,0 +1,41 @@
+"""Extend reason field to unlimited length
+
+Revision ID: 2bc85b76a38b
+Revises: 40d8ee1342f2
+Create Date: 2021-11-10 12:14:51.368498+00:00
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "2bc85b76a38b"
+down_revision = "40d8ee1342f2"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column(
+        "Changes",
+        "reason",
+        existing_type=sa.VARCHAR(length=500),
+        type_=sa.Text(),
+        existing_nullable=False,
+        schema="pepys",
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.alter_column(
+        "Changes",
+        "reason",
+        existing_type=sa.Text(),
+        type_=sa.VARCHAR(length=500),
+        existing_nullable=False,
+        schema="pepys",
+    )
+    # ### end Alembic commands ###
diff --git a/migrations/sqlite_versions/2021-11-10_cebecb0b77a1_extend_reason_field_to_unlimited_length.py b/migrations/sqlite_versions/2021-11-10_cebecb0b77a1_extend_reason_field_to_unlimited_length.py
new file mode 100644
index 000000000..944ad3c75
--- /dev/null
+++ b/migrations/sqlite_versions/2021-11-10_cebecb0b77a1_extend_reason_field_to_unlimited_length.py
@@ -0,0 +1,34 @@
+"""Extend reason field to unlimited length
+
+Revision ID: cebecb0b77a1
+Revises: e5c78b6abb2f
+Create Date: 2021-11-10 12:15:56.532772+00:00
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "cebecb0b77a1"
+down_revision = "e5c78b6abb2f"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table("Changes", schema=None) as batch_op:
+        batch_op.alter_column(
+            "reason", existing_type=sa.VARCHAR(length=500), type_=sa.Text(), existing_nullable=False
+        )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table("Changes", schema=None) as batch_op:
+        batch_op.alter_column(
+            "reason", existing_type=sa.Text(), type_=sa.VARCHAR(length=500), existing_nullable=False
+        )
+
+    # ### end Alembic commands ###
diff --git a/pepys_admin/maintenance/dialogs/serial_participant_dialog.py b/pepys_admin/maintenance/dialogs/serial_participant_dialog.py
index 2c9371a8e..5981db84d 100644
--- a/pepys_admin/maintenance/dialogs/serial_participant_dialog.py
+++ b/pepys_admin/maintenance/dialogs/serial_participant_dialog.py
@@ -129,6 +129,11 @@ def handle_add(self):
             self.error_label.text = "You must enter a valid end time, or leave it empty"
             return
 
+        if values["start"] is not None and values["end"] is not None:
+            if values["start"] > values["end"]:
+                self.error_label.text = "The end time must be after the start time"
+                return
+
         values["privacy"] = self.privacy_field.text
 
         self.future.set_result(values)
diff --git a/pepys_admin/maintenance/tasks_gui.py b/pepys_admin/maintenance/tasks_gui.py
index ea583125d..ccc89ad08 100644
--- a/pepys_admin/maintenance/tasks_gui.py
+++ b/pepys_admin/maintenance/tasks_gui.py
@@ -261,6 +261,18 @@ def validate_fields(self, current_task, updated_fields):
             self.show_validation_error(missing_fields=missing_fields)
             return False
 
+        if not isinstance(current_task, self.data_store.db_classes.Series):
+            start = updated_fields.get("start", current_task.start)
+            end = updated_fields.get("end", current_task.end)
+
+            if start is not None and end is not None:
+                if start > end:
+                    self.show_messagebox(
+                        "Validation Error",
+                        "The start time must be before the end time",
+                    )
+                    return False
+
         return True
 
     def handle_duplicate(self):
diff --git a/pepys_admin/maintenance/widgets/filter_widget_utils.py b/pepys_admin/maintenance/widgets/filter_widget_utils.py
index b8b0ac4d7..67560f9c9 100644
--- a/pepys_admin/maintenance/widgets/filter_widget_utils.py
+++ b/pepys_admin/maintenance/widgets/filter_widget_utils.py
@@ -2,6 +2,7 @@
 from typing import List
 
 import sqlalchemy
+from sqlalchemy.dialects.postgresql.base import UUID
 from sqlalchemy.sql.expression import cast
 
 from pepys_import.core.store.data_store import DataStore
@@ -122,7 +123,7 @@ def handle_three_variables(class_obj, output, idx, query_list, data_store):
 
     if ops == "LIKE":
         try:
-            if isinstance(col.type, UUIDType):
+            if isinstance(col.type, (UUID, UUIDType)):
                 query_list.append(cast(col, sqlalchemy.String).ilike(f"%{value}%"))
             else:
                 query_list.append(col.ilike(f"%{value}%"))
diff --git a/pepys_admin/snapshot_cli.py b/pepys_admin/snapshot_cli.py
index 8d8c9b11a..f369db9f8 100644
--- a/pepys_admin/snapshot_cli.py
+++ b/pepys_admin/snapshot_cli.py
@@ -8,7 +8,18 @@
 
 from pepys_admin.base_cli import BaseShell
 from pepys_admin.merge import MergeDatabases
-from pepys_admin.snapshot_helpers import export_metadata_tables, export_reference_tables
+from pepys_admin.snapshot_helpers import (
+    _select_wargame,
+    export_all_measurement_tables,
+    export_measurement_tables_filtered_by_location,
+    export_measurement_tables_filtered_by_serial_participation,
+    export_measurement_tables_filtered_by_time,
+    export_measurement_tables_filtered_by_wargame_participation,
+    export_metadata_tables,
+    export_reference_tables,
+    get_number_from_user,
+    get_time_from_user,
+)
 from pepys_admin.utils import database_at_latest_revision, get_default_export_folder
 from pepys_import.core.store.data_store import DataStore
 from pepys_import.core.store.db_status import TableTypes
@@ -25,7 +36,12 @@ class SnapshotShell(BaseShell):
 
     choices = """(1) Create snapshot with Reference data
 (2) Create snapshot with Reference data & Metadata
-(3) Merge databases
+(3) Create snapshot with all data
+(4) Create snapshot with all data, filtered by time
+(5) Create snapshot with all data, filtered by location
+(6) Create snapshot with all data, filtered by wargame
+(7) Create snapshot with all data, filtered by serial
+(8) Merge databases
 (.) Back
 """
     prompt = "(pepys-admin) (snapshot) "
@@ -37,7 +53,12 @@ def __init__(self, data_store):
             ".": self.do_cancel,
             "1": self.do_export_reference_data,
             "2": self.do_export_reference_data_and_metadata,
-            "3": self.do_merge_databases,
+            "3": self.do_export_all_data,
+            "4": self.do_export_all_data_filter_time,
+            "5": self.do_export_all_data_filter_location,
+            "6": self.do_export_all_data_filter_wargame_participation,
+            "7": self.do_export_all_data_filter_serial_participation,
+            "8": self.do_merge_databases,
         }
 
     @staticmethod
@@ -79,7 +100,7 @@ def do_export_reference_data(self):
         destination_store, path = self._create_destination_store()
         reference_table_objects = self.data_store.meta_classes[TableTypes.REFERENCE]
         export_reference_tables(self.data_store, destination_store, reference_table_objects)
-        print(f"Reference tables are successfully exported!\nYou can find it here: '{path}'.")
+        print(f"Reference tables successfully exported!\nYou can find it here: '{path}'.")
 
     def do_export_reference_data_and_metadata(self):
         """Exports reference data and metadata."""
@@ -106,8 +127,121 @@ def do_export_reference_data_and_metadata(self):
         privacy_ids = [privacy_dict[name] for name in selected_privacies]
         export_metadata_tables(self.data_store, destination_store, privacy_ids)
         print(
-            f"Reference and metadata tables are successfully exported!\nYou can find it here: '{path}'."
+            f"Reference and metadata tables successfully exported!\nYou can find it here: '{path}'."
+        )
+
+    def _export_all_ref_and_metadata(self):
+        if is_schema_created(self.data_store.engine, self.data_store.db_type) is False:
+            print("Cannot export data from an empty database")
+            return None, ""
+
+        destination_store, path = self._create_destination_store()
+
+        print("Exporting reference and metadata tables")
+        # Export reference tables
+        reference_table_objects = self.data_store.meta_classes[TableTypes.REFERENCE]
+        export_reference_tables(self.data_store, destination_store, reference_table_objects)
+
+        # Export metadata tables
+        export_metadata_tables(self.data_store, destination_store)
+
+        return destination_store, path
+
+    def do_export_all_data(self):
+        destination_store, path = self._export_all_ref_and_metadata()
+        if destination_store is None:
+            return
+
+        export_all_measurement_tables(self.data_store, destination_store)
+        print(f"Data successfully exported!\nYou can find it here: '{path}'.")
+
+    def do_export_all_data_filter_time(self):
+        start_time = get_time_from_user("Start time")
+        end_time = get_time_from_user("End time")
+
+        destination_store, path = self._export_all_ref_and_metadata()
+        if destination_store is None:
+            return
+
+        export_measurement_tables_filtered_by_time(
+            self.data_store, destination_store, start_time, end_time
+        )
+        print(f"Data successfully exported!\nYou can find it here: '{path}'.")
+
+    def do_export_all_data_filter_location(self):
+        ymin = get_number_from_user("Enter bottom latitude: ")
+        ymax = get_number_from_user("Enter top latitude: ")
+        xmin = get_number_from_user("Enter left longitude: ")
+        xmax = get_number_from_user("Enter right longitude: ")
+
+        destination_store, path = self._export_all_ref_and_metadata()
+        if destination_store is None:
+            return
+
+        export_measurement_tables_filtered_by_location(
+            self.data_store, destination_store, xmin, ymin, xmax, ymax
+        )
+        print(f"Data successfully exported!\nYou can find it here: '{path}'.")
+
+    def do_export_all_data_filter_serial_participation(self):
+        with self.data_store.session_scope():
+            selected_wargame_id = _select_wargame(self.data_store)
+            if selected_wargame_id is None:
+                return
+
+            results = (
+                self.data_store.session.query(
+                    self.data_store.db_classes.Serial.serial_number,
+                    self.data_store.db_classes.Serial.serial_id,
+                )
+                .filter(self.data_store.db_classes.Serial.wargame_id == selected_wargame_id)
+                .all()
+            )
+            serial_dict = dict(results)
+
+            if len(serial_dict) == 0:
+                print("No serials defined in selected wargame")
+                return
+
+            selected_serial_number = iterfzf(serial_dict.keys(), prompt="Select serial: ")
+            selected_serial_id = serial_dict[selected_serial_number]
+
+            selected_serial = (
+                self.data_store.session.query(self.data_store.db_classes.Serial)
+                .filter(self.data_store.db_classes.Serial.serial_id == selected_serial_id)
+                .one()
+            )
+
+        destination_store, path = self._export_all_ref_and_metadata()
+        if destination_store is None:
+            return
+
+        export_measurement_tables_filtered_by_serial_participation(
+            self.data_store, destination_store, selected_serial
+        )
+        print(f"Data successfully exported!\nYou can find it here: '{path}'.")
+
+    def do_export_all_data_filter_wargame_participation(self):
+        with self.data_store.session_scope():
+            selected_wargame_id = _select_wargame(self.data_store)
+
+            if selected_wargame_id is None:
+                return
+
+            selected_wargame = (
+                self.data_store.session.query(self.data_store.db_classes.Wargame)
+                .filter(self.data_store.db_classes.Wargame.wargame_id == selected_wargame_id)
+                .one()
+            )
+
+        destination_store, path = self._export_all_ref_and_metadata()
+        if destination_store is None:
+            return
+
+        export_measurement_tables_filtered_by_wargame_participation(
+            self.data_store, destination_store, selected_wargame
         )
+        print(f"Data successfully exported!\nYou can find it here: '{path}'.")
 
     def do_merge_databases(self):
         file_completer = PathCompleter(expanduser=True)
diff --git a/pepys_admin/snapshot_helpers.py b/pepys_admin/snapshot_helpers.py
index 56687063b..d567bb597 100644
--- a/pepys_admin/snapshot_helpers.py
+++ b/pepys_admin/snapshot_helpers.py
@@ -1,6 +1,14 @@
 import inspect
+from datetime import datetime
+
+from geoalchemy2.elements import WKTElement
+from iterfzf import iterfzf
+from prompt_toolkit.shortcuts import prompt
+from sqlalchemy import or_
+from sqlalchemy.sql.functions import func
 
 from pepys_import.core.store import sqlite_db
+from pepys_import.core.store.db_status import TableTypes
 
 
 def row_to_dict(table_object, data_store):
@@ -40,6 +48,32 @@ def find_sqlite_table_object(table_object, data_store):
         return table_object
 
 
+def get_time_from_user(prompt_text):
+    valid = False
+    while not valid:
+        time_str = prompt(f"{prompt_text} (YYYY-MM-DD HH:MM:SS): ")
+        try:
+            time_obj = datetime.strptime(time_str, "%Y-%m-%d %H:%M:%S")
+            valid = True
+        except ValueError:
+            print("Invalid time entered, please try again")
+
+    return time_obj
+
+
+def get_number_from_user(prompt_text):
+    valid = False
+    while not valid:
+        value = prompt(prompt_text)
+        try:
+            float_value = float(value)
+            valid = True
+        except ValueError:
+            print("Invalid number entered, please try again")
+
+    return float_value
+
+
 def export_reference_tables(source_store, destination_store, table_objects):
     """Copies table objects from :code:`source_store` to :code:`destination_store`.
 
@@ -58,40 +92,47 @@ def export_reference_tables(source_store, destination_store, table_objects):
             destination_store.session.bulk_insert_mappings(object_, dict_values)
 
 
-def export_metadata_tables(source_store, destination_store, privacy_ids):
-    """Copies :code:`Platform`, :code:`Sensor` and :code:`Synonym` objects from
+def export_metadata_tables(source_store, destination_store, privacy_ids=None):
+    """Copies :code:`Platform`, :code:`Sensor`, :code:`Datafile` and :code:`Synonym` objects from
     :code:`source_store` to :code:`destination_store`.
 
     :param source_store: A :class:`DataStore` object to fetch objects
     :type source_store: DataStore
     :param destination_store: A :class:`DataStore` object to copy the objects from source_store
     :type destination_store: DataStore
-    :param privacy_ids: A list of Privacy ID's which is used to filter Platform and Sensor objects
+    :param privacy_ids: A list of Privacy ID's which is used to filter objects
     :type privacy_ids: List
     :return:
     """
     for table_object in [
         source_store.db_classes.Platform,
         source_store.db_classes.Sensor,
+        source_store.db_classes.Datafile,
         source_store.db_classes.Synonym,
     ]:
         with source_store.session_scope():
             dict_values = list()
             if table_object.__name__ == "Platform":
-                values = (
-                    source_store.session.query(table_object)
-                    .filter(table_object.privacy_id.in_(privacy_ids))
-                    .all()
-                )
+                query = source_store.session.query(table_object)
+                if privacy_ids is not None:
+                    query = query.filter(table_object.privacy_id.in_(privacy_ids))
+                values = query.all()
+
                 platform_ids = [row.platform_id for row in values]
             elif table_object.__name__ == "Sensor":
-                values = (
-                    source_store.session.query(table_object)
-                    .filter(table_object.host.in_(platform_ids))
-                    .filter(table_object.privacy_id.in_(privacy_ids))
-                    .all()
+                query = source_store.session.query(table_object).filter(
+                    table_object.host.in_(platform_ids)
                 )
+                if privacy_ids is not None:
+                    query = query.filter(table_object.privacy_id.in_(privacy_ids))
+                values = query.all()
                 sensor_ids = [row.sensor_id for row in values]
+            elif table_object.__name__ == "Datafile":
+                query = source_store.session.query(table_object)
+                if privacy_ids is not None:
+                    query = query.filter(table_object.privacy_id.in_(privacy_ids))
+
+                values = query.all()
             else:
                 all_ids = list()
                 all_ids.extend(platform_ids)
@@ -108,3 +149,294 @@ def export_metadata_tables(source_store, destination_store, privacy_ids):
             object_ = find_sqlite_table_object(table_object, source_store)
             with destination_store.session_scope():
                 destination_store.session.bulk_insert_mappings(object_, dict_values)
+
+
+def export_all_measurement_tables(source_store, destination_store):
+    measurement_table_objects = source_store.meta_classes[TableTypes.MEASUREMENT]
+
+    with source_store.session_scope():
+        with destination_store.session_scope():
+            for table in measurement_table_objects:
+                export_measurement_table_with_filter(source_store, destination_store, table)
+
+
+def export_measurement_table_with_filter(source_store, destination_store, table, filter=None):
+    if isinstance(table, str):
+        table_object = getattr(source_store.db_classes, table)
+    else:
+        table_object = table
+
+    dict_values = []
+    query = source_store.session.query(table_object)
+
+    if filter is not None:
+        query = filter(table_object, query)
+
+    results = query.all()
+
+    if len(results) == 0:
+        return
+
+    data_attributes = []
+    for col in table_object.__table__.columns:
+        prop = getattr(table_object, col.name).property
+        data_attributes.append(prop.key)
+
+    for row in results:
+        d = {}
+        for attrib in data_attributes:
+            d[attrib] = getattr(row, attrib)
+        dict_values.append(d)
+
+    object_ = find_sqlite_table_object(table, source_store)
+    with destination_store.session_scope():
+        destination_store.session.bulk_insert_mappings(object_, dict_values)
+
+    print(f"Exported {len(results)} entries from {table.__name__}")
+
+
+def export_measurement_tables_filtered_by_time(
+    source_store, destination_store, start_time, end_time
+):
+    def time_attribute_filter(table_object, query):
+        """Filters on the time attribute of a table, so works for State, Contact, LogsHolding and Comment"""
+        query = query.filter(table_object.time >= start_time)
+        query = query.filter(table_object.time <= end_time)
+
+        return query
+
+    def start_end_attribute_filter(table_object, query):
+        return _start_end_filter(table_object, query, start_time, end_time)
+
+    with source_store.session_scope():
+        with destination_store.session_scope():
+            tables_with_time_attribute = [
+                source_store.db_classes.State,
+                source_store.db_classes.Contact,
+                source_store.db_classes.Comment,
+                source_store.db_classes.LogsHolding,
+                source_store.db_classes.Media,
+            ]
+            for table in tables_with_time_attribute:
+                export_measurement_table_with_filter(
+                    source_store, destination_store, table, time_attribute_filter
+                )
+
+            tables_with_start_end_attributes = [
+                source_store.db_classes.Activation,
+                source_store.db_classes.Geometry1,
+            ]
+            for table in tables_with_start_end_attributes:
+                export_measurement_table_with_filter(
+                    source_store,
+                    destination_store,
+                    table,
+                    start_end_attribute_filter,
+                )
+
+
+def export_measurement_tables_filtered_by_location(
+    source_store, destination_store, xmin, ymin, xmax, ymax
+):
+    def location_attribute_filter(table_object, query):
+        # Note: We can't use the ST_MakeEnvelope function, as it's not supported by spatialite
+        # so we have to create the WKT polygon manually. This is only done once for the filter
+        # so it shouldn't have an efficiency impact
+        wkt = f"POLYGON(({xmin} {ymin},{xmin} {ymax},{xmax} {ymax},{xmax} {ymin},{xmin} {ymin}))"
+        query = query.filter(
+            func.ST_Within(
+                table_object.location,
+                WKTElement(wkt, srid=4326),
+            )
+        )
+        return query
+
+    def geometry_attribute_filter(table_object, query):
+        # Note: We can't use the ST_MakeEnvelope function, as it's not supported by spatialite
+        # so we have to create the WKT polygon manually. This is only done once for the filter
+        # so it shouldn't have an efficiency impact
+        wkt = f"POLYGON(({xmin} {ymin},{xmin} {ymax},{xmax} {ymax},{xmax} {ymin},{xmin} {ymin}))"
+        query = query.filter(
+            func.ST_Within(
+                table_object.geometry,
+                WKTElement(wkt, srid=4326),
+            )
+        )
+        return query
+
+    with source_store.session_scope():
+        with destination_store.session_scope():
+            tables_with_location = [
+                source_store.db_classes.State,
+                source_store.db_classes.Contact,
+                source_store.db_classes.Media,
+            ]
+
+            for table in tables_with_location:
+                export_measurement_table_with_filter(
+                    source_store, destination_store, table, location_attribute_filter
+                )
+
+            export_measurement_table_with_filter(
+                source_store,
+                destination_store,
+                source_store.db_classes.Geometry1,
+                geometry_attribute_filter,
+            )
+
+
+def export_measurement_tables_filtered_by_wargame_participation(
+    source_store, destination_store, selected_wargame
+):
+    def wargame_participation_filter_with_time(table_object, query):
+        # Note: We can't use the ST_MakeEnvelope function, as it's not supported by spatialite
+        # so we have to create the WKT polygon manually. This is only done once for the filter
+        # so it shouldn't have an efficiency impact
+        wargame_start = selected_wargame.start
+        wargame_end = selected_wargame.end
+        query = query.filter(
+            source_store.db_classes.Platform.wargame_participations_objects.contains(
+                selected_wargame
+            ),
+            table_object.time >= wargame_start,
+            table_object.time <= wargame_end,
+        )
+        return query
+
+    def wargame_participation_filter_with_start_end(table_object, query):
+        wargame_start = selected_wargame.start
+        wargame_end = selected_wargame.end
+        query = query.filter(
+            source_store.db_classes.Platform.wargame_participations_objects.contains(
+                selected_wargame
+            )
+        )
+        query = _start_end_filter(table_object, query, wargame_start, wargame_end)
+
+        return query
+
+    with source_store.session_scope():
+        with destination_store.session_scope():
+            tables_with_time = [
+                source_store.db_classes.State,
+                source_store.db_classes.Contact,
+                source_store.db_classes.Comment,
+            ]
+
+            for table in tables_with_time:
+                export_measurement_table_with_filter(
+                    source_store, destination_store, table, wargame_participation_filter_with_time
+                )
+
+            export_measurement_table_with_filter(
+                source_store,
+                destination_store,
+                source_store.db_classes.Activation,
+                wargame_participation_filter_with_start_end,
+            )
+
+
+def export_measurement_tables_filtered_by_serial_participation(
+    source_store, destination_store, selected_serial
+):
+    with source_store.session_scope():
+        with destination_store.session_scope():
+            # Get a list of all Platforms taking part in the serial, and their participation times
+            participants = source_store.session.query(
+                source_store.db_classes.SerialParticipant
+            ).filter(
+                source_store.db_classes.SerialParticipant.serial_id == selected_serial.serial_id
+            )
+
+            for participant in participants:
+                platform_id = participant.platform.platform_id
+                start_time = (
+                    participant.start if participant.start is not None else selected_serial.start
+                )
+                end_time = participant.end if participant.end is not None else selected_serial.end
+
+                print(f"{platform_id}: {start_time}, {end_time}")
+
+                def filter_function_time(table_object, query):
+                    query = query.filter(
+                        table_object.platform_id == platform_id,
+                        table_object.time >= start_time,
+                        table_object.time <= end_time,
+                    )
+                    return query
+
+                def filter_function_start_end(table_object, query):
+                    query = query.filter(table_object.platform_id == platform_id)
+                    query = _start_end_filter(table_object, query, start_time, end_time)
+                    return query
+
+                tables_with_time = [
+                    source_store.db_classes.State,
+                    source_store.db_classes.Contact,
+                    source_store.db_classes.Comment,
+                ]
+
+                for table in tables_with_time:
+                    export_measurement_table_with_filter(
+                        source_store, destination_store, table, filter_function_time
+                    )
+
+                export_measurement_table_with_filter(
+                    source_store,
+                    destination_store,
+                    source_store.db_classes.Activation,
+                    filter_function_start_end,
+                )
+
+
+def _start_end_filter(table_object, query, start_time, end_time):
+    query = query.filter(
+        or_(
+            (  # Deal with entries where start is missing, so just do a standard 'between' search on end
+                (table_object.start == None)  # noqa
+                & (table_object.end >= start_time)
+                & (table_object.end <= end_time)
+            ),
+            (  # Deal with entries where end is missing, so just do a standard 'between' search on start
+                (table_object.end == None)  # noqa
+                & (table_object.start >= start_time)
+                & (table_object.start <= end_time)
+            ),
+            (  # Deal with entries where we have both start and end, and want to test all overlap possibilities
+                (table_object.start != None)
+                & (table_object.end != None)
+                & (
+                    ((table_object.start >= start_time) & (table_object.end <= end_time))
+                    | (
+                        (table_object.start >= start_time)
+                        & (table_object.start <= end_time)
+                        & (table_object.end >= end_time)
+                    )
+                    | (
+                        (table_object.start <= start_time)
+                        & (table_object.end <= end_time)
+                        & (table_object.end >= start_time)
+                    )
+                    | ((table_object.start <= start_time) & (table_object.end >= end_time))
+                )
+            ),
+        )
+    )
+    return query
+
+
+def _select_wargame(data_store):
+    results = data_store.session.query(
+        data_store.db_classes.Wargame.name,
+        data_store.db_classes.Wargame.wargame_id,
+    ).all()
+    wargame_dict = dict(results)
+
+    if len(wargame_dict) == 0:
+        print("No wargames defined")
+        return None
+
+    selected_wargame_name = iterfzf(wargame_dict.keys(), prompt="Select wargame: ")
+    selected_wargame_id = wargame_dict[selected_wargame_name]
+
+    return selected_wargame_id
diff --git a/pepys_admin/utils.py b/pepys_admin/utils.py
index 476890db0..7253c09cc 100644
--- a/pepys_admin/utils.py
+++ b/pepys_admin/utils.py
@@ -187,23 +187,29 @@ def database_at_latest_revision(db_path):
         )
         return False
 
+    versions = read_latest_revisions_file()
+    if len(versions) == 0:
+        return False
+
+    if slave_version == versions["LATEST_SQLITE_VERSION"]:
+        return True
+    return False
+
+
+def read_latest_revisions_file():
     try:
         with open(os.path.join(MIGRATIONS_DIRECTORY, "latest_revisions.json"), "r") as file:
             versions = json.load(file)
     except Exception:
         custom_print_formatted_text(format_error_message("Could not find latest_revisions.json"))
-        return
+        return {}
 
     if "LATEST_SQLITE_VERSION" not in versions:
         custom_print_formatted_text(
             format_error_message("Latest revision IDs couldn't found from latest_revisions.json")
         )
-        return
-
-    if slave_version == versions["LATEST_SQLITE_VERSION"]:
-        return True
-    else:
-        return False
+        return {}
+    return versions
 
 
 class StdoutAndFileWriter:
diff --git a/pepys_import/__init__.py b/pepys_import/__init__.py
index b36c93213..885f91e27 100644
--- a/pepys_import/__init__.py
+++ b/pepys_import/__init__.py
@@ -4,5 +4,5 @@
 
 __author__ = "Ian Mayo"
 __email__ = "ian@planetmayo.com"
-__version__ = "0.0.31"
+__version__ = "0.0.33"
 __build_timestamp__ = None
diff --git a/pepys_import/cli.py b/pepys_import/cli.py
index fd81d2cc9..ec2dd32bb 100644
--- a/pepys_import/cli.py
+++ b/pepys_import/cli.py
@@ -111,6 +111,7 @@ def process(
             db_name=config.DB_NAME,
             db_type=config.DB_TYPE,
             missing_data_resolver=resolver_obj,
+            error_on_db_version_mismatch=True,
         )
     elif type(db) is dict:
         data_store = DataStore(
@@ -121,6 +122,7 @@ def process(
             db_name=db["name"],
             db_type=db["type"],
             missing_data_resolver=resolver_obj,
+            error_on_db_version_mismatch=True,
         )
     else:
         data_store = DataStore(
@@ -131,6 +133,7 @@ def process(
             db_name=db,
             db_type="sqlite",
             missing_data_resolver=resolver_obj,
+            error_on_db_version_mismatch=True,
         )
     if not is_schema_created(data_store.engine, data_store.db_type):
         # The number of tables don't match the expected number of tables, so check
diff --git a/pepys_import/core/store/common_db.py b/pepys_import/core/store/common_db.py
index 7a8d1e70e..8f9d72bfe 100644
--- a/pepys_import/core/store/common_db.py
+++ b/pepys_import/core/store/common_db.py
@@ -1,3 +1,5 @@
+import uuid
+
 from prompt_toolkit import prompt
 from prompt_toolkit.validation import Validator
 from sqlalchemy.ext.associationproxy import association_proxy
@@ -15,7 +17,7 @@
 from pepys_import.core.validators.enhanced_validator import EnhancedValidator
 from pepys_import.utils.data_store_utils import chunked_list, shorten_uuid
 from pepys_import.utils.import_utils import import_validators
-from pepys_import.utils.sqlalchemy_utils import get_primary_key_for_table
+from pepys_import.utils.sqlalchemy_utils import get_lowest_privacy, get_primary_key_for_table
 from pepys_import.utils.text_formatting_utils import format_error_menu
 
 LOCAL_BASIC_VALIDATORS = []
@@ -159,6 +161,10 @@ class PlatformMixin:
     def wargame_participations(self):
         return association_proxy("participations", "wargame_name")
 
+    @declared_attr
+    def wargame_participations_objects(self):
+        return association_proxy("participations", "wargame")
+
     @declared_attr
     def platform_type(self):
         return relationship("PlatformType", lazy="joined", innerjoin=True, uselist=False)
@@ -221,6 +227,41 @@ def get_sensor(
         sensor_type_obj = data_store.search_sensor_type(sensor_type)
         privacy_obj = data_store.search_privacy(privacy)
         if sensor_type_obj is None or privacy_obj is None:
+            # We don't have access to the platform type attribute on self
+            # as it has been expunged by now, so query the database and check it
+            platform = (
+                data_store.session.query(data_store.db_classes.Platform)
+                .filter(data_store.db_classes.Platform.platform_id == self.platform_id)
+                .one()
+            )
+            platform_type_name = platform.platform_type_name
+            if platform_type_name == "Unknown":
+                # If we're dealing with an unknown Platform, then don't ask the user for
+                # sensor details, just create them with whatever information we've got
+                # and use UUIDs/Unknown for the missing bits
+                if sensor_name is None:
+                    sensor_name = str(uuid.uuid4())
+
+                if sensor_type_obj is None:
+                    sensor_type = "Unknown"
+                else:
+                    sensor_type = sensor_type_obj.name
+
+                if privacy_obj is None:
+                    privacy = get_lowest_privacy(data_store)
+                else:
+                    privacy = privacy_obj.name
+
+                return data_store.add_to_sensors(
+                    name=sensor_name,
+                    sensor_type=sensor_type,
+                    host_name=None,
+                    host_nationality=None,
+                    host_identifier=None,
+                    host_id=self.platform_id,
+                    privacy=privacy,
+                    change_id=change_id,
+                )
             resolved_data = data_store.missing_data_resolver.resolve_sensor(
                 data_store, sensor_name, sensor_type, self.platform_id, privacy, change_id
             )
@@ -1892,6 +1933,10 @@ def sensor_name(self):
     def source(self):
         return relationship("Datafile", lazy="joined", uselist=False)
 
+    @declared_attr
+    def platform_id(self):
+        return association_proxy("sensor", "host")
+
     # @declared_attr
     # def source_reference(self):
     #     return association_proxy("source", "reference")
diff --git a/pepys_import/core/store/data_store.py b/pepys_import/core/store/data_store.py
index 2f70428d5..fc639178a 100644
--- a/pepys_import/core/store/data_store.py
+++ b/pepys_import/core/store/data_store.py
@@ -1,6 +1,7 @@
 import csv
 import os
 import sys
+import uuid
 from contextlib import contextmanager
 from datetime import datetime
 from getpass import getuser
@@ -17,6 +18,7 @@
 from sqlalchemy_utils import dependent_objects, get_referencing_foreign_keys, merge_references
 
 from paths import MIGRATIONS_DIRECTORY, PEPYS_IMPORT_DIRECTORY
+from pepys_admin.utils import read_latest_revisions_file
 from pepys_import import __build_timestamp__, __version__
 from pepys_import.core.formats import unit_registry
 from pepys_import.core.formats.location import Location
@@ -39,7 +41,11 @@
 from pepys_import.utils.value_transforming_utils import format_datetime
 
 from ...utils.error_handling import handle_database_errors, handle_first_connection_error
-from ...utils.sqlalchemy_utils import get_primary_key_for_table, sqlalchemy_object_to_json
+from ...utils.sqlalchemy_utils import (
+    get_lowest_privacy,
+    get_primary_key_for_table,
+    sqlalchemy_object_to_json,
+)
 from ...utils.table_name_utils import table_name_to_class_name
 from ...utils.text_formatting_utils import custom_print_formatted_text, format_error_message
 from .db_base import BasePostGIS, BaseSpatiaLite
@@ -80,11 +86,13 @@ def __init__(
         missing_data_resolver=DefaultResolver(),
         welcome_text="Pepys_import",
         show_status=True,
+        error_on_db_version_mismatch=False,
     ):
 
         self.missing_data_resolver = missing_data_resolver
         self.welcome_text = welcome_text
         self.show_status = show_status
+        self.error_on_db_version_mismatch = error_on_db_version_mismatch
 
         # TEMP list of values for defaulted IDs, to be replaced by missing info lookup mechanism
         self.default_user_id = 1  # DevUser
@@ -143,6 +151,8 @@ def __init__(
         self.meta_classes = {}
         self.setup_table_type_mapping()
 
+        self.latest_revisions = read_latest_revisions_file()
+
         if db_name == ":memory:":
             self.in_memory_database = True
         else:
@@ -210,9 +220,9 @@ def __init__(
             show_software_meta_info(
                 __version__, __build_timestamp__, self.db_type, self.db_name, db_host
             )
-            # The 'pepys-import' banner is 61 characters wide, so making a line
+            # The 'pepys-import' banner is 78 characters wide, so making a line
             # of the same length makes things prettier
-            print("-" * 61)
+            print("-" * 78)
 
     def initialise(self):
         """Create schemas for the database"""
@@ -365,7 +375,23 @@ def check_migration_version(self, revision_list):
 
                 # Database has been found and is the correct length
                 if table_contents[0][0] in revision_list:
-                    # The returned contents is found in our list of ID's so we can return out of the function and carry on
+                    if self.db_type == "sqlite":
+                        key_name = "LATEST_SQLITE_VERSION"
+                    else:
+                        key_name = "LATEST_POSTGRES_VERSION"
+
+                    if table_contents[0][0] != self.latest_revisions[key_name]:
+                        if self.error_on_db_version_mismatch:
+                            print(
+                                "ERROR: The database is not at the latest revision. Please ask an administrator to run the database migration."
+                            )
+                            sys.exit(1)
+                        else:
+                            print("*" * 78)
+                            print(
+                                "WARNING: The database is not at the latest revision.\nPlease ask an administrator to run the database migration."
+                            )
+                            print("*" * 78)
                     return
                 else:
                     # The returned contents is not found in our list of ID's so we need to have an error
@@ -966,6 +992,7 @@ def get_platform(
         trigraph=None,
         quadgraph=None,
         change_id=None,
+        unknown=False,
     ):
         """
         Adds an entry to the platforms table for the specified platform
@@ -987,8 +1014,47 @@ def get_platform(
         :type identifier: String
         :param change_id: ID of the :class:`Change` object
         :type change_id: Integer or UUID
+        :param unknown: Whether to create this as an 'unknown' Platform, which won't ask
+                        the user to resolve details, and will assign Unknown nationality
+                        and Platform Types
+        :type unknown: Boolean
         :return: Created Platform entity
         """
+        if unknown:
+            # The importer has specifically said it wants to get a 'unknown platform' (ie. one with an unknown
+            # nationality and platform type) back from this call.
+            if platform_name is not None:
+                # The importer has provided a name, which we assume means they think this name is unique
+                # (like an IMO ID), so we search for this name and return it if it exists
+                # We'll use the first three characters of the platform name as the identifier - as this will have been used
+                # when the unknown platform was automatically created
+                platform = self.find_platform(
+                    name=platform_name, identifier=platform_name, nationality="Unknown"
+                )
+                # If we found a platform with that name etc, then return it
+                if platform:
+                    return platform
+            else:
+                # If we haven't been given a name, make up a UUID name
+                platform_name = str(uuid.uuid4())
+
+            chosen_platform_type = self.add_to_platform_types(
+                "Unknown", change_id, default_data_interval_secs=60
+            )
+
+            chosen_nationality = self.add_to_nationalities("Unknown", change_id)
+
+            lowest_privacy = get_lowest_privacy(self)
+            return self.add_to_platforms(
+                name=platform_name,
+                trigraph=platform_name[:3],
+                quadgraph=platform_name[:4],
+                identifier=platform_name,
+                nationality=chosen_nationality.name,
+                platform_type=chosen_platform_type.name,
+                privacy=lowest_privacy,
+                change_id=change_id,
+            )
 
         # Check for name match in existing Platforms
         # If identifier and nationality are None then this just searches synonyms
@@ -1012,7 +1078,14 @@ def get_platform(
             or privacy_obj is None
         ):
             resolved_data = self.missing_data_resolver.resolve_platform(
-                self, platform_name, identifier, platform_type, nationality, privacy, change_id
+                self,
+                platform_name,
+                identifier,
+                platform_type,
+                nationality,
+                privacy,
+                change_id,
+                quadgraph,
             )
             # It means that new platform added as a synonym and existing platform returned
             if isinstance(resolved_data, self.db_classes.Platform):
@@ -1047,6 +1120,17 @@ def get_platform(
             change_id=change_id,
         )
 
+    def get_platform_name_from_quad(self, quadgraph):
+        platform = (
+            self.session.query(self.db_classes.Platform)
+            .filter(func.lower(self.db_classes.Platform.quadgraph) == lowercase_or_none(quadgraph))
+            .first()
+        )
+
+        if platform is not None:
+            return platform.name
+        return None
+
     def get_status(
         self,
         table_type,
@@ -2492,3 +2576,19 @@ def export_objects_to_csv(
             writer.writerow(headers)
             writer.writerows(entries)
             file.close()
+
+    def ask_for_missing_info(
+        self, question, default_value, min_value=None, max_value=None, allow_empty=False
+    ):
+        """Requests any missing information that isn't stored in the database
+        e.g. missing date/time info
+        :param question: The question to ask the resolver
+        :param default_value: The default value to use if unresolved
+        :param min_value: The minimum value allowed for the result, optional
+        :param max_value: The maximum value allowed for the result, optional
+        :param allow_empty: Whether to allow an empty input (for accepting defaults), optional
+        :return: The missing information requested
+        """
+        return self.missing_data_resolver.resolve_missing_info(
+            question, default_value, min_value, max_value, allow_empty
+        )
diff --git a/pepys_import/core/store/postgres_db.py b/pepys_import/core/store/postgres_db.py
index 3bc38f51b..36f50a4e6 100644
--- a/pepys_import/core/store/postgres_db.py
+++ b/pepys_import/core/store/postgres_db.py
@@ -350,7 +350,7 @@ class Change(BasePostGIS):
     )
     modified = Column(DATE, nullable=False)
     reason = Column(
-        String(500), CheckConstraint("reason <> ''", name="ck_Changes_reason"), nullable=False
+        Text(), CheckConstraint("reason <> ''", name="ck_Changes_reason"), nullable=False
     )
     datafile_id = Column(
         UUID(as_uuid=True),
diff --git a/pepys_import/core/store/sqlite_db.py b/pepys_import/core/store/sqlite_db.py
index c96c15cc0..0c01d9d02 100644
--- a/pepys_import/core/store/sqlite_db.py
+++ b/pepys_import/core/store/sqlite_db.py
@@ -352,7 +352,7 @@ class Change(BaseSpatiaLite):
     )
     modified = Column(DATE, nullable=False)
     reason = Column(
-        String(500), CheckConstraint("reason <> ''", name="ck_Changes_reason"), nullable=False
+        Text(), CheckConstraint("reason <> ''", name="ck_Changes_reason"), nullable=False
     )
     datafile_id = Column(
         UUIDType, ForeignKey("Datafiles.datafile_id", onupdate="cascade", ondelete="SET NULL")
diff --git a/pepys_import/database/default_data/Help Texts.csv b/pepys_import/database/default_data/Help Texts.csv
index e1b65c664..742162f8d 100644
--- a/pepys_import/database/default_data/Help Texts.csv
+++ b/pepys_import/database/default_data/Help Texts.csv
@@ -2,7 +2,7 @@ id,guidance
 RESOLVE_DATAFILE_TYPE,"The category of datafile. These categories are steadily maturing, but please see an expert user if the relevant category isn't present."
 RESOLVE_PRIVACY,"The classification of the object. You can search for a classification if it's not listed. If it's not found, please see an expert user if the relevant classification isn't present."
 RESOLVE_DATAFILE,"The Datafile Resolver. It asks for the classification of the datafile and saves the datafile to the database."
-RESOLVE_PLATFORM,"The Platform Resolver. Allows the user to select a platform from existing ones in the database or to add a new platform."
+RESOLVE_PLATFORM,"The Platform Resolver. Allows the user to select a platform from existing ones in the database or to add a new platform.  Options are provided to skip the provision of supporting metadata for platforms when necessary."
 RESOLVE_SENSOR,"The Sensor Resolver. Allows the user to select a sensor from existing ones in the database or to add a new sensor ."
 FUZZY_SEARCH_PLATFORM,"Search platform by its name, identifier, or nationality. If entered name is not found, you will be redirected to adding a new platform prompt."
 ADD_TO_PLATFORMS,"Add a new platform by typing platform name, identifier, trigraph, quadgraph; choosing nationality, platform type, and classification respectively."
diff --git a/pepys_import/database/default_data/Nationalities.csv b/pepys_import/database/default_data/Nationalities.csv
index 6a5b72711..0292f43d4 100644
--- a/pepys_import/database/default_data/Nationalities.csv
+++ b/pepys_import/database/default_data/Nationalities.csv
@@ -254,4 +254,5 @@ Wallis and Futuna,
 Western Sahara,
 Yemen,
 Zambia,
-Zimbabwe,
\ No newline at end of file
+Zimbabwe,
+Unknown,
\ No newline at end of file
diff --git a/pepys_import/database/default_data/Platform Types.csv b/pepys_import/database/default_data/Platform Types.csv
index 7ba0740eb..2639c1552 100644
--- a/pepys_import/database/default_data/Platform Types.csv
+++ b/pepys_import/database/default_data/Platform Types.csv
@@ -1,20 +1,35 @@
 name,default_data_interval_secs
-Naval - aircraft,2
-Naval - frigate,30
-Naval - auxiliary,60
+Attack Craft,30
+Commercial aircraft,2
+Fishing Vessel,60
+High Speed Craft,30
+Law Enforcement,60
+Medical,60
+Merchant,60
+Naval - aircraft carrier,30
+Naval - amphib,30
+Naval - auxiliary,30
+Naval - cruiser,30
 Naval - destroyer,30
-Naval - survey,60
-Naval - minesweeper,60
+Naval - ExCon,30
+Naval - fixed wing,2
+Naval - frigate,30
+Naval - minewarfare,30
+Naval - miscellaneous,30
 Naval - patrol,30
-Naval - aircraft carrier,30
+Naval - rotary wing,5
 Naval - submarine,30
-Naval - miscellaneous,30
-Merchant,60
-Tug,60
-Tanker,60
-Law Enforcement,30
+Naval - survey,30
+Passenger vessel,60
 Pleasure Craft,30
-Search and Rescue,30
-Fishing Vessel,60
-Passenger/Ferry,60
-High Speed Craft,30
\ No newline at end of file
+Research vessel,60
+Sailing vessel,60
+Search and Rescue,60
+Offshore support,60
+Civilian - uncrewed surface vehicle,30
+Civilian - uncrewed underwater vehicle,30
+Civilian - uncrewed air vehicle,5
+Naval - uncrewed surface vehicle,30
+Naval - uncrewed underwater vehicle,30
+Naval - uncrewed air vehicle,5
+Unknown,60
\ No newline at end of file
diff --git a/pepys_import/database/default_data/Platforms.csv b/pepys_import/database/default_data/Platforms.csv
index 61608ec9a..2048a65f6 100644
--- a/pepys_import/database/default_data/Platforms.csv
+++ b/pepys_import/database/default_data/Platforms.csv
@@ -2,4 +2,4 @@ name,identifier,nationality,platform_type,privacy
 ADRI,A643,United Kingdom,Naval - frigate,Public
 JEAN,A816,United Kingdom,Naval - frigate,Public
 NARV,C045,United Kingdom,Naval - patrol,Public
-SPAR,P543,France,Tug,Public
+SPAR,P543,France,Offshore support,Public
diff --git a/pepys_import/database/default_data/Sensor Types.csv b/pepys_import/database/default_data/Sensor Types.csv
index 4d962ecb6..776159b66 100644
--- a/pepys_import/database/default_data/Sensor Types.csv
+++ b/pepys_import/database/default_data/Sensor Types.csv
@@ -10,3 +10,4 @@ Broadcast
 Location-Satellite
 Location-Inertial
 Location-Dead-reckoning
+Unknown
\ No newline at end of file
diff --git a/pepys_import/file/file_processor.py b/pepys_import/file/file_processor.py
index 3886c4fca..060775a4d 100644
--- a/pepys_import/file/file_processor.py
+++ b/pepys_import/file/file_processor.py
@@ -382,6 +382,8 @@ def process_file(
                 errors.extend(importer.errors)
                 errors.extend(validation_errors)
 
+            data_store.missing_data_resolver.reset_per_file_settings()
+
             # If all tests pass for all parsers, commit datafile
             if not errors:
                 # Keep track of some details for the import summary
diff --git a/pepys_import/file/highlighter/highlighter.py b/pepys_import/file/highlighter/highlighter.py
index 0f0db7f58..2cfd2e171 100644
--- a/pepys_import/file/highlighter/highlighter.py
+++ b/pepys_import/file/highlighter/highlighter.py
@@ -30,6 +30,15 @@ def __init__(self, filename: str, number_of_lines=None, datafile=None):
 
         self.importer_highlighting_levels = {}
 
+    def reinitialise(self, filename, datafile):
+        """Re-initialise the highlighted file object, to set a new filename as the source
+        of data to be highlighted.
+
+        Designed to be called with a new filename from within `_load_this_file` in an importer
+        if the original file needs transforming before being processed and highlighted."""
+        self.__init__(filename=filename, datafile=datafile)
+        self.datafile = datafile
+
     def chars_debug(self):
         """
         Debug method, to check contents of chars
diff --git a/pepys_import/file/importer.py b/pepys_import/file/importer.py
index c54fdbe75..aba8b811f 100644
--- a/pepys_import/file/importer.py
+++ b/pepys_import/file/importer.py
@@ -1,6 +1,7 @@
 import os
 from abc import ABC, abstractmethod
 
+import sqlalchemy
 from tqdm import tqdm
 
 from pepys_import.file.highlighter.level import HighlightLevel
@@ -197,7 +198,11 @@ def get_cached_sensor(self, data_store, sensor_name, sensor_type, platform_id, c
                 change_id=change_id,
             )
 
-            data_store.session.expunge(resolved_sensor)
+            try:
+                data_store.session.expunge(resolved_sensor)
+            except sqlalchemy.exc.InvalidRequestError:
+                # If it's already expunged then don't do it again
+                pass
             # And store it in the cache for next time
             self.platform_sensor_mapping[platform_id] = resolved_sensor
         else:
@@ -217,7 +222,9 @@ def get_cached_sensor(self, data_store, sensor_name, sensor_type, platform_id, c
 
         return resolved_sensor
 
-    def get_cached_platform(self, data_store, platform_name, change_id):
+    def get_cached_platform(
+        self, data_store, platform_name, change_id, quadgraph=None, unknown=False
+    ):
         if platform_name:
             # Look for this name in the cache first
             platform_from_cache = self.platform_cache.get(platform_name)
@@ -226,7 +233,7 @@ def get_cached_platform(self, data_store, platform_name, change_id):
 
         # Otherwise use the resolver to find it
         resolved_platform = data_store.get_platform(
-            platform_name=platform_name, change_id=change_id
+            platform_name=platform_name, quadgraph=quadgraph, change_id=change_id, unknown=unknown
         )
 
         # Put in the cache
diff --git a/pepys_import/resolvers/command_line_resolver.py b/pepys_import/resolvers/command_line_resolver.py
index 87cea062b..edfc20c93 100644
--- a/pepys_import/resolvers/command_line_resolver.py
+++ b/pepys_import/resolvers/command_line_resolver.py
@@ -1,13 +1,15 @@
 import sys
+import uuid
 
 from prompt_toolkit import prompt
-from prompt_toolkit.validation import Validator
+from prompt_toolkit.validation import ValidationError, Validator
 from tabulate import tabulate
 
 from pepys_import.core.store.constants import NATIONALITY, PLATFORM, PRIVACY
 from pepys_import.resolvers import constants
 from pepys_import.resolvers.command_line_input import create_menu, get_fuzzy_completer, is_valid
 from pepys_import.resolvers.data_resolver import DataResolver
+from pepys_import.utils.sqlalchemy_utils import get_lowest_privacy
 from pepys_import.utils.text_formatting_utils import (
     custom_print_formatted_text,
     format_command,
@@ -27,6 +29,9 @@ def is_number(text):
 
 
 class CommandLineResolver(DataResolver):
+    def __init__(self):
+        self.store_all_platforms_as_unknown = False
+
     def resolve_datafile(self, data_store, datafile_name, datafile_type, privacy, change_id):
         """
         This method resolves datafile type and privacy. It asks user whether to create
@@ -120,11 +125,27 @@ def resolve_datafile(self, data_store, datafile_name, datafile_type, privacy, ch
             sys.exit(1)
 
     def resolve_platform(
-        self, data_store, platform_name, identifier, platform_type, nationality, privacy, change_id
+        self,
+        data_store,
+        platform_name,
+        identifier,
+        platform_type,
+        nationality,
+        privacy,
+        change_id,
+        quadgraph=None,
     ):
+        if self.store_all_platforms_as_unknown:
+            return self.add_unknown_platform(data_store, platform_name, change_id)
+
         print_new_section_title("Resolve Platform")
         platform_details = []
-        final_options = ["Add a new platform", "Search for existing platform"]
+        final_options = [
+            "Add a new platform",
+            "Search for existing platform",
+            "Store as Unknown platform",
+            "Store remaining platforms in this datafile as Unknown",
+        ]
         if platform_name:
             # If we've got a platform_name, then we can search for all platforms
             # with this name, and present a list to the user to choose from,
@@ -192,11 +213,43 @@ def is_valid_dynamic(option):  # pragma: no cover
                 privacy,
                 change_id,
             )
-        elif 3 <= int(choice) <= len(choices):
+        elif choice == str(3):
+            return self.add_unknown_platform(data_store, platform_name, change_id)
+        elif choice == str(4):
+            self.store_all_platforms_as_unknown = True
+            return self.add_unknown_platform(data_store, platform_name, change_id)
+        elif 5 <= int(choice) <= len(choices):
             # One of the pre-existing platforms was chosen
-            platform_index = int(choice) - 3
+            platform_index = int(choice) - 5
             return platforms[platform_index]
 
+    def add_unknown_platform(self, data_store, platform_name, change_id):
+        if platform_name is None:
+            platform_name = str(uuid.uuid4())
+        identifier = platform_name
+        trigraph = platform_name[:3]
+        quadgraph = platform_name[:4]
+
+        chosen_platform_type = data_store.add_to_platform_types(
+            "Unknown", change_id, default_data_interval_secs=60
+        )
+
+        chosen_nationality = data_store.add_to_nationalities("Unknown", change_id)
+        chosen_privacy = data_store.search_privacy(get_lowest_privacy(data_store))
+
+        return (
+            platform_name,
+            trigraph,
+            quadgraph,
+            identifier,
+            chosen_platform_type,
+            chosen_nationality,
+            chosen_privacy,
+        )
+
+    def reset_per_file_settings(self):
+        self.store_all_platforms_as_unknown = False
+
     def resolve_sensor(self, data_store, sensor_name, sensor_type, host_id, privacy, change_id):
         print_new_section_title("Resolve Sensor")
         Platform = data_store.db_classes.Platform
@@ -378,6 +431,26 @@ def is_valid_dynamic(option):  # pragma: no cover
             if selected_object:
                 return selected_object
 
+    def resolve_missing_info(
+        self, question, default_value, min_value=None, max_value=None, allow_empty=False
+    ):
+        question_to_ask = question
+        if allow_empty is True:
+            question_to_ask += f" (Default: {default_value})"
+        if isinstance(default_value, int):
+            # Apply some validation if int expected
+            info = prompt(
+                format_command(question_to_ask),
+                validator=MinMaxValidator(min_value, max_value, allow_empty),
+            )
+        else:
+            # Assume caller to validate if not number
+            info = prompt(format_command(question_to_ask))
+        if info is None or info == "":
+            print(f"Using default value: {default_value}")
+            info = default_value
+        return info
+
     def fuzzy_search_reference(
         self,
         data_store,
@@ -1024,3 +1097,30 @@ def is_valid_choice(option):  # pragma: no cover
         elif choice == ".":
             print("-" * 60, "\nReturning to the previous menu\n")
             return self.resolve_sensor(data_store, sensor_name, None, host_id, None, change_id)
+
+
+class MinMaxValidator(Validator):
+    """A validator to check numerical values are between a given minimum/maximum value"""
+
+    def __init__(self, minimum=None, maximum=None, allow_empty=False):
+        """Creates a new validator to check numerical values between two values
+        :param minimum: The minimum value (inclusive) allowed by the validator
+        :param maximum: The maximum value (inclusive) allowed by the validator
+        :param allow_empty: Whether we allow an empty string to be parsed
+        """
+        self.min = minimum
+        self.max = maximum
+        self.allow_empty = allow_empty
+
+    def validate(self, document):
+        to_validate = document.text
+        if is_number(to_validate):
+            number = int(to_validate)
+            if self.min and number < self.min:
+                raise ValidationError(len(document.text), f"Number must be {self.min} or higher")
+            if self.max and number > self.max:
+                raise ValidationError(len(document.text), f"Number must be {self.max} or lower")
+        elif self.allow_empty is True and to_validate == "":
+            pass
+        else:
+            raise ValidationError(len(document.text), "This input contains non-numeric characters")
diff --git a/pepys_import/resolvers/data_resolver.py b/pepys_import/resolvers/data_resolver.py
index 06c239992..398cdf6fc 100644
--- a/pepys_import/resolvers/data_resolver.py
+++ b/pepys_import/resolvers/data_resolver.py
@@ -4,7 +4,15 @@
 class DataResolver(ABC):
     @abstractmethod
     def resolve_platform(
-        self, data_store, platform_name, identifier, platform_type, nationality, privacy, change_id
+        self,
+        data_store,
+        platform_name,
+        identifier,
+        platform_type,
+        nationality,
+        privacy,
+        change_id,
+        quadgraph=None,
     ):
         """
         Implementation method should return any data necessary to create a platform.
@@ -22,6 +30,8 @@ def resolve_platform(
         :type privacy: String
         :param change_id: ID of the :class:`Change` object
         :type change_id: UUID
+        :param quadgraph: The quadgraph that the platform is known by
+        :type quadgraph: String
         :return:
         """
 
@@ -64,3 +74,24 @@ def resolve_datafile(self, data_store, datafile_name, datafile_type, privacy, ch
         :type change_id: UUID
         :return:
         """
+
+    @abstractmethod
+    def resolve_missing_info(
+        self, question, default_value, min_value=None, max_value=None, allow_empty=False
+    ):
+        """Implementation method should return the data for the given property name
+        as asked for in the question
+
+        :param property_name: The name of the property asked for
+        :param question: The question to resolve
+        :param default_value: The default value to use if unresolved
+        :param min_value: The minimum value allowed for the result, optional
+        :param max_value: The maximum value allowed for the result, optional
+        :param allow_empty: Whether to allow an empty value (e.g. to accept default), optional
+        :return: The missing information requested
+        """
+
+    @abstractmethod
+    def reset_per_file_settings(self):
+        """Implementation method should do anything needed to reset per-file settings,
+        such as whether to treat all platforms as unknown."""
diff --git a/pepys_import/resolvers/default_resolver.py b/pepys_import/resolvers/default_resolver.py
index f1d896a2a..0c8cf551d 100644
--- a/pepys_import/resolvers/default_resolver.py
+++ b/pepys_import/resolvers/default_resolver.py
@@ -7,7 +7,7 @@ class DefaultResolver(DataResolver):
     default_trigraph = "PL1"
     default_quadgraph = "PLT1"
     default_identifier = "123"
-    default_platform_type = "Warship"
+    default_platform_type = "Naval - frigate"
     default_nationality = "UK"
     default_sensor_name = "SENSOR-1"
     default_sensor_type = "Position"
@@ -16,8 +16,19 @@ class DefaultResolver(DataResolver):
     default_datafile_name = "DATAFILE-1"
     default_datafile_type = "DATAFILE-TYPE-1"
 
+    def reset_per_file_settings(self):
+        pass
+
     def resolve_platform(
-        self, data_store, platform_name, identifier, platform_type, nationality, privacy, change_id
+        self,
+        data_store,
+        platform_name,
+        identifier,
+        platform_type,
+        nationality,
+        privacy,
+        change_id,
+        quadgraph=None,
     ):
         # needs to establish defaults for platform_name, platform_type, nationality and privacy
         if not platform_name:
@@ -57,7 +68,7 @@ def resolve_platform(
             return (
                 platform_name,
                 self.default_trigraph,
-                self.default_quadgraph,
+                quadgraph if quadgraph is not None else self.default_quadgraph,
                 identifier,
                 platform_type,
                 nationality,
@@ -150,3 +161,8 @@ def resolve_datafile(self, data_store, datafile_name, datafile_type, privacy, ch
             )
 
         return datafile_name, datafile_type, privacy
+
+    def resolve_missing_info(
+        self, question, default_value, min_value=None, max_value=None, allow_empty=False
+    ):
+        return default_value
diff --git a/pepys_import/utils/sqlite_utils.py b/pepys_import/utils/sqlite_utils.py
index d0f006082..be62c7098 100644
--- a/pepys_import/utils/sqlite_utils.py
+++ b/pepys_import/utils/sqlite_utils.py
@@ -5,7 +5,7 @@
 if SYSTEM == "Linux":
     PLATFORM_EXTENSION_PATH = "mod_spatialite"
 elif SYSTEM == "Darwin":  # Darwin is MacOS  pragma: no cover (tests only run on Linux)
-    PLATFORM_EXTENSION_PATH = "/usr/local/lib/mod_spatialite.dylib"
+    PLATFORM_EXTENSION_PATH = "mod_spatialite"
 elif SYSTEM == "Windows":  # pragma: no cover (tests only run on Linux)
     PLATFORM_EXTENSION_PATH = "mod_spatialite"
 
diff --git a/pepys_import/utils/timezone_utils.py b/pepys_import/utils/timezone_utils.py
new file mode 100644
index 000000000..52f5b8fb6
--- /dev/null
+++ b/pepys_import/utils/timezone_utils.py
@@ -0,0 +1,27 @@
+TIMEZONE_MAPPINGS = {
+    "A": "+0100",
+    "B": "+0200",
+    "C": "+0300",
+    "D": "+0400",
+    "E": "+0500",
+    "F": "+0600",
+    "G": "+0700",
+    "H": "+0800",
+    "I": "+0900",
+    "K": "+1000",
+    "L": "+1100",
+    "M": "+1200",
+    "N": "-0100",
+    "O": "-0200",
+    "P": "-0300",
+    "Q": "-0400",
+    "R": "-0500",
+    "S": "-0600",
+    "T": "-0700",
+    "U": "-0800",
+    "V": "-0900",
+    "W": "-1000",
+    "X": "-1100",
+    "Y": "-1200",
+    "Z": "+0000",
+}
diff --git a/pepys_timeline/static/image/Warship.svg b/pepys_timeline/static/image/Warship.svg
deleted file mode 100755
index cfdbae34f..000000000
--- a/pepys_timeline/static/image/Warship.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
-  <rect id="Rectangle-2" fill="#fff" x="24.5" y="23" width="52" height="21"/>
-  <polygon id="Rectangle" fill="#fff" points="7 42.5 95 42.5 87 74 14.5 74" style="pointer-events: none;"/>
-  <circle id="origin" cx="51" cy="58.25" r="2.5" style="fill: rgb(255, 0, 0);"/>
-</svg>
diff --git a/pepys_timeline/static/image/attack_craft.svg b/pepys_timeline/static/image/attack_craft.svg
new file mode 100644
index 000000000..635d972d1
--- /dev/null
+++ b/pepys_timeline/static/image/attack_craft.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5311 39.5212L2.92905 23.799C2.52209 23.1326 3.00167 22.2778 3.78248 22.2778H9.03529L14.4253 12.0664C14.7716 11.4105 15.4524 11 16.1941 11H21.5168C22.2531 11 22.737 11.7688 22.4184 12.4326L17.6941 22.2778H46.2175C46.9983 22.2778 47.4779 23.1326 47.0709 23.799L37.4689 39.5212C37.2873 39.8186 36.9639 40 36.6155 40H13.3845C13.0361 40 12.7127 39.8186 12.5311 39.5212Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/commercial_aircraft.svg b/pepys_timeline/static/image/commercial_aircraft.svg
new file mode 100644
index 000000000..9f0a3f1db
--- /dev/null
+++ b/pepys_timeline/static/image/commercial_aircraft.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M25 7C23.8954 7 23 7.89543 23 9V15H18C16.8954 15 16 15.8954 16 17V22H4C2.89543 22 2 22.8954 2 24C2 25.1046 2.89543 26 4 26H16V31C16 32.1046 16.8954 33 18 33H32C33.1046 33 34 32.1046 34 31V26H46C47.1046 26 48 25.1046 48 24C48 22.8954 47.1046 22 46 22H34V17C34 15.8954 33.1046 15 32 15H27V9C27 7.89543 26.1046 7 25 7ZM29 24C29 26.2091 27.2091 28 25 28C22.7909 28 21 26.2091 21 24C21 21.7909 22.7909 20 25 20C27.2091 20 29 21.7909 29 24Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/favicon.ico b/pepys_timeline/static/image/favicon.ico
deleted file mode 100755
index f14e243d0..000000000
Binary files a/pepys_timeline/static/image/favicon.ico and /dev/null differ
diff --git a/pepys_timeline/static/image/fishing_vessel.svg b/pepys_timeline/static/image/fishing_vessel.svg
new file mode 100644
index 000000000..d17177366
--- /dev/null
+++ b/pepys_timeline/static/image/fishing_vessel.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>Fishing vessel</title>
+<g id="Fishing_vessel">
+	<path class="st0" d="M25,36.9c-2.5,0-4.5-2-4.5-4.5c0,0,0,0,0,0c0-0.1,0-0.1,0-0.2c0-2.5,2-4.5,4.5-4.5s4.5,2,4.5,4.5l0,0
+		c0,0.1,0,0.1,0,0.2C29.5,34.9,27.5,36.9,25,36.9z M48.4,24.1c0.3-1.1-0.2-2.2-1.3-2.5c-0.3-0.1-0.6-0.1-0.9-0.1L3.2,26.7
+		c-1.1,0.1-1.9,1.1-1.8,2.2c0,0.2,0.1,0.4,0.2,0.6l4.5,10.4c0.6,1.5,2.1,2.4,3.7,2.4h29.9c1.7,0,3.3-1.1,3.8-2.8L48.4,24.1z"/>
+	<path class="st0" d="M6.4,17.4v4h6.8v-4H6.4z M22,22.1v1.7l7.1-0.8v-1L22,22.1z M13.9,13.1v3.7h7.5v-3.7L13.9,13.1z M21.3,13.1
+		L21.3,13.1h0.2l0,0H21.3z M13.9,22.1v2.6l7.5-0.8v-1.8L13.9,22.1z M6.4,22.1v3.4l6.8-0.7v-2.7L6.4,22.1z M13.9,17.5v4h7.5v-4H13.9z
+		 M13.9,17.5v4h7.5v-4H13.9z M13.9,17.5v4h7.5v-4H13.9z M13.9,17.5v4h7.5v-4H13.9z M13.9,17.5v4h7.5v-4H13.9z"/>
+	<path class="st0" d="M44.1,8.6c-0.6-0.6-1.5-0.6-2.1,0l-8.1,8.1V9.3c0-0.8-0.7-1.5-1.5-1.5s-1.5,0.7-1.5,1.5v13.4l3-0.3V21
+		l10.3-10.3C44.7,10.2,44.7,9.2,44.1,8.6z"/>
+</g>
+</svg>
diff --git a/pepys_timeline/static/image/high_speed_craft.svg b/pepys_timeline/static/image/high_speed_craft.svg
new file mode 100644
index 000000000..52a035ea5
--- /dev/null
+++ b/pepys_timeline/static/image/high_speed_craft.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.1941 10C15.0815 10 14.0603 10.6157 13.541 11.5996L8.43239 21.2778H3.78249C2.22088 21.2778 1.26171 22.9875 2.07564 24.3202L11.6777 40.0424C12.041 40.6372 12.6876 41 13.3846 41H36.6155C37.3124 41 37.9591 40.6372 38.3223 40.0424L47.9244 24.3202C48.7383 22.9875 47.7791 21.2778 46.2175 21.2778H19.2832L23.32 12.8653C23.9571 11.5376 22.9895 10 21.5169 10H16.1941ZM15.3097 12.5332C15.4828 12.2052 15.8232 12 16.1941 12H21.5169L16.7926 21.8451L16.1051 23.2778H17.6941H46.2175L36.6155 39H13.3846L3.78249 23.2778H9.03531H9.63822L9.91967 22.7446L15.3097 12.5332ZM20.845 34.64C20.635 34.69 20.365 34.715 20.035 34.715C19.375 34.715 18.815 34.65 18.355 34.52C17.905 34.38 17.525 34.225 17.215 34.055L16.555 35.915C16.695 35.995 16.865 36.08 17.065 36.17C17.275 36.26 17.52 36.345 17.8 36.425C18.09 36.505 18.42 36.57 18.79 36.62C19.16 36.68 19.575 36.71 20.035 36.71C21.405 36.71 22.43 36.445 23.11 35.915C23.79 35.375 24.13 34.62 24.13 33.65C24.13 33.15 24.065 32.725 23.935 32.375C23.805 32.025 23.605 31.72 23.335 31.46C23.075 31.2 22.745 30.975 22.345 30.785C21.945 30.585 21.475 30.385 20.935 30.185C20.675 30.095 20.435 30.01 20.215 29.93C20.005 29.84 19.815 29.745 19.645 29.645C19.485 29.535 19.36 29.415 19.27 29.285C19.18 29.155 19.135 28.995 19.135 28.805C19.135 28.485 19.255 28.25 19.495 28.1C19.745 27.94 20.15 27.86 20.71 27.86C21.21 27.86 21.635 27.92 21.985 28.04C22.345 28.15 22.67 28.28 22.96 28.43L23.635 26.585C23.305 26.405 22.885 26.24 22.375 26.09C21.865 25.94 21.26 25.865 20.56 25.865C19.97 25.865 19.44 25.94 18.97 26.09C18.5 26.23 18.1 26.435 17.77 26.705C17.44 26.975 17.185 27.305 17.005 27.695C16.825 28.075 16.735 28.505 16.735 28.985C16.735 29.465 16.82 29.875 16.99 30.215C17.16 30.545 17.38 30.83 17.65 31.07C17.92 31.3 18.225 31.495 18.565 31.655C18.905 31.805 19.245 31.94 19.585 32.06C20.365 32.32 20.915 32.565 21.235 32.795C21.555 33.015 21.715 33.295 21.715 33.635C21.715 33.795 21.69 33.94 21.64 34.07C21.59 34.2 21.5 34.315 21.37 34.415C21.24 34.505 21.065 34.58 20.845 34.64ZM32.6205 26.81C31.7905 26.26 30.6005 25.985 29.0505 25.985C28.6205 25.985 28.1205 26.005 27.5505 26.045C26.9805 26.085 26.4405 26.155 25.9305 26.255V36.5H28.2705V33.02H29.0055C30.5655 33.02 31.7655 32.745 32.6055 32.195C33.4455 31.635 33.8655 30.73 33.8655 29.48C33.8655 28.24 33.4505 27.35 32.6205 26.81ZM28.6905 27.995C28.8605 27.985 29.0305 27.98 29.2005 27.98C29.5405 27.98 29.8505 28.005 30.1305 28.055C30.4105 28.095 30.6455 28.175 30.8355 28.295C31.0355 28.405 31.1855 28.555 31.2855 28.745C31.3955 28.935 31.4505 29.175 31.4505 29.465C31.4505 30.065 31.2455 30.475 30.8355 30.695C30.4255 30.915 29.8155 31.025 29.0055 31.025H28.2705V28.025C28.3905 28.015 28.5305 28.005 28.6905 27.995Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/index.html b/pepys_timeline/static/image/index.html
new file mode 100644
index 000000000..65954c474
--- /dev/null
+++ b/pepys_timeline/static/image/index.html
@@ -0,0 +1,174 @@
+<!doctype html>
+<html class="no-js" lang="en">
+
+<head>
+  <meta charset="utf-8">
+  <meta name="description" content="SVG icons">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>SVG icons</title>
+
+  <link rel="apple-touch-icon" href="apple-touch-icon.png">
+  <!-- Place favicon.ico in the root directory -->
+
+  <!-- build:css styles/vendor.css -->
+  <!-- bower:css -->
+  <!-- endbower -->
+  <!-- endbuild -->
+
+  <link href="https://fonts.googleapis.com/css?family=Bowlby+One+SC|Montserrat:300,400,900" rel="stylesheet">
+  <style type="text/css">
+    body {
+      font-family: Montserrat, Arial,sans-serif;
+      background-color: #000033;
+      color: #ffffff;
+    }
+    .title-page {
+      text-align: center;
+      color: #ffffff;
+      font-size: 48px;
+      font-family: Montserrat, Arial, sans-serif;
+    }
+    .subtitle {
+      text-align: center;
+      font-size: 25px;
+      font-weight: 300;
+    }
+    .icon-name {
+      font-weight: 300;
+      font-size: 16px;
+      margin-left: 15px;
+    }
+    .svg-row {
+      max-width: 900px;
+      margin-left: auto;
+      margin-right: auto;
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .svg-col {
+      padding: 0 20px;
+      display: flex;
+      align-items: center;
+      margin-bottom: 30px;
+      flex-basis: 0;
+      flex-grow: 1;
+      max-width: 100%;
+      position: relative;
+      width: 100%;
+      min-height: 1px;
+    }
+    .icsvg-img {
+      width: 50px;
+      height: 50px;
+    }
+  </style>
+</head>
+
+<body>
+  <h2 class="title-page">SVG icon</h2>
+ 
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="agi.svg" />
+      <div class="icon-name">agi</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="auxiliary.svg" />
+      <div class="icon-name">auxiliary</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="boghammer.svg" />
+      <div class="icon-name">boghammer</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="carrier.svg" />
+      <div class="icon-name">carrier</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="coastal-radar-site.svg" />
+      <div class="icon-name">coastal radar site</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="datum.svg" />
+      <div class="icon-name">datum</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="destroyer.svg" />
+      <div class="icon-name">destroyer</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="fast-attack-craft.svg" />
+      <div class="icon-name">fast attack craft</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="fishing-vessel.svg" />
+      <div class="icon-name">fishing vessel</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="fixed-wing-aircraft.svg" />
+      <div class="icon-name">fixed wing aircraft</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="frigate.svg" />
+      <div class="icon-name">frigate</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="frigate-ta.svg" />
+      <div class="icon-name">frigate ta</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="helicopter.svg" />
+      <div class="icon-name">helicopter</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="mcmv.svg" />
+      <div class="icon-name">mcmv</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="merchant-vessel.svg" />
+      <div class="icon-name">merchant vessel</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="mine.svg" />
+      <div class="icon-name">mine</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="missile.svg" />
+      <div class="icon-name">missile</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="mpa.svg" />
+      <div class="icon-name">mpa</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="pirate.svg" />
+      <div class="icon-name">pirate</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="submarine-1.svg" />
+      <div class="icon-name">submarine 1</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="submarine-2.svg" />
+      <div class="icon-name">submarine 2</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="task-group.svg" />
+      <div class="icon-name">task group</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="torpedo.svg" />
+      <div class="icon-name">torpedo</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+      <div class="icon-name">unknown</div>
+    </div>
+  </div>
+
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="unmanned-airborne-vehicle.svg" />
+      <div class="icon-name">unmanned airborne vehicle</div>
+    </div>  
+  </div>
+
+</body>
+
+</html>
diff --git a/pepys_timeline/static/image/index_borders.html b/pepys_timeline/static/image/index_borders.html
new file mode 100644
index 000000000..ec93c2178
--- /dev/null
+++ b/pepys_timeline/static/image/index_borders.html
@@ -0,0 +1,182 @@
+<!doctype html>
+<html class="no-js" lang="en">
+
+<head>
+  <meta charset="utf-8">
+  <meta name="description" content="SVG icons">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>SVG icons</title>
+
+  <link rel="apple-touch-icon" href="apple-touch-icon.png">
+  <!-- Place favicon.ico in the root directory -->
+
+  <!-- build:css styles/vendor.css -->
+  <!-- bower:css -->
+  <!-- endbower -->
+  <!-- endbuild -->
+
+  <link href="https://fonts.googleapis.com/css?family=Bowlby+One+SC|Montserrat:300,400,900" rel="stylesheet">
+  <style type="text/css">
+    body {
+      font-family: Montserrat, Arial,sans-serif;
+    }
+    .missing {
+      text-align: center;
+      background-color: aquamarine;
+    }
+    .title-page {
+      text-align: center;
+      color: #fc984b;
+      font-size: 48px;
+    }
+    .subtitle {
+      text-align: center;
+      font-size: 25px;
+      font-weight: 300;
+    }
+    .icon-name {
+      font-weight: 300;
+      font-size: 16px;
+    }
+    .svg-row {
+      margin-left: auto;
+      margin-right: auto;
+      display: flex;
+      flex-wrap: wrap;
+    }
+    .svg-col {
+      padding: 0 20px;
+      display: flex;
+      align-items: center;
+      margin-bottom: 30px;
+      flex-basis: 0;
+      flex-grow: 1;
+      max-width: 100%;
+      position: relative;
+      width: 100%;
+      min-height: 1px;
+    }
+    .icsvg-img {
+      border:1px;
+      background-color: #aaa;
+      height:60px;
+    }
+  </style>
+</head>
+
+<body>
+  <h2 class="title-page">Pepys Platform types</h2>
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="attack_craft.svg" />
+      <div class="icon-name">Attack Craft</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="commercial_aircraft.svg" />
+      <div class="icon-name">Commercial Aircraft</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="fishing-vessel.svg" />
+      <div class="icon-name">Fishing Vessel</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="high_speed_craft.svg" />
+      <div class="icon-name">High speed craft</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="law_enforcement.svg" />
+      <div class="icon-name">Law enforcement</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="medical.svg" />
+      <div class="icon-name">Medical</div>
+    </div>
+  </div>
+  <div class="svg-row">
+    <div class="svg-col"><img class="icsvg-img" src="merchant.svg" />
+      <div class="icon-name">Merchant</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="naval_aircraft_carrier.svg" />
+      <div class="icon-name">Naval - aircraft carrier</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="naval_amphib.svg" />
+      <div class="icon-name">Naval - amphib</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="naval_auxiliary.svg" />
+      <div class="icon-name">Naval - auxiliary</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="naval_cruiser.svg" />
+      <div class="icon-name">Naval - cruiser</div>
+    </div>
+    <div class="svg-col"><img class="icsvg-img" src="naval_destroyer.svg" />
+      <div class="icon-name">Naval - destroyer</div>
+    </div>
+    <div class="svg-row">
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Naval - ExCon</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="naval_fixed_wing.svg" />
+        <div class="icon-name">Naval - fixed wing</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="naval_frigate.svg" />
+        <div class="icon-name">Naval - frigate</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="naval_minewarfare.svg" />
+        <div class="icon-name">Naval - minewarfare</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Naval - miscellaneous</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="naval_patrol.svg" />
+        <div class="icon-name">Naval - patrol</div>
+      </div>
+    </div>
+    <div class="svg-row">
+      <div class="svg-col"><img class="icsvg-img" src="naval_rotary_wing.svg" />
+        <div class="icon-name">Naval - rotary wing</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="naval_submarine.svg" />
+        <div class="icon-name">Naval - submarine</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="naval_survey.svg" />
+        <div class="icon-name">Naval - survey</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="passenger_vessel.svg" />
+        <div class="icon-name">Passenger vessel</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="pleasure_craft.svg" />
+        <div class="icon-name">Pleasure Craft</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="research_vessel.svg" />
+        <div class="icon-name">Research vessel</div>
+      </div>
+    </div>
+    <div class="svg-row">
+      <div class="svg-col"><img class="icsvg-img" src="sailing_vessel.svg" />
+        <div class="icon-name">Sailing vessel</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Search and Rescue</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Offshore support</div>
+      </div>
+    </div>
+    <div class="svg-row">
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Civilian - uncrewed surface vehicle</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Civilian - uncrewed underwater vehicle</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Civilian - uncrewed air vehicle</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Naval - uncrewed surface vehicle</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Naval - uncrewed underwater vehicle</div>
+      </div>
+      <div class="svg-col"><img class="icsvg-img" src="unknown.svg" />
+        <div class="icon-name missing">Naval - uncrewed air vehicle</div>
+      </div>
+    </div>
+
+</body>
+
+</html>
diff --git a/pepys_timeline/static/image/jammer.svg b/pepys_timeline/static/image/jammer.svg
deleted file mode 100755
index b7b600fcf..000000000
--- a/pepys_timeline/static/image/jammer.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
-  <circle id="Oval-9" cx="48.5" cy="52.5" r="14.5"/>
-  <polygon points="0.172001 57.384 1.387 64.885 4.342 73.44 8.078 80.856 13.22 88.255 19.001 93.764 33.093 79.667 28.576 75.073 26.081 71.598 23.736 67.377 21.695 62.769 20.62 59.275 19.752 54.098" style="fill: rgb(3, 0, 0);"/>
-  <polygon points="25.941 10.703 33.967 7.102 43.001 4.932 52.159 4.888 60.774 6.256 67.955 8.418 62.308 27.831 56.315 25.968 50.559 24.802 45.533 24.941 40.298 26.127 34.099 28.922" style="fill: rgb(3, 0, 0);"/>
-  <polygon points="98.396 59.14 96.829 67.416 93.685 75.482 89.136 83.128 83.559 89.529 76.605 95.068 63.987 79.443 69.375 75.047 73.335 70.442 75.796 66.331 77.561 61.711 78.715 55.311" style="fill: rgb(3, 0, 0);"/>
-  <circle id="origin" cx="48.5" cy="52.5" r="2.5" style="fill: rgb(255, 0, 0);"/>
-</svg>
\ No newline at end of file
diff --git a/pepys_timeline/static/image/law_enforcement.svg b/pepys_timeline/static/image/law_enforcement.svg
new file mode 100644
index 000000000..d107fa0d6
--- /dev/null
+++ b/pepys_timeline/static/image/law_enforcement.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M35.925 20.9524V20.9524C35.4086 20.9524 34.9899 20.5337 34.9899 20.0173V11C34.9899 10.4477 34.5422 10 33.9899 10H15.5455C14.9932 10 14.5455 10.4477 14.5455 11V19.9524C14.5455 20.5047 14.0977 20.9524 13.5455 20.9524H3.81842C3.03001 20.9524 2.55167 21.8222 2.97394 22.488L13.7863 39.5356C13.9697 39.8248 14.2883 40 14.6307 40H24.425M35.925 20.9524L24.425 40M35.925 20.9524H40.525M24.425 40H29.025M40.525 20.9524H46.2189C46.9994 20.9524 47.479 21.8067 47.0726 22.473L36.6762 39.5207C36.4946 39.8184 36.1711 40 35.8224 40H29.025M40.525 20.9524L29.025 40" stroke="white" stroke-width="2"/>
+</svg>
diff --git a/pepys_timeline/static/image/medical.svg b/pepys_timeline/static/image/medical.svg
new file mode 100644
index 000000000..7857e78a3
--- /dev/null
+++ b/pepys_timeline/static/image/medical.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.7039 9C14.505 9 13.533 9.98806 13.533 11.2069V19.8768H4.17442C2.4565 19.8768 1.41925 21.8092 2.34881 23.2779L12.9247 39.9873C13.3242 40.6185 14.0119 41 14.7503 41H35.6566C36.4086 41 37.107 40.6043 37.5027 39.9542L47.6718 23.2449C48.5666 21.7746 47.5263 19.8768 45.8257 19.8768H35.9654V11.2069C35.9654 9.98806 34.9935 9 33.7945 9H15.7039ZM15.7039 11.2069H33.7945V19.8768C33.7945 21.0957 34.7664 22.0837 35.9654 22.0837H45.8257L46.7487 22.6643L45.8257 22.0837L35.6566 38.7931H14.7503L4.17442 22.0837H13.533C14.732 22.0837 15.7039 21.0957 15.7039 19.8768V11.2069ZM18.0056 25.3696C17.7559 24.8289 17.5334 24.3765 17.338 24.0124H14.9121C14.8035 24.7738 14.7004 25.6289 14.6027 26.5779C14.5159 27.5158 14.4345 28.4979 14.3585 29.5241C14.2934 30.5393 14.2282 31.56 14.1631 32.5862C14.1088 33.6124 14.06 34.5779 14.0166 35.4827H16.4914C16.5239 34.3683 16.5674 33.1545 16.6216 31.8414C16.6759 30.5283 16.7573 29.2041 16.8659 27.8689C17.0612 28.3324 17.2783 28.8455 17.5171 29.4083C17.7559 29.971 17.9893 30.5338 18.2172 31.0965C18.456 31.6593 18.684 32.2 18.901 32.7186C19.1181 33.2262 19.3027 33.662 19.4546 34.0262H21.2293C21.3813 33.662 21.5658 33.2262 21.7829 32.7186C22 32.2 22.2225 31.6593 22.4504 31.0965L23.1668 29.4083C23.4056 28.8455 23.6227 28.3324 23.8181 27.8689C23.9266 29.2041 24.008 30.5283 24.0623 31.8414C24.1166 33.1545 24.16 34.3683 24.1925 35.4827H26.6673C26.6239 34.5779 26.5697 33.6124 26.5045 32.5862C26.4503 31.56 26.3851 30.5393 26.3091 29.5241C26.244 28.4979 26.1626 27.5158 26.0649 26.5779C25.9781 25.6289 25.8804 24.7738 25.7719 24.0124H23.4599C23.2645 24.3765 23.0366 24.8289 22.7761 25.3696C22.5264 25.8993 22.2605 26.4786 21.9783 27.1076C21.7069 27.7255 21.4355 28.36 21.1642 29.011C20.8928 29.662 20.6377 30.2745 20.3989 30.8483C20.1601 30.2745 19.9051 29.662 19.6337 29.011C19.3624 28.36 19.0856 27.7255 18.8034 27.1076C18.532 26.4786 18.2661 25.8993 18.0056 25.3696ZM28.2216 24.0124V35.4827H36.2158V33.3145H30.7615V30.5503H35.2715V28.4317H30.7615V26.1807H35.8413V24.0124H28.2216Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/merchant.svg b/pepys_timeline/static/image/merchant.svg
old mode 100755
new mode 100644
index dd94b5a6f..ed82f1e44
--- a/pepys_timeline/static/image/merchant.svg
+++ b/pepys_timeline/static/image/merchant.svg
@@ -1,6 +1,17 @@
 <?xml version="1.0" encoding="utf-8"?>
-<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
-  <rect id="Rectangle-2" fill="#000000" x="24.5" y="23" width="52" height="21"/>
-  <polygon id="Rectangle" fill="#000000" points="7 42.5 95 42.5 87 74 14.5 74" style="pointer-events: none;"/>
-  <circle id="origin" cx="51" cy="58.25" r="2.5" style="fill: rgb(255, 0, 0);"/>
-</svg>
\ No newline at end of file
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>Merchant vessel</title>
+<g id="Merchant_vessel_vessel">
+	<path class="st0" d="M25,34.3c-2.5,0-4.5-2-4.5-4.5c0,0,0,0,0,0c0-0.1,0-0.1,0-0.2c0-2.5,2-4.5,4.5-4.5s4.5,2,4.5,4.5l0,0
+		c0,0.1,0,0.1,0,0.2C29.5,32.3,27.5,34.3,25,34.3z M48.4,21.5c0.3-1-0.2-2.2-1.3-2.5c-0.3-0.1-0.6-0.1-0.9-0.1L3.2,24
+		c-1.1,0.1-1.9,1.1-1.7,2.2c0,0.2,0.1,0.4,0.1,0.5l4.5,10.4c0.6,1.5,2.1,2.4,3.7,2.4h29.9c1.7,0,3.3-1.1,3.8-2.8L48.4,21.5z"/>
+	<path class="st0" d="M6.4,14.8v4h6.8v-4H6.4z M22,19.4v1.7l7.1-0.8v-1L22,19.4z M13.9,10.4v3.7h7.5v-3.7L13.9,10.4z M21.3,10.4
+		L21.3,10.4h0.2l0,0H21.3z M13.9,19.5V22l7.5-0.8v-1.8L13.9,19.5z M6.4,19.5v3.3l6.8-0.7v-2.7L6.4,19.5z M13.9,14.8v4h7.5v-4H13.9z
+		 M13.9,14.8v4h7.5v-4H13.9z M13.9,14.8v4h7.5v-4H13.9z M13.9,14.8v4h7.5v-4H13.9z M13.9,14.8v4h7.5v-4H13.9z"/>
+</g>
+</svg>
diff --git a/pepys_timeline/static/image/missile.svg b/pepys_timeline/static/image/missile.svg
deleted file mode 100755
index ec74bf2e1..000000000
--- a/pepys_timeline/static/image/missile.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg width="100px" height="100px" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg">
-  <polygon id="Triangle-2" points="49.5 0 67.5 100 32.5 100"/>
-  <circle id="origin" cx="50" cy="69" r="2.5" style="fill: rgb(0, 255, 0);"/>
-</svg>
diff --git a/pepys_timeline/static/image/naval_aircraft_carrier.svg b/pepys_timeline/static/image/naval_aircraft_carrier.svg
new file mode 100644
index 000000000..686a52d10
--- /dev/null
+++ b/pepys_timeline/static/image/naval_aircraft_carrier.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7.22962 14.2369L23.7296 45.6315C24.4791 47.0575 26.5209 47.0575 27.2704 45.6315L42.4598 16.7305C43.1598 15.3987 42.194 13.8 40.6895 13.8H22.3254V5C22.3254 3.89543 21.43 3 20.3254 3H9C7.89543 3 7 3.89543 7 5V13.3064C7 13.6306 7.0788 13.9499 7.22962 14.2369ZM25.5 30C27.9853 30 30 27.9853 30 25.5C30 23.0147 27.9853 21 25.5 21C23.0147 21 21 23.0147 21 25.5C21 27.9853 23.0147 30 25.5 30Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_amphib.svg b/pepys_timeline/static/image/naval_amphib.svg
new file mode 100644
index 000000000..654a27342
--- /dev/null
+++ b/pepys_timeline/static/image/naval_amphib.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M34.1449 6H16.4348C15.8825 6 15.4348 6.44771 15.4348 7V20.7121H7.17771C6.32278 20.7121 5.86208 21.7153 6.41925 22.3638L24.9718 43.9551C25.5417 44.6184 26.3727 45 27.2472 45H42.2609C42.8132 45 43.2609 44.5523 43.2609 44V43.3013C43.2609 42.7491 42.8132 42.3013 42.2609 42.3013H27.6087L43.6891 22.3394C44.2161 21.6852 43.7504 20.7121 42.9103 20.7121H35.1449V7C35.1449 6.44772 34.6972 6 34.1449 6Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_auxiliary.svg b/pepys_timeline/static/image/naval_auxiliary.svg
new file mode 100644
index 000000000..bb7e010d9
--- /dev/null
+++ b/pepys_timeline/static/image/naval_auxiliary.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7863 23.5158C15.8908 23.5158 16.7863 22.6204 16.7863 21.5158V13C16.7863 11.8954 17.6817 11 18.7863 11H30.2137C31.3183 11 32.2137 11.8954 32.2137 13V21.5158C32.2137 22.6204 33.1092 23.5158 34.2137 23.5158H44C45.1046 23.5158 46 24.4112 46 25.5158V38C46 39.1046 45.1046 40 44 40H5C3.89543 40 3 39.1046 3 38V25.5158C3 24.4112 3.89543 23.5158 5 23.5158H14.7863ZM24.5 35C26.9853 35 29 32.9853 29 30.5C29 28.0147 26.9853 26 24.5 26C22.0147 26 20 28.0147 20 30.5C20 32.9853 22.0147 35 24.5 35Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_cruiser.svg b/pepys_timeline/static/image/naval_cruiser.svg
new file mode 100644
index 000000000..da3ab8dbb
--- /dev/null
+++ b/pepys_timeline/static/image/naval_cruiser.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2414 3H31.7196C32.4427 3 33.0288 3.58663 33.0288 4.31028V12.8966C33.0288 13.6203 33.615 14.2069 34.338 14.2069H40.6891C41.667 14.2069 42.2997 15.2409 41.8553 16.1127L26.4738 46.2852C25.9924 47.2297 24.6478 47.2405 24.1512 46.3039L8.15452 16.1314C7.69184 15.2587 8.32388 14.2069 9.31099 14.2069H15.623C16.346 14.2069 16.9322 13.6203 16.9322 12.8966V4.31028C16.9322 3.58663 17.5183 3 18.2414 3ZM24.9805 33.1186C28.0727 33.1186 30.5793 30.6099 30.5793 27.5152C30.5793 24.4204 28.0727 21.9117 24.9805 21.9117C21.8884 21.9117 19.3817 24.4204 19.3817 27.5152C19.3817 30.6099 21.8884 33.1186 24.9805 33.1186Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_destroyer.svg b/pepys_timeline/static/image/naval_destroyer.svg
new file mode 100644
index 000000000..2aa1ac8b9
--- /dev/null
+++ b/pepys_timeline/static/image/naval_destroyer.svg
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>Destroyer</title>
+<path class="st0" d="M39.9,16.8c-0.6-0.4-1.3-0.6-2.1-0.6H26.4v-5h10.2c0.7,0,1.3-0.6,1.3-1.3V9.6c0,0,0,0,0,0
+	c0-0.7-0.6-1.3-1.3-1.3H26.5V6.1c0-0.8-0.7-1.5-1.5-1.5s-1.5,0.7-1.5,1.5v2.2H13.4c-0.7,0-1.3,0.6-1.2,1.3v0.3
+	c0,0.7,0.5,1.3,1.2,1.3h10.1v5H12.2c-2.2,0-4,1.8-4,4c0,0.7,0.2,1.4,0.6,2.1l12.8,21.2c1.1,1.9,3.6,2.5,5.5,1.3
+	c0.6-0.3,1-0.8,1.3-1.3l12.8-21.1C42.3,20.4,41.7,18,39.9,16.8z M25,34.1c-2.5,0-4.5-2-4.5-4.5c0-2.5,2-4.5,4.5-4.5s4.5,2,4.5,4.5
+	C29.5,32.1,27.5,34.1,25,34.1z"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_fixed_wing.svg b/pepys_timeline/static/image/naval_fixed_wing.svg
new file mode 100644
index 000000000..4f915cd1a
--- /dev/null
+++ b/pepys_timeline/static/image/naval_fixed_wing.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M23 8C23 6.89543 23.8954 6 25 6C26.1046 6 27 6.89543 27 8V14C27 14.0668 26.9967 14.1329 26.9903 14.1981C31.2413 15.0566 34.5151 18.6111 34.9507 23.0006C34.9671 23.0002 34.9835 23 35 23H47C48.1046 23 49 23.8954 49 25C49 26.1045 48.1046 27 47 27H35C34.8479 27 34.6998 26.983 34.5575 26.9508C33.2985 31.0333 29.4957 34 25 34C20.5043 34 16.7015 31.0333 15.4425 26.9508C15.3002 26.983 15.1521 27 15 27H3C1.89543 27 1 26.1045 1 25C1 23.8954 1.89543 23 3 23H15C15.0165 23 15.0329 23.0002 15.0493 23.0006C15.4849 18.6111 18.7587 15.0566 23.0097 14.1981C23.0033 14.1329 23 14.0668 23 14V8ZM25 28.5C27.4853 28.5 29.5 26.4853 29.5 24C29.5 21.5147 27.4853 19.5 25 19.5C22.5147 19.5 20.5 21.5147 20.5 24C20.5 26.4853 22.5147 28.5 25 28.5Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_frigate.svg b/pepys_timeline/static/image/naval_frigate.svg
new file mode 100644
index 000000000..b73708c7e
--- /dev/null
+++ b/pepys_timeline/static/image/naval_frigate.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>Frigate</title>
+<g id="Frigate">
+	<path id="triangle_2" class="st0" d="M11.2,8.5l12.5,13.8c1.3,1.5,1.3,3.8,0,5.4L11.2,41.5c-2.5,2.8-6.8,0.8-6.8-3.1V11.6
+		C4.4,7.7,8.7,5.7,11.2,8.5z"/>
+	<g id="triangle_1">
+		<path class="st0" d="M38.8,41.5L26.3,27.7c-1.3-1.5-1.3-3.8,0-5.4L38.8,8.5c2.5-2.8,6.8-0.8,6.8,3.1v26.7
+			C45.6,42.3,41.3,44.3,38.8,41.5z"/>
+	</g>
+</g>
+</svg>
diff --git a/pepys_timeline/static/image/naval_minewarfare.svg b/pepys_timeline/static/image/naval_minewarfare.svg
new file mode 100644
index 000000000..b3f1a5ad3
--- /dev/null
+++ b/pepys_timeline/static/image/naval_minewarfare.svg
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>MCMV</title>
+<path id="MCMV" class="st0" d="M25,35.6c-2.5,0-4.5-2-4.5-4.5s2-4.5,4.5-4.5s4.5,2,4.5,4.5C29.5,33.6,27.5,35.6,25,35.6
+	C25,35.6,25,35.6,25,35.6z M13.9,43.8h22.2c2.2,0,4-1.8,4-4V8.2c0-1.1-0.9-2-2-2c-0.6,0-1.2,0.3-1.6,0.7L25.8,20.3
+	c-0.3,0.4-1,0.5-1.4,0.2c-0.1,0-0.1-0.1-0.2-0.2L13.5,7c-0.7-0.9-1.9-1-2.8-0.3c-0.5,0.4-0.7,1-0.7,1.6v31.6
+	C9.9,42,11.7,43.8,13.9,43.8z"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_patrol.svg b/pepys_timeline/static/image/naval_patrol.svg
new file mode 100644
index 000000000..7f8f1145a
--- /dev/null
+++ b/pepys_timeline/static/image/naval_patrol.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M44.9331 6.57701L32.9579 42.5841C32.4775 44.0286 31.1512 45 29.6593 45H5.16374C4.11498 45 3.6034 43.6909 4.36329 42.9517L43.0331 5.33397C43.9125 4.4785 45.3239 5.40191 44.9331 6.57701ZM26.5112 39.4881C29.7855 39.4881 32.4398 36.7736 32.4398 33.425C32.4398 30.0765 29.7855 27.3619 26.5112 27.3619C23.2369 27.3619 20.5826 30.0765 20.5826 33.425C20.5826 36.7736 23.2369 39.4881 26.5112 39.4881Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_rotary_wing.svg b/pepys_timeline/static/image/naval_rotary_wing.svg
new file mode 100644
index 000000000..63897fe2c
--- /dev/null
+++ b/pepys_timeline/static/image/naval_rotary_wing.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>Helicopter</title>
+<path class="st0" d="M43.1,4.4c-1.6-1.6-4.1-1.6-5.7,0L25,16.8L12.6,4.4c-1.6-1.6-4.1-1.6-5.7,0c-1.6,1.6-1.6,4.1,0,5.7L21,24.2
+	v18.6c0,2.2,1.8,4,4,4s4-1.8,4-4V24.1l14.1-14.1C44.6,8.5,44.6,6,43.1,4.4z"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_submarine.svg b/pepys_timeline/static/image/naval_submarine.svg
new file mode 100644
index 000000000..f18d8042d
--- /dev/null
+++ b/pepys_timeline/static/image/naval_submarine.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M47.3266 31.6963L40.734 23.9174C40.354 23.4691 39.796 23.2105 39.2083 23.2105H26.5257V9.97554C26.5257 8.88448 25.6412 8 24.5501 8C23.4591 8 22.5746 8.88448 22.5746 9.97554V23.2105H9.55603C8.90984 23.2105 8.30347 23.5227 7.9281 24.0487L2.37207 31.8337C1.85639 32.5563 1.8787 33.5323 2.42686 34.2305L7.92572 41.235C8.30487 41.718 8.88485 42 9.49886 42H39.2651C39.8199 42 40.3497 41.7696 40.728 41.3638L47.2637 34.3531C47.9551 33.6115 47.9821 32.4698 47.3266 31.6963ZM24.5 37C26.9853 37 29 34.9853 29 32.5C29 30.0147 26.9853 28 24.5 28C22.0147 28 20 30.0147 20 32.5C20 34.9853 22.0147 37 24.5 37Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/naval_survey.svg b/pepys_timeline/static/image/naval_survey.svg
new file mode 100644
index 000000000..e41ffe2e7
--- /dev/null
+++ b/pepys_timeline/static/image/naval_survey.svg
@@ -0,0 +1,5 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.731 33.6708C14.5562 33.1065 14.3575 32.5265 14.135 31.9308C13.9284 31.3352 13.7218 30.7395 13.5151 30.1438H7.0781C6.87148 30.7395 6.65691 31.3352 6.4344 31.9308C6.22778 32.5265 6.03705 33.1065 5.86222 33.6708H2C2.61986 31.9152 3.20794 30.2928 3.76422 28.8036C4.32051 27.3144 4.8609 25.9115 5.3854 24.5947C5.92579 23.278 6.45029 22.0318 6.9589 20.8562C7.4834 19.6648 8.02379 18.5048 8.58008 17.3762H12.1324C12.6728 18.5048 13.2052 19.6648 13.7297 20.8562C14.2542 22.0318 14.7787 23.278 15.3032 24.5947C15.8436 25.9115 16.3919 27.3144 16.9482 28.8036C17.5045 30.2928 18.0926 31.9152 18.7124 33.6708H14.731ZM10.2728 21.0678C10.1933 21.3029 10.0741 21.6243 9.91516 22.0318C9.75622 22.4394 9.57344 22.9096 9.36682 23.4426C9.1602 23.9756 8.92974 24.5634 8.67544 25.2061C8.43703 25.8488 8.19067 26.5228 7.93637 27.2282H12.633C12.3787 26.5228 12.1324 25.8488 11.894 25.2061C11.6555 24.5634 11.4251 23.9756 11.2026 23.4426C10.9959 22.9096 10.8132 22.4394 10.6542 22.0318C10.4953 21.6243 10.3681 21.3029 10.2728 21.0678Z" fill="white"/>
+<path d="M28.3039 20.1978C26.5715 20.1978 25.3159 20.6759 24.5371 21.6321C23.7741 22.5726 23.3927 23.8658 23.3927 25.5118C23.3927 26.3112 23.4881 27.0401 23.6788 27.6985C23.8695 28.3412 24.1556 28.8976 24.5371 29.3679C24.9185 29.8382 25.3953 30.2065 25.9675 30.473C26.5397 30.7238 27.2072 30.8492 27.9701 30.8492C28.3834 30.8492 28.733 30.8414 29.0191 30.8257C29.3211 30.81 29.5834 30.7787 29.8059 30.7317V25.065H33.5251V33.13C33.08 33.3024 32.3648 33.4827 31.3794 33.6708C30.394 33.8746 29.1781 33.9765 27.7317 33.9765C26.492 33.9765 25.3635 33.7884 24.3463 33.4122C23.345 33.036 22.4867 32.4873 21.7715 31.7663C21.0563 31.0452 20.5 30.1595 20.1027 29.1093C19.7212 28.059 19.5305 26.8598 19.5305 25.5118C19.5305 24.148 19.745 22.941 20.1742 21.8907C20.6033 20.8405 21.1914 19.9548 21.9384 19.2337C22.6854 18.497 23.5596 17.9405 24.5609 17.5643C25.5781 17.1881 26.6589 17 27.8033 17C28.5821 17 29.2814 17.0549 29.9012 17.1646C30.537 17.2586 31.0774 17.3762 31.5224 17.5173C31.9834 17.6427 32.3569 17.7759 32.6429 17.917C32.9449 18.0581 33.1595 18.1678 33.2866 18.2462L32.2138 21.1853C31.7052 20.9189 31.1171 20.6916 30.4496 20.5035C29.7979 20.2997 29.0827 20.1978 28.3039 20.1978Z" fill="white"/>
+<path d="M41.4914 30.8728C42.0159 30.8728 42.4451 30.8336 42.7788 30.7552C43.1285 30.6611 43.4067 30.5436 43.6133 30.4025C43.8199 30.2457 43.9629 30.0655 44.0424 29.8617C44.1219 29.6579 44.1616 29.4306 44.1616 29.1798C44.1616 28.6468 43.9073 28.2079 43.3987 27.8631C42.8901 27.5025 42.0159 27.1185 40.7762 26.7109C40.2358 26.5228 39.6954 26.3112 39.155 26.0761C38.6146 25.8253 38.1299 25.5196 37.7007 25.1591C37.2716 24.7828 36.9219 24.3361 36.6517 23.8188C36.3816 23.2858 36.2465 22.6432 36.2465 21.8907C36.2465 21.1383 36.3895 20.4643 36.6756 19.8686C36.9617 19.2573 37.367 18.74 37.8915 18.3167C38.416 17.8935 39.0517 17.5722 39.7987 17.3527C40.5458 17.1176 41.3881 17 42.3259 17C43.4384 17 44.4 17.1176 45.2106 17.3527C46.0212 17.5878 46.6888 17.8465 47.2132 18.1286L46.1404 21.0207C45.6795 20.7856 45.1629 20.5818 44.5908 20.4094C44.0345 20.2213 43.359 20.1272 42.5643 20.1272C41.6742 20.1272 41.0305 20.2527 40.6332 20.5035C40.2517 20.7386 40.061 21.107 40.061 21.6086C40.061 21.9064 40.1325 22.1572 40.2756 22.361C40.4186 22.5648 40.6173 22.7529 40.8716 22.9253C41.1418 23.0821 41.4438 23.231 41.7775 23.3721C42.1272 23.4975 42.5087 23.6307 42.9219 23.7718C43.7802 24.0853 44.5272 24.3988 45.1629 24.7123C45.7987 25.0101 46.3232 25.3628 46.7364 25.7704C47.1656 26.178 47.4834 26.6561 47.6901 27.2047C47.8967 27.7533 48 28.4195 48 29.2033C48 30.7238 47.4596 31.9073 46.3788 32.7538C45.298 33.5846 43.6689 34 41.4914 34C40.7603 34 40.1007 33.953 39.5127 33.8589C38.9246 33.7805 38.4001 33.6787 37.9392 33.5532C37.4941 33.4278 37.1047 33.2946 36.771 33.1535C36.4531 33.0124 36.1829 32.8792 35.9604 32.7538L37.0094 29.8382C37.5021 30.1047 38.106 30.3476 38.8213 30.5671C39.5524 30.7709 40.4424 30.8728 41.4914 30.8728Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/passenger_vessel.svg b/pepys_timeline/static/image/passenger_vessel.svg
new file mode 100644
index 000000000..2d033da1a
--- /dev/null
+++ b/pepys_timeline/static/image/passenger_vessel.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M15.7039 9C14.505 9 13.533 9.98806 13.533 11.2069V19.8768H4.17442C2.4565 19.8768 1.41925 21.8092 2.34881 23.2779L12.9247 39.9873C13.3242 40.6185 14.0119 41 14.7503 41H35.6566C36.4086 41 37.107 40.6043 37.5027 39.9542L47.6718 23.2449C48.5666 21.7746 47.5263 19.8768 45.8257 19.8768H35.9654V11.2069C35.9654 9.98806 34.9935 9 33.7945 9H15.7039ZM15.7039 11.2069H33.7945V19.8768C33.7945 21.0957 34.7664 22.0837 35.9654 22.0837H45.8257L46.7487 22.6643L45.8257 22.0837L35.6566 38.7931H14.7503L4.17442 22.0837H13.533C14.732 22.0837 15.7039 21.0957 15.7039 19.8768V11.2069ZM29.2865 22.6518C28.2054 21.9235 26.6554 21.5594 24.6365 21.5594C24.0764 21.5594 23.4251 21.5859 22.6827 21.6389C21.9402 21.6918 21.2369 21.7845 20.5726 21.9169V35.4827H23.6205V30.8747H24.5778C26.6098 30.8747 28.1728 30.5106 29.2669 29.7823C30.3611 29.0408 30.9081 27.8424 30.9081 26.1873C30.9081 24.5453 30.3676 23.3669 29.2865 22.6518ZM24.1676 24.2209C24.389 24.2077 24.6104 24.2011 24.8318 24.2011C25.2747 24.2011 25.6785 24.2342 26.0432 24.3004C26.4079 24.3533 26.714 24.4593 26.9615 24.6182C27.222 24.7638 27.4173 24.9624 27.5476 25.214C27.6909 25.4656 27.7625 25.7834 27.7625 26.1674C27.7625 26.9619 27.4955 27.5048 26.9615 27.7961C26.4274 28.0874 25.6329 28.2331 24.5778 28.2331H23.6205V24.2606C23.7768 24.2474 23.9592 24.2342 24.1676 24.2209Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/pleasure_craft.svg b/pepys_timeline/static/image/pleasure_craft.svg
new file mode 100644
index 000000000..c0ab8afc3
--- /dev/null
+++ b/pepys_timeline/static/image/pleasure_craft.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.5311 39.5212L2.92905 23.799C2.52209 23.1326 3.00167 22.2778 3.78248 22.2778H9.03529L14.4253 12.0664C14.7716 11.4105 15.4524 11 16.1941 11H21.5168C22.2531 11 22.737 11.7688 22.4184 12.4326L17.6941 22.2778H46.2175C46.9983 22.2778 47.4779 23.1326 47.0709 23.799L37.4689 39.5212C37.2873 39.8186 36.9639 40 36.6155 40H13.3845C13.0361 40 12.7127 39.8186 12.5311 39.5212Z" stroke="white" stroke-width="2"/>
+</svg>
diff --git a/pepys_timeline/static/image/research_vessel.svg b/pepys_timeline/static/image/research_vessel.svg
new file mode 100644
index 000000000..5611f7bbb
--- /dev/null
+++ b/pepys_timeline/static/image/research_vessel.svg
@@ -0,0 +1,5 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.5462 32.7095C13.3876 32.2116 13.2074 31.6999 13.0056 31.1743C12.8182 30.6487 12.6308 30.1231 12.4434 29.5975H6.60549C6.4181 30.1231 6.2235 30.6487 6.0217 31.1743C5.8343 31.6999 5.66133 32.2116 5.50277 32.7095H2C2.56217 31.1604 3.09552 29.7289 3.60003 28.4149C4.10454 27.101 4.59464 25.8631 5.07033 24.7012C5.56043 23.5394 6.03611 22.4398 6.49738 21.4025C6.97306 20.3513 7.46316 19.3278 7.96768 18.332H11.1894C11.6795 19.3278 12.1623 20.3513 12.638 21.4025C13.1137 22.4398 13.5894 23.5394 14.0651 24.7012C14.5552 25.8631 15.0525 27.101 15.557 28.4149C16.0615 29.7289 16.5949 31.1604 17.157 32.7095H13.5462ZM9.50284 21.5892C9.43077 21.7967 9.32266 22.0802 9.17851 22.4398C9.03436 22.7994 8.86859 23.2144 8.6812 23.6846C8.49381 24.1549 8.2848 24.6736 8.05417 25.2407C7.83794 25.8077 7.61452 26.4025 7.38388 27.0249H11.6434C11.4128 26.4025 11.1894 25.8077 10.9731 25.2407C10.7569 24.6736 10.5479 24.1549 10.3461 23.6846C10.1587 23.2144 9.99294 22.7994 9.84879 22.4398C9.70465 22.0802 9.58933 21.7967 9.50284 21.5892Z" fill="white"/>
+<path d="M25.8558 20.8216C24.2846 20.8216 23.1459 21.2434 22.4396 22.0871C21.7477 22.917 21.4017 24.0581 21.4017 25.5104C21.4017 26.2158 21.4882 26.8589 21.6612 27.4398C21.8341 28.0069 22.0936 28.4979 22.4396 28.9129C22.7855 29.3278 23.218 29.6528 23.7369 29.888C24.2558 30.1093 24.8612 30.2199 25.5531 30.2199C25.9279 30.2199 26.245 30.213 26.5045 30.1992C26.7784 30.1853 27.0162 30.1577 27.218 30.1162V25.1162H30.5911V32.2324C30.1875 32.3845 29.5388 32.5436 28.6451 32.7095C27.7514 32.8893 26.6486 32.9793 25.3369 32.9793C24.2126 32.9793 23.1891 32.8133 22.2666 32.4813C21.3585 32.1494 20.5801 31.6653 19.9314 31.029C19.2827 30.3928 18.7782 29.6113 18.4179 28.6846C18.0719 27.758 17.8989 26.6999 17.8989 25.5104C17.8989 24.3071 18.0935 23.242 18.4827 22.3154C18.8719 21.3887 19.4053 20.6072 20.0828 19.971C20.7603 19.3209 21.5531 18.8299 22.4612 18.4979C23.3837 18.166 24.3639 18 25.4018 18C26.1081 18 26.7423 18.0484 27.3045 18.1452C27.8811 18.2282 28.3712 18.332 28.7748 18.4564C29.1928 18.5671 29.5316 18.6846 29.791 18.8091C30.0649 18.9336 30.2595 19.0304 30.3748 19.0996L29.4019 21.6929C28.9406 21.4578 28.4072 21.2573 27.8018 21.0913C27.2108 20.9115 26.5622 20.8216 25.8558 20.8216Z" fill="white"/>
+<path d="M36.5619 25.5104C36.5619 26.2158 36.6484 26.852 36.8214 27.4191C37.0088 27.9862 37.2683 28.4772 37.5998 28.8921C37.9458 29.2932 38.3638 29.6044 38.8539 29.8257C39.344 30.047 39.9062 30.1577 40.5404 30.1577C41.1602 30.1577 41.7152 30.047 42.2053 29.8257C42.7098 29.6044 43.1278 29.2932 43.4594 28.8921C43.8053 28.4772 44.0648 27.9862 44.2378 27.4191C44.4252 26.852 44.5189 26.2158 44.5189 25.5104C44.5189 24.805 44.4252 24.1687 44.2378 23.6017C44.0648 23.0207 43.8053 22.5297 43.4594 22.1286C43.1278 21.7137 42.7098 21.3956 42.2053 21.1743C41.7152 20.953 41.1602 20.8423 40.5404 20.8423C39.9062 20.8423 39.344 20.9599 38.8539 21.195C38.3638 21.4163 37.9458 21.7344 37.5998 22.1494C37.2683 22.5505 37.0088 23.0415 36.8214 23.6224C36.6484 24.1895 36.5619 24.8188 36.5619 25.5104ZM48 25.5104C48 26.7414 47.8054 27.8271 47.4162 28.7676C47.0414 29.6943 46.5225 30.4758 45.8594 31.112C45.1963 31.7344 44.4035 32.2047 43.481 32.5228C42.5729 32.8409 41.5927 33 40.5404 33C39.517 33 38.5512 32.8409 37.6431 32.5228C36.7349 32.2047 35.9421 31.7344 35.2646 31.112C34.5871 30.4758 34.0538 29.6943 33.6646 28.7676C33.2754 27.8271 33.0808 26.7414 33.0808 25.5104C33.0808 24.2794 33.2826 23.2006 33.6862 22.2739C34.0898 21.3333 34.6304 20.545 35.3079 19.9087C35.9998 19.2725 36.7926 18.7953 37.6863 18.4772C38.5944 18.1591 39.5458 18 40.5404 18C41.5638 18 42.5296 18.1591 43.4378 18.4772C44.3459 18.7953 45.1387 19.2725 45.8162 19.9087C46.4937 20.545 47.027 21.3333 47.4162 22.2739C47.8054 23.2006 48 24.2794 48 25.5104Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/sailing_vessel.svg b/pepys_timeline/static/image/sailing_vessel.svg
new file mode 100644
index 000000000..40846ee82
--- /dev/null
+++ b/pepys_timeline/static/image/sailing_vessel.svg
@@ -0,0 +1,3 @@
+<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3463 5.3491C24.3463 5.1563 24.5011 5 24.6919 5H26.9583C27.0132 5 27.0651 5.01292 27.1112 5.0359C27.5682 4.98525 28.052 5.11713 28.4499 5.48193L44.1277 19.8551C45.2979 20.9278 44.5465 22.8931 42.9663 22.8931H27.8645C27.6711 22.8931 27.4834 22.8691 27.3039 22.8238V25.3457H29.0785H44.692C46.524 25.3457 47.6233 27.4007 46.6194 28.9485L36.8901 43.9481C36.4643 44.6045 35.7397 45 34.9627 45H16.0617C15.3228 45 14.6288 44.6421 14.1954 44.0377L3.44202 29.038C2.33881 27.4992 3.42726 25.3457 5.30824 25.3457H24.3463V5.3491ZM24.9379 27.673H5.30824L16.0617 42.6726H34.9627L44.692 27.673H29.0785H24.9379ZM27.8645 20.5658V8.08831L41.4746 20.5658L27.8645 20.5658Z" fill="white"/>
+</svg>
diff --git a/pepys_timeline/static/image/unknown.svg b/pepys_timeline/static/image/unknown.svg
new file mode 100644
index 000000000..23c4dd2e3
--- /dev/null
+++ b/pepys_timeline/static/image/unknown.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>Frigate</title>
+<g>
+	<path class="st0" d="M10.9,15.5c0-1.8,0.6-3.6,1.7-5.4s2.8-3.3,5-4.5s4.8-1.8,7.7-1.8c2.7,0,5.1,0.5,7.2,1.5s3.7,2.4,4.8,4.1
+		c1.1,1.7,1.7,3.6,1.7,5.6c0,1.6-0.3,3-1,4.2c-0.6,1.2-1.4,2.2-2.3,3.1c-0.9,0.9-2.5,2.3-4.8,4.4c-0.6,0.6-1.1,1.1-1.5,1.5
+		c-0.4,0.4-0.7,0.8-0.9,1.2s-0.3,0.7-0.4,1.1c-0.1,0.4-0.3,1-0.5,1.9c-0.4,2-1.5,2.9-3.3,2.9c-1,0-1.8-0.3-2.5-1
+		c-0.7-0.6-1-1.6-1-2.8c0-1.6,0.2-2.9,0.7-4.1c0.5-1.2,1.1-2.2,1.9-3c0.8-0.9,1.9-1.9,3.3-3.1c1.2-1.1,2.1-1.8,2.6-2.4
+		c0.5-0.5,1-1.1,1.4-1.8c0.4-0.7,0.5-1.4,0.5-2.1c0-1.5-0.6-2.8-1.7-3.8c-1.1-1-2.6-1.5-4.3-1.5c-2.1,0-3.6,0.5-4.6,1.6
+		c-1,1-1.8,2.6-2.5,4.6c-0.6,2.1-1.8,3.2-3.6,3.2c-1.1,0-1.9-0.4-2.7-1.1C11.3,17.1,10.9,16.3,10.9,15.5z M24.7,46.3
+		c-1.1,0-2.1-0.4-3-1.1s-1.3-1.8-1.3-3.1c0-1.2,0.4-2.2,1.2-3c0.8-0.8,1.8-1.2,3-1.2c1.2,0,2.2,0.4,3,1.2c0.8,0.8,1.2,1.8,1.2,3
+		c0,1.3-0.4,2.3-1.3,3.1C26.7,45.9,25.8,46.3,24.7,46.3z"/>
+</g>
+</svg>
diff --git a/pepys_timeline/static/image/unmanned-airborne-vehicle.svg b/pepys_timeline/static/image/unmanned-airborne-vehicle.svg
new file mode 100644
index 000000000..dfa088805
--- /dev/null
+++ b/pepys_timeline/static/image/unmanned-airborne-vehicle.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 24.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="icons_set" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 viewBox="0 0 50 50" style="enable-background:new 0 0 50 50;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:#FFFFFF;}
+</style>
+<title>UAS</title>
+<path id="UAS" class="st0" d="M42,4.6L2.3,12.9c-0.5,0.1-0.9,0.7-0.8,1.2c0.1,0.4,0.4,0.7,0.8,0.8l30.4,6c0.6,0.1,1.1,0.5,1.3,1
+	l12.3,24.5c0.3,0.5,0.9,0.6,1.4,0.3c0.3-0.2,0.5-0.6,0.5-0.9L44.4,6.4c-0.1-1.1-1.1-1.9-2.2-1.8C42.2,4.6,42.1,4.6,42,4.6z"/>
+</svg>
diff --git a/pepys_timeline/templates/index.html b/pepys_timeline/templates/index.html
old mode 100755
new mode 100644
index 7a3919475..9b3a7226d
--- a/pepys_timeline/templates/index.html
+++ b/pepys_timeline/templates/index.html
@@ -211,10 +211,14 @@ <h2 id="error-message"></h2>
 
 		<!-- This has to stay here for templating -->
 		<script type="text/javascript">
+			function platformTypeToIcon(platformType) {
+			  return platformType.replaceAll('-', '').replaceAll('_', '').replaceAll(/\s+/g,"_").toLowerCase();
+			}
 			function getParticipantIconUrl(participant) {
 				return "{{ url_for('static', filename='image/PLATFORM_TYPE.svg') }}".replace(
-					"PLATFORM_TYPE", participant["platform-type"]
-				);
+						"PLATFORM_TYPE",
+						platformTypeToIcon(participant["platform-type"])
+					);
 			}
 		</script>
 
@@ -226,4 +230,4 @@ <h2 id="error-message"></h2>
 		
 	</body>
 
-</html>
\ No newline at end of file
+</html>
diff --git a/requirements.txt b/requirements.txt
index 3428f9441..727331097 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -20,4 +20,5 @@ sqlalchemy-utils==0.37.*
 Flask
 Flask-CacheBuster
 flask-cors
-waitress
\ No newline at end of file
+waitress
+lxml
\ No newline at end of file
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 46b25b952..52a90f7e7 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -6,7 +6,7 @@ flake8==3.*
 tox==3.14.0
 coverage==5.*
 pytest>=5.3.5
-Sphinx==3.*
+Sphinx==4.*
 twine==3.*
 nose2
 black
diff --git a/runtime.txt b/runtime.txt
index a48890ea3..febc1b8cf 100644
--- a/runtime.txt
+++ b/runtime.txt
@@ -1,1 +1,1 @@
-python-3.8.10
+python-3.9.8
diff --git a/setup.cfg b/setup.cfg
index cb06297a0..b786619fc 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.0.31
+current_version = 0.0.33
 commit = True
 tag = True
 
diff --git a/setup.py b/setup.py
index b4669226b..2924931b0 100644
--- a/setup.py
+++ b/setup.py
@@ -29,6 +29,7 @@
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
     ],
     description="Python library to handle database ingest of geospatial track (and other) data ",
     install_requires=requirements,
@@ -43,6 +44,6 @@
     test_suite="tests",
     tests_require=test_requirements,
     url="https://github.com/debrief/pepys-import",
-    version="0.0.31",
+    version="0.0.33",
     zip_safe=False,
 )
diff --git a/tox.ini b/tox.ini
deleted file mode 100644
index 5e80ceef4..000000000
--- a/tox.ini
+++ /dev/null
@@ -1,21 +0,0 @@
-[tox]
-envlist = py27, py35, py36, py37, py38, flake8
-
-[travis]
-python =
-    3.8: py38
-    3.7: py37
-    3.6: py36
-    3.5: py35
-    2.7: py27
-
-[testenv:flake8]
-basepython = python
-deps = flake8
-commands = flake8 pepys_import
-
-[testenv]
-setenv =
-    PYTHONPATH = {toxinidir}
-
-commands = python setup.py test


Test patch:
diff --git a/.github/workflows/test_pepys.yml b/.github/workflows/test_pepys.yml
index b127889d8..880b94fe9 100644
--- a/.github/workflows/test_pepys.yml
+++ b/.github/workflows/test_pepys.yml
@@ -24,10 +24,10 @@ jobs:
           sudo apt remove postgresql-14 postgresql-client
           sudo apt install -y postgresql-13-postgis-3 sqlite3 libgdal-dev libproj-dev libproj-dev libsqlite3-dev spatialite-bin libsqlite3-mod-spatialite
 
-      - name: Set up Python 3.8
+      - name: Set up Python 3.9
         uses: actions/setup-python@v2
         with:
-          python-version: 3.8
+          python-version: 3.9
   
       - name: Install pip dependencies
         shell: bash -l {0}
@@ -82,10 +82,10 @@ jobs:
     steps:
       - uses: actions/checkout@v2
 
-      - name: Set up Python 3.8
+      - name: Set up Python 3.9
         uses: actions/setup-python@v2
         with:
-          python-version: 3.8
+          python-version: 3.9
 
       # - name: Install Postgres 12
       #   uses: crazy-max/ghaction-chocolatey@v1
diff --git a/migrations/latest_revisions.json b/migrations/latest_revisions.json
index cd2ebdd79..176c42968 100644
--- a/migrations/latest_revisions.json
+++ b/migrations/latest_revisions.json
@@ -1,4 +1,4 @@
 {
-    "LATEST_SQLITE_VERSION": "e5c78b6abb2f",
-    "LATEST_POSTGRES_VERSION": "40d8ee1342f2"
+    "LATEST_SQLITE_VERSION": "cebecb0b77a1",
+    "LATEST_POSTGRES_VERSION": "2bc85b76a38b"
 }
\ No newline at end of file
diff --git a/tests/benchmarks/test_import_benchmark.py b/tests/benchmarks/test_import_benchmark.py
index 1b6c3f24e..37f82725d 100644
--- a/tests/benchmarks/test_import_benchmark.py
+++ b/tests/benchmarks/test_import_benchmark.py
@@ -9,7 +9,10 @@
 FILE_DIR = os.path.dirname(__file__)
 
 
-def run_import(processor, file_path):
+def run_import(file_path):
+    processor = FileProcessor(archive=False)
+    processor.load_importers_dynamically()
+
     store = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
     store.initialise()
 
@@ -18,12 +21,8 @@ def run_import(processor, file_path):
 
 @pytest.mark.benchmark(min_time=0.1, max_time=2.0, min_rounds=10, warmup=False)
 def test_single_rep_file_import_short(benchmark):
-    processor = FileProcessor(archive=False)
-    processor.load_importers_dynamically()
-
     benchmark(
         run_import,
-        processor=processor,
         file_path=os.path.join(FILE_DIR, "benchmark_data/rep_test1.rep"),
     )
 
@@ -43,10 +42,7 @@ def test_single_rep_file_import_long(benchmark):
 
     benchmark.pedantic(
         run_import,
-        args=(
-            processor,
-            os.path.join(FILE_DIR, "benchmark_data/bulk_data.rep"),
-        ),
+        args=(os.path.join(FILE_DIR, "benchmark_data/bulk_data.rep"),),
         iterations=1,
         rounds=1,
     )
diff --git a/tests/gui_tests/test_column_data.py b/tests/gui_tests/test_column_data.py
index c4fd1b571..11bd9fff0 100644
--- a/tests/gui_tests/test_column_data.py
+++ b/tests/gui_tests/test_column_data.py
@@ -300,6 +300,7 @@ def test_column_data_platform():
                 "Yemen",
                 "Zambia",
                 "Zimbabwe",
+                "Unknown",
             ],
         },
         "nationality name": {
@@ -564,6 +565,7 @@ def test_column_data_platform():
                 "Yemen",
                 "Zambia",
                 "Zimbabwe",
+                "Unknown",
             ],
         },
         "platform id": {
@@ -581,25 +583,40 @@ def test_column_data_platform():
             "system_name": "platform_type",
             "type": "string",
             "values": [
-                "Naval - aircraft",
-                "Naval - frigate",
+                "Attack Craft",
+                "Commercial aircraft",
+                "Fishing Vessel",
+                "High Speed Craft",
+                "Law Enforcement",
+                "Medical",
+                "Merchant",
+                "Naval - aircraft carrier",
+                "Naval - amphib",
                 "Naval - auxiliary",
+                "Naval - cruiser",
                 "Naval - destroyer",
-                "Naval - survey",
-                "Naval - minesweeper",
+                "Naval - ExCon",
+                "Naval - fixed wing",
+                "Naval - frigate",
+                "Naval - minewarfare",
+                "Naval - miscellaneous",
                 "Naval - patrol",
-                "Naval - aircraft carrier",
+                "Naval - rotary wing",
                 "Naval - submarine",
-                "Naval - miscellaneous",
-                "Merchant",
-                "Tug",
-                "Tanker",
-                "Law Enforcement",
+                "Naval - survey",
+                "Passenger vessel",
                 "Pleasure Craft",
+                "Research vessel",
+                "Sailing vessel",
                 "Search and Rescue",
-                "Fishing Vessel",
-                "Passenger/Ferry",
-                "High Speed Craft",
+                "Offshore support",
+                "Civilian - uncrewed surface vehicle",
+                "Civilian - uncrewed underwater vehicle",
+                "Civilian - uncrewed air vehicle",
+                "Naval - uncrewed surface vehicle",
+                "Naval - uncrewed underwater vehicle",
+                "Naval - uncrewed air vehicle",
+                "Unknown",
             ],
         },
         "platform type name": {
@@ -608,25 +625,40 @@ def test_column_data_platform():
             "system_name": "platform_type_name",
             "type": "string",
             "values": [
+                "Attack Craft",
+                "Civilian - uncrewed air vehicle",
+                "Civilian - uncrewed surface vehicle",
+                "Civilian - uncrewed underwater vehicle",
+                "Commercial aircraft",
                 "Fishing Vessel",
                 "High Speed Craft",
                 "Law Enforcement",
+                "Medical",
                 "Merchant",
-                "Naval - aircraft",
+                "Naval - ExCon",
                 "Naval - aircraft carrier",
+                "Naval - amphib",
                 "Naval - auxiliary",
+                "Naval - cruiser",
                 "Naval - destroyer",
+                "Naval - fixed wing",
                 "Naval - frigate",
-                "Naval - minesweeper",
+                "Naval - minewarfare",
                 "Naval - miscellaneous",
                 "Naval - patrol",
+                "Naval - rotary wing",
                 "Naval - submarine",
                 "Naval - survey",
-                "Passenger/Ferry",
+                "Naval - uncrewed air vehicle",
+                "Naval - uncrewed surface vehicle",
+                "Naval - uncrewed underwater vehicle",
+                "Offshore support",
+                "Passenger vessel",
                 "Pleasure Craft",
+                "Research vessel",
+                "Sailing vessel",
                 "Search and Rescue",
-                "Tanker",
-                "Tug",
+                "Unknown",
             ],
         },
         "privacy": {
@@ -845,25 +877,40 @@ def test_column_data_platform_type():
             "system_name": "name",
             "type": "string",
             "values": [
+                "Attack Craft",
+                "Civilian - uncrewed air vehicle",
+                "Civilian - uncrewed surface vehicle",
+                "Civilian - uncrewed underwater vehicle",
+                "Commercial aircraft",
                 "Fishing Vessel",
                 "High Speed Craft",
                 "Law Enforcement",
+                "Medical",
                 "Merchant",
-                "Naval - aircraft",
+                "Naval - ExCon",
                 "Naval - aircraft carrier",
+                "Naval - amphib",
                 "Naval - auxiliary",
+                "Naval - cruiser",
                 "Naval - destroyer",
+                "Naval - fixed wing",
                 "Naval - frigate",
-                "Naval - minesweeper",
+                "Naval - minewarfare",
                 "Naval - miscellaneous",
                 "Naval - patrol",
+                "Naval - rotary wing",
                 "Naval - submarine",
                 "Naval - survey",
-                "Passenger/Ferry",
+                "Naval - uncrewed air vehicle",
+                "Naval - uncrewed surface vehicle",
+                "Naval - uncrewed underwater vehicle",
+                "Offshore support",
+                "Passenger vessel",
                 "Pleasure Craft",
+                "Research vessel",
+                "Sailing vessel",
                 "Search and Rescue",
-                "Tanker",
-                "Tug",
+                "Unknown",
             ],
         },
         "platform type id": {
@@ -1523,6 +1570,7 @@ def test_edit_data_platform():
                 "Yemen",
                 "Zambia",
                 "Zimbabwe",
+                "Unknown",
             ],
         },
         "platform type": {
@@ -1534,25 +1582,40 @@ def test_edit_data_platform():
             "system_name": "platform_type",
             "type": "string",
             "values": [
-                "Naval - aircraft",
-                "Naval - frigate",
+                "Attack Craft",
+                "Commercial aircraft",
+                "Fishing Vessel",
+                "High Speed Craft",
+                "Law Enforcement",
+                "Medical",
+                "Merchant",
+                "Naval - aircraft carrier",
+                "Naval - amphib",
                 "Naval - auxiliary",
+                "Naval - cruiser",
                 "Naval - destroyer",
-                "Naval - survey",
-                "Naval - minesweeper",
+                "Naval - ExCon",
+                "Naval - fixed wing",
+                "Naval - frigate",
+                "Naval - minewarfare",
+                "Naval - miscellaneous",
                 "Naval - patrol",
-                "Naval - aircraft carrier",
+                "Naval - rotary wing",
                 "Naval - submarine",
-                "Naval - miscellaneous",
-                "Merchant",
-                "Tug",
-                "Tanker",
-                "Law Enforcement",
+                "Naval - survey",
+                "Passenger vessel",
                 "Pleasure Craft",
+                "Research vessel",
+                "Sailing vessel",
                 "Search and Rescue",
-                "Fishing Vessel",
-                "Passenger/Ferry",
-                "High Speed Craft",
+                "Offshore support",
+                "Civilian - uncrewed surface vehicle",
+                "Civilian - uncrewed underwater vehicle",
+                "Civilian - uncrewed air vehicle",
+                "Naval - uncrewed surface vehicle",
+                "Naval - uncrewed underwater vehicle",
+                "Naval - uncrewed air vehicle",
+                "Unknown",
             ],
         },
         "privacy": {
diff --git a/tests/gui_tests/test_gui_methods.py b/tests/gui_tests/test_gui_methods.py
index c5cdf6820..ec9e23fcc 100644
--- a/tests/gui_tests/test_gui_methods.py
+++ b/tests/gui_tests/test_gui_methods.py
@@ -319,6 +319,7 @@ def test_generating_column_data(pytestconfig, test_datastore):
                 "Yemen",
                 "Zambia",
                 "Zimbabwe",
+                "Unknown",
             ],
         },
         "nationality name": {
@@ -583,6 +584,7 @@ def test_generating_column_data(pytestconfig, test_datastore):
                 "Yemen",
                 "Zambia",
                 "Zimbabwe",
+                "Unknown",
             ],
         },
         "platform id": {
@@ -600,25 +602,40 @@ def test_generating_column_data(pytestconfig, test_datastore):
             "system_name": "platform_type",
             "type": "string",
             "values": [
-                "Naval - aircraft",
-                "Naval - frigate",
+                "Attack Craft",
+                "Commercial aircraft",
+                "Fishing Vessel",
+                "High Speed Craft",
+                "Law Enforcement",
+                "Medical",
+                "Merchant",
+                "Naval - aircraft carrier",
+                "Naval - amphib",
                 "Naval - auxiliary",
+                "Naval - cruiser",
                 "Naval - destroyer",
-                "Naval - survey",
-                "Naval - minesweeper",
+                "Naval - ExCon",
+                "Naval - fixed wing",
+                "Naval - frigate",
+                "Naval - minewarfare",
+                "Naval - miscellaneous",
                 "Naval - patrol",
-                "Naval - aircraft carrier",
+                "Naval - rotary wing",
                 "Naval - submarine",
-                "Naval - miscellaneous",
-                "Merchant",
-                "Tug",
-                "Tanker",
-                "Law Enforcement",
+                "Naval - survey",
+                "Passenger vessel",
                 "Pleasure Craft",
+                "Research vessel",
+                "Sailing vessel",
                 "Search and Rescue",
-                "Fishing Vessel",
-                "Passenger/Ferry",
-                "High Speed Craft",
+                "Offshore support",
+                "Civilian - uncrewed surface vehicle",
+                "Civilian - uncrewed underwater vehicle",
+                "Civilian - uncrewed air vehicle",
+                "Naval - uncrewed surface vehicle",
+                "Naval - uncrewed underwater vehicle",
+                "Naval - uncrewed air vehicle",
+                "Unknown",
             ],
         },
         "platform type name": {
@@ -627,25 +644,40 @@ def test_generating_column_data(pytestconfig, test_datastore):
             "system_name": "platform_type_name",
             "type": "string",
             "values": [
+                "Attack Craft",
+                "Civilian - uncrewed air vehicle",
+                "Civilian - uncrewed surface vehicle",
+                "Civilian - uncrewed underwater vehicle",
+                "Commercial aircraft",
                 "Fishing Vessel",
                 "High Speed Craft",
                 "Law Enforcement",
+                "Medical",
                 "Merchant",
-                "Naval - aircraft",
+                "Naval - ExCon",
                 "Naval - aircraft carrier",
+                "Naval - amphib",
                 "Naval - auxiliary",
+                "Naval - cruiser",
                 "Naval - destroyer",
+                "Naval - fixed wing",
                 "Naval - frigate",
-                "Naval - minesweeper",
+                "Naval - minewarfare",
                 "Naval - miscellaneous",
                 "Naval - patrol",
+                "Naval - rotary wing",
                 "Naval - submarine",
                 "Naval - survey",
-                "Passenger/Ferry",
+                "Naval - uncrewed air vehicle",
+                "Naval - uncrewed surface vehicle",
+                "Naval - uncrewed underwater vehicle",
+                "Offshore support",
+                "Passenger vessel",
                 "Pleasure Craft",
+                "Research vessel",
+                "Sailing vessel",
                 "Search and Rescue",
-                "Tanker",
-                "Tug",
+                "Unknown",
             ],
         },
         "privacy": {
diff --git a/tests/jchat_importer_date_sensitive_files/year_month_rollover.html b/tests/jchat_importer_date_sensitive_files/year_month_rollover.html
new file mode 100644
index 000000000..8fde05e0b
--- /dev/null
+++ b/tests/jchat_importer_date_sensitive_files/year_month_rollover.html
@@ -0,0 +1,29 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[31092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>Same month</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[01092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Month rollover</i></font></span>
+  </div>
+  <div id="34bbb=34538">
+    <tt><font>[30101444A]</font></tt><b><a href=""><font>PLT1_WE</font></a></b><span class="msgcontent"><font><i>Same month</i></font></span>
+  </div>
+  <div id="34bbb=34536">
+    <tt><font>[01112854A]</font></tt><b><a href=""><font>SPLD_ME</font></a></b><span class="msgcontent"><font><i>Month rollover</i></font></span>
+  </div>
+  <div id="34bbb=34535">
+    <tt><font>[31020809A]</font></tt><b><a href=""><font>SPLD_XO</font></a></b><span class="msgcontent"><font><i>Same month</i></font></span>
+  </div>
+  <div id="34bbb=34535">
+    <tt><font>[01030809A]</font></tt><b><a href=""><font>SPLD_XO</font></a></b><span class="msgcontent"><font><i>Year and month rollover</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/csv_files/Nationalities.csv b/tests/sample_data/csv_files/Nationalities.csv
index 6a5b72711..0292f43d4 100644
--- a/tests/sample_data/csv_files/Nationalities.csv
+++ b/tests/sample_data/csv_files/Nationalities.csv
@@ -254,4 +254,5 @@ Wallis and Futuna,
 Western Sahara,
 Yemen,
 Zambia,
-Zimbabwe,
\ No newline at end of file
+Zimbabwe,
+Unknown,
\ No newline at end of file
diff --git a/tests/sample_data/csv_files/for_synonym_tests/Nationalities.csv b/tests/sample_data/csv_files/for_synonym_tests/Nationalities.csv
index 6a5b72711..0292f43d4 100644
--- a/tests/sample_data/csv_files/for_synonym_tests/Nationalities.csv
+++ b/tests/sample_data/csv_files/for_synonym_tests/Nationalities.csv
@@ -254,4 +254,5 @@ Wallis and Futuna,
 Western Sahara,
 Yemen,
 Zambia,
-Zimbabwe,
\ No newline at end of file
+Zimbabwe,
+Unknown,
\ No newline at end of file
diff --git a/tests/sample_data/csv_files/for_synonym_tests_bad/Nationalities.csv b/tests/sample_data/csv_files/for_synonym_tests_bad/Nationalities.csv
index 6a5b72711..0292f43d4 100644
--- a/tests/sample_data/csv_files/for_synonym_tests_bad/Nationalities.csv
+++ b/tests/sample_data/csv_files/for_synonym_tests_bad/Nationalities.csv
@@ -254,4 +254,5 @@ Wallis and Futuna,
 Western Sahara,
 Yemen,
 Zambia,
-Zimbabwe,
\ No newline at end of file
+Zimbabwe,
+Unknown,
\ No newline at end of file
diff --git a/tests/sample_data/csv_files/missing_data/Nationalities.csv b/tests/sample_data/csv_files/missing_data/Nationalities.csv
index 6a5b72711..0292f43d4 100644
--- a/tests/sample_data/csv_files/missing_data/Nationalities.csv
+++ b/tests/sample_data/csv_files/missing_data/Nationalities.csv
@@ -254,4 +254,5 @@ Wallis and Futuna,
 Western Sahara,
 Yemen,
 Zambia,
-Zimbabwe,
\ No newline at end of file
+Zimbabwe,
+Unknown,
\ No newline at end of file
diff --git a/tests/sample_data/csv_files/missing_data/Platform Types.csv b/tests/sample_data/csv_files/missing_data/Platform Types.csv
index 7ba0740eb..2639c1552 100644
--- a/tests/sample_data/csv_files/missing_data/Platform Types.csv
+++ b/tests/sample_data/csv_files/missing_data/Platform Types.csv
@@ -1,20 +1,35 @@
 name,default_data_interval_secs
-Naval - aircraft,2
-Naval - frigate,30
-Naval - auxiliary,60
+Attack Craft,30
+Commercial aircraft,2
+Fishing Vessel,60
+High Speed Craft,30
+Law Enforcement,60
+Medical,60
+Merchant,60
+Naval - aircraft carrier,30
+Naval - amphib,30
+Naval - auxiliary,30
+Naval - cruiser,30
 Naval - destroyer,30
-Naval - survey,60
-Naval - minesweeper,60
+Naval - ExCon,30
+Naval - fixed wing,2
+Naval - frigate,30
+Naval - minewarfare,30
+Naval - miscellaneous,30
 Naval - patrol,30
-Naval - aircraft carrier,30
+Naval - rotary wing,5
 Naval - submarine,30
-Naval - miscellaneous,30
-Merchant,60
-Tug,60
-Tanker,60
-Law Enforcement,30
+Naval - survey,30
+Passenger vessel,60
 Pleasure Craft,30
-Search and Rescue,30
-Fishing Vessel,60
-Passenger/Ferry,60
-High Speed Craft,30
\ No newline at end of file
+Research vessel,60
+Sailing vessel,60
+Search and Rescue,60
+Offshore support,60
+Civilian - uncrewed surface vehicle,30
+Civilian - uncrewed underwater vehicle,30
+Civilian - uncrewed air vehicle,5
+Naval - uncrewed surface vehicle,30
+Naval - uncrewed underwater vehicle,30
+Naval - uncrewed air vehicle,5
+Unknown,60
\ No newline at end of file
diff --git a/tests/sample_data/csv_files/missing_data/Sensor Types.csv b/tests/sample_data/csv_files/missing_data/Sensor Types.csv
index a6dc775df..776159b66 100644
--- a/tests/sample_data/csv_files/missing_data/Sensor Types.csv
+++ b/tests/sample_data/csv_files/missing_data/Sensor Types.csv
@@ -9,4 +9,5 @@ Comms
 Broadcast
 Location-Satellite
 Location-Inertial
-Location-Dead-reckoning
\ No newline at end of file
+Location-Dead-reckoning
+Unknown
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/ABC_123.01_XYZ b/tests/sample_data/jchat_files/ABC_123.01_XYZ
new file mode 100644
index 000000000..ac793fdcf
--- /dev/null
+++ b/tests/sample_data/jchat_files/ABC_123.01_XYZ
@@ -0,0 +1,20 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>COMMS TEST</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+  <div id="34bbb=34538">
+    <tt><font>[22101444A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/combined_format.html b/tests/sample_data/jchat_files/combined_format.html
new file mode 100644
index 000000000..505db8e57
--- /dev/null
+++ b/tests/sample_data/jchat_files/combined_format.html
@@ -0,0 +1,26 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div msgid="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><font><a href="">DAUN_AS</a></font></b><font><i>Modern - has i tag</i></font>
+  </div>
+  <div msgid="34bbb=34536">
+    <tt><font>[23112854A]</font></tt> <b><font><a href="">SPLD_ME</a></font></b><font>Modern - <br>no i tag<br>but has multiple<br>breaks</font>
+  </div>
+  <div msgid="34bbb=34535">
+    <tt><font>[06010809A]</font></tt><b><font><a href="">SPLD_XO</a></font></b><font>Modern - no i tag - no breaks</font>
+  </div>
+  <div id="34abc=34537">
+    <tt><font>[07010809A]</font></tt><b><a href=""><font>ABCD_CMD</font></a></b><font>Legacy - font a swap - no i tag - no breaks</font>
+  </div>
+  <div id="34abc=34537">
+    <tt><font>[08010809A]</font></tt><b><a href=""><font>ABCD_CMD</font></a></b><font><i>Legacy - font a swap - has i tag - no breaks</i></font>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/connect_disconnect.html b/tests/sample_data/jchat_files/connect_disconnect.html
new file mode 100644
index 000000000..29df113bb
--- /dev/null
+++ b/tests/sample_data/jchat_files/connect_disconnect.html
@@ -0,0 +1,29 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>SPLD_AB</font></a></b><span class="msgcontent"><font><i>COMMS TEST</i></font></span>
+  </div>
+  <div align="center" class="msgcontent">
+    -- room is disconnected -- <br>
+  </div>
+  <div align="center" class="msgcontent">
+    -- room is reconnected -- <br>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+  <div align="center" class="msgcontent">
+    -- room is disconnected -- <br>
+  </div>
+  <div align="center" class="msgcontent">
+    -- room is reconnected -- <br>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/jchat_breaks_in_message.html b/tests/sample_data/jchat_files/jchat_breaks_in_message.html
new file mode 100644
index 000000000..e99db5756
--- /dev/null
+++ b/tests/sample_data/jchat_files/jchat_breaks_in_message.html
@@ -0,0 +1,20 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>COMMS<br/>TEST</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay<br>bravo<br></i></font></span>
+  </div>
+  <div id="34bbb=34538">
+    <tt><font>[22101444A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay<br>multiple<br>bravos<br><br>in<br>same<br/>tag</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/jchat_modern_format.html b/tests/sample_data/jchat_files/jchat_modern_format.html
new file mode 100644
index 000000000..189759864
--- /dev/null
+++ b/tests/sample_data/jchat_files/jchat_modern_format.html
@@ -0,0 +1,20 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div msgid="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><font><a href="">DAUN_AS</a></font></b><font><i>COMMS TEST</i></font>
+  </div>
+  <div msgid="34bbb=34536">
+    <tt><font>[23112854A]</font></tt> <b><font><a href="">SPLD_ME</a></font></b><font>Replay bravo<br>- no i tag and<br>breaks</font>
+  </div>
+  <div msgid="34bbb=34535">
+    <tt><font>[06010809A]</font></tt><b><font><a href="">SPLD_XO</a></font></b><font>Replay bravo - no i tag</font>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/jchat_no_ext b/tests/sample_data/jchat_files/jchat_no_ext
new file mode 100644
index 000000000..718a000c7
--- /dev/null
+++ b/tests/sample_data/jchat_files/jchat_no_ext
@@ -0,0 +1,26 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>COMMS TEST</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+  <div id="34bbb=34538">
+    <tt><font>[22101444A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+  <div id="34bbb=34536">
+    <tt><font>[23112854A]</font></tt><b><a href=""><font>SPLD_ME</font></a></b><span class="msgcontent"><font><i>Replay bravo - next day</i></font></span>
+  </div>
+  <div id="34bbb=34535">
+    <tt><font>[06010809A]</font></tt><b><a href=""><font>SPLD_XO</font></a></b><span class="msgcontent"><font><i>Replay bravo - next month</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/jchat_sample.html b/tests/sample_data/jchat_files/jchat_sample.html
new file mode 100644
index 000000000..1ad650331
--- /dev/null
+++ b/tests/sample_data/jchat_files/jchat_sample.html
@@ -0,0 +1,26 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>COMMS TEST</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+  <div id="34bbb=34538">
+    <tt><font>[22101444A]</font></tt><b><a href=""><font>PLT1_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+  <div id="34bbb=34536">
+    <tt><font>[23112854A]</font></tt><b><a href=""><font>SPLD_ME</font></a></b><span class="msgcontent"><font><i>Replay bravo - next day</i></font></span>
+  </div>
+  <div id="34bbb=34535">
+    <tt><font>[06010809A]</font></tt><b><a href=""><font>SPLD_XO</font></a></b><span class="msgcontent"><font><i>Replay bravo - next month</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/jchat_unusual_chars.html b/tests/sample_data/jchat_files/jchat_unusual_chars.html
new file mode 100644
index 000000000..a70f6038c
--- /dev/null
+++ b/tests/sample_data/jchat_files/jchat_unusual_chars.html
@@ -0,0 +1,20 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>COMMS&nbsp; &#8217;TEST</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay&amp;&#8216; &#8230;bravo</i></font></span>
+  </div>
+  <div id="34bbb=34538">
+    <tt><font>[22101444A]</font></tt><b><a href=""><font>PLT1_WE</font></a></b><span class="msgcontent"><font><i>Replay&#61514;&#8211;bravo</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/legacy_no_i.html b/tests/sample_data/jchat_files/legacy_no_i.html
new file mode 100644
index 000000000..b6ba26f9e
--- /dev/null
+++ b/tests/sample_data/jchat_files/legacy_no_i.html
@@ -0,0 +1,17 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font><i>COMMS TEST</i></font></span>
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>DAUN_AS</font></a></b><span class="msgcontent"><font>COMMS TEST - no i</font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/sample_data/jchat_files/marker_messages.html b/tests/sample_data/jchat_files/marker_messages.html
new file mode 100644
index 000000000..ed6ada3a5
--- /dev/null
+++ b/tests/sample_data/jchat_files/marker_messages.html
@@ -0,0 +1,21 @@
+<html>
+<head>
+  <style type="text/css">
+    <!--
+      span.msgcontent { color: #0 }
+    -->
+  </style>
+</head>
+<body>
+  <div id="34544=34534">
+    <tt><font>[22092744A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>COMMS TEST</i></font></span>
+  </div>
+  <div id="marker">
+    <hr>
+
+  </div>
+  <div id="34aaa=34534">
+    <tt><font>[22092944A]</font></tt><b><a href=""><font>SPLD_WE</font></a></b><span class="msgcontent"><font><i>Replay bravo</i></font></span>
+  </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/tests/test_admin_cli.py b/tests/test_admin_cli.py
index 3feb01517..d8a6aed97 100644
--- a/tests/test_admin_cli.py
+++ b/tests/test_admin_cli.py
@@ -965,15 +965,13 @@ def setUp(self, patched_prompt) -> None:
     def test_do_export_reference_data_postgres(self, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data()
         output = temp_output.getvalue()
-        assert "Reference tables are successfully exported!" in output
+        assert "Reference tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM DatafileTypes;")
@@ -983,24 +981,20 @@ def test_do_export_reference_data_postgres(self, patched_input):
         assert "GPX" in names
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     @patch("pepys_admin.snapshot_cli.ptk_prompt", return_value="test.db")
     @patch("pepys_admin.snapshot_cli.iterfzf", return_value=["Public", "Public Sensitive"])
     def test_do_export_reference_data_and_metadata_postgres(self, patched_iterfzf, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data_and_metadata()
         output = temp_output.getvalue()
-        assert "Reference and metadata tables are successfully exported!" in output
+        assert "Reference and metadata tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM SensorTypes;")
@@ -1024,24 +1018,20 @@ def test_do_export_reference_data_and_metadata_postgres(self, patched_iterfzf, p
         assert "test" in table_dict.values()
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     @patch("pepys_admin.snapshot_cli.ptk_prompt", return_value="test.db")
     @patch("pepys_admin.snapshot_cli.iterfzf", return_value=["Public"])
     def test_do_export_reference_data_and_metadata_public(self, patched_iterfzf, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data_and_metadata()
         output = temp_output.getvalue()
-        assert "Reference and metadata tables are successfully exported!" in output
+        assert "Reference and metadata tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM SensorTypes;")
@@ -1067,9 +1057,7 @@ def test_do_export_reference_data_and_metadata_public(self, patched_iterfzf, pat
         assert "test-2" not in table_dict.values()
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     @patch("pepys_admin.snapshot_cli.ptk_prompt", return_value="test.db")
     @patch("pepys_admin.snapshot_cli.iterfzf", return_value=["Public Sensitive"])
@@ -1078,15 +1066,13 @@ def test_do_export_reference_data_and_metadata_public_sensitive(
     ):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data_and_metadata()
         output = temp_output.getvalue()
-        assert "Reference and metadata tables are successfully exported!" in output
+        assert "Reference and metadata tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM SensorTypes;")
@@ -1102,9 +1088,7 @@ def test_do_export_reference_data_and_metadata_public_sensitive(
         assert len(results) == 0
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
 
 class SnapshotShellMergingTestCase(unittest.TestCase):
@@ -1402,15 +1386,13 @@ def setUp(self, patched_prompt) -> None:
     def test_do_export_reference_data(self, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data()
         output = temp_output.getvalue()
-        assert "Reference tables are successfully exported!" in output
+        assert "Reference tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM DatafileTypes;")
@@ -1420,18 +1402,14 @@ def test_do_export_reference_data(self, patched_input):
         assert "GPX" in names
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     @patch("pepys_admin.snapshot_cli.custom_print_formatted_text", side_effect=side_effect)
     @patch("pepys_admin.snapshot_cli.ptk_prompt")
     def test_do_export_reference_data_invalid_filename(self, patched_input, patched_print):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         with open("already_existing_file.db", "w") as f:
             f.write("Hello, world")
@@ -1443,7 +1421,7 @@ def test_do_export_reference_data_invalid_filename(self, patched_input, patched_
         output = temp_output.getvalue()
 
         assert "There is already a file named 'already_existing_file.db'" in output
-        assert "Reference tables are successfully exported!" in output
+        assert "Reference tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM DatafileTypes;")
@@ -1453,9 +1431,7 @@ def test_do_export_reference_data_invalid_filename(self, patched_input, patched_
         assert "GPX" in names
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         if os.path.exists("already_existing_file.db"):
             os.remove("already_existing_file.db")
@@ -1465,15 +1441,13 @@ def test_do_export_reference_data_invalid_filename(self, patched_input, patched_
     def test_do_export_reference_data_and_metadata(self, patched_iterfzf, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data_and_metadata()
         output = temp_output.getvalue()
-        assert "Reference and metadata tables are successfully exported!" in output
+        assert "Reference and metadata tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM SensorTypes;")
@@ -1497,24 +1471,20 @@ def test_do_export_reference_data_and_metadata(self, patched_iterfzf, patched_in
         assert "test" in table_dict.values()
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     @patch("pepys_admin.snapshot_cli.ptk_prompt", return_value="test.db")
     @patch("pepys_admin.snapshot_cli.iterfzf", return_value=["Public"])
     def test_do_export_reference_data_and_metadata_public(self, patched_iterfzf, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data_and_metadata()
         output = temp_output.getvalue()
-        assert "Reference and metadata tables are successfully exported!" in output
+        assert "Reference and metadata tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM SensorTypes;")
@@ -1540,9 +1510,7 @@ def test_do_export_reference_data_and_metadata_public(self, patched_iterfzf, pat
         assert "test-2" not in table_dict.values()
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     @patch("pepys_admin.snapshot_cli.ptk_prompt", return_value="test.db")
     @patch("pepys_admin.snapshot_cli.iterfzf", return_value=["Public Sensitive"])
@@ -1551,15 +1519,13 @@ def test_do_export_reference_data_and_metadata_public_sensitive(
     ):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
             self.shell.do_export_reference_data_and_metadata()
         output = temp_output.getvalue()
-        assert "Reference and metadata tables are successfully exported!" in output
+        assert "Reference and metadata tables successfully exported!" in output
 
         connection = sqlite3.connect("test.db")
         results = connection.execute("SELECT name FROM SensorTypes;")
@@ -1575,9 +1541,7 @@ def test_do_export_reference_data_and_metadata_public_sensitive(
         assert len(results) == 0
         connection.close()
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
     def test_do_cancel(self):
         temp_output = StringIO()
@@ -1620,9 +1584,7 @@ def test_postcmd(self, patched_print):
     def test_do_export_reference_and_metadata_cancelling(self, patched_iterfzf, patched_input):
         # Delete test.db file first, in case it is hanging around from another test
         # If we don't do this, we can get into an infinite loop
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
 
         temp_output = StringIO()
         with redirect_stdout(temp_output):
@@ -1630,9 +1592,218 @@ def test_do_export_reference_and_metadata_cancelling(self, patched_iterfzf, patc
         output = temp_output.getvalue()
         assert "Returning to the previous menu" in output
 
-        path = os.path.join(CURRENT_DIR, "test.db")
-        if os.path.exists(path):
-            os.remove(path)
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt", return_value="test.db")
+    def test_do_export_all_data(self, patched_ptkprompt):
+        remove_file_if_exists("test.db")
+
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            self.shell.do_export_all_data()
+        output = temp_output.getvalue()
+        assert "Data successfully exported" in output
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_helpers.prompt")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_time")
+    def test_do_export_all_data_filter_time(
+        self, patched_export, patched_prompt, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        patched_prompt.side_effect = ["2000-01-01 00:00:00", "2005-05-25 00:00:00"]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        self.shell.do_export_all_data_filter_time()
+
+        patched_export.assert_called_with(
+            ANY, ANY, datetime(2000, 1, 1, 0, 0), datetime(2005, 5, 25, 0, 0)
+        )
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_helpers.prompt")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_time")
+    def test_do_export_all_data_filter_time_invalid_type(
+        self, patched_export, patched_prompt, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        patched_prompt.side_effect = ["abcd", "2000-01-01 00:00:00", "2005-05-25 00:00:00"]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            self.shell.do_export_all_data_filter_time()
+        output = temp_output.getvalue()
+        assert "Invalid time entered, please try again" in output
+
+        patched_export.assert_called_with(
+            ANY, ANY, datetime(2000, 1, 1, 0, 0), datetime(2005, 5, 25, 0, 0)
+        )
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_helpers.prompt")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_time")
+    def test_do_export_all_data_filter_time_invalid_date(
+        self, patched_export, patched_prompt, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        patched_prompt.side_effect = [
+            "2000-15-50 00:00:00",
+            "2000-01-01 00:00:00",
+            "2005-05-25 00:00:00",
+        ]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            self.shell.do_export_all_data_filter_time()
+        output = temp_output.getvalue()
+        assert "Invalid time entered, please try again" in output
+
+        patched_export.assert_called_with(
+            ANY, ANY, datetime(2000, 1, 1, 0, 0), datetime(2005, 5, 25, 0, 0)
+        )
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_helpers.prompt")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_location")
+    def test_do_export_all_data_filter_location(
+        self, patched_export, patched_prompt, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        patched_prompt.side_effect = ["0", "40", "-10", "10"]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        self.shell.do_export_all_data_filter_location()
+
+        patched_export.assert_called_with(ANY, ANY, -10, 0, 10, 40)
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_helpers.prompt")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_location")
+    def test_do_export_all_data_filter_location_invalid(
+        self, patched_export, patched_prompt, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        patched_prompt.side_effect = ["abc", "0", "40", "-10", "10"]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            self.shell.do_export_all_data_filter_location()
+        output = temp_output.getvalue()
+        assert "Invalid number entered, please try again" in output
+
+        patched_export.assert_called_with(ANY, ANY, -10, 0, 10, 40)
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_helpers.iterfzf")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_wargame_participation")
+    def test_do_export_all_data_filter_wargame(
+        self, patched_export, patched_iterfzf, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        with self.store.session_scope():
+            priv_id = self.store.session.query(self.store.db_classes.Privacy).all()[0].privacy_id
+            change_id = self.store.add_to_changes(
+                "USER", datetime.utcnow(), "Creating test tasks/participants"
+            ).change_id
+            s1 = self.store.db_classes.Series(name="Test Series", privacy_id=priv_id)
+
+            wg1 = self.store.db_classes.Wargame(
+                name="Test Wargame",
+                start=datetime(2000, 1, 1),
+                end=datetime(2000, 2, 1),
+                privacy_id=priv_id,
+            )
+            wg1.series = s1
+
+            self.store.session.add_all([s1, wg1])
+
+            plat1 = (
+                self.store.session.query(self.store.db_classes.Platform)
+                .filter(self.store.db_classes.Platform.name == "Test Platform")
+                .one()
+            )
+
+            wg1.add_participant(
+                data_store=self.store, platform=plat1, privacy="Private", change_id=change_id
+            )
+
+        patched_iterfzf.side_effect = ["Test Wargame"]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        self.shell.do_export_all_data_filter_wargame_participation()
+
+        patched_export.assert_called_once()
+        assert patched_export.call_args[0][-1].name == "Test Wargame"
+
+        remove_file_if_exists("test.db")
+
+    @patch("pepys_admin.snapshot_cli.ptk_prompt")
+    @patch("pepys_admin.snapshot_cli.iterfzf")
+    @patch("pepys_admin.snapshot_helpers.iterfzf")
+    @patch("pepys_admin.snapshot_cli.export_measurement_tables_filtered_by_serial_participation")
+    def test_do_export_all_data_filter_serial(
+        self, patched_export, patched_iterfzf, patched_iterfzf2, patched_db_prompt
+    ):
+        remove_file_if_exists("test.db")
+
+        with self.store.session_scope():
+            priv_id = self.store.session.query(self.store.db_classes.Privacy).all()[0].privacy_id
+            s1 = self.store.db_classes.Series(name="Test Series", privacy_id=priv_id)
+
+            wg1 = self.store.db_classes.Wargame(
+                name="Test Wargame",
+                start=datetime(2000, 1, 1),
+                end=datetime(2000, 2, 1),
+                privacy_id=priv_id,
+            )
+            wg1.series = s1
+
+            self.store.session.add_all([s1, wg1])
+
+            serial1 = self.store.db_classes.Serial(
+                serial_number="Test Serial",
+                exercise="Test Exercise",
+                start=datetime(2000, 1, 5),
+                end=datetime(2000, 1, 10),
+                environment="Test Environment",
+                privacy_id=priv_id,
+            )
+
+            serial1.wargame = wg1
+
+            self.store.session.add(serial1)
+
+        patched_iterfzf.side_effect = ["Test Wargame"]
+        patched_iterfzf2.side_effect = ["Test Serial"]
+        patched_db_prompt.side_effect = ["test.db"]
+
+        self.shell.do_export_all_data_filter_serial_participation()
+
+        patched_export.assert_called_once()
+        assert patched_export.call_args[0][-1].serial_number == "Test Serial"
+
+        remove_file_if_exists("test.db")
 
 
 @patch("pepys_admin.base_cli.custom_print_formatted_text")
@@ -1758,5 +1929,11 @@ def test_viewer_mode_valid_db(patched_vs):
     patched_vs.assert_called()
 
 
+def remove_file_if_exists(filename):
+    path = os.path.join(CURRENT_DIR, filename)
+    if os.path.exists(path):
+        os.remove(path)
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_command_line_resolver.py b/tests/test_command_line_resolver.py
index 7aff18747..f7868b37f 100644
--- a/tests/test_command_line_resolver.py
+++ b/tests/test_command_line_resolver.py
@@ -7,11 +7,17 @@
 from uuid import uuid4
 
 import pytest
+from prompt_toolkit.document import Document
+from prompt_toolkit.validation import ValidationError
 
 from pepys_import.core.store.data_store import DataStore
 from pepys_import.resolvers import constants
 from pepys_import.resolvers.command_line_input import is_valid
-from pepys_import.resolvers.command_line_resolver import CommandLineResolver, is_number
+from pepys_import.resolvers.command_line_resolver import (
+    CommandLineResolver,
+    MinMaxValidator,
+    is_number,
+)
 from pepys_import.utils.text_formatting_utils import formatted_text_to_str
 
 DIR_PATH = os.path.dirname(__file__)
@@ -369,7 +375,7 @@ def test_fuzzy_search_add_platform_to_synonym(self, menu_prompt):
         menu_prompt.side_effect = ["PLATFORM-1 / 123 / UK", "1"]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id)
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id)
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id)
             nationality = self.store.add_to_nationalities("UK", self.change_id)
 
             platform = self.store.add_to_platforms(
@@ -403,7 +409,7 @@ def test_fuzzy_search_add_new_platform(self, resolver_prompt, menu_prompt):
         resolver_prompt.side_effect = ["TEST", "123", "TST", "TEST"]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id)
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id)
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id)
             nationality = self.store.add_to_nationalities("UK", self.change_id)
             self.store.add_to_platforms(
                 "PLATFORM-1",
@@ -444,10 +450,10 @@ def test_fuzzy_search_add_new_platform(self, resolver_prompt, menu_prompt):
     def test_resolve_platform_select_existing_platform(self, resolver_prompt, menu_prompt):
         """Test whether a new platform entity is created or not"""
 
-        menu_prompt.side_effect = ["3"]
+        menu_prompt.side_effect = ["5"]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id)
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id)
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id)
             uk_nat = self.store.add_to_nationalities("UK", priority=1, change_id=self.change_id)
             fr_nat = self.store.add_to_nationalities("France", priority=2, change_id=self.change_id)
             self.store.add_to_platforms(
@@ -494,7 +500,7 @@ def test_resolver_platform_with_fuzzy_searches(self, resolver_platform, menu_pro
 
         # Select "Search for existing platform"->Type "TEST"->Type name/trigraph/quadgraph/identifier
         # ->Select "Search for an existing nationality"->Select "UK"->Select "Search for an existing
-        # platform type"->Select "Warship"->Select "Search for an existing classification"->Select
+        # platform type"->Select "Naval - frigate"->Select "Search for an existing classification"->Select
         # "Public"->Select "Yes"
         menu_prompt.side_effect = [
             "2",
@@ -502,7 +508,7 @@ def test_resolver_platform_with_fuzzy_searches(self, resolver_platform, menu_pro
             "1",
             "UK",
             "1",
-            "Warship",
+            "Naval - frigate",
             "1",
             "Public",
             "1",
@@ -510,7 +516,7 @@ def test_resolver_platform_with_fuzzy_searches(self, resolver_platform, menu_pro
         resolver_platform.side_effect = ["TEST", "123", "TST", "TEST"]
         with self.store.session_scope():
             self.store.add_to_privacies("Public", 0, self.change_id)
-            self.store.add_to_platform_types("Warship", self.change_id)
+            self.store.add_to_platform_types("Naval - frigate", self.change_id)
             self.store.add_to_nationalities("UK", self.change_id)
             (
                 platform_name,
@@ -533,7 +539,7 @@ def test_resolver_platform_with_fuzzy_searches(self, resolver_platform, menu_pro
             self.assertEqual(trigraph, "TST")
             self.assertEqual(quadgraph, "TEST")
             self.assertEqual(identifier, "123")
-            self.assertEqual(platform_type.name, "Warship")
+            self.assertEqual(platform_type.name, "Naval - frigate")
             self.assertEqual(nationality.name, "UK")
             self.assertEqual(privacy.name, "Public")
 
@@ -545,7 +551,7 @@ def test_resolver_platform_edit_given_values(self, resolver_prompt, menu_prompt)
         # Select "Add a new platform"->Type name/trigraph/quadgraph/identifier->
         # Select "No, make further edits"->Type name/trigraph/quadgraph/identifier->
         # Select "Search for an existing nationality"->Select "UK"->
-        # Select "Search for an existing platform type"->Select "Warship"->Select
+        # Select "Search for an existing platform type"->Select "Naval - frigate"->Select
         # "Search for an existing classification"->Select "Public"->Select "Yes"
         menu_prompt.side_effect = [
             "1",
@@ -553,7 +559,7 @@ def test_resolver_platform_edit_given_values(self, resolver_prompt, menu_prompt)
             "1",
             "UK",
             "1",
-            "Warship",
+            "Naval - frigate",
             "1",
             "Public",
             "1",
@@ -570,7 +576,7 @@ def test_resolver_platform_edit_given_values(self, resolver_prompt, menu_prompt)
         ]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id).name
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id).name
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id).name
             nationality = self.store.add_to_nationalities("UK", self.change_id).name
             (
                 platform_name,
@@ -593,7 +599,7 @@ def test_resolver_platform_edit_given_values(self, resolver_prompt, menu_prompt)
             self.assertEqual(trigraph, "TST")
             self.assertEqual(quadgraph, "TEST")
             self.assertEqual(identifier, "123")
-            self.assertEqual(platform_type.name, "Warship")
+            self.assertEqual(platform_type.name, "Naval - frigate")
             self.assertEqual(nationality.name, "UK")
             self.assertEqual(privacy.name, "Public")
 
@@ -605,7 +611,7 @@ def test_resolver_platform_edit_given_values_2(self, resolver_prompt, menu_promp
         # Select "Add a new platform"->Type name/trigraph/quadgraph/identifier->Select "No"->
         # Select "Add a new platform"->Type name/trigraph/quadgraph/identifier->
         # Select "Search for an existing nationality"->Select "UK"->
-        # Select "Search for an existing platform type"->Select "Warship"->Select
+        # Select "Search for an existing platform type"->Select "Naval - frigate"->Select
         # "Search for an existing classification"->Select "Public"->Select "Yes"
         menu_prompt.side_effect = [
             "1",
@@ -614,7 +620,7 @@ def test_resolver_platform_edit_given_values_2(self, resolver_prompt, menu_promp
             "1",
             "UK",
             "1",
-            "Warship",
+            "Naval - frigate",
             "1",
             "Public",
             "1",
@@ -631,7 +637,7 @@ def test_resolver_platform_edit_given_values_2(self, resolver_prompt, menu_promp
         ]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id).name
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id).name
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id).name
             nationality = self.store.add_to_nationalities("UK", self.change_id).name
             (
                 platform_name,
@@ -654,7 +660,7 @@ def test_resolver_platform_edit_given_values_2(self, resolver_prompt, menu_promp
             self.assertEqual(trigraph, "TST")
             self.assertEqual(quadgraph, "TEST")
             self.assertEqual(identifier, "123")
-            self.assertEqual(platform_type.name, "Warship")
+            self.assertEqual(platform_type.name, "Naval - frigate")
             self.assertEqual(nationality.name, "UK")
             self.assertEqual(privacy.name, "Public")
 
@@ -670,7 +676,7 @@ def test_resolver_platform_new_privacy_given(self, resolver_prompt, menu_prompt)
             "10",
         ]
         with self.store.session_scope():
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id).name
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id).name
             nationality = self.store.add_to_nationalities("UK", self.change_id).name
             (
                 platform_name,
@@ -709,7 +715,7 @@ def side_effect(text):
         resolver_prompt.side_effect = ["TEST", "123", "TST", "TEST"]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id)
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id)
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id)
             nationality = self.store.add_to_nationalities("UK", self.change_id)
             self.store.add_to_platforms(
                 "PLATFORM-1",
@@ -756,7 +762,7 @@ def side_effect(text):
             "1",
             "UK",
             "1",
-            "Warship",
+            "Naval - frigate",
             "1",
             "Public",
             "1",
@@ -773,7 +779,7 @@ def side_effect(text):
         ]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id).name
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id).name
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id).name
             nationality = self.store.add_to_nationalities("UK", self.change_id).name
             temp_output = StringIO()
             with redirect_stdout(temp_output):
@@ -818,7 +824,7 @@ def side_effect(text):
         ]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id).name
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id).name
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id).name
             nationality = self.store.add_to_nationalities("UK", self.change_id).name
             temp_output = StringIO()
             with redirect_stdout(temp_output):
@@ -1291,6 +1297,26 @@ def test_resolve_sensor_wrong_platform(self):
                 change_id=self.change_id,
             )
 
+    @patch("pepys_import.resolvers.command_line_resolver.prompt")
+    def test_resolve_missing_info(self, resolver_prompt):
+        resolver_prompt.side_effect = ["TEST_VALUE"]
+        info = self.resolver.resolve_missing_info("What do you need?", "HELLO")
+        self.assertEqual(info, "TEST_VALUE")
+
+    @patch("pepys_import.resolvers.command_line_resolver.prompt")
+    def test_resolve_missing_info_default_fallback(self, resolver_prompt):
+        resolver_prompt.side_effect = [None]
+        info_none = self.resolver.resolve_missing_info("A question", "DEFAULT")
+        self.assertEqual(info_none, "DEFAULT")
+
+        resolver_prompt.side_effect = [""]
+        info_empty = self.resolver.resolve_missing_info("A question", "DEFAULT")
+        self.assertEqual(info_empty, "DEFAULT")
+
+        resolver_prompt.side_effect = [20]
+        info_value = self.resolver.resolve_missing_info("A question", 0)
+        self.assertEqual(info_value, 20)
+
     @patch("pepys_import.resolvers.command_line_resolver.create_menu")
     def test_fuzzy_search_sensor_empty_name_and_choice_in_sensor(self, menu_prompt):
         menu_prompt.side_effect = [
@@ -1555,7 +1581,7 @@ def test_cancelling_fuzzy_search_platform(self, menu_prompt):
         menu_prompt.side_effect = [".", ".", "."]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id)
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id)
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id)
             nationality = self.store.add_to_nationalities("UK", self.change_id)
             self.store.add_to_platforms(
                 "PLATFORM-1",
@@ -1579,7 +1605,7 @@ def test_cancelling_fuzzy_search_platform_when_given_platform_name(self, menu_pr
         menu_prompt.side_effect = ["PLATFORM-1 / 123 / UK", ".", ".", "."]
         with self.store.session_scope():
             privacy = self.store.add_to_privacies("Public", 0, self.change_id)
-            platform_type = self.store.add_to_platform_types("Warship", self.change_id)
+            platform_type = self.store.add_to_platform_types("Naval - frigate", self.change_id)
             nationality = self.store.add_to_nationalities("UK", self.change_id)
             self.store.add_to_platforms(
                 "PLATFORM-1",
@@ -1785,6 +1811,74 @@ def test_get_sensor_adds_resolved_sensor_successfully(self, resolver_prompt, men
             self.assertEqual(sensors[-1].name, "SENSOR-TEST")
 
 
+class MinMaxValidatorTests(unittest.TestCase):
+    def test_min_max_validator_none(self):
+        validator = MinMaxValidator(None, None)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("ABC"))
+
+        validator.validate(Document("20"))
+
+    def test_min_max_validator_min_only(self):
+        validator = MinMaxValidator(15, None)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("14"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("0"))
+        validator.validate(Document("15"))
+        validator.validate(Document("16"))
+        validator.validate(Document("122001"))
+
+    def test_min_max_validator_max_only(self):
+        validator = MinMaxValidator(None, 15)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("16"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("122001"))
+        validator.validate(Document("15"))
+        validator.validate(Document("14"))
+        validator.validate(Document("0"))
+
+    def test_min_max_validator_same_value(self):
+        validator = MinMaxValidator(15, 15)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("16"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("14"))
+        validator.validate(Document("15"))
+
+    def test_min_max_validator_min_and_max(self):
+        validator = MinMaxValidator(10, 15)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("16"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("9"))
+        validator.validate(Document("15"))
+        validator.validate(Document("10"))
+        validator.validate(Document("13"))
+
+    def test_min_max_validator_allow_empty(self):
+        validator = MinMaxValidator(10, 15, True)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("16"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("9"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("TEXT"))
+        validator.validate(Document(""))
+
+    def test_min_max_validator__do_not_allow_empty(self):
+        validator = MinMaxValidator(10, 15, False)
+        with pytest.raises(ValidationError):
+            validator.validate(Document("16"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("9"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document("TEXT"))
+        with pytest.raises(ValidationError):
+            validator.validate(Document(""))
+
+
 @pytest.mark.parametrize(
     "number,expected_result",
     [
diff --git a/tests/test_datastore_utils.py b/tests/test_datastore_utils.py
index d39384761..cafe52106 100644
--- a/tests/test_datastore_utils.py
+++ b/tests/test_datastore_utils.py
@@ -120,6 +120,71 @@ def test_check_migration_version_is_found():
     ds.check_migration_version(revision)
 
 
+def test_check_migration_version_is_latest_revision():
+    # Create a new alembic version database - function should pass
+    ds = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
+    revision = ["version_id", "test_version_id", "latest_version_id"]
+    ds.latest_revisions = {"LATEST_SQLITE_VERSION": "latest_version_id"}
+
+    # Run once to create the table and stamp latest version
+    create_alembic_version_table(ds.engine, ds.db_type)
+
+    with ds.engine.begin() as connection:
+        connection.execute(text("UPDATE alembic_version SET version_num = 'latest_version_id';"))
+
+    # Shouldn't print anything
+    temp_output = StringIO()
+    with redirect_stdout(temp_output):
+        ds.check_migration_version(revision)
+    output = temp_output.getvalue()
+
+    assert output == ""
+
+
+def test_check_migration_version_is_not_latest_revision_warning():
+    # Create a new alembic version database - function should pass
+    ds = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
+    revision = ["version_id", "test_version_id", "latest_version_id"]
+    ds.latest_revisions = {"LATEST_SQLITE_VERSION": "newer_version"}
+
+    # Run once to create the table and stamp latest version
+    create_alembic_version_table(ds.engine, ds.db_type)
+
+    with ds.engine.begin() as connection:
+        connection.execute(text("UPDATE alembic_version SET version_num = 'latest_version_id';"))
+
+    # Shouldn't print anything
+    temp_output = StringIO()
+    with redirect_stdout(temp_output):
+        ds.check_migration_version(revision)
+    output = temp_output.getvalue()
+
+    assert "WARNING: The database is not at the latest revision" in output
+
+
+def test_check_migration_version_is_not_latest_revision_error():
+    # Create a new alembic version database - function should pass
+    ds = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
+    revision = ["version_id", "test_version_id", "latest_version_id"]
+    ds.latest_revisions = {"LATEST_SQLITE_VERSION": "newer_version"}
+    ds.error_on_db_version_mismatch = True
+
+    # Run once to create the table and stamp latest version
+    create_alembic_version_table(ds.engine, ds.db_type)
+
+    with ds.engine.begin() as connection:
+        connection.execute(text("UPDATE alembic_version SET version_num = 'latest_version_id';"))
+
+    # Shouldn't print anything
+    temp_output = StringIO()
+    with redirect_stdout(temp_output):
+        with pytest.raises(SystemExit):
+            ds.check_migration_version(revision)
+    output = temp_output.getvalue()
+
+    assert "ERROR: The database is not at the latest revision" in output
+
+
 def test_check_migration_version_no_revisions():
     # Call the function with no revisions in the list - function should call sys.exit(1)
     ds = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
@@ -385,6 +450,70 @@ def test_check_migration_version_is_found(self):
 
         self.store.check_migration_version(revision)
 
+    def test_check_migration_version_is_latest_revision(self):
+        revision = ["version_id", "test_version_id", "latest_version_id"]
+        self.store.latest_revisions = {"LATEST_POSTGRES_VERSION": "latest_version_id"}
+
+        # Run once to create the table and stamp latest version
+        create_alembic_version_table(self.store.engine, self.store.db_type)
+
+        with self.store.engine.begin() as connection:
+            connection.execute(
+                text("UPDATE pepys.alembic_version SET version_num = 'latest_version_id';")
+            )
+
+        # Shouldn't print anything
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            self.store.check_migration_version(revision)
+        output = temp_output.getvalue()
+
+        assert output == ""
+
+    def test_check_migration_version_is_not_latest_revision_warning(self):
+        # Create a new alembic version database - function should pass
+        revision = ["version_id", "test_version_id", "latest_version_id"]
+        self.store.latest_revisions = {"LATEST_POSTGRES_VERSION": "newer_version"}
+
+        # Run once to create the table and stamp latest version
+        create_alembic_version_table(self.store.engine, self.store.db_type)
+
+        with self.store.engine.begin() as connection:
+            connection.execute(
+                text("UPDATE alembic_version SET version_num = 'latest_version_id';")
+            )
+
+        # Shouldn't print anything
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            self.store.check_migration_version(revision)
+        output = temp_output.getvalue()
+
+        assert "WARNING: The database is not at the latest revision" in output
+
+    def test_check_migration_version_is_not_latest_revision_error(self):
+        # Create a new alembic version database - function should pass
+        revision = ["version_id", "test_version_id", "latest_version_id"]
+        self.store.latest_revisions = {"LATEST_POSTGRES_VERSION": "newer_version"}
+        self.store.error_on_db_version_mismatch = True
+
+        # Run once to create the table and stamp latest version
+        create_alembic_version_table(self.store.engine, self.store.db_type)
+
+        with self.store.engine.begin() as connection:
+            connection.execute(
+                text("UPDATE alembic_version SET version_num = 'latest_version_id';")
+            )
+
+        # Shouldn't print anything
+        temp_output = StringIO()
+        with redirect_stdout(temp_output):
+            with pytest.raises(SystemExit):
+                self.store.check_migration_version(revision)
+        output = temp_output.getvalue()
+
+        assert "ERROR: The database is not at the latest revision" in output
+
     def test_check_migration_version_no_revisions(self):
         # Call the function with no revisions in the list - function should call sys.exit(1)
         revisions = []
diff --git a/tests/test_default_resolver.py b/tests/test_default_resolver.py
index 51fe95ad9..6726cdb6a 100644
--- a/tests/test_default_resolver.py
+++ b/tests/test_default_resolver.py
@@ -119,7 +119,7 @@ def test_resolver_platform(self):
             self.assertEqual(trigraph, "PL1")
             self.assertEqual(quadgraph, "PLT1")
             self.assertEqual(identifier, "123")
-            self.assertEqual(platform_type.name, "Warship")
+            self.assertEqual(platform_type.name, "Naval - frigate")
             self.assertEqual(nationality.name, "UK")
             self.assertEqual(privacy.name, "Public")
 
@@ -185,6 +185,13 @@ def test_resolver_datafile(self):
             self.assertEqual(datafile_type.name, "DATAFILE-TYPE-1")
             self.assertEqual(privacy.name, "Public")
 
+    def test_resolve_missing_info(self):
+        info_str = self.resolver.resolve_missing_info("Test question?", "DEFAULT")
+        self.assertEqual(info_str, "DEFAULT")
+
+        info_int = self.resolver.resolve_missing_info("The Question", 10)
+        self.assertEqual(info_int, 10)
+
 
 class DefaultResolverTestCaseWithNoRefLoaded(unittest.TestCase):
     """
diff --git a/tests/test_export_csv.py b/tests/test_export_csv.py
index 1fcd09e5f..abf564457 100644
--- a/tests/test_export_csv.py
+++ b/tests/test_export_csv.py
@@ -36,13 +36,30 @@ def test_convert_ids_to_objects():
     with ds.session_scope():
         ds.populate_reference()
         ds.populate_metadata()
-        entries = ds.session.query(ds.db_classes.PlatformType).all()[:8]
+        entries = (
+            ds.session.query(ds.db_classes.PlatformType)
+            .order_by(ds.db_classes.PlatformType.name)
+            .all()[:8]
+        )
         ids = [entry.platform_type_id for entry in entries]
 
     results = ds.convert_ids_to_objects(ids, ds.db_classes.PlatformType)
     assert results is not None
     assert len(results) == 8
-    assert "Naval -" in str(results[0])
+    names = [r.name for r in results]
+
+    assert set(names) == set(
+        [
+            "Attack Craft",
+            "Civilian - uncrewed air vehicle",
+            "Civilian - uncrewed surface vehicle",
+            "Civilian - uncrewed underwater vehicle",
+            "Commercial aircraft",
+            "Fishing Vessel",
+            "High Speed Craft",
+            "Law Enforcement",
+        ]
+    )
 
 
 def test_convert_ids_to_objects_no_ids():
diff --git a/tests/test_import_cli.py b/tests/test_import_cli.py
index 40049a889..b0e2640bf 100644
--- a/tests/test_import_cli.py
+++ b/tests/test_import_cli.py
@@ -204,6 +204,7 @@ def test_process_db_none(patched_file_proc, patched_data_store):
         db_name=DB_NAME,
         db_type=DB_TYPE,
         missing_data_resolver=ANY,  # We don't care about this argument, and it's hard to test
+        error_on_db_version_mismatch=ANY,
     )
 
 
@@ -257,6 +258,7 @@ def test_training_mode_setup(patched_input, patched_data_store):
         db_name=db_name,
         db_type="sqlite",
         missing_data_resolver=ANY,  # We don't care about this argument, and it's hard to test
+        error_on_db_version_mismatch=ANY,
     )
 
     # Reset PEPYS_CONFIG_FILE to what it was at the start of the test
diff --git a/tests/test_load_jchat.py b/tests/test_load_jchat.py
new file mode 100644
index 000000000..22690902e
--- /dev/null
+++ b/tests/test_load_jchat.py
@@ -0,0 +1,683 @@
+import os
+import unicodedata
+import unittest
+from datetime import datetime
+
+from importers.jchat_importer import JChatImporter
+from pepys_import.core.store.data_store import DataStore
+from pepys_import.file.file_processor import FileProcessor
+from tests.utils import check_errors_for_file_contents
+
+FILE_PATH = os.path.dirname(__file__)
+NO_EXT_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/jchat_no_ext")
+BREAKS_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/jchat_breaks_in_message.html")
+DATA_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/jchat_sample.html")
+UNUSUAL_CHARS_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/jchat_unusual_chars.html")
+MARKER_MESSAGES_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/marker_messages.html")
+ROOM_MESSAGES_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/connect_disconnect.html")
+NO_HTML_EXT_DOT_IN_NAME_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/ABC_123.01_XYZ")
+YEAR_MONTH_ROLLOVER_PATH = os.path.join(
+    FILE_PATH, "jchat_importer_date_sensitive_files/year_month_rollover.html"
+)
+MODERN_FORMAT_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/jchat_modern_format.html")
+LEGACY_NO_I_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/legacy_no_i.html")
+COMBINED_FILE_PATH = os.path.join(FILE_PATH, "sample_data/jchat_files/combined_format.html")
+
+
+class JChatTests(unittest.TestCase):
+    def setUp(self):
+        self.store = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
+        self.store.initialise()
+
+    def tearDown(self):
+        pass
+
+    def test_no_file_suffix(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(NO_EXT_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 5
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 2
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert results[0].content == "COMMS TEST"
+
+    def test_breaks_in_message(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(BREAKS_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 3
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 2
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "Replay bravo"
+            assert results[2].content == "Replay multiple bravos in same tag"
+
+    def test_existing_quad_with_new_quads(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(DATA_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 5
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 3
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "Replay bravo"
+            assert results[2].content == "Replay bravo"
+            assert results[3].content == "Replay bravo - next day"
+            assert results[4].content == "Replay bravo - next month"
+
+    def test_html_character_representation(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(UNUSUAL_CHARS_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 3
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 3
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert unicodedata.normalize("NFKC", results[0].content) == "COMMS  ’TEST"
+            assert unicodedata.normalize("NFKC", results[1].content) == "Replay&‘ ...bravo"
+            assert unicodedata.normalize("NFKC", results[2].content) == "Replay–bravo"
+
+    def test_room_connections_disconnections_ignored(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(ROOM_MESSAGES_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 2
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 1
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "Replay bravo"
+
+    def test_modern_jchat_format(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(MODERN_FORMAT_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 3
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 2
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert len(results) == 3
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "Replay bravo - no i tag and breaks"
+            assert results[2].content == "Replay bravo - no i tag"
+
+    def test_legacy_jchat_format_without_i_tag(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(LEGACY_NO_I_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 2
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 1
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert len(results) == 2
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "COMMS TEST - no i"
+
+    def test_marker_messages_ignored(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(MARKER_MESSAGES_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 2
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 1
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert len(results) == 2
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "Replay bravo"
+
+    def test_filename_with_dot_in_name_no_html_ext(self):
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(NO_HTML_EXT_DOT_IN_NAME_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 3
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 2
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert results[0].content == "COMMS TEST"
+            assert results[1].content == "Replay bravo"
+            assert results[2].content == "Replay bravo"
+
+    def test_year_month_rollover(self):
+        processor = FileProcessor(archive=False)
+        importer = JChatImporter()
+        processor.register_importer(importer)
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # Fixed Year/month to avoid flaky tests
+        importer.year = 2020
+        importer.month = 10
+
+        # parse the data
+        processor.process(YEAR_MONTH_ROLLOVER_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 6
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 3
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert results[0].time == datetime(2020, 10, 31, 8, 27, 44)
+            assert results[1].time == datetime(2020, 11, 1, 8, 29, 44)
+            assert results[2].time == datetime(2020, 11, 30, 9, 14, 44)
+            assert results[3].time == datetime(2020, 12, 1, 10, 28, 54)
+            assert results[4].time == datetime(2020, 12, 31, 1, 8, 9)
+            assert results[5].time == datetime(2021, 1, 1, 2, 8, 9)
+
+    def test_combined_format(self):
+        """Testing a file with elements of both styles to help develop a more generic approach"""
+        processor = FileProcessor(archive=False)
+        processor.register_importer(JChatImporter())
+        # check states empty
+        with self.store.session_scope():
+            # there must be no states at the beginning
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 0
+
+            # there must be no platforms at the beginning
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 0
+
+            # there must be no datafiles at the beginning
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 0
+
+        # parse the data
+        processor.process(COMBINED_FILE_PATH, self.store, False)
+
+        # check data got created
+        with self.store.session_scope():
+            # there must be states after the import
+            comments = self.store.session.query(self.store.db_classes.Comment).all()
+            assert len(comments) == 5
+
+            # there must be platforms after the import
+            platforms = self.store.session.query(self.store.db_classes.Platform).all()
+            assert len(platforms) == 3
+
+            # there must be one datafile afterwards
+            datafiles = self.store.session.query(self.store.db_classes.Datafile).all()
+            assert len(datafiles) == 1
+
+            results = (
+                self.store.session.query(self.store.db_classes.Comment)
+                .order_by(self.store.db_classes.Comment.time)
+                .all()
+            )
+            assert len(results) == 5
+            assert results[0].content == "Modern - has i tag"
+            assert results[1].content == "Modern - no i tag but has multiple breaks"
+            assert results[2].content == "Modern - no i tag - no breaks"
+            assert results[3].content == "Legacy - font a swap - no i tag - no breaks"
+            assert results[4].content == "Legacy - font a swap - has i tag - no breaks"
+
+    def test_invalid_missing_timestamp(self):
+        html_string = """<html>
+            <head>
+              <style type="text/css">
+                <!--
+                    span.msgcontent { color: #0 }
+                -->
+              </style>
+            </head>
+            <body>
+                <div id="34544=34534">
+                    <b><a href=""><font>DAUN_AS</font></a></b>
+                    <span class="msgcontent"><font><i>COMMS<br>TEST</i></font></span>
+                </div>
+            <body>
+        <html>"""
+        importer = JChatImporter()
+
+        check_errors_for_file_contents(
+            html_string,
+            "Unable to read message 34544=34534. No timestamp provided",
+            importer,
+            "no_timestamp.html",
+        )
+
+    def test_invalid_invalid_timestamp(self):
+        html_string = """<html>
+            <head>
+              <style type="text/css">
+                <!--
+                    span.msgcontent { color: #0 }
+                -->
+              </style>
+            </head>
+            <body>
+                <div id="34544=34534">
+                    <tt><font>[2209744A]</font></tt>
+                    <b><a href=""><font>DAUN_AS</font></a></b>
+                    <span class="msgcontent"><font><i>COMMS<br>TEST</i></font></span>
+                </div>
+            <body>
+        <html>"""
+        importer = JChatImporter()
+
+        check_errors_for_file_contents(
+            html_string,
+            "Invalid JChat timestamp 2209744A at 34544=34534",
+            importer,
+            "invalid_timestamp.html",
+        )
+
+    def test_invalid_missing_platform(self):
+        html_string = """<html>
+            <head>
+              <style type="text/css">
+                <!--
+                    span.msgcontent { color: #0 }
+                -->
+              </style>
+            </head>
+            <body>
+                <div id="34544=34534">
+                    <tt><font>[22092744A]</font></tt>
+                    <span class="msgcontent"><font><i>COMMS<br>TEST</i></font></span>
+                </div>
+            <body>
+        <html>"""
+        importer = JChatImporter()
+
+        check_errors_for_file_contents(
+            html_string,
+            "Unable to read message 34544=34534. No platform provided",
+            importer,
+            "no_platform.html",
+        )
+
+    def test_invalid_missing_message(self):
+        html_string = """<html>
+            <head>
+              <style type="text/css">
+                <!--
+                    span.msgcontent { color: #0 }
+                -->
+              </style>
+            </head>
+            <body>
+                <div id="34544=34534">
+                    <tt><font>[22092744A]</font></tt>
+                    <b><a href=""><font>DAUN_AS</font></a></b>
+                </div>
+            <body>
+        <html>"""
+        importer = JChatImporter()
+
+        check_errors_for_file_contents(
+            html_string,
+            "Unable to read message 34544=34534. No message provided",
+            importer,
+            "no_message",
+        )
+
+    def test_empty_message(self):
+        html_string = """<html>
+            <head>
+              <style type="text/css">
+                <!--
+                    span.msgcontent { color: #0 }
+                -->
+              </style>
+            </head>
+            <body>
+                <div id="34544=34534">
+                    <tt><font>[22092744A]</font></tt>
+                    <b><a href=""><font>DAUN_AS</font></a></b>
+                    <span class="msgcontent"><font></font></span>
+                </div>
+            <body>
+        <html>"""
+        importer = JChatImporter()
+
+        check_errors_for_file_contents(
+            html_string, "Unable to parse JChat message 34544=34534.", importer, "no_message"
+        )
+
+    @staticmethod
+    def test_simplify_html_no_html():
+        simple_string = "A simple string with no tags"
+        result = JChatImporter.simplify_jchat_html(simple_string)
+        assert result == simple_string
+
+    @staticmethod
+    def test_simplify_html_no_banned_tags():
+        html_string = "<html><head>Test</head><body>A simple string with no tags</body></html>"
+        result = JChatImporter.simplify_jchat_html(html_string)
+        assert result == html_string
+
+    @staticmethod
+    def test_simplify_html_banned_tags():
+        html_string = """
+        <html>
+            <head> Header </head>
+            <body>
+                <div id="34544=34534">
+                    <tt><font>[22092744A]</font></tt>
+                    <b><a href=""><font>DAUN_AS</font></a></b>
+                    <span class="msgcontent"><font><i>COMMS<br>TEST</i></font></span>
+                </div>
+            <body>
+        <html>"""
+
+        expected = """
+        <html>
+            <head> Header </head>
+            <body>
+                <div id="34544=34534">
+                    <tt><font>[22092744A]</font></tt>
+                    <b><a href=""><font>DAUN_AS</font></a></b>
+                    <font>COMMS TEST</font>
+                </div>
+            <body>
+        <html>"""
+
+        result = JChatImporter.simplify_jchat_html(html_string)
+        assert result == expected
diff --git a/tests/test_merging.py b/tests/test_merging.py
index 63e4dff42..0f0eafd69 100644
--- a/tests/test_merging.py
+++ b/tests/test_merging.py
@@ -2160,7 +2160,7 @@ def setUp(self, patched_input, patched_iterfzf):
         new_default_resolver.default_trigraph = "SL1"
         new_default_resolver.default_quadgraph = "SLT1"
         new_default_resolver.default_identifier = "123-SLAVE"
-        new_default_resolver.default_platform_type = "Warship-SLAVE"
+        new_default_resolver.default_platform_type = "Naval - frigate-SLAVE"
         new_default_resolver.default_nationality = "UK-SLAVE"
         new_default_resolver.default_sensor_name = "SENSOR-1-SLAVE"
         new_default_resolver.default_sensor_type = "Position-SLAVE"
@@ -2199,10 +2199,10 @@ def setUp(self, patched_input, patched_iterfzf):
             self.master_store.add_to_platform_types("Shared_PT_1", change_id=change_id)
 
             m_plat_1 = self.master_store.add_to_platforms(
-                "Master_Platform_1", "123", "UK", "Warship", "Public", change_id=change_id
+                "Master_Platform_1", "123", "UK", "Naval - frigate", "Public", change_id=change_id
             )
             shared_plat_1 = self.master_store.add_to_platforms(
-                "Shared_Platform_1", "234", "UK", "Warship", "Public", change_id=change_id
+                "Shared_Platform_1", "234", "UK", "Naval - frigate", "Public", change_id=change_id
             )
 
             self.master_store.add_to_synonyms(
@@ -2229,13 +2229,13 @@ def setUp(self, patched_input, patched_iterfzf):
             self.slave_store.add_to_platform_types("Shared_PT_1", change_id=change_id)
 
             s_plat_1 = self.slave_store.add_to_platforms(
-                "Slave_Platform_1", "123", "UK", "Warship", "Public", change_id=change_id
+                "Slave_Platform_1", "123", "UK", "Naval - frigate", "Public", change_id=change_id
             )
             shared_plat_1 = self.slave_store.add_to_platforms(
                 "Shared_Platform_1",
                 "234",
                 "UK",
-                "Warship",
+                "Naval - frigate",
                 "Public",
                 trigraph="P12",
                 change_id=change_id,
@@ -2281,7 +2281,7 @@ def test_export_alter_merge(self):
         # Check statistics
         assert "| Nationality        |                 1 |       1 |          0 |" in output
         assert "| PlatformType       |                 1 |       2 |          0 |" in output
-        assert "| Datafiles          |                 0 |       1 |          0 |" in output
+        assert "| Datafiles          |                 2 |       1 |          0 |" in output
         assert "| Platform           |                 4 |       2 |          1 |" in output
         assert "| Sensor             |                 5 |       1 |          0 |" in output
         assert "| State       |     402 |" in output
@@ -2291,7 +2291,7 @@ def test_export_alter_merge(self):
         assert "  - uk_track.rep" in output
         assert "  - UK-SLAVE" in output
         assert "  - SPLENDID" in output
-        assert "  - Warship-SLAVE" in output
+        assert "  - Naval - frigate-SLAVE" in output
         assert "  - PRIVACY-1-SLAVE" in output
         assert "  - Slave_Platform_1_Synonym" in output
 
@@ -2462,7 +2462,7 @@ def setUp(self, patched_input, patched_iterfzf):
         new_default_resolver.default_trigraph = "SL1"
         new_default_resolver.default_quadgraph = "SLT1"
         new_default_resolver.default_identifier = "123-SLAVE"
-        new_default_resolver.default_platform_type = "Warship-SLAVE"
+        new_default_resolver.default_platform_type = "Naval - frigate-SLAVE"
         new_default_resolver.default_nationality = "UK-SLAVE"
         new_default_resolver.default_sensor_name = "SENSOR-1-SLAVE"
         new_default_resolver.default_sensor_type = "Position-SLAVE"
@@ -2501,10 +2501,10 @@ def setUp(self, patched_input, patched_iterfzf):
             self.master_store.add_to_platform_types("Shared_PT_1", change_id=change_id)
 
             m_plat_1 = self.master_store.add_to_platforms(
-                "Master_Platform_1", "123", "UK", "Warship", "Public", change_id=change_id
+                "Master_Platform_1", "123", "UK", "Naval - frigate", "Public", change_id=change_id
             )
             shared_plat_1 = self.master_store.add_to_platforms(
-                "Shared_Platform_1", "234", "UK", "Warship", "Public", change_id=change_id
+                "Shared_Platform_1", "234", "UK", "Naval - frigate", "Public", change_id=change_id
             )
 
             self.master_store.add_to_synonyms(
@@ -2531,10 +2531,10 @@ def setUp(self, patched_input, patched_iterfzf):
             self.slave_store.add_to_platform_types("Shared_PT_1", change_id=change_id)
 
             s_plat_1 = self.slave_store.add_to_platforms(
-                "Slave_Platform_1", "123", "UK", "Warship", "Public", change_id=change_id
+                "Slave_Platform_1", "123", "UK", "Naval - frigate", "Public", change_id=change_id
             )
             shared_plat_1 = self.slave_store.add_to_platforms(
-                "Shared_Platform_1", "234", "UK", "Warship", "Public", change_id=change_id
+                "Shared_Platform_1", "234", "UK", "Naval - frigate", "Public", change_id=change_id
             )
 
             self.slave_store.add_to_synonyms(
diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py
new file mode 100644
index 000000000..a408d6b38
--- /dev/null
+++ b/tests/test_snapshots.py
@@ -0,0 +1,500 @@
+import os
+import unittest
+from datetime import datetime
+
+from pepys_admin.snapshot_helpers import (
+    export_all_measurement_tables,
+    export_measurement_tables_filtered_by_location,
+    export_measurement_tables_filtered_by_serial_participation,
+    export_measurement_tables_filtered_by_time,
+    export_measurement_tables_filtered_by_wargame_participation,
+    export_metadata_tables,
+    export_reference_tables,
+)
+from pepys_import.core.store.data_store import DataStore
+from pepys_import.core.store.db_status import TableTypes
+from pepys_import.file.file_processor import FileProcessor
+from pepys_import.utils.sqlalchemy_utils import get_primary_key_for_table
+
+FILE_PATH = os.path.dirname(__file__)
+NISIDA_PATH = os.path.join(FILE_PATH, "sample_data/track_files/nisida/nisida_example.txt")
+
+
+class TestSnapshots(unittest.TestCase):
+    def setUp(self):
+        if os.path.exists("destination.db"):
+            os.remove("destination.db")
+
+        self.source_store = DataStore("", "", "", 0, db_name="source.db", db_type="sqlite")
+        self.source_store.initialise()
+        with self.source_store.session_scope():
+            self.source_store.populate_reference()
+            self.source_store.populate_metadata()
+
+        processor = FileProcessor(archive=False)
+        processor.load_importers_dynamically()
+        processor.process(NISIDA_PATH, self.source_store, False)
+
+        self.destination_store = DataStore(
+            "",
+            "",
+            "",
+            0,
+            db_name="destination.db",
+            db_type="sqlite",
+            show_status=False,
+            welcome_text=None,
+        )
+        self.destination_store.initialise()
+
+        # Export reference tables
+        reference_table_objects = self.source_store.meta_classes[TableTypes.REFERENCE]
+        export_reference_tables(self.source_store, self.destination_store, reference_table_objects)
+
+        # Export metadata tables
+        export_metadata_tables(self.source_store, self.destination_store)
+
+    def tearDown(self):
+        if os.path.exists("source.db"):
+            os.remove("source.db")
+
+        if os.path.exists("destination.db"):
+            os.remove("destination.db")
+
+    def _check_tables_equal(self, tables):
+        with self.source_store.session_scope():
+            with self.destination_store.session_scope():
+                for table in tables:
+                    print(f"Table = {table}")
+                    source_table = getattr(self.source_store.db_classes, table)
+                    source_values = self.source_store.session.query(source_table).all()
+                    source_count = len(source_values)
+
+                    destination_table = getattr(self.destination_store.db_classes, table)
+                    destination_values = self.destination_store.session.query(
+                        destination_table
+                    ).all()
+                    destination_count = len(destination_values)
+
+                    assert source_count == destination_count
+
+                    source_ids = [
+                        getattr(item, get_primary_key_for_table(source_table))
+                        for item in source_values
+                    ]
+                    destination_ids = [
+                        getattr(item, get_primary_key_for_table(destination_table))
+                        for item in destination_values
+                    ]
+
+                    assert set(source_ids) == set(destination_ids)
+
+    def test_export_all(self):
+        export_all_measurement_tables(self.source_store, self.destination_store)
+        self._check_tables_equal(
+            [
+                "State",
+                "Contact",
+                "Comment",
+                "Activation",
+                "LogsHolding",
+                "Geometry1",
+                "Media",
+            ]
+        )
+
+    def test_export_filtered_by_time_with_time_field_1(self):
+        # Start is part-way through measurements, so only some should be exported
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 10, 31, 10, 45),
+            datetime(2004, 1, 1),
+        )
+
+        with self.source_store.session_scope():
+            with self.destination_store.session_scope():
+                # Check State table has only two items left in it now
+                state_entries = self.destination_store.session.query(
+                    self.destination_store.db_classes.State
+                ).all()
+                assert len(state_entries) == 2
+                times = [entry.time for entry in state_entries]
+                assert times == [datetime(2003, 10, 31, 12, 0), datetime(2003, 10, 31, 12, 6)]
+
+                comment_entries = self.destination_store.session.query(
+                    self.destination_store.db_classes.Comment
+                ).all()
+                assert len(comment_entries) == 6
+                times = [entry.time for entry in comment_entries]
+                assert times == [
+                    datetime(2003, 10, 31, 10, 56),
+                    datetime(2003, 10, 31, 11, 0),
+                    datetime(2003, 10, 31, 12, 6),
+                    datetime(2003, 10, 31, 12, 12),
+                    datetime(2003, 10, 31, 13, 5),
+                    datetime(2003, 10, 31, 21, 0),
+                ]
+
+    def test_export_filtered_by_time_with_time_field_2(self):
+        # Both start and end are before any of the measurements, so everything should
+        # be exported
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 9, 1, 10, 23),
+            datetime(2004, 9, 5, 16, 45),
+        )
+
+        self._check_tables_equal(
+            [
+                "State",
+                "Contact",
+                "Comment",
+                "LogsHolding",
+                "Media",
+            ]
+        )
+
+    def _check_start_end_times(self, results, correct_start_end_times):
+        def _parse(s):
+            if s is None:
+                return None
+            return datetime.strptime(s, "%Y-%m-%d %H:%M")
+
+        start_end_times = [(a.start, a.end) for a in results]
+
+        parsed_correct_start_end_times = [
+            (_parse(a), _parse(b)) for (a, b) in correct_start_end_times
+        ]
+
+        assert set(start_end_times) == set(parsed_correct_start_end_times)
+
+    def test_export_filtered_by_time_start_and_end_field_1(self):
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 10, 31, 12, 00),
+            datetime(2004, 11, 1, 12, 00),
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(results) == 2
+
+            self._check_start_end_times(
+                results, [("2003-10-31 13:00", "2003-10-31 14:50"), (None, "2003-10-31 15:00")]
+            )
+
+    def test_export_filtered_by_time_start_and_end_field_2(self):
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 10, 31, 10, 1),
+            datetime(2003, 10, 31, 11, 2),
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(results) == 2
+
+            self._check_start_end_times(
+                results, [("2003-10-31 10:02", None), ("2003-10-31 11:00", "2003-10-31 11:03")]
+            )
+
+    def test_export_filtered_by_time_start_and_end_field_3(self):
+        # Before all activation entries, so no results should be returned
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 9, 1, 10, 1),
+            datetime(2003, 9, 2, 11, 2),
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(results) == 0
+
+    def test_export_filtered_by_time_start_and_end_field_4(self):
+        # After all activation entries, so no results should be returned
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 12, 31, 10, 1),
+            datetime(2003, 12, 31, 11, 2),
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(results) == 0
+
+    def test_export_filtered_by_time_start_and_end_field_5(self):
+        export_measurement_tables_filtered_by_time(
+            self.source_store,
+            self.destination_store,
+            datetime(2003, 10, 31, 9, 30),
+            datetime(2003, 10, 31, 14, 0),
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(results) == 4
+
+            self._check_start_end_times(
+                results,
+                [
+                    ("2003-10-31 10:02", None),
+                    ("2003-10-31 13:00", "2003-10-31 14:50"),
+                    ("2003-10-31 10:00", None),
+                    ("2003-10-31 11:00", "2003-10-31 11:03"),
+                ],
+            )
+
+    def test_export_filtered_by_location_no_overlap(self):
+        export_measurement_tables_filtered_by_location(
+            self.source_store, self.destination_store, -90, 0, -85, 10
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.State
+            ).all()
+
+            assert len(results) == 0
+
+    def test_export_filtered_by_location_partial_overlap(self):
+        export_measurement_tables_filtered_by_location(
+            self.source_store, self.destination_store, 4.1, 36, 4.3, 37
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.State
+            ).all()
+
+            assert len(results) == 2
+
+            times = [s.time for s in results]
+            assert times == [
+                datetime(2003, 10, 31, 10, 2),
+                datetime(2003, 10, 31, 12, 0),
+            ]
+
+    def test_export_filtered_by_location_complete_overlap(self):
+        export_measurement_tables_filtered_by_location(
+            self.source_store, self.destination_store, 2, 30, 7, 40
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.State
+            ).all()
+
+            assert len(results) == 5
+
+    def test_export_filtered_by_wargame_no_overlap(self):
+        # Create wargame and participants with wargame times
+        # not overlapping with any data
+        wg = self._create_wargame(start_time=datetime(2000, 1, 1), end_time=datetime(2000, 2, 1))
+
+        export_measurement_tables_filtered_by_wargame_participation(
+            self.source_store, self.destination_store, wg
+        )
+
+        with self.destination_store.session_scope():
+            for table in ["State", "Comment", "Contact", "Activation"]:
+                results = self.destination_store.session.query(
+                    getattr(self.destination_store.db_classes, table)
+                ).all()
+
+                assert len(results) == 0
+
+    def test_export_filtered_by_wargame_with_overlap(self):
+        wg = self._create_wargame(
+            start_time=datetime(2003, 10, 31, 11, 0), end_time=datetime(2003, 11, 1, 12, 0)
+        )
+
+        export_measurement_tables_filtered_by_wargame_participation(
+            self.source_store, self.destination_store, wg
+        )
+
+        with self.destination_store.session_scope():
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.State
+            ).all()
+
+            assert len(results) == 2
+
+            times = [s.time for s in results]
+            assert set(times) == set([datetime(2003, 10, 31, 12, 0), datetime(2003, 10, 31, 12, 6)])
+
+            results = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(results) == 3
+
+            starts = [s.start for s in results]
+            assert set(starts) == set(
+                [
+                    None,
+                    datetime(2003, 10, 31, 11, 0),
+                    datetime(2003, 10, 31, 13, 0),
+                ]
+            )
+
+    def test_export_filtered_by_serial_no_overlap(self):
+        serial = self._create_serial(
+            start_time=datetime(2000, 1, 1),
+            end_time=datetime(2000, 2, 1),
+            p1_start_time=datetime(2000, 1, 2),
+            p1_end_time=datetime(2000, 1, 3),
+        )
+
+        export_measurement_tables_filtered_by_serial_participation(
+            self.source_store, self.destination_store, serial
+        )
+
+        with self.destination_store.session_scope():
+            for table in ["State", "Comment", "Contact", "Activation"]:
+                results = self.destination_store.session.query(
+                    getattr(self.destination_store.db_classes, table)
+                ).all()
+
+                assert len(results) == 0
+
+    def test_export_filtered_by_serial_with_overlap(self):
+        serial = self._create_serial(
+            start_time=datetime(2003, 10, 31, 11, 0),
+            end_time=datetime(2003, 11, 1, 12, 0),
+            p1_start_time=datetime(2003, 10, 31, 11, 30),
+            p1_end_time=datetime(2003, 10, 31, 14, 55),
+        )
+
+        export_measurement_tables_filtered_by_serial_participation(
+            self.source_store, self.destination_store, serial
+        )
+
+        with self.destination_store.session_scope():
+            # Check States
+            states = self.destination_store.session.query(
+                self.destination_store.db_classes.State
+            ).all()
+
+            assert len(states) == 2
+            times = [s.time for s in states]
+            assert set(times) == set([datetime(2003, 10, 31, 12, 0), datetime(2003, 10, 31, 12, 6)])
+
+            # Check Activations
+            activations = self.destination_store.session.query(
+                self.destination_store.db_classes.Activation
+            ).all()
+
+            assert len(activations) == 1
+            times = [s.start for s in activations]
+            assert times == [datetime(2003, 10, 31, 13, 0)]
+
+    def _create_wargame(self, start_time, end_time):
+        with self.source_store.session_scope():
+            priv_id = (
+                self.source_store.session.query(self.source_store.db_classes.Privacy)
+                .all()[0]
+                .privacy_id
+            )
+            change_id = self.source_store.add_to_changes(
+                "USER", datetime.utcnow(), "Creating test tasks/participants"
+            ).change_id
+            s1 = self.source_store.db_classes.Series(name="Test Series", privacy_id=priv_id)
+
+            wg1 = self.source_store.db_classes.Wargame(
+                name="Test Wargame",
+                start=start_time,
+                end=end_time,
+                privacy_id=priv_id,
+            )
+            wg1.series = s1
+
+            self.source_store.session.add_all([s1, wg1])
+
+            plat1 = (
+                self.source_store.session.query(self.source_store.db_classes.Platform)
+                .filter(self.source_store.db_classes.Platform.name == "ADRI")
+                .filter(self.source_store.db_classes.Platform.identifier == "123")
+                .one()
+            )
+
+            wg1.add_participant(
+                data_store=self.source_store, platform=plat1, privacy="Private", change_id=change_id
+            )
+
+        return wg1
+
+    def _create_serial(self, start_time, end_time, p1_start_time, p1_end_time):
+        with self.source_store.session_scope():
+            priv_id = (
+                self.source_store.session.query(self.source_store.db_classes.Privacy)
+                .all()[0]
+                .privacy_id
+            )
+            change_id = self.source_store.add_to_changes(
+                "USER", datetime.utcnow(), "Creating test tasks/participants"
+            ).change_id
+            s1 = self.source_store.db_classes.Series(name="Test Series", privacy_id=priv_id)
+
+            wg1 = self.source_store.db_classes.Wargame(
+                name="Test Wargame",
+                start=start_time,
+                end=end_time,
+                privacy_id=priv_id,
+            )
+            wg1.series = s1
+
+            self.source_store.session.add_all([s1, wg1])
+
+            plat1 = (
+                self.source_store.session.query(self.source_store.db_classes.Platform)
+                .filter(self.source_store.db_classes.Platform.name == "ADRI")
+                .filter(self.source_store.db_classes.Platform.identifier == "123")
+                .one()
+            )
+
+            p1 = wg1.add_participant(
+                data_store=self.source_store, platform=plat1, privacy="Private", change_id=change_id
+            )
+
+            serial1 = self.source_store.db_classes.Serial(
+                serial_number="Test Serial",
+                exercise="Test Exercise",
+                start=start_time,
+                end=end_time,
+                environment="Test Environment",
+                privacy_id=priv_id,
+            )
+            serial1.wargame = wg1
+
+            serial1.add_participant(
+                data_store=self.source_store,
+                wargame_participant=p1,
+                start=p1_start_time,
+                end=p1_end_time,
+                force_type="Blue",
+                privacy="Private",
+                change_id=change_id,
+            )
+
+        return serial1
diff --git a/tests/test_unknown_platforms.py b/tests/test_unknown_platforms.py
new file mode 100644
index 000000000..3425bcc4a
--- /dev/null
+++ b/tests/test_unknown_platforms.py
@@ -0,0 +1,74 @@
+import unittest
+from datetime import datetime
+from uuid import UUID
+
+from pepys_import.core.store.data_store import DataStore
+
+
+class UnknownPlatformTestCase(unittest.TestCase):
+    def setUp(self):
+        self.ds = DataStore("", "", "", 0, ":memory:", db_type="sqlite")
+        self.ds.initialise()
+        with self.ds.session_scope():
+            self.ds.populate_reference()
+            self.ds.populate_metadata()
+
+        with self.ds.session_scope():
+            self.change_id = self.ds.add_to_changes("TEST", datetime.now(), "TEST reason").change_id
+
+    def test_get_platform_unknown_no_name(self):
+        plat = self.ds.get_platform(change_id=self.change_id, unknown=True)
+
+        # This will fail if the name isn't a well-formed UUID
+        _ = UUID(plat.name)
+
+        assert plat.identifier == plat.name
+        assert plat.platform_type_name == "Unknown"
+        assert plat.nationality_name == "Unknown"
+        assert plat.privacy_name == "Public"
+
+    def test_get_platform_unknown_with_name(self):
+        plat = self.ds.get_platform(
+            platform_name="Test Name", change_id=self.change_id, unknown=True
+        )
+
+        assert plat.name == "Test Name"
+        assert plat.identifier == plat.name
+        assert plat.platform_type_name == "Unknown"
+        assert plat.nationality_name == "Unknown"
+        assert plat.privacy_name == "Public"
+
+    def test_get_sensor_no_details(self):
+        plat = self.ds.get_platform(
+            platform_name="Test Name", change_id=self.change_id, unknown=True
+        )
+
+        sensor = plat.get_sensor(self.ds, change_id=self.change_id)
+
+        _ = UUID(sensor.name)
+        assert sensor.sensor_type_name == "Unknown"
+        assert sensor.host__name == "Test Name"
+        assert sensor.privacy_name == "Public"
+
+    def test_get_sensor_given_name(self):
+        plat = self.ds.get_platform(
+            platform_name="Test Name", change_id=self.change_id, unknown=True
+        )
+
+        sensor = plat.get_sensor(self.ds, sensor_name="Test Sensor Name", change_id=self.change_id)
+
+        assert sensor.name == "Test Sensor Name"
+        assert sensor.sensor_type_name == "Unknown"
+        assert sensor.host__name == "Test Name"
+        assert sensor.privacy_name == "Public"
+
+    def test_get_sensor_given_type(self):
+        plat = self.ds.get_platform(
+            platform_name="Test Name", change_id=self.change_id, unknown=True
+        )
+
+        sensor = plat.get_sensor(self.ds, sensor_type="Radar", change_id=self.change_id)
+
+        assert sensor.sensor_type_name == "Radar"
+        assert sensor.host__name == "Test Name"
+        assert sensor.privacy_name == "Public"