@jbrowse/text-indexing-core 4.2.1 → 5.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.d.ts CHANGED
@@ -1,4 +1,9 @@
1
- export * from './util.ts';
2
- export * from './types/common.ts';
1
+ export { decodeURIComponentNoThrow, defaultAttributesToIndex, defaultFeatureTypesToExclude, indexableAdapters, isSupportedIndexingAdapter, } from './util.ts';
2
+ export type { Gff3IndexerOptions, Gff3Adapter, Gff3TabixAdapter, GtfAdapter, IndexableFormat, IndexerOptions, LocalPathLocation, Track, UriLocation, VcfAdapter, VcfTabixAdapter, } from './util.ts';
3
+ export { createReadlineInterface, generateMeta, getLocalOrRemoteStream, guessAdapterFromFileName, isURL, makeLocation, parseAttributes, } from './types/common.ts';
4
+ export { TRIX_DIR, sanitizeForFilename, trixFileNames, trixFilePaths, trixFileUris, } from './trixPaths.ts';
3
5
  export { indexGff3 } from './types/gff3Adapter.ts';
6
+ export { indexGtf } from './types/gtfAdapter.ts';
4
7
  export { indexVcf } from './types/vcfAdapter.ts';
8
+ export { indexFiles } from './indexFiles.ts';
9
+ export type { TrackIndexProgress } from './indexFiles.ts';
package/esm/index.js CHANGED
@@ -1,4 +1,7 @@
1
- export * from "./util.js";
2
- export * from "./types/common.js";
3
- export { indexGff3 } from "./types/gff3Adapter.js";
4
- export { indexVcf } from "./types/vcfAdapter.js";
1
+ export { decodeURIComponentNoThrow, defaultAttributesToIndex, defaultFeatureTypesToExclude, indexableAdapters, isSupportedIndexingAdapter, } from './util.js';
2
+ export { createReadlineInterface, generateMeta, getLocalOrRemoteStream, guessAdapterFromFileName, isURL, makeLocation, parseAttributes, } from './types/common.js';
3
+ export { TRIX_DIR, sanitizeForFilename, trixFileNames, trixFilePaths, trixFileUris, } from './trixPaths.js';
4
+ export { indexGff3 } from './types/gff3Adapter.js';
5
+ export { indexGtf } from './types/gtfAdapter.js';
6
+ export { indexVcf } from './types/vcfAdapter.js';
7
+ export { indexFiles } from './indexFiles.js';
@@ -0,0 +1,15 @@
1
+ import type { Track } from './util.ts';
2
+ export interface TrackIndexProgress {
3
+ onStart?: (totalBytes: number) => void;
4
+ onUpdate?: (processedBytes: number) => void;
5
+ onDone?: () => void;
6
+ }
7
+ export declare function indexFiles({ tracks, attributesToIndex, outDir, featureTypesToExclude, featureTypesToInclude, makeProgress, checkAbort, }: {
8
+ tracks: Track[];
9
+ attributesToIndex: string[];
10
+ outDir: string;
11
+ featureTypesToExclude: string[];
12
+ featureTypesToInclude?: string[];
13
+ makeProgress?: (trackId: string) => TrackIndexProgress;
14
+ checkAbort?: () => void;
15
+ }): AsyncGenerator<string, void, unknown>;
@@ -0,0 +1,55 @@
1
+ import { indexGff3 } from './types/gff3Adapter.js';
2
+ import { indexGtf } from './types/gtfAdapter.js';
3
+ import { indexVcf } from './types/vcfAdapter.js';
4
+ import { indexableAdapters } from './util.js';
5
+ function getIndexingLocation(track, locationKey) {
6
+ const { adapter } = track;
7
+ // adapter[locationKey] is the canonical location object, e.g. gffGzLocation;
8
+ // fall back to the adapter itself to support the shorthand where a bare `uri`
9
+ // sits directly on the adapter
10
+ const loc = (adapter?.[locationKey] ?? adapter);
11
+ return loc?.locationType === 'LocalPathLocation' ? loc.localPath : loc?.uri;
12
+ }
13
+ const noop = () => { };
14
+ // shared generator that streams index records for a set of tracks. Dispatches
15
+ // to the gff3/vcf indexers based on adapter type; per-track attribute and
16
+ // exclude/include overrides from textSearching take precedence over the
17
+ // defaults.
18
+ export async function* indexFiles({ tracks, attributesToIndex, outDir, featureTypesToExclude, featureTypesToInclude, makeProgress, checkAbort, }) {
19
+ for (const track of tracks) {
20
+ checkAbort?.();
21
+ const { adapter, textSearching, trackId } = track;
22
+ const indexable = indexableAdapters[adapter?.type ?? ''];
23
+ const inLocation = indexable
24
+ ? getIndexingLocation(track, indexable.locationKey)
25
+ : undefined;
26
+ if (indexable && inLocation) {
27
+ const progress = makeProgress?.(trackId);
28
+ const common = {
29
+ config: track,
30
+ attributesToIndex: textSearching?.indexingAttributes ?? attributesToIndex,
31
+ inLocation,
32
+ outDir,
33
+ onStart: progress?.onStart ?? noop,
34
+ onUpdate: progress?.onUpdate ?? noop,
35
+ checkAbort,
36
+ };
37
+ if (indexable.format === 'gff3') {
38
+ yield* indexGff3({
39
+ ...common,
40
+ featureTypesToExclude: textSearching?.indexingFeatureTypesToExclude ??
41
+ featureTypesToExclude,
42
+ featureTypesToInclude: textSearching?.indexingFeatureTypesToInclude ??
43
+ featureTypesToInclude,
44
+ });
45
+ }
46
+ else if (indexable.format === 'gtf') {
47
+ yield* indexGtf(common);
48
+ }
49
+ else {
50
+ yield* indexVcf(common);
51
+ }
52
+ progress?.onDone?.();
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,34 @@
1
+ /** Every trix artifact lands in this directory under the output directory. */
2
+ export declare const TRIX_DIR = "trix";
3
+ export declare function sanitizeForFilename(name: string): string;
4
+ /**
5
+ * The three files an index called `name` writes, sanitized once.
6
+ *
7
+ * Everything that writes a trix artifact or points a config at one derives its
8
+ * paths from here. They used to sanitize independently and one of them didn't:
9
+ * the CLI wrote `.ix`/`.ixx` under the raw name while `generateMeta` and
10
+ * `createTrixAdapter` sanitized, so a trackId holding a `/` aimed the write at
11
+ * a `trix/` subdirectory that does not exist, and the rest of the
12
+ * Windows-invalid set wrote a file no search would look for.
13
+ */
14
+ export declare function trixFileNames(name: string): {
15
+ ix: string;
16
+ ixx: string;
17
+ meta: string;
18
+ };
19
+ /** {@link trixFileNames} as filesystem paths under `outDir`, for a writer. */
20
+ export declare function trixFilePaths(outDir: string, name: string): {
21
+ ix: string;
22
+ ixx: string;
23
+ meta: string;
24
+ };
25
+ /**
26
+ * {@link trixFileNames} as URIs relative to the config, for an adapter conf.
27
+ * Always `/`-joined — a URI is not a filesystem path, so this one does not go
28
+ * through `path.join`, which would emit `\` on Windows.
29
+ */
30
+ export declare function trixFileUris(name: string): {
31
+ ix: string;
32
+ ixx: string;
33
+ meta: string;
34
+ };
@@ -0,0 +1,55 @@
1
+ import path from 'node:path';
2
+ /** Every trix artifact lands in this directory under the output directory. */
3
+ export const TRIX_DIR = 'trix';
4
+ // Windows reserves these device names even with an extension, so a track or
5
+ // assembly literally named e.g. NUL would otherwise yield an unusable NUL.ix
6
+ const windowsReservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
7
+ // makes `name` safe as a filename on Windows: replaces the invalid characters
8
+ // \ / : * ? " < > |, drops trailing dots/spaces (Windows silently strips them),
9
+ // and escapes reserved device names
10
+ export function sanitizeForFilename(name) {
11
+ const cleaned = name
12
+ .replaceAll(/[\\/:*?"<>|]/g, '_')
13
+ .replace(/(?<![. ])[. ]+$/, '');
14
+ return windowsReservedName.test(cleaned) ? `_${cleaned}` : cleaned;
15
+ }
16
+ /**
17
+ * The three files an index called `name` writes, sanitized once.
18
+ *
19
+ * Everything that writes a trix artifact or points a config at one derives its
20
+ * paths from here. They used to sanitize independently and one of them didn't:
21
+ * the CLI wrote `.ix`/`.ixx` under the raw name while `generateMeta` and
22
+ * `createTrixAdapter` sanitized, so a trackId holding a `/` aimed the write at
23
+ * a `trix/` subdirectory that does not exist, and the rest of the
24
+ * Windows-invalid set wrote a file no search would look for.
25
+ */
26
+ export function trixFileNames(name) {
27
+ const safeName = sanitizeForFilename(name);
28
+ return {
29
+ ix: `${safeName}.ix`,
30
+ ixx: `${safeName}.ixx`,
31
+ meta: `${safeName}_meta.json`,
32
+ };
33
+ }
34
+ /** {@link trixFileNames} as filesystem paths under `outDir`, for a writer. */
35
+ export function trixFilePaths(outDir, name) {
36
+ const names = trixFileNames(name);
37
+ return {
38
+ ix: path.join(outDir, TRIX_DIR, names.ix),
39
+ ixx: path.join(outDir, TRIX_DIR, names.ixx),
40
+ meta: path.join(outDir, TRIX_DIR, names.meta),
41
+ };
42
+ }
43
+ /**
44
+ * {@link trixFileNames} as URIs relative to the config, for an adapter conf.
45
+ * Always `/`-joined — a URI is not a filesystem path, so this one does not go
46
+ * through `path.join`, which would emit `\` on Windows.
47
+ */
48
+ export function trixFileUris(name) {
49
+ const names = trixFileNames(name);
50
+ return {
51
+ ix: `${TRIX_DIR}/${names.ix}`,
52
+ ixx: `${TRIX_DIR}/${names.ixx}`,
53
+ meta: `${TRIX_DIR}/${names.meta}`,
54
+ };
55
+ }
@@ -1,7 +1,7 @@
1
- import readline from 'readline';
2
- import { Readable } from 'stream';
1
+ import readline from 'node:readline';
2
+ import { Readable } from 'node:stream';
3
3
  import type { LocalPathLocation, Track, UriLocation } from '../util.ts';
4
- export declare function isURL(FileName: string): boolean;
4
+ export declare function isURL(fileName: string): boolean;
5
5
  export declare function getLocalOrRemoteStream({ file, out, onStart, onUpdate, }: {
6
6
  file: string;
7
7
  out: string;
@@ -12,12 +12,12 @@ export declare function createReadlineInterface(stream: Readable, inLocation: st
12
12
  export declare function parseAttributes(infoString: string, decodeFunc: (s: string) => string): Record<string, string | undefined>;
13
13
  export declare function makeLocation(location: string, protocol: string): UriLocation | LocalPathLocation;
14
14
  export declare function guessAdapterFromFileName(filePath: string): Track;
15
- export declare function sanitizeForFilename(name: string): string;
16
- export declare function generateMeta({ configs, attributesToIndex, outDir, name, featureTypesToExclude, assemblyNames, }: {
15
+ export declare function generateMeta({ configs, attributesToIndex, outDir, name, featureTypesToExclude, featureTypesToInclude, assemblyNames, }: {
17
16
  configs: Track[];
18
17
  attributesToIndex: string[];
19
18
  outDir: string;
20
19
  name: string;
21
20
  featureTypesToExclude: string[];
21
+ featureTypesToInclude?: string[];
22
22
  assemblyNames: string[];
23
23
  }): void;
@@ -1,18 +1,20 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import readline from 'readline';
4
- import { Readable } from 'stream';
5
- import { fileURLToPath } from 'url';
6
- import { createGunzip } from 'zlib';
7
- export function isURL(FileName) {
8
- let url;
1
+ import { Buffer } from 'node:buffer';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ import { Readable } from 'node:stream';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createGunzip } from 'node:zlib';
8
+ import { trixFilePaths } from '../trixPaths.js';
9
+ import { indexableAdapters } from '../util.js';
10
+ export function isURL(fileName) {
9
11
  try {
10
- url = new URL(FileName);
12
+ const url = new URL(fileName);
13
+ return url.protocol === 'http:' || url.protocol === 'https:';
11
14
  }
12
- catch (_) {
15
+ catch {
13
16
  return false;
14
17
  }
15
- return url.protocol === 'http:' || url.protocol === 'https:';
16
18
  }
17
19
  function convertFileUrlToPath(fileUrl) {
18
20
  try {
@@ -22,9 +24,49 @@ function convertFileUrlToPath(fileUrl) {
22
24
  }
23
25
  }
24
26
  catch {
27
+ // not a valid URL
25
28
  }
26
29
  return undefined;
27
30
  }
31
+ // Turn a WHATWG ReadableStream into a node Readable by driving its reader.
32
+ //
33
+ // DO NOT replace this with `Readable.fromWeb(body)`. It looks equivalent and
34
+ // passes tests (undici's fetch body IS a node:stream/web stream), but the
35
+ // desktop indexing worker runs on Chromium's global fetch, whose body is a DOM
36
+ // ReadableStream from a different realm. `Readable.fromWeb`'s internal
37
+ // `instanceof` check rejects it with the baffling "must be an instance of
38
+ // ReadableStream. Received an instance of ReadableStream", and remote (URL)
39
+ // track indexing breaks. `getReader()` is standard on every WHATWG stream
40
+ // regardless of realm, so this works everywhere. See common.test.ts
41
+ // "foreign realm" regression test.
42
+ function webStreamToNodeReadable(reader) {
43
+ return new Readable({
44
+ read() {
45
+ reader
46
+ .read()
47
+ .then(({ done, value }) => {
48
+ this.push(done ? null : Buffer.from(value));
49
+ })
50
+ .catch((e) => {
51
+ this.destroy(e);
52
+ });
53
+ },
54
+ // Cancel the source when the Readable is destroyed early (e.g. an aborted
55
+ // index) so the underlying fetch connection is released rather than left
56
+ // hanging — the cleanup Readable.fromWeb would do for us if it accepted
57
+ // this stream.
58
+ destroy(error, callback) {
59
+ reader
60
+ .cancel(error ?? undefined)
61
+ .then(() => {
62
+ callback(error);
63
+ })
64
+ .catch(() => {
65
+ callback(error);
66
+ });
67
+ },
68
+ });
69
+ }
28
70
  export async function getLocalOrRemoteStream({ file, out, onStart, onUpdate, }) {
29
71
  let receivedBytes = 0;
30
72
  if (isURL(file)) {
@@ -38,19 +80,12 @@ export async function getLocalOrRemoteStream({ file, out, onStart, onUpdate, })
38
80
  if (!body) {
39
81
  throw new Error(`Failed to fetch ${file}: no response body`);
40
82
  }
83
+ // node-fetch gives a node Readable directly; global fetch gives a web
84
+ // ReadableStream we adapt (see webStreamToNodeReadable — do not swap in
85
+ // Readable.fromWeb here).
41
86
  const nodeStream = body instanceof Readable
42
87
  ? body
43
- : new Readable({
44
- async read() {
45
- const reader = body.getReader();
46
- let result = await reader.read();
47
- while (!result.done) {
48
- this.push(result.value);
49
- result = await reader.read();
50
- }
51
- this.push(null);
52
- },
53
- });
88
+ : webStreamToNodeReadable(body.getReader());
54
89
  nodeStream.on('data', chunk => {
55
90
  receivedBytes += chunk.length;
56
91
  onUpdate(receivedBytes);
@@ -58,6 +93,7 @@ export async function getLocalOrRemoteStream({ file, out, onStart, onUpdate, })
58
93
  return nodeStream;
59
94
  }
60
95
  else {
96
+ // Handle file:// URLs by converting to local path
61
97
  const localPath = convertFileUrlToPath(file) ?? file;
62
98
  const filename = path.isAbsolute(localPath)
63
99
  ? localPath
@@ -73,19 +109,10 @@ export async function getLocalOrRemoteStream({ file, out, onStart, onUpdate, })
73
109
  }
74
110
  }
75
111
  export function createReadlineInterface(stream, inLocation) {
76
- const isGzipped = /.b?gz$/.exec(inLocation);
77
- let inputStream;
78
- if (isGzipped) {
79
- const gunzip = createGunzip();
80
- inputStream = stream.pipe(gunzip);
81
- }
82
- else {
83
- inputStream = stream;
84
- }
85
- const rl = readline.createInterface({
86
- input: inputStream,
87
- });
88
- return rl;
112
+ const inputStream = /\.b?gz$/i.test(inLocation)
113
+ ? stream.pipe(createGunzip())
114
+ : stream;
115
+ return readline.createInterface({ input: inputStream });
89
116
  }
90
117
  export function parseAttributes(infoString, decodeFunc) {
91
118
  const result = {};
@@ -114,61 +141,33 @@ export function makeLocation(location, protocol) {
114
141
  }
115
142
  throw new Error(`invalid protocol ${protocol}`);
116
143
  }
144
+ // ordered most-specific-first so e.g. `.vcf.gz` matches before `.vcf`.
145
+ //
146
+ // `.gtf.gz` maps to GtfAdapter rather than GtfTabixAdapter on purpose: this
147
+ // guesser only invents a throwaway track for `text-index --file`, which streams
148
+ // the file start to finish and never opens the .tbi, and GtfAdapter's
149
+ // gtfLocation takes a gzipped path (the reader gunzips on the suffix). A track
150
+ // in a config.json is the other case — `jbrowse add-track` gives a bgzipped GTF
151
+ // a GtfTabixAdapter, which is indexable too, from its own gtfGzLocation.
152
+ const adapterGuesses = [
153
+ { regex: /\.vcf\.b?gz$/i, type: 'VcfTabixAdapter' },
154
+ { regex: /\.gff3?\.b?gz$/i, type: 'Gff3TabixAdapter' },
155
+ { regex: /\.gtf(\.b?gz)?$/i, type: 'GtfAdapter' },
156
+ { regex: /\.vcf$/i, type: 'VcfAdapter' },
157
+ { regex: /\.gff3?$/i, type: 'Gff3Adapter' },
158
+ ];
117
159
  export function guessAdapterFromFileName(filePath) {
118
- const protocol = isURL(filePath) ? 'uri' : 'localPath';
119
- const name = path.basename(filePath);
120
- if (/\.vcf\.b?gz$/i.test(filePath)) {
121
- return {
122
- trackId: name,
123
- name: name,
124
- assemblyNames: [],
125
- adapter: {
126
- type: 'VcfTabixAdapter',
127
- vcfGzLocation: makeLocation(filePath, protocol),
128
- },
129
- };
130
- }
131
- else if (/\.gff3?\.b?gz$/i.test(filePath)) {
132
- return {
133
- trackId: name,
134
- name,
135
- assemblyNames: [],
136
- adapter: {
137
- type: 'Gff3TabixAdapter',
138
- gffGzLocation: makeLocation(filePath, protocol),
139
- },
140
- };
141
- }
142
- else if (/\.gtf?$/i.test(filePath)) {
160
+ const guess = adapterGuesses.find(g => g.regex.test(filePath));
161
+ if (guess) {
162
+ const name = path.basename(filePath);
163
+ const protocol = isURL(filePath) ? 'uri' : 'localPath';
143
164
  return {
144
165
  trackId: name,
145
166
  name,
146
167
  assemblyNames: [],
147
168
  adapter: {
148
- type: 'GtfAdapter',
149
- gtfLocation: makeLocation(filePath, protocol),
150
- },
151
- };
152
- }
153
- else if (/\.vcf$/i.test(filePath)) {
154
- return {
155
- trackId: name,
156
- name,
157
- assemblyNames: [],
158
- adapter: {
159
- type: 'VcfAdapter',
160
- vcfLocation: makeLocation(filePath, protocol),
161
- },
162
- };
163
- }
164
- else if (/\.gff3?$/i.test(filePath)) {
165
- return {
166
- trackId: name,
167
- name,
168
- assemblyNames: [],
169
- adapter: {
170
- type: 'Gff3Adapter',
171
- gffLocation: makeLocation(filePath, protocol),
169
+ type: guess.type,
170
+ [indexableAdapters[guess.type].locationKey]: makeLocation(filePath, protocol),
172
171
  },
173
172
  };
174
173
  }
@@ -176,20 +175,18 @@ export function guessAdapterFromFileName(filePath) {
176
175
  throw new Error(`Unsupported file type ${filePath}`);
177
176
  }
178
177
  }
179
- export function sanitizeForFilename(name) {
180
- return name.replace(/[\\/:*?"<>|]/g, '_');
181
- }
182
- export function generateMeta({ configs, attributesToIndex, outDir, name, featureTypesToExclude, assemblyNames, }) {
183
- const safeName = sanitizeForFilename(name);
184
- fs.writeFileSync(path.join(outDir, 'trix', `${safeName}_meta.json`), JSON.stringify({
178
+ export function generateMeta({ configs, attributesToIndex, outDir, name, featureTypesToExclude, featureTypesToInclude, assemblyNames, }) {
179
+ fs.writeFileSync(trixFilePaths(outDir, name).meta, JSON.stringify({
185
180
  dateCreated: new Date().toISOString(),
186
181
  tracks: configs.map(config => {
187
182
  const { trackId, textSearching, adapter } = config;
188
183
  return {
189
184
  trackId,
190
- attributesIndexed: textSearching?.indexingAttributes || attributesToIndex,
191
- excludedTypes: textSearching?.indexingFeatureTypesToExclude ||
185
+ attributesIndexed: textSearching?.indexingAttributes ?? attributesToIndex,
186
+ excludedTypes: textSearching?.indexingFeatureTypesToExclude ??
192
187
  featureTypesToExclude,
188
+ includedTypes: textSearching?.indexingFeatureTypesToInclude ??
189
+ featureTypesToInclude,
193
190
  adapterConf: adapter,
194
191
  };
195
192
  }),
@@ -1,11 +1,20 @@
1
- export declare function indexGff3({ config, attributesToIndex, inLocation, outDir, featureTypesToExclude, onStart, onUpdate, }: {
2
- config: {
3
- trackId: string;
4
- };
5
- attributesToIndex: string[];
6
- inLocation: string;
7
- outDir: string;
8
- featureTypesToExclude: string[];
9
- onStart: (totalBytes: number) => void;
10
- onUpdate: (progressBytes: number) => void;
11
- }): AsyncGenerator<string, void, unknown>;
1
+ import type { Gff3IndexerOptions } from '../util.ts';
2
+ /**
3
+ * Stream one GFF3 into trix records, one line per indexed feature.
4
+ *
5
+ * Two type gates, and they answer different questions. `featureTypesToExclude`
6
+ * names types this file is known to carry and nobody would search for — it is
7
+ * the right shape when you own the vocabulary, which is why the default is
8
+ * `exon,CDS`. `featureTypesToInclude` names the types worth indexing and drops
9
+ * everything else, which is the only shape that stays correct against a
10
+ * vocabulary someone else controls: an NCBI RefSeq GFF3 draws from 115 feature
11
+ * types, 80 of which are leaf regulatory/repeat/alignment records carrying no
12
+ * name (a `match` labels itself with a bare UUID, a `cDNA_match` with an MD5,
13
+ * every `biological_region` with the literal string "biological region"), and a
14
+ * deny list written against the types seen so far leaks the next one NCBI adds.
15
+ * The allow list for that file is ~33 names — gene, pseudogene, and the
16
+ * transcript-level types — and does not grow.
17
+ *
18
+ * Both apply when both are given: include admits, exclude then narrows.
19
+ */
20
+ export declare function indexGff3({ config, attributesToIndex, inLocation, outDir, featureTypesToExclude, featureTypesToInclude, onStart, onUpdate, checkAbort, }: Gff3IndexerOptions): AsyncGenerator<string, void, unknown>;
@@ -1,6 +1,24 @@
1
- import { decodeURIComponentNoThrow } from "../util.js";
2
- import { createReadlineInterface, getLocalOrRemoteStream, parseAttributes, } from "./common.js";
3
- export async function* indexGff3({ config, attributesToIndex, inLocation, outDir, featureTypesToExclude, onStart, onUpdate, }) {
1
+ import { decodeURIComponentNoThrow } from '../util.js';
2
+ import { createReadlineInterface, getLocalOrRemoteStream, parseAttributes, } from './common.js';
3
+ /**
4
+ * Stream one GFF3 into trix records, one line per indexed feature.
5
+ *
6
+ * Two type gates, and they answer different questions. `featureTypesToExclude`
7
+ * names types this file is known to carry and nobody would search for — it is
8
+ * the right shape when you own the vocabulary, which is why the default is
9
+ * `exon,CDS`. `featureTypesToInclude` names the types worth indexing and drops
10
+ * everything else, which is the only shape that stays correct against a
11
+ * vocabulary someone else controls: an NCBI RefSeq GFF3 draws from 115 feature
12
+ * types, 80 of which are leaf regulatory/repeat/alignment records carrying no
13
+ * name (a `match` labels itself with a bare UUID, a `cDNA_match` with an MD5,
14
+ * every `biological_region` with the literal string "biological region"), and a
15
+ * deny list written against the types seen so far leaks the next one NCBI adds.
16
+ * The allow list for that file is ~33 names — gene, pseudogene, and the
17
+ * transcript-level types — and does not grow.
18
+ *
19
+ * Both apply when both are given: include admits, exclude then narrows.
20
+ */
21
+ export async function* indexGff3({ config, attributesToIndex, inLocation, outDir, featureTypesToExclude, featureTypesToInclude, onStart, onUpdate, checkAbort, }) {
4
22
  const { trackId } = config;
5
23
  const stream = await getLocalOrRemoteStream({
6
24
  file: inLocation,
@@ -10,19 +28,35 @@ export async function* indexGff3({ config, attributesToIndex, inLocation, outDir
10
28
  });
11
29
  const rl = createReadlineInterface(stream, inLocation);
12
30
  const excludeSet = new Set(featureTypesToExclude);
31
+ // Absent rather than empty, so an empty list means "no allow list" and not
32
+ // "index nothing" — a caller that reads the list out of a config gets the
33
+ // former when the slot is unset, and silently indexing zero features would
34
+ // look exactly like a successful run.
35
+ const includeSet = featureTypesToInclude?.length
36
+ ? new Set(featureTypesToInclude)
37
+ : undefined;
13
38
  const encodedTrackId = encodeURIComponent(trackId);
14
39
  for await (const line of rl) {
15
- if (!line.trim()) {
16
- continue;
17
- }
18
- else if (line.startsWith('#')) {
40
+ checkAbort?.();
41
+ if (!line.trim() || line.startsWith('#')) {
19
42
  continue;
20
43
  }
21
44
  else if (line.startsWith('>')) {
22
45
  break;
23
46
  }
24
47
  const [seq_id, , type, start, end, , , , col9] = line.split('\t');
25
- if (!excludeSet.has(type)) {
48
+ // a valid GFF3 feature row has 9 tab-delimited columns; skip malformed or
49
+ // truncated lines that would otherwise make parseAttributes throw on an
50
+ // undefined column 9 (mirrors the guard in the vcf indexer)
51
+ if (seq_id === undefined ||
52
+ type === undefined ||
53
+ start === undefined ||
54
+ end === undefined ||
55
+ col9 === undefined) {
56
+ continue;
57
+ }
58
+ if (!excludeSet.has(type) &&
59
+ (includeSet === undefined || includeSet.has(type))) {
26
60
  const col9attrs = parseAttributes(col9, decodeURIComponentNoThrow);
27
61
  const attrs = attributesToIndex
28
62
  .map(attr => col9attrs[attr])
@@ -0,0 +1,25 @@
1
+ import type { IndexerOptions } from '../util.ts';
2
+ /**
3
+ * Parse the GTF 9th column (`gene_id "X"; transcript_id "Y";`), mirroring
4
+ * plugins/gtf so the indexed text matches what the track displays: entries are
5
+ * `key value` separated by a space (not `key=value`), values may be quoted, a
6
+ * repeated key expresses multiple values, and a quoted value may contain the
7
+ * ';' the entries are split on (`note "a; b"`), so its pieces are rejoined
8
+ * before the value is read.
9
+ */
10
+ export declare function parseGtfAttributes(attrString: string): Record<string, string[]>;
11
+ /**
12
+ * GTF, unlike GFF3, has no gene or transcript rows: a gene exists only as the
13
+ * exon/CDS/UTR rows repeating its `gene_id`. Indexing per row would point every
14
+ * hit at a single exon, so rows are grouped by `gene_id` and `transcript_id`
15
+ * and each group is emitted once spanning all of its rows.
16
+ *
17
+ * Grouping is what keeps one entry per gene rather than one per exon, so
18
+ * `featureTypesToExclude` does not apply here — dropping rows would only
19
+ * truncate the spans.
20
+ *
21
+ * The group map is held until EOF because GTF rows for one locus are not
22
+ * guaranteed contiguous. It is keyed by gene/transcript id, so it stays far
23
+ * smaller than the whole-file parse plugins/gtf already does to display a GTF.
24
+ */
25
+ export declare function indexGtf({ config, attributesToIndex, inLocation, outDir, onStart, onUpdate, checkAbort, }: IndexerOptions): AsyncGenerator<string, void, unknown>;
@@ -0,0 +1,149 @@
1
+ import { createReadlineInterface, getLocalOrRemoteStream } from './common.js';
2
+ // GFF3-style attribute names have no GTF equivalent, so the shared defaults
3
+ // (`Name`, `ID`, `symbol`) would index nothing. Each requested name also pulls
4
+ // in its GTF spellings, so `jbrowse text-index` works on a GTF out of the box
5
+ // while an explicit `--attributes gene_biotype` still works verbatim.
6
+ const gtfAttributeAliases = {
7
+ Name: ['gene_name', 'transcript_name'],
8
+ ID: ['gene_id', 'transcript_id'],
9
+ symbol: ['gene_name'],
10
+ };
11
+ function expandAttributeNames(attributesToIndex) {
12
+ return [
13
+ ...new Set(attributesToIndex.flatMap(a => [a, ...(gtfAttributeAliases[a] ?? [])])),
14
+ ];
15
+ }
16
+ /**
17
+ * True when an entry opens a double quote it never closes, meaning the ';' it
18
+ * was split on fell inside the value instead of ending the entry.
19
+ */
20
+ function hasUnclosedQuote(entry) {
21
+ const first = entry.indexOf('"');
22
+ return first !== -1 && !entry.includes('"', first + 1);
23
+ }
24
+ /**
25
+ * Parse the GTF 9th column (`gene_id "X"; transcript_id "Y";`), mirroring
26
+ * plugins/gtf so the indexed text matches what the track displays: entries are
27
+ * `key value` separated by a space (not `key=value`), values may be quoted, a
28
+ * repeated key expresses multiple values, and a quoted value may contain the
29
+ * ';' the entries are split on (`note "a; b"`), so its pieces are rejoined
30
+ * before the value is read.
31
+ */
32
+ export function parseGtfAttributes(attrString) {
33
+ const attrs = {};
34
+ const entries = attrString.split(';');
35
+ for (let i = 0; i < entries.length; i++) {
36
+ let entry = entries[i];
37
+ while (hasUnclosedQuote(entry) && i + 1 < entries.length) {
38
+ entry += `;${entries[++i]}`;
39
+ }
40
+ const trimmed = entry.trim();
41
+ const sp = trimmed.indexOf(' ');
42
+ if (sp !== -1) {
43
+ const key = trimmed.slice(0, sp);
44
+ const value = trimmed
45
+ .slice(sp + 1)
46
+ .trim()
47
+ .replaceAll(/^"|"$/g, '')
48
+ // a comma separates record ids in the trix .ix format, so it can never
49
+ // survive into an indexed word
50
+ .replaceAll(',', ' ');
51
+ if (value) {
52
+ ;
53
+ (attrs[key] ??= []).push(value);
54
+ }
55
+ }
56
+ }
57
+ return attrs;
58
+ }
59
+ // composite map keys join refName + id; a separator that cannot occur in
60
+ // either keeps two loci from colliding
61
+ const SEP = '\u0000';
62
+ /**
63
+ * GTF, unlike GFF3, has no gene or transcript rows: a gene exists only as the
64
+ * exon/CDS/UTR rows repeating its `gene_id`. Indexing per row would point every
65
+ * hit at a single exon, so rows are grouped by `gene_id` and `transcript_id`
66
+ * and each group is emitted once spanning all of its rows.
67
+ *
68
+ * Grouping is what keeps one entry per gene rather than one per exon, so
69
+ * `featureTypesToExclude` does not apply here — dropping rows would only
70
+ * truncate the spans.
71
+ *
72
+ * The group map is held until EOF because GTF rows for one locus are not
73
+ * guaranteed contiguous. It is keyed by gene/transcript id, so it stays far
74
+ * smaller than the whole-file parse plugins/gtf already does to display a GTF.
75
+ */
76
+ export async function* indexGtf({ config, attributesToIndex, inLocation, outDir, onStart, onUpdate, checkAbort, }) {
77
+ const { trackId } = config;
78
+ const stream = await getLocalOrRemoteStream({
79
+ file: inLocation,
80
+ out: outDir,
81
+ onStart,
82
+ onUpdate,
83
+ });
84
+ const rl = createReadlineInterface(stream, inLocation);
85
+ const encodedTrackId = encodeURIComponent(trackId);
86
+ const attributeNames = expandAttributeNames(attributesToIndex);
87
+ const groups = new Map();
88
+ function accumulate(key, refName, start, end, values) {
89
+ if (values.length > 0) {
90
+ const group = groups.get(key);
91
+ if (group) {
92
+ group.start = Math.min(group.start, start);
93
+ group.end = Math.max(group.end, end);
94
+ for (const value of values) {
95
+ group.attrs.add(value);
96
+ }
97
+ }
98
+ else {
99
+ groups.set(key, { refName, start, end, attrs: new Set(values) });
100
+ }
101
+ }
102
+ }
103
+ for await (const line of rl) {
104
+ checkAbort?.();
105
+ if (line.trim() && !line.startsWith('#')) {
106
+ const [seq_id, , , start, end, , , , col9] = line.split('\t');
107
+ if (seq_id !== undefined &&
108
+ start !== undefined &&
109
+ end !== undefined &&
110
+ col9 !== undefined) {
111
+ const parsed = parseGtfAttributes(col9);
112
+ const geneId = parsed.gene_id?.[0];
113
+ const transcriptId = parsed.transcript_id?.[0];
114
+ const s = Number(start);
115
+ const e = Number(end);
116
+ // `gene_*` names describe the gene, everything else the transcript, so
117
+ // a gene entry is labeled by its gene_name rather than by whichever
118
+ // transcript happened to come first
119
+ const geneValues = [];
120
+ const otherValues = [];
121
+ for (const name of attributeNames) {
122
+ for (const value of parsed[name] ?? []) {
123
+ ;
124
+ (name.startsWith('gene') ? geneValues : otherValues).push(value);
125
+ }
126
+ }
127
+ if (transcriptId === undefined) {
128
+ const values = [...geneValues, ...otherValues];
129
+ // no id to group on: merge rows that carry identical values, which
130
+ // still collapses the repeated rows of one feature into one entry
131
+ const key = geneId ?? values.join(SEP);
132
+ accumulate(`gene${SEP}${seq_id}${SEP}${key}`, seq_id, s, e, values);
133
+ }
134
+ else {
135
+ accumulate(`tx${SEP}${seq_id}${SEP}${transcriptId}`, seq_id, s, e, otherValues);
136
+ if (geneId !== undefined) {
137
+ accumulate(`gene${SEP}${seq_id}${SEP}${geneId}`, seq_id, s, e, geneValues);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ for (const { refName, start, end, attrs } of groups.values()) {
144
+ const locStr = `${refName}:${start}..${end}`;
145
+ const encodedAttrs = [...attrs].map(a => `"${encodeURIComponent(a)}"`);
146
+ const record = `["${encodeURIComponent(locStr)}"|"${encodedTrackId}"|${encodedAttrs.join('|')}]`;
147
+ yield `${record} ${[...attrs].join(' ')}\n`;
148
+ }
149
+ }
@@ -1,10 +1,2 @@
1
- export declare function indexVcf({ config, attributesToIndex, inLocation, outDir, onStart, onUpdate, }: {
2
- config: {
3
- trackId: string;
4
- };
5
- attributesToIndex: string[];
6
- inLocation: string;
7
- outDir: string;
8
- onStart: (totalBytes: number) => void;
9
- onUpdate: (progressBytes: number) => void;
10
- }): AsyncGenerator<string, void, unknown>;
1
+ import type { IndexerOptions } from '../util.ts';
2
+ export declare function indexVcf({ config, attributesToIndex, inLocation, outDir, onStart, onUpdate, checkAbort, }: IndexerOptions): AsyncGenerator<string, void, unknown>;
@@ -1,6 +1,6 @@
1
- import { decodeURIComponentNoThrow } from "../util.js";
2
- import { createReadlineInterface, getLocalOrRemoteStream, parseAttributes, } from "./common.js";
3
- export async function* indexVcf({ config, attributesToIndex, inLocation, outDir, onStart, onUpdate, }) {
1
+ import { decodeURIComponentNoThrow } from '../util.js';
2
+ import { createReadlineInterface, getLocalOrRemoteStream, parseAttributes, } from './common.js';
3
+ export async function* indexVcf({ config, attributesToIndex, inLocation, outDir, onStart, onUpdate, checkAbort, }) {
4
4
  const { trackId } = config;
5
5
  const stream = await getLocalOrRemoteStream({
6
6
  file: inLocation,
@@ -11,25 +11,46 @@ export async function* indexVcf({ config, attributesToIndex, inLocation, outDir,
11
11
  const rl = createReadlineInterface(stream, inLocation);
12
12
  const encodedTrackId = encodeURIComponent(trackId);
13
13
  for await (const line of rl) {
14
- if (line.startsWith('#')) {
14
+ checkAbort?.();
15
+ if (!line.trim() || line.startsWith('#')) {
15
16
  continue;
16
17
  }
17
- const [ref, pos, id, _ref, _alt, _qual, _filter, info] = line.split('\t');
18
- if (id === '.') {
18
+ const [ref, pos, id, , , , , info] = line.split('\t');
19
+ // a valid VCF data row has at least 8 tab-delimited columns; skip blank or
20
+ // malformed/short lines that would otherwise emit garbage records or throw
21
+ if (ref === undefined ||
22
+ pos === undefined ||
23
+ id === undefined ||
24
+ info === undefined) {
19
25
  continue;
20
26
  }
21
27
  const fields = parseAttributes(info, decodeURIComponentNoThrow);
22
- const end = fields.END;
23
- const locStr = `${ref}:${pos}..${end || +pos + 1}`;
28
+ // an empty `END=` parses to 0 via Number(''), which is finite; require a
29
+ // non-empty value before trusting it, else fall back to pos+1
30
+ const endNum = Number(fields.END);
31
+ const end = fields.END && Number.isFinite(endNum) ? endNum : Number(pos) + 1;
32
+ const locStr = `${ref}:${pos}..${end}`;
24
33
  const encodedLocStr = encodeURIComponent(locStr);
25
34
  const infoAttrs = attributesToIndex
26
35
  .map(attr => fields[attr])
27
36
  .filter((f) => !!f);
28
- const encodedInfoAttrs = infoAttrs.map(a => `"${encodeURIComponent(a)}"`);
29
- for (const variantId of id.split(',')) {
30
- const encodedId = encodeURIComponent(variantId);
31
- const record = `["${encodedLocStr}"|"${encodedTrackId}"|"${encodedId}"${encodedInfoAttrs.length > 0 ? `|${encodedInfoAttrs.join('|')}` : ''}]`;
32
- yield `${record} ${variantId}\n`;
37
+ // VCF separates multiple IDs with ';' (matching @gmod/vcf), not ','. '.' is
38
+ // the no-id sentinel; such a row is still worth indexing by its INFO
39
+ // attributes, it just has no id to search by
40
+ const variantIds = id === '.' ? [] : id.split(';').filter(Boolean);
41
+ // one record per id so each is separately searchable, plus an id-less
42
+ // record when the row carries only attributes
43
+ const attrGroups = variantIds.length
44
+ ? variantIds.map(variantId => [variantId, ...infoAttrs])
45
+ : [infoAttrs];
46
+ for (const attrs of attrGroups) {
47
+ if (attrs.length > 0) {
48
+ // attributes go in the record so the adapter can display them, and in
49
+ // the trailing word list so trix indexes them as searchable terms
50
+ const encodedAttrs = attrs.map(a => `"${encodeURIComponent(a)}"`);
51
+ const record = `["${encodedLocStr}"|"${encodedTrackId}"|${encodedAttrs.join('|')}]`;
52
+ yield `${record} ${[...new Set(attrs)].join(' ')}\n`;
53
+ }
33
54
  }
34
55
  }
35
56
  }
package/esm/util.d.ts CHANGED
@@ -28,18 +28,49 @@ export interface VcfAdapter {
28
28
  vcfLocation: Loc;
29
29
  }
30
30
  export interface Track {
31
+ type?: string;
31
32
  adapter?: {
32
33
  type: string;
33
34
  [key: string]: unknown;
34
35
  };
35
36
  textSearching?: {
36
37
  indexingFeatureTypesToExclude?: string[];
38
+ indexingFeatureTypesToInclude?: string[];
37
39
  indexingAttributes?: string[];
38
40
  [key: string]: unknown;
39
41
  };
42
+ metadata?: {
43
+ skipTextIndex?: boolean;
44
+ [key: string]: unknown;
45
+ };
40
46
  name: string;
41
47
  assemblyNames: string[];
42
48
  trackId: string;
49
+ category?: string[];
50
+ description?: string;
51
+ }
52
+ export declare const defaultAttributesToIndex: string[];
53
+ export declare const defaultFeatureTypesToExclude: string[];
54
+ export type IndexableFormat = 'gff3' | 'gtf' | 'vcf';
55
+ export declare const indexableAdapters: Record<string, {
56
+ locationKey: string;
57
+ format: IndexableFormat;
58
+ }>;
59
+ export declare function isSupportedIndexingAdapter(type?: string): boolean;
60
+ export interface IndexerOptions {
61
+ config: {
62
+ trackId: string;
63
+ };
64
+ attributesToIndex: string[];
65
+ inLocation: string;
66
+ outDir: string;
67
+ onStart: (totalBytes: number) => void;
68
+ onUpdate: (progressBytes: number) => void;
69
+ checkAbort?: () => void;
70
+ }
71
+ export interface Gff3IndexerOptions extends IndexerOptions {
72
+ featureTypesToExclude: string[];
73
+ featureTypesToInclude?: string[];
43
74
  }
44
75
  export declare function decodeURIComponentNoThrow(uri: string): string;
45
76
  export {};
package/esm/util.js CHANGED
@@ -1,8 +1,25 @@
1
+ export const defaultAttributesToIndex = ['Name', 'ID', 'symbol'];
2
+ export const defaultFeatureTypesToExclude = ['exon', 'CDS'];
3
+ // single source of truth for what `jbrowse text-index` can index: which adapter
4
+ // types, where each keeps its file location, and which line parser to use.
5
+ // Everything else (the adapter dispatch, the CLI's supported() gate, the
6
+ // filename guesser) derives from this table.
7
+ export const indexableAdapters = {
8
+ Gff3Adapter: { locationKey: 'gffLocation', format: 'gff3' },
9
+ Gff3TabixAdapter: { locationKey: 'gffGzLocation', format: 'gff3' },
10
+ GtfAdapter: { locationKey: 'gtfLocation', format: 'gtf' },
11
+ GtfTabixAdapter: { locationKey: 'gtfGzLocation', format: 'gtf' },
12
+ VcfAdapter: { locationKey: 'vcfLocation', format: 'vcf' },
13
+ VcfTabixAdapter: { locationKey: 'vcfGzLocation', format: 'vcf' },
14
+ };
15
+ export function isSupportedIndexingAdapter(type = '') {
16
+ return type in indexableAdapters;
17
+ }
1
18
  export function decodeURIComponentNoThrow(uri) {
2
19
  try {
3
20
  return decodeURIComponent(uri);
4
21
  }
5
- catch (e) {
22
+ catch {
6
23
  return uri;
7
24
  }
8
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/text-indexing-core",
3
- "version": "4.2.1",
3
+ "version": "5.0.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "JBrowse 2 core text indexing routines for parsing GFF3 and VCF files",
6
6
  "keywords": [
@@ -22,10 +22,7 @@
22
22
  "main": "esm/index.js",
23
23
  "types": "esm/index.d.ts",
24
24
  "exports": {
25
- ".": {
26
- "types": "./esm/index.d.ts",
27
- "import": "./esm/index.js"
28
- }
25
+ ".": "./esm/index.js"
29
26
  },
30
27
  "files": [
31
28
  "esm"
@@ -36,7 +33,7 @@
36
33
  },
37
34
  "sideEffects": false,
38
35
  "scripts": {
39
- "build:esm": "tsc -p tsconfig.build.esm.json",
36
+ "build:esm": "node ../../node_modules/typescript7/bin/tsc --build tsconfig.build.esm.json",
40
37
  "build": "pnpm build:esm",
41
38
  "test": "cd ../..; jest --passWithNoTests packages/text-indexing-core",
42
39
  "clean": "rimraf esm *.tsbuildinfo",