diff abjbrowse2.py @ 13:1d86925dbb4c draft

planemo upload for repository https://github.com/galaxyproject/tools-iuc/tree/master/tools/jbrowse2 commit 873a12803692b0a84814a6dc08331d772d0e5492-dirty
author fubar
date Mon, 22 Jan 2024 11:52:19 +0000
parents
children 7c2e28e144f3
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/abjbrowse2.py	Mon Jan 22 11:52:19 2024 +0000
@@ -0,0 +1,1097 @@
+#!/usr/bin/env python
+import argparse
+import binascii
+import datetime
+import hashlib
+import json
+import logging
+import os
+import re
+import shutil
+import struct
+import subprocess
+import tempfile
+import xml.etree.ElementTree as ET
+from collections import defaultdict
+
+logging.basicConfig(level=logging.INFO)
+log = logging.getLogger('jbrowse')
+TODAY = datetime.datetime.now().strftime("%Y-%m-%d")
+GALAXY_INFRASTRUCTURE_URL = None
+
+
+class ColorScaling(object):
+
+    COLOR_FUNCTION_TEMPLATE = """
+    function(feature, variableName, glyphObject, track) {{
+        var score = {score};
+        {opacity}
+        return 'rgba({red}, {green}, {blue}, ' + opacity + ')';
+    }}
+    """
+
+    COLOR_FUNCTION_TEMPLATE_QUAL = r"""
+    function(feature, variableName, glyphObject, track) {{
+        var search_up = function self(sf, attr){{
+            if(sf.get(attr) !== undefined){{
+                return sf.get(attr);
+            }}
+            if(sf.parent() === undefined) {{
+                return;
+            }}else{{
+                return self(sf.parent(), attr);
+            }}
+        }};
+
+        var search_down = function self(sf, attr){{
+            if(sf.get(attr) !== undefined){{
+                return sf.get(attr);
+            }}
+            if(sf.children() === undefined) {{
+                return;
+            }}else{{
+                var kids = sf.children();
+                for(var child_idx in kids){{
+                    var x = self(kids[child_idx], attr);
+                    if(x !== undefined){{
+                        return x;
+                    }}
+                }}
+                return;
+            }}
+        }};
+
+        var color = ({user_spec_color} || search_up(feature, 'color') || search_down(feature, 'color') || {auto_gen_color});
+        var score = (search_up(feature, 'score') || search_down(feature, 'score'));
+        {opacity}
+        if(score === undefined){{ opacity = 1; }}
+        var result = /^#?([a-f\d]{{2}})([a-f\d]{{2}})([a-f\d]{{2}})$/i.exec(color);
+        var red = parseInt(result[1], 16);
+        var green = parseInt(result[2], 16);
+        var blue = parseInt(result[3], 16);
+        if(isNaN(opacity) || opacity < 0){{ opacity = 0; }}
+        return 'rgba(' + red + ',' + green + ',' + blue + ',' + opacity + ')';
+    }}
+    """
+
+    OPACITY_MATH = {
+        'linear': """
+            var opacity = (score - ({min})) / (({max}) - ({min}));
+        """,
+        'logarithmic': """
+            var opacity = Math.log10(score - ({min})) / Math.log10(({max}) - ({min}));
+        """,
+        'blast': """
+            var opacity = 0;
+            if(score == 0.0) {{
+                opacity = 1;
+            }} else {{
+                opacity = (20 - Math.log10(score)) / 180;
+            }}
+        """
+    }
+
+    BREWER_COLOUR_IDX = 0
+    BREWER_COLOUR_SCHEMES = [
+        (166, 206, 227),
+        (31, 120, 180),
+        (178, 223, 138),
+        (51, 160, 44),
+        (251, 154, 153),
+        (227, 26, 28),
+        (253, 191, 111),
+        (255, 127, 0),
+        (202, 178, 214),
+        (106, 61, 154),
+        (255, 255, 153),
+        (177, 89, 40),
+        (228, 26, 28),
+        (55, 126, 184),
+        (77, 175, 74),
+        (152, 78, 163),
+        (255, 127, 0),
+    ]
+
+    BREWER_DIVERGING_PALLETES = {
+        'BrBg': ("#543005", "#003c30"),
+        'PiYg': ("#8e0152", "#276419"),
+        'PRGn': ("#40004b", "#00441b"),
+        'PuOr': ("#7f3b08", "#2d004b"),
+        'RdBu': ("#67001f", "#053061"),
+        'RdGy': ("#67001f", "#1a1a1a"),
+        'RdYlBu': ("#a50026", "#313695"),
+        'RdYlGn': ("#a50026", "#006837"),
+        'Spectral': ("#9e0142", "#5e4fa2"),
+    }
+
+    def __init__(self):
+        self.brewer_colour_idx = 0
+
+    def rgb_from_hex(self, hexstr):
+        # http://stackoverflow.com/questions/4296249/how-do-i-convert-a-hex-triplet-to-an-rgb-tuple-and-back
+        return struct.unpack('BBB', binascii.unhexlify(hexstr))
+
+    def min_max_gff(self, gff_file):
+        min_val = None
+        max_val = None
+        with open(gff_file, 'r') as handle:
+            for line in handle:
+                try:
+                    value = float(line.split('\t')[5])
+                    min_val = min(value, (min_val or value))
+                    max_val = max(value, (max_val or value))
+
+                    if value < min_val:
+                        min_val = value
+
+                    if value > max_val:
+                        max_val = value
+                except Exception:
+                    pass
+        return min_val, max_val
+
+    def hex_from_rgb(self, r, g, b):
+        return '#%02x%02x%02x' % (r, g, b)
+
+    def _get_colours(self):
+        r, g, b = self.BREWER_COLOUR_SCHEMES[self.brewer_colour_idx % len(self.BREWER_COLOUR_SCHEMES)]
+        self.brewer_colour_idx += 1
+        return r, g, b
+
+    def parse_menus(self, track):
+        trackConfig = {'menuTemplate': [{}, {}, {}, {}]}
+
+        if 'menu' in track['menus']:
+            menu_list = [track['menus']['menu']]
+            if isinstance(track['menus']['menu'], list):
+                menu_list = track['menus']['menu']
+
+            for m in menu_list:
+                tpl = {
+                    'action': m['action'],
+                    'label': m.get('label', '{name}'),
+                    'iconClass': m.get('iconClass', 'dijitIconBookmark'),
+                }
+                if 'url' in m:
+                    tpl['url'] = m['url']
+                if 'content' in m:
+                    tpl['content'] = m['content']
+                if 'title' in m:
+                    tpl['title'] = m['title']
+
+                trackConfig['menuTemplate'].append(tpl)
+
+        return trackConfig
+
+    def parse_colours(self, track, trackFormat, gff3=None):
+        # Wiggle tracks have a bicolor pallete
+        trackConfig = {'style': {}}
+        if trackFormat == 'wiggle':
+
+            trackConfig['style']['pos_color'] = track['wiggle']['color_pos']
+            trackConfig['style']['neg_color'] = track['wiggle']['color_neg']
+
+            if trackConfig['style']['pos_color'] == '__auto__':
+                trackConfig['style']['neg_color'] = self.hex_from_rgb(*self._get_colours())
+                trackConfig['style']['pos_color'] = self.hex_from_rgb(*self._get_colours())
+
+            # Wiggle tracks can change colour at a specified place
+            bc_pivot = track['wiggle']['bicolor_pivot']
+            if bc_pivot not in ('mean', 'zero'):
+                # The values are either one of those two strings
+                # or a number
+                bc_pivot = float(bc_pivot)
+            trackConfig['bicolor_pivot'] = bc_pivot
+        elif 'scaling' in track:
+            if track['scaling']['method'] == 'ignore':
+                if track['scaling']['scheme']['color'] != '__auto__':
+                    trackConfig['style']['color'] = track['scaling']['scheme']['color']
+                else:
+                    trackConfig['style']['color'] = self.hex_from_rgb(*self._get_colours())
+            else:
+                # Scored method
+                algo = track['scaling']['algo']
+                # linear, logarithmic, blast
+                scales = track['scaling']['scales']
+                # type __auto__, manual (min, max)
+                scheme = track['scaling']['scheme']
+                # scheme -> (type (opacity), color)
+                # ==================================
+                # GENE CALLS OR BLAST
+                # ==================================
+                if trackFormat == 'blast':
+                    red, green, blue = self._get_colours()
+                    color_function = self.COLOR_FUNCTION_TEMPLATE.format(**{
+                        'score': "feature._parent.get('score')",
+                        'opacity': self.OPACITY_MATH['blast'],
+                        'red': red,
+                        'green': green,
+                        'blue': blue,
+                    })
+                    trackConfig['style']['color'] = color_function.replace('\n', '')
+                elif trackFormat == 'gene_calls':
+                    # Default values, based on GFF3 spec
+                    min_val = 0
+                    max_val = 1000
+                    # Get min/max and build a scoring function since JBrowse doesn't
+                    if scales['type'] == 'automatic' or scales['type'] == '__auto__':
+                        min_val, max_val = self.min_max_gff(gff3)
+                    else:
+                        min_val = scales.get('min', 0)
+                        max_val = scales.get('max', 1000)
+
+                    if scheme['color'] == '__auto__':
+                        user_color = 'undefined'
+                        auto_color = "'%s'" % self.hex_from_rgb(*self._get_colours())
+                    elif scheme['color'].startswith('#'):
+                        user_color = "'%s'" % self.hex_from_rgb(*self.rgb_from_hex(scheme['color'][1:]))
+                        auto_color = 'undefined'
+                    else:
+                        user_color = 'undefined'
+                        auto_color = "'%s'" % self.hex_from_rgb(*self._get_colours())
+
+                    color_function = self.COLOR_FUNCTION_TEMPLATE_QUAL.format(**{
+                        'opacity': self.OPACITY_MATH[algo].format(**{'max': max_val, 'min': min_val}),
+                        'user_spec_color': user_color,
+                        'auto_gen_color': auto_color,
+                    })
+
+                    trackConfig['style']['color'] = color_function.replace('\n', '')
+        return trackConfig
+
+
+def etree_to_dict(t):
+    if t is None:
+        return {}
+
+    d = {t.tag: {} if t.attrib else None}
+    children = list(t)
+    if children:
+        dd = defaultdict(list)
+        for dc in map(etree_to_dict, children):
+            for k, v in dc.items():
+                dd[k].append(v)
+        d = {t.tag: {k: v[0] if len(v) == 1 else v for k, v in dd.items()}}
+    if t.attrib:
+        d[t.tag].update(('@' + k, v) for k, v in t.attrib.items())
+    if t.text:
+        text = t.text.strip()
+        if children or t.attrib:
+            if text:
+                d[t.tag]['#text'] = text
+        else:
+            d[t.tag] = text
+    return d
+
+
+# score comes from feature._parent.get('score') or feature.get('score')
+
+INSTALLED_TO = os.path.dirname(os.path.realpath(__file__))
+
+
+def metadata_from_node(node):
+    metadata = {}
+    try:
+        if len(node.findall('dataset')) != 1:
+            # exit early
+            return metadata
+    except Exception:
+        return {}
+
+    for (key, value) in node.findall('dataset')[0].attrib.items():
+        metadata['dataset_%s' % key] = value
+
+    for (key, value) in node.findall('history')[0].attrib.items():
+        metadata['history_%s' % key] = value
+
+    for (key, value) in node.findall('metadata')[0].attrib.items():
+        metadata['metadata_%s' % key] = value
+
+    for (key, value) in node.findall('tool')[0].attrib.items():
+        metadata['tool_%s' % key] = value
+
+    # Additional Mappings applied:
+    metadata['dataset_edam_format'] = '<a target="_blank" href="http://edamontology.org/{0}">{1}</a>'.format(metadata['dataset_edam_format'], metadata['dataset_file_ext'])
+    metadata['history_user_email'] = '<a href="mailto:{0}">{0}</a>'.format(metadata['history_user_email'])
+    metadata['history_display_name'] = '<a target="_blank" href="{galaxy}/history/view/{encoded_hist_id}">{hist_name}</a>'.format(
+        galaxy=GALAXY_INFRASTRUCTURE_URL,
+        encoded_hist_id=metadata['history_id'],
+        hist_name=metadata['history_display_name']
+    )
+    metadata['tool_tool'] = '<a target="_blank" href="{galaxy}/datasets/{encoded_id}/show_params">{tool_id}</a>'.format(
+        galaxy=GALAXY_INFRASTRUCTURE_URL,
+        encoded_id=metadata['dataset_id'],
+        tool_id=metadata['tool_tool_id'],
+        # tool_version=metadata['tool_tool_version'],
+    )
+    return metadata
+
+
+class JbrowseConnector(object):
+
+    def __init__(self, jbrowse, outdir, genomes):
+        self.cs = ColorScaling()
+        self.jbrowse = jbrowse
+        self.outdir = outdir
+        self.genome_paths = genomes
+        self.tracksToIndex = []
+
+        # This is the id of the current assembly
+        self.assembly_ids = {}
+        self.current_assembly_id = []
+
+        # If upgrading, look at the existing data
+        self.check_existing(self.outdir)
+
+        self.clone_jbrowse(self.jbrowse, self.outdir)
+
+        self.process_genomes()
+
+    def subprocess_check_call(self, command, output=None):
+        if output:
+            log.debug('cd %s && %s >  %s', self.outdir, ' '.join(command), output)
+            subprocess.check_call(command, cwd=self.outdir, stdout=output)
+        else:
+            log.debug('cd %s && %s', self.outdir, ' '.join(command))
+            subprocess.check_call(command, cwd=self.outdir)
+
+    def subprocess_popen(self, command):
+        log.debug('cd %s && %s', self.outdir, command)
+        p = subprocess.Popen(command, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        output, err = p.communicate()
+        retcode = p.returncode
+        if retcode != 0:
+            log.error('cd %s && %s', self.outdir, command)
+            log.error(output)
+            log.error(err)
+            raise RuntimeError("Command failed with exit code %s" % (retcode))
+
+    def subprocess_check_output(self, command):
+        log.debug('cd %s && %s', self.outdir, ' '.join(command))
+        return subprocess.check_output(command, cwd=self.outdir)
+
+    def symlink_or_copy(self, src, dest):
+        if 'GALAXY_JBROWSE_SYMLINKS' in os.environ and bool(os.environ['GALAXY_JBROWSE_SYMLINKS']):
+            cmd = ['ln', '-s', src, dest]
+        else:
+            cmd = ['cp', src, dest]
+
+        return self.subprocess_check_call(cmd)
+
+    def symlink_or_copy_load_action(self):
+        if 'GALAXY_JBROWSE_SYMLINKS' in os.environ and bool(os.environ['GALAXY_JBROWSE_SYMLINKS']):
+            return 'symlink'
+        else:
+            return 'copy'
+
+    def check_existing(self, destination):
+        existing = os.path.join(destination, 'data', "config.json")
+        if os.path.exists(existing):
+            with open(existing, 'r') as existing_conf:
+                conf = json.load(existing_conf)
+                if 'assemblies' in conf:
+                    for assembly in conf['assemblies']:
+                        if 'name' in assembly:
+                            self.assembly_ids[assembly['name']] = None
+
+    def process_genomes(self):
+        for genome_node in self.genome_paths:
+            # We only expect one input genome per run. This for loop is just
+            # easier to write than the alternative / catches any possible
+            # issues.
+            self.add_assembly(genome_node['path'], genome_node['label'])
+
+    def add_assembly(self, path, label, default=True):
+        # Find a non-existing filename for the new genome
+        # (to avoid colision when upgrading an existing instance)
+        rel_seq_path = os.path.join('data', 'assembly')
+        seq_path = os.path.join(self.outdir, rel_seq_path)
+        fn_try = 1
+        while (os.path.exists(seq_path + '.fasta') or os.path.exists(seq_path + '.fasta.gz')
+               or os.path.exists(seq_path + '.fasta.gz.fai') or os.path.exists(seq_path + '.fasta.gz.gzi')):
+            rel_seq_path = os.path.join('data', 'assembly%s' % fn_try)
+            seq_path = os.path.join(self.outdir, rel_seq_path)
+            fn_try += 1
+
+        # Find a non-existing label for the new genome
+        # (to avoid colision when upgrading an existing instance)
+        lab_try = 1
+        uniq_label = label
+        while uniq_label in self.assembly_ids:
+            uniq_label = label + str(lab_try)
+            lab_try += 1
+
+        # Find a default scaffold to display
+        # TODO this may not be necessary in the future, see https://github.com/GMOD/jbrowse-components/issues/2708
+        with open(path, 'r') as fa_handle:
+            fa_header = fa_handle.readline()[1:].strip().split(' ')[0]
+
+        self.assembly_ids[uniq_label] = fa_header
+        if default:
+            self.current_assembly_id = uniq_label
+
+        copied_genome = seq_path + '.fasta'
+        shutil.copy(path, copied_genome)
+
+        # Compress with bgzip
+        cmd = ['bgzip', copied_genome]
+        self.subprocess_check_call(cmd)
+
+        # FAI Index
+        cmd = ['samtools', 'faidx', copied_genome + '.gz']
+        self.subprocess_check_call(cmd)
+
+        self.subprocess_check_call([
+            'jbrowse', 'add-assembly',
+            '--load', 'inPlace',
+            '--name', uniq_label,
+            '--type', 'bgzipFasta',
+            '--target', os.path.join(self.outdir, 'data'),
+            '--skipCheck',
+            rel_seq_path + '.fasta.gz'])
+
+        return uniq_label
+
+    def text_index(self):
+        # Index tracks
+        args = [
+            'jbrowse', 'text-index',
+            '--target', os.path.join(self.outdir, 'data'),
+            '--assemblies', self.current_assembly_id,
+        ]
+
+        tracks = ','.join(self.tracksToIndex)
+        if tracks:
+            args += ['--tracks', tracks]
+
+            self.subprocess_check_call(args)
+
+    def _blastxml_to_gff3(self, xml, min_gap=10):
+        gff3_unrebased = tempfile.NamedTemporaryFile(delete=False)
+        cmd = ['python', os.path.join(INSTALLED_TO, 'blastxml_to_gapped_gff3.py'),
+               '--trim', '--trim_end', '--include_seq', '--min_gap', str(min_gap), xml]
+        log.debug('cd %s && %s > %s', self.outdir, ' '.join(cmd), gff3_unrebased.name)
+        subprocess.check_call(cmd, cwd=self.outdir, stdout=gff3_unrebased)
+        gff3_unrebased.close()
+        return gff3_unrebased.name
+
+    def _prepare_track_style(self, xml_conf):
+
+        style_data = {
+            "type": "LinearBasicDisplay"
+        }
+
+        if 'display' in xml_conf['style']:
+            style_data['type'] = xml_conf['style']['display']
+            del xml_conf['style']['display']
+
+        style_data['displayId'] = "%s_%s" % (xml_conf['label'], style_data['type'])
+
+        style_data.update(xml_conf['style'])
+
+        return {'displays': [style_data]}
+
+    def add_blastxml(self, data, trackData, blastOpts, **kwargs):
+        gff3 = self._blastxml_to_gff3(data, min_gap=blastOpts['min_gap'])
+
+        if 'parent' in blastOpts and blastOpts['parent'] != 'None':
+            gff3_rebased = tempfile.NamedTemporaryFile(delete=False)
+            cmd = ['python', os.path.join(INSTALLED_TO, 'gff3_rebase.py')]
+            if blastOpts.get('protein', 'false') == 'true':
+                cmd.append('--protein2dna')
+            cmd.extend([os.path.realpath(blastOpts['parent']), gff3])
+            log.debug('cd %s && %s > %s', self.outdir, ' '.join(cmd), gff3_rebased.name)
+            subprocess.check_call(cmd, cwd=self.outdir, stdout=gff3_rebased)
+            gff3_rebased.close()
+
+            # Replace original gff3 file
+            shutil.copy(gff3_rebased.name, gff3)
+            os.unlink(gff3_rebased.name)
+
+        rel_dest = os.path.join('data', trackData['label'] + '.gff')
+        dest = os.path.join(self.outdir, rel_dest)
+
+        self._sort_gff(gff3, dest)
+        os.unlink(gff3)
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest + '.gz', config=style_json)
+
+    def add_bigwig(self, data, trackData, wiggleOpts, **kwargs):
+
+        rel_dest = os.path.join('data', trackData['label'] + '.bw')
+        dest = os.path.join(self.outdir, rel_dest)
+        self.symlink_or_copy(os.path.realpath(data), dest)
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)
+
+    # Anything ending in "am" (Bam or Cram)
+    def add_xam(self, data, trackData, xamOpts, index=None, ext="bam", **kwargs):
+
+        index_ext = "bai"
+        if ext == "cram":
+            index_ext = "crai"
+
+        rel_dest = os.path.join('data', trackData['label'] + '.%s' % ext)
+        dest = os.path.join(self.outdir, rel_dest)
+
+        self.symlink_or_copy(os.path.realpath(data), dest)
+
+        if index is not None and os.path.exists(os.path.realpath(index)):
+            # xai most probably made by galaxy and stored in galaxy dirs, need to copy it to dest
+            self.subprocess_check_call(['cp', os.path.realpath(index), dest + '.%s' % index_ext])
+        else:
+            # Can happen in exotic condition
+            # e.g. if bam imported as symlink with datatype=unsorted.bam, then datatype changed to bam
+            #      => no index generated by galaxy, but there might be one next to the symlink target
+            #      this trick allows to skip the bam sorting made by galaxy if already done outside
+            if os.path.exists(os.path.realpath(data) + '.%s' % index_ext):
+                self.symlink_or_copy(os.path.realpath(data) + '.%s' % index_ext, dest + '.%s' % index_ext)
+            else:
+                log.warn('Could not find a bam index (.%s file) for %s', (index_ext, data))
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)
+
+    def add_vcf(self, data, trackData, vcfOpts={}, zipped=False, **kwargs):
+
+        if zipped:
+            rel_dest = os.path.join('data', trackData['label'] + '.vcf.gz')
+            dest = os.path.join(self.outdir, rel_dest)
+            shutil.copy(os.path.realpath(data), dest)
+        else:
+            rel_dest = os.path.join('data', trackData['label'] + '.vcf')
+            dest = os.path.join(self.outdir, rel_dest)
+            shutil.copy(os.path.realpath(data), dest)
+
+            cmd = ['bgzip', dest]
+            self.subprocess_check_call(cmd)
+            cmd = ['tabix', dest + '.gz']
+            self.subprocess_check_call(cmd)
+
+            rel_dest = os.path.join('data', trackData['label'] + '.vcf.gz')
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)
+
+    def add_gff(self, data, format, trackData, gffOpts, **kwargs):
+        rel_dest = os.path.join('data', trackData['label'] + '.gff')
+        dest = os.path.join(self.outdir, rel_dest)
+
+        self._sort_gff(data, dest)
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest + '.gz', config=style_json)
+
+    def add_bed(self, data, format, trackData, gffOpts, **kwargs):
+        rel_dest = os.path.join('data', trackData['label'] + '.bed')
+        dest = os.path.join(self.outdir, rel_dest)
+
+        self._sort_bed(data, dest)
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest + '.gz', config=style_json)
+
+    def add_paf(self, data, trackData, pafOpts, **kwargs):
+        rel_dest = os.path.join('data', trackData['label'] + '.paf')
+        dest = os.path.join(self.outdir, rel_dest)
+
+        self.symlink_or_copy(os.path.realpath(data), dest)
+
+        added_assembly = self.add_assembly(pafOpts['genome'], pafOpts['genome_label'], default=False)
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, assemblies=[self.current_assembly_id, added_assembly], config=style_json)
+
+    def add_hic(self, data, trackData, hicOpts, **kwargs):
+        rel_dest = os.path.join('data', trackData['label'] + '.hic')
+        dest = os.path.join(self.outdir, rel_dest)
+
+        self.symlink_or_copy(os.path.realpath(data), dest)
+
+        style_json = self._prepare_track_style(trackData)
+
+        self._add_track(trackData['label'], trackData['key'], trackData['category'], rel_dest, config=style_json)
+
+    def add_sparql(self, url, query, query_refnames, trackData):
+
+        json_track_data = {
+            "type": "FeatureTrack",
+            "trackId": id,
+            "name": trackData['label'],
+            "adapter": {
+                "type": "SPARQLAdapter",
+                "endpoint": {
+                    "uri": url,
+                    "locationType": "UriLocation"
+                },
+                "queryTemplate": query
+            },
+            "category": [
+                trackData['category']
+            ],
+            "assemblyNames": [
+                self.current_assembly_id
+            ]
+        }
+
+        if query_refnames:
+            json_track_data['adapter']['refNamesQueryTemplate']: query_refnames
+
+        self.subprocess_check_call([
+            'jbrowse', 'add-track-json',
+            '--target', os.path.join(self.outdir, 'data'),
+            json_track_data])
+
+        # Doesn't work as of 1.6.4, might work in the future
+        # self.subprocess_check_call([
+        #     'jbrowse', 'add-track',
+        #     '--trackType', 'sparql',
+        #     '--name', trackData['label'],
+        #     '--category', trackData['category'],
+        #     '--target', os.path.join(self.outdir, 'data'),
+        #     '--trackId', id,
+        #     '--config', '{"queryTemplate": "%s"}' % query,
+        #     url])
+
+    def _add_track(self, id, label, category, path, assemblies=[], config=None):
+
+        assemblies_opt = self.current_assembly_id
+        if assemblies:
+            assemblies_opt = ','.join(assemblies)
+
+        cmd = [
+            'jbrowse', 'add-track',
+            '--load', 'inPlace',
+            '--name', label,
+            '--category', category,
+            '--target', os.path.join(self.outdir, 'data'),
+            '--trackId', id,
+            '--assemblyNames', assemblies_opt
+        ]
+
+        if config:
+            cmd.append('--config')
+            cmd.append(json.dumps(config))
+
+        cmd.append(path)
+
+        self.subprocess_check_call(cmd)
+
+    def _sort_gff(self, data, dest):
+        # Only index if not already done
+        if not os.path.exists(dest):
+            cmd = "gff3sort.pl --precise '%s' | grep -v \"^$\" > '%s'" % (data, dest)
+            self.subprocess_popen(cmd)
+
+            self.subprocess_check_call(['bgzip', '-f', dest])
+            self.subprocess_check_call(['tabix', '-f', '-p', 'gff', dest + '.gz'])
+
+    def _sort_bed(self, data, dest):
+        # Only index if not already done
+        if not os.path.exists(dest):
+            cmd = ['sort', '-k1,1', '-k2,2n', data]
+            with open(dest, 'w') as handle:
+                self.subprocess_check_call(cmd, output=handle)
+
+            self.subprocess_check_call(['bgzip', '-f', dest])
+            self.subprocess_check_call(['tabix', '-f', '-p', 'bed', dest + '.gz'])
+
+    def process_annotations(self, track):
+
+        category = track['category'].replace('__pd__date__pd__', TODAY)
+        outputTrackConfig = {
+            'category': category,
+        }
+
+        mapped_chars = {
+            '>': '__gt__',
+            '<': '__lt__',
+            "'": '__sq__',
+            '"': '__dq__',
+            '[': '__ob__',
+            ']': '__cb__',
+            '{': '__oc__',
+            '}': '__cc__',
+            '@': '__at__',
+            '#': '__pd__',
+            "": '__cn__'
+        }
+
+        for i, (dataset_path, dataset_ext, track_human_label, extra_metadata) in enumerate(track['trackfiles']):
+            # Unsanitize labels (element_identifiers are always sanitized by Galaxy)
+            for key, value in mapped_chars.items():
+                track_human_label = track_human_label.replace(value, key)
+
+            log.info('Processing track %s / %s (%s)', category, track_human_label, dataset_ext)
+            outputTrackConfig['key'] = track_human_label
+            # We add extra data to hash for the case of REST + SPARQL.
+            if 'conf' in track and 'options' in track['conf'] and 'url' in track['conf']['options']:
+                rest_url = track['conf']['options']['url']
+            else:
+                rest_url = ''
+
+            # I chose to use track['category'] instead of 'category' here. This
+            # is intentional. This way re-running the tool on a different date
+            # will not generate different hashes and make comparison of outputs
+            # much simpler.
+            hashData = [str(dataset_path), track_human_label, track['category'], rest_url, self.current_assembly_id]
+            hashData = '|'.join(hashData).encode('utf-8')
+            outputTrackConfig['label'] = hashlib.md5(hashData).hexdigest() + '_%s' % i
+            outputTrackConfig['metadata'] = extra_metadata
+
+            outputTrackConfig['style'] = track['style']
+
+            if 'menus' in track['conf']['options']:
+                menus = self.cs.parse_menus(track['conf']['options'])
+                outputTrackConfig.update(menus)
+
+            if dataset_ext in ('gff', 'gff3'):
+                self.add_gff(dataset_path, dataset_ext, outputTrackConfig,
+                             track['conf']['options']['gff'])
+            elif dataset_ext == 'bed':
+                self.add_bed(dataset_path, dataset_ext, outputTrackConfig,
+                             track['conf']['options']['gff'])
+            elif dataset_ext == 'bigwig':
+                self.add_bigwig(dataset_path, outputTrackConfig,
+                                track['conf']['options']['wiggle'])
+            elif dataset_ext == 'bam':
+                real_indexes = track['conf']['options']['pileup']['bam_indices']['bam_index']
+                if not isinstance(real_indexes, list):
+                    # <bam_indices>
+                    #  <bam_index>/path/to/a.bam.bai</bam_index>
+                    # </bam_indices>
+                    #
+                    # The above will result in the 'bam_index' key containing a
+                    # string. If there are two or more indices, the container
+                    # becomes a list. Fun!
+                    real_indexes = [real_indexes]
+
+                self.add_xam(dataset_path, outputTrackConfig,
+                             track['conf']['options']['pileup'],
+                             index=real_indexes[i], ext="bam")
+            elif dataset_ext == 'cram':
+                real_indexes = track['conf']['options']['cram']['cram_indices']['cram_index']
+                if not isinstance(real_indexes, list):
+                    # <bam_indices>
+                    #  <bam_index>/path/to/a.bam.bai</bam_index>
+                    # </bam_indices>
+                    #
+                    # The above will result in the 'bam_index' key containing a
+                    # string. If there are two or more indices, the container
+                    # becomes a list. Fun!
+                    real_indexes = [real_indexes]
+
+                self.add_xam(dataset_path, outputTrackConfig,
+                             track['conf']['options']['cram'],
+                             index=real_indexes[i], ext="cram")
+            elif dataset_ext == 'blastxml':
+                self.add_blastxml(dataset_path, outputTrackConfig, track['conf']['options']['blast'])
+            elif dataset_ext == 'vcf':
+                self.add_vcf(dataset_path, outputTrackConfig)
+            elif dataset_ext == 'vcf_bgzip':
+                self.add_vcf(dataset_path, outputTrackConfig, zipped=True)
+            elif dataset_ext == 'rest':
+                self.add_rest(track['conf']['options']['rest']['url'], outputTrackConfig)
+            elif dataset_ext == 'synteny':
+                self.add_paf(dataset_path, outputTrackConfig,
+                             track['conf']['options']['synteny'])
+            elif dataset_ext == 'hic':
+                self.add_hic(dataset_path, outputTrackConfig,
+                             track['conf']['options']['hic'])
+            elif dataset_ext == 'sparql':
+                sparql_query = track['conf']['options']['sparql']['query']
+                for key, value in mapped_chars.items():
+                    sparql_query = sparql_query.replace(value, key)
+                sparql_query_refnames = track['conf']['options']['sparql']['query_refnames']
+                for key, value in mapped_chars.items():
+                    sparql_query_refnames = sparql_query_refnames.replace(value, key)
+                self.add_sparql(track['conf']['options']['sparql']['url'], sparql_query, sparql_query_refnames, outputTrackConfig)
+            else:
+                log.warn('Do not know how to handle %s', dataset_ext)
+
+            # Return non-human label for use in other fields
+            yield outputTrackConfig['label']
+
+    def add_default_session(self, data):
+        """
+            Add some default session settings: set some assemblies/tracks on/off
+        """
+        tracks_data = []
+
+        # TODO using the default session for now, but check out session specs in the future https://github.com/GMOD/jbrowse-components/issues/2708
+
+        # We need to know the track type from the config.json generated just before
+        config_path = os.path.join(self.outdir, 'data', 'config.json')
+        track_types = {}
+        with open(config_path, 'r') as config_file:
+            config_json = json.load(config_file)
+
+        for track_conf in config_json['tracks']:
+            track_types[track_conf['trackId']] = track_conf['type']
+
+        for on_track in data['visibility']['default_on']:
+            # TODO several problems with this currently
+            # - we are forced to copy the same kind of style config as the per track config from _prepare_track_style (not exactly the same though)
+            # - we get an error when refreshing the page
+            # - this could be solved by session specs, see https://github.com/GMOD/jbrowse-components/issues/2708
+            style_data = {
+                "type": "LinearBasicDisplay",
+                "height": 100
+            }
+
+            if on_track in data['style']:
+                if 'display' in data['style'][on_track]:
+                    style_data['type'] = data['style'][on_track]['display']
+                    del data['style'][on_track]['display']
+
+                style_data.update(data['style'][on_track])
+
+            if on_track in data['style_labels']:
+                # TODO fix this: it should probably go in a renderer block (SvgFeatureRenderer) but still does not work
+                # TODO move this to per track displays?
+                style_data['labels'] = data['style_labels'][on_track]
+
+            tracks_data.append({
+                "type": track_types[on_track],
+                "configuration": on_track,
+                "displays": [
+                    style_data
+                ]
+            })
+
+        # The view for the assembly we're adding
+        view_json = {
+            "type": "LinearGenomeView",
+            "tracks": tracks_data
+        }
+
+        refName = None
+        if data.get('defaultLocation', ''):
+            loc_match = re.search(r'^(\w+):(\d+)\.+(\d+)$', data['defaultLocation'])
+            if loc_match:
+                refName = loc_match.group(1)
+                start = int(loc_match.group(2))
+                end = int(loc_match.group(3))
+        elif self.assembly_ids[self.current_assembly_id] is not None:
+            refName = self.assembly_ids[self.current_assembly_id]
+            start = 0
+            end = 1000000  # Booh, hard coded! waiting for https://github.com/GMOD/jbrowse-components/issues/2708
+
+        if refName is not None:
+            # TODO displayedRegions is not just zooming to the region, it hides the rest of the chromosome
+            view_json['displayedRegions'] = [{
+                "refName": refName,
+                "start": start,
+                "end": end,
+                "reversed": False,
+                "assemblyName": self.current_assembly_id
+            }]
+
+        session_name = data.get('session_name', "New session")
+        if not session_name:
+            session_name = "New session"
+
+        # Merge with possibly existing defaultSession (if upgrading a jbrowse instance)
+        session_json = {}
+        if 'defaultSession' in config_json:
+            session_json = config_json['defaultSession']
+
+        session_json["name"] = session_name
+
+        if 'views' not in session_json:
+            session_json['views'] = []
+
+        session_json['views'].append(view_json)
+
+        config_json['defaultSession'] = session_json
+
+        with open(config_path, 'w') as config_file:
+            json.dump(config_json, config_file, indent=2)
+
+    def add_general_configuration(self, data):
+        """
+            Add some general configuration to the config.json file
+        """
+
+        config_path = os.path.join(self.outdir, 'data', 'config.json')
+        with open(config_path, 'r') as config_file:
+            config_json = json.load(config_file)
+
+        config_data = {}
+
+        config_data['disableAnalytics'] = data.get('analytics', 'false') == 'true'
+
+        config_data['theme'] = {
+            "palette": {
+                "primary": {
+                    "main": data.get('primary_color', '#0D233F')
+                },
+                "secondary": {
+                    "main": data.get('secondary_color', '#721E63')
+                },
+                "tertiary": {
+                    "main": data.get('tertiary_color', '#135560')
+                },
+                "quaternary": {
+                    "main": data.get('quaternary_color', '#FFB11D')
+                },
+            },
+            "typography": {
+                "fontSize": int(data.get('font_size', 10))
+            },
+        }
+
+        config_json['configuration'].update(config_data)
+
+        with open(config_path, 'w') as config_file:
+            json.dump(config_json, config_file, indent=2)
+
+    def clone_jbrowse(self, jbrowse_dir, destination):
+        """Clone a JBrowse directory into a destination directory.
+        """
+
+        copytree(jbrowse_dir, destination)
+
+        try:
+            shutil.rmtree(os.path.join(destination, 'test_data'))
+        except OSError as e:
+            log.error("Error: %s - %s." % (e.filename, e.strerror))
+
+        if not os.path.exists(os.path.join(destination, 'data')):
+            # It can already exist if upgrading an instance
+            os.makedirs(os.path.join(destination, 'data'))
+            log.info("makedir %s" % (os.path.join(destination, 'data')))
+
+        os.symlink('./data/config.json', os.path.join(destination, 'config.json'))
+
+
+def copytree(src, dst, symlinks=False, ignore=None):
+    for item in os.listdir(src):
+        s = os.path.join(src, item)
+        d = os.path.join(dst, item)
+        if os.path.isdir(s):
+            shutil.copytree(s, d, symlinks, ignore)
+        else:
+            shutil.copy2(s, d)
+
+
+def parse_style_conf(item):
+    if 'type' in item.attrib and item.attrib['type'] in ['boolean', 'integer']:
+        if item.attrib['type'] == 'boolean':
+            return item.text in ("yes", "true", "True")
+        elif item.attrib['type'] == 'integer':
+            return int(item.text)
+    else:
+        return item.text
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description="", epilog="")
+    parser.add_argument('xml', type=argparse.FileType('r'), help='Track Configuration')
+
+    parser.add_argument('--jbrowse', help='Folder containing a jbrowse release')
+    parser.add_argument('--outdir', help='Output directory', default='out')
+    parser.add_argument('--version', '-V', action='version', version="%(prog)s 0.8.0")
+    args = parser.parse_args()
+
+    tree = ET.parse(args.xml.name)
+    root = tree.getroot()
+
+    # This should be done ASAP
+    GALAXY_INFRASTRUCTURE_URL = root.find('metadata/galaxyUrl').text
+    # Sometimes this comes as `localhost` without a protocol
+    if not GALAXY_INFRASTRUCTURE_URL.startswith('http'):
+        # so we'll prepend `http://` and hope for the best. Requests *should*
+        # be GET and not POST so it should redirect OK
+        GALAXY_INFRASTRUCTURE_URL = 'http://' + GALAXY_INFRASTRUCTURE_URL
+
+    jc = JbrowseConnector(
+        jbrowse=args.jbrowse,
+        outdir=args.outdir,
+        genomes=[
+            {
+                'path': os.path.realpath(x.attrib['path']),
+                'meta': metadata_from_node(x.find('metadata')),
+                'label': x.attrib['label']
+            }
+            for x in root.findall('metadata/genomes/genome')
+        ]
+    )
+
+    default_session_data = {
+        'visibility': {
+            'default_on': [],
+            'default_off': [],
+        },
+        'style': {},
+        'style_labels': {}
+    }
+
+    # TODO add metadata to tracks
+    for track in root.findall('tracks/track'):
+        track_conf = {}
+        track_conf['trackfiles'] = []
+
+        trackfiles = track.findall('files/trackFile')
+        if trackfiles:
+            for x in track.findall('files/trackFile'):
+                if trackfiles:
+                    metadata = metadata_from_node(x.find('metadata'))
+
+                    track_conf['trackfiles'].append((
+                        os.path.realpath(x.attrib['path']),
+                        x.attrib['ext'],
+                        x.attrib['label'],
+                        metadata
+                    ))
+        else:
+            # For tracks without files (rest, sparql)
+            track_conf['trackfiles'].append((
+                '',  # N/A, no path for rest or sparql
+                track.attrib['format'],
+                track.find('options/label').text,
+                {}
+            ))
+
+        track_conf['category'] = track.attrib['cat']
+        track_conf['format'] = track.attrib['format']
+        track_conf['style'] = {item.tag: parse_style_conf(item) for item in track.find('options/style')}
+
+        track_conf['style'] = {item.tag: parse_style_conf(item) for item in track.find('options/style')}
+
+        track_conf['style_labels'] = {item.tag: parse_style_conf(item) for item in track.find('options/style_labels')}
+
+        track_conf['conf'] = etree_to_dict(track.find('options'))
+        keys = jc.process_annotations(track_conf)
+
+        for key in keys:
+            default_session_data['visibility'][track.attrib.get('visibility', 'default_off')].append(key)
+
+        default_session_data['style'][key] = track_conf['style']  # TODO do we need this anymore?
+        default_session_data['style_labels'][key] = track_conf['style_labels']
+
+    default_session_data['defaultLocation'] = root.find('metadata/general/defaultLocation').text
+    default_session_data['session_name'] = root.find('metadata/general/session_name').text
+
+    general_data = {
+        'analytics': root.find('metadata/general/analytics').text,
+        'primary_color': root.find('metadata/general/primary_color').text,
+        'secondary_color': root.find('metadata/general/secondary_color').text,
+        'tertiary_color': root.find('metadata/general/tertiary_color').text,
+        'quaternary_color': root.find('metadata/general/quaternary_color').text,
+        'font_size': root.find('metadata/general/font_size').text,
+    }
+
+    jc.add_default_session(default_session_data)
+    jc.add_general_configuration(general_data)
+    jc.text_index()
+