All files / src/browse route.ts

100% Statements 76/76
98.57% Branches 69/70
100% Functions 5/5
100% Lines 76/76

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234                                                                                            2x 2x 2x 2x 2x                                                       2x 25x 25x 7x 7x   2x 364x       364x       364x 364x           363x       334x         242x 242x 242x 242x 326x 81x 81x                       161x 161x 161x 161x 128x 364x 43x 43x           286x               302x 301x             299x 59x 59x                 403x 403x 403x                   2x 39x 39x                           2x 134x       124x 3x 3x 124x 2x 2x 124x 124x 2x 2x 124x       6x 6x 124x 1x 1x 124x 3x 3x 124x 1x 1x 124x 1x 1x 124x 3x 3x 6x 6x  
/**
 * What a file is, judged by its name alone.
 *
 * `Session.routeOf` has decided this since the day a second file could be
 * dropped, and it stayed private while one caller asked. A folder view is the
 * second: it draws a row per name in a directory nobody has opened, so it has to
 * answer *would this do anything* before any byte is read — and answering it
 * with a second regular expression somewhere else is how a `.bedpe` comes to be
 * loadable by dropping and greyed out in a list.
 *
 * So the rule moved here and the session calls it. One table, four answers.
 *
 * ## What it was not told
 *
 * The table is only as right as the list of names it was taught, and it was
 * taught them one format at a time — so every format that arrived after it had
 * to be added, and four kinds never were. A **structure** (§25) and a **saved
 * workspace** were drawn dimmed in a folder although a drop opened both; and so
 * were the text tables a §8 format is routinely published under — a loop table
 * from chromosight is `loops.tsv`, a GEO supplement is `peaks.txt.gz` — and every
 * `.bgz`, which is gnomAD's suffix for a VCF. An audit of every name each reader
 * accepts against this table is what found them; `test/unit/browse-route.test.ts`
 * holds the whole list, so the next format is checked against it rather than
 * found dimmed.
 *
 * ## Why a name and not the bytes
 *
 * Because the bytes are the thing this is avoiding. A folder of two hundred
 * entries would be two hundred reads to draw a list, over a network for a
 * bucket, and the answer would still be wrong for the file whose extension lies.
 * The application already routes by name everywhere else — `Session.dropped`
 * sends a `.cool` to the map and a `.bw` to the tracks without opening either —
 * and the format detection that *does* read bytes (§3.1) runs after, where it
 * can refuse with a message.
 *
 * `unknown` is therefore "this name does not say", not "this file is broken" —
 * and a name that does not say is not always one with nothing behind it. A
 * `yeast.txt` is a table of something, and what it is is the loader's question
 * (§8.3): `Session.open` asks its bytes before it tries a door, and a folder
 * offers it for the same reason a drop takes it ({@link unroutableNote}). What a
 * folder draws dimmed is a name this app has no road for at all, and a row
 * saying so is more use than a row that is absent: a reader looking at a
 * directory wants to see the directory, and a `.cram` sitting there unhighlighted
 * answers *why is nothing happening when I click it* before it is asked.
 */
 
import { splitUri } from '../ui/labels'
import { devFeature } from '../state/devFeatures'
import { looksLikeConfig } from '../state/SessionConfig'
import { isStructureName } from '../annotations/g3d'
import { looksLikeHubUrl } from '../state/hub'
 
/**
 * Which of the app's front doors a name belongs to, or that it does not say.
 *
 * `workspace` is the third door and not a kind of file: a saved session is the
 * tabs it was saved from, opened by the config road whatever the tab in front
 * holds, which is why a row of one carries the new-session mark alone (§20.5).
 */
export type FileRoute = 'map' | 'genome' | 'workspace' | 'unknown'
 
/**
 * Route a URI or a bare file name.
 *
 * Takes the last path segment, so a full URL, a `::entries/rep1` URI and a plain
 * name all answer the same — and splits on both separators, because a Windows
 * path reaches this from a dropped file's `name` on that platform.
 */
/**
 * The route of a file whose address may not say, with a name that does.
 *
 * A Drive file is addressed by an id (`…/files/<id>?alt=media`) and a Dropbox
 * file by a temporary link, and neither ends in the file's name — so the
 * backend that listed it hands the name along (§24.9), and the name decides
 * the door where the address cannot. The address wins where it does say,
 * because the address is what will be read: a store row labelled *the ATAC
 * track* whose URL ends in `.mcool` is a map.
 */
export function routeFor(uri: string, label?: string): FileRoute {
  const byAddress = routeOf(uri)
  if (byAddress !== 'unknown' || label === undefined) return byAddress
  return routeOf(label)
}
 
export function routeOf(uri: string): FileRoute {
  const name = nameOf(uri)
  // A saved workspace, before anything asks what file it is, as `Session.dropped`
  // asks: by the one test the config road uses, so a folder and a drop cannot
  // disagree about which `.json` is a session.
  if (looksLikeConfig(name)) return 'workspace'
  // The multi-genome containers only while they are read (§29): switched off
  // they are names this viewer does not open, and the row says so in the
  // sentence every other such name gets.
  if (/\.m?metacool$/i.test(name)) return devFeature('metacool') ? 'map' : 'unknown'
  if (/\.([ms]?cool|hic|h5|hdf5)$/i.test(name)) return 'map'
  // A `.pairs` is a contact list rather than a matrix, and it still belongs at
  // the map's door: what comes out of it is a map, after one question the file
  // cannot answer for itself (§3.7). Routing is about which front door a name
  // belongs to, and this one's is not the browser's. Bgzipped under either
  // suffix: the reader knows a pairs file by its `## pairs format` line.
  if (/\.pairs(\.b?gz)?$/i.test(name)) return 'map'
  // A HiC-Pro matrix, which is a contact map made of two text files (§3.9). The
  // `.bed` beside it stays on the genome door: it is a valid BED as well as a
  // bin grid, and which of the two it is depends on what it was dropped with.
  if (/\.matrix(\.gz)?$/i.test(name)) return 'map'
  // `.bgz` beside `.gz` throughout: it is what `bgzip` is told to write when
  // the name should say the file is indexable — gnomAD publishes its VCFs as
  // `.vcf.bgz` — and the loader knows gzip and BGZF by their magic, not their
  // suffix. And `.bg`, which is bedtools' own name for a bedGraph.
  if (
    /\.(bw|bigwig|bb|bigbed|chrom\.sizes|sizes|genome|fai|fa|fasta|fna|fas|gtf|gff3?|bed|bedpe|narrowPeak|broadPeak|gappedPeak|vcf|bdg|bedgraph|bg|wig)(\.b?gz)?$/i.test(
      name,
    )
  ) {
    return 'genome'
  }
  // The UCSC names (§8.17–§8.21). On the genome door with every other
  // annotation, and `.2bit` among them because it is a sequence — the same door
  // a FASTA goes through, and the one an assembly hub's genome arrives at. A
  // `.barChart` and a `.bigBarChart` are here too: the loader has read both by
  // their bytes since §8.17, and a folder listing that dimmed them was the
  // router disagreeing with the loader about a name it had never been taught.
  //
  // `refFlat.txt` and `refGene.txt` are here without a dot-extension of their
  // own, because that is how they are published: IGV's genome table names 29 of
  // them, and a router that insisted on a suffix would go on greying out
  // exactly the gene models this release exists to open.
  if (
    /\.(interact|biginteract|longrange|longtabix|genepred|genepredext|refflat|bedmethyl|bigmethyl|barchart|bigbarchart|2bit)(\.txt)?(\.b?gz)?$/i.test(
      name,
    ) ||
    /(^|[._-])(refflat|refgene|ncbirefseq|knowngene)\.txt(\.b?gz)?$/i.test(name)
  ) {
    return 'genome'
  }
  // A BAM annotates a genome and can declare one, so it is the same door as a
  // bigWig (§8.13) — even though, unlike every other name on that list, it may
  // yet be refused for want of an index. Routing is about which front door a
  // name belongs to; whether the file behind it opens is settled by its bytes,
  // where a refusal can carry a message.
  if (/\.bam$/i.test(name)) return 'genome'
  // A cytoBand and a junction file, each published under a name rather than an
  // extension of its own (§8.22, §8.23): UCSC's `cytoBand.txt.gz`, STAR's
  // `SJ.out.tab`, a `junctions.bed` (which the list above takes already) and
  // leafcutter's `.junc`. A cytoBand declares a genome, so it is the door a
  // browser opens from — under the plural as well, which is what a file named
  // for its bands rather than for UCSC's table is called, the demo's own
  // `Yeast_cytobands.txt` among them.
  if (/cytoband(s|ideo)?(\.txt)?(\.b?gz)?$/i.test(name)) return 'genome'
  if (/(sj\.out\.tab|\.junc)(\.b?gz)?$/i.test(name)) return 'genome'
  // A 3D structure (§25): the genome door, because a model is a row of the
  // annotation store whose every bead is an interval — dropped on an empty tab
  // it opens a browser over the sequences its beads cover, with the model in a
  // cell of its own — by the same name test that makes its row a structure the
  // moment the loader begins it, on the Structures card and in a cell before a
  // byte is read (`annotations/g3d.ts`).
  if (isStructureName(name)) return 'genome'
  return 'unknown'
}
 
/**
 * The last path segment, with the query and the fragment taken off it.
 *
 * Neither is part of a name, and both reach here: a bucket URL carries a query
 * string, and a HiC-Pro pair names its bin file in a `#bins=` fragment (§3.9) —
 * which ends in `.bed`, and routed the *matrix* to the genome door.
 */
function nameOf(uri: string): string {
  return (splitUri(uri).path.split(/[\\/]/).pop() ?? uri).split(/[?#]/)[0]
}
 
/**
 * Whether a name is a text table's — `.txt`, `.tsv`, `.tab`, compressed or not —
 * which says only that its rows will say what it is (§8.3).
 *
 * Such a name routes `unknown` and is offered all the same ({@link
 * unroutableNote}); the session asks its bytes, and refuses one no reader took
 * as the text it is rather than as a contact map that is not one.
 */
export function isTextTableName(uri: string): boolean {
  return /\.(txt|tsv|tab)(\.b?gz)?$/i.test(nameOf(uri))
}
 
/**
 * A one-line reason a row is not offered, or `null` when it is.
 *
 * Written where the routing is, so the list and the router cannot come to
 * disagree about which names do nothing.
 *
 * A name the router cannot place is still offered when it is a text table —
 * `.txt`, `.tsv`, `.tab`, compressed or not — because those are the names §8's
 * formats are published under when their own suffix is not used, and the
 * loader reads them by their bytes (§8.3): dropped, a `loops.tsv` opens as the
 * BEDPE it is. Dimming it said a file the app opens was one it did not.
 */
export function unroutableNote(name: string): string | null {
  if (routeOf(name) !== 'unknown') return null
  // A track hub's files end in `.txt` too, and none of them is a table: a hub
  // opens as a store, from its address, and its other files are read from it.
  // Asked before the text tables below, which would offer them.
  if (looksLikeHubUrl(name)) {
    return 'A UCSC track hub, which opens as a store: paste its address into the stores drawer.'
  }
  if (/(^|[\\/])(genomes|trackdb)\.txt$/i.test(name)) {
    return 'Part of a UCSC track hub, which opens as a store from its hub.txt.'
  }
  if (isTextTableName(name)) return null
  if (/\.tbi$/i.test(name)) {
    return 'A tabix index. Load the file it indexes and this is picked up with it.'
  }
  if (/\.(bai|csi)$/i.test(name)) {
    // Worth its own sentence rather than falling into the line below, because a
    // reader looking at a `.bai` greyed out beside a `.bam` is one step from
    // concluding the pair cannot be opened at all.
    return 'A BAM index. Load the .bam it indexes and this is picked up with it.'
  }
  if (/\.px2$/i.test(name)) {
    return 'A pairix index. Load the .pairs.gz it indexes and this is picked up with it.'
  }
  if (/\.(cram|crai)$/i.test(name)) {
    return 'CRAM needs its exact reference genome, which this viewer will not fetch. Convert it with `samtools view -b`.'
  }
  if (/\.gzi$/i.test(name)) {
    return 'A bgzip index, which this viewer does not use: a compressed FASTA is read whole.'
  }
  if (/\.csv(\.b?gz)?$/i.test(name)) {
    return 'Comma-separated text, which no reader here takes: the tables this viewer reads are tab- or space-separated.'
  }
  if (/\.(bz2|xz|zst)$/i.test(name)) {
    return 'Compressed with bzip2, xz or zstd, which this viewer cannot inflate. Recompress it with gzip, or bgzip to index it.'
  }
  return 'Not a format this viewer opens.'
}