Warning, /firebird/packages/firebird-core/src/loaders.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * Loader contracts for the extension system.
0003 *
0004 * A loader teaches Firebird to open a file format or URL scheme. Loaders are
0005 * core citizens (plain TS, worker-safe interfaces); the Angular layer collects
0006 * implementations through the `GEOMETRY_LOADERS` / `EVENT_LOADERS` DI tokens
0007 * (`withGeometryLoader()` / `withEventLoader()`).
0008 *
0009 * A loader returns data and never touches the display: the display picks the
0010 * first registered loader whose `canLoad()` claims a source, hands it a
0011 * `LoaderContext`, and puts what the loader returns on screen. Failures are
0012 * rejections that carry the reason (HTTP status, unsupported version, ...):
0013 * the display shows that message to the user.
0014 *
0015 * `meta.fileExtensions` and `meta.urlSchemes` are static declarations that
0016 * drive UI (open-dialog filters, drop zones) without the loader writing UI code.
0017 *
0018 * Two-stage claiming: `canLoad()` decides from the source alone (extension,
0019 * scheme) and is enough for formats whose name says what they are. When the
0020 * name is ambiguous - a `.root` file holds detector geometry OR events, and
0021 * nothing outside tells you which - the control that opened the file probes
0022 * its contents and asks `canLoadContent()`. Each loader keeps the knowledge of
0023 * what its own format looks like inside; the control only carries facts around.
0024 */
0025
0026 import type { Object3D } from "three";
0027 import type { DataExchange } from "./model/data-exchange";
0028
0029 /**
0030 * What a loader can be pointed at: a URL or path, or a file the user picked or
0031 * dropped (which is never uploaded - loaders read it in place).
0032 */
0033 export type DataSource = string | File;
0034
0035 /** Static, declarative description of what a loader can open. */
0036 export interface DataLoaderMeta {
0037 /** Unique id, e.g. 'root-geometry' or 'firebird-dex'. */
0038 id: string;
0039 /** Human-readable label for menus and error messages. */
0040 label: string;
0041 /** Extensions this loader claims, with dots: ['.root', '.firebird.json']. */
0042 fileExtensions: string[];
0043 /**
0044 * Optional URL schemes this loader claims, e.g. ['root://']. A scheme
0045 * claims only a source whose file name has no extension or one listed in
0046 * `fileExtensions`: `root://host//run.root` and `root://host//run` match
0047 * ['root://'], `root://host//run.firebird.zip` does not. Another loader's
0048 * format therefore never lands here because of its scheme.
0049 */
0050 urlSchemes?: string[];
0051 /**
0052 * True when the loader's format supports the interactive open flow: report
0053 * the event count first, let the user pick a range, convert on demand (the
0054 * ROOT converter does this). Loaders without it load the whole source in
0055 * one `loadEvents` call — the open-event panel shows the result directly
0056 * instead of offering the picker.
0057 */
0058 offersEventPicker?: boolean;
0059 }
0060
0061 /** One top-level object inside a container file, with the class it holds. */
0062 export interface ContentEntry {
0063 name: string;
0064 className: string;
0065 }
0066
0067 /**
0068 * Neutral facts about what a container file holds - no interpretation. For a
0069 * ROOT file these are its top-level keys, so a detector geometry shows up as
0070 * `{name: 'Default', className: 'TGeoManager'}` and event data as
0071 * `{name: 'events', className: 'TTree'}`.
0072 */
0073 export interface FileContentProbe {
0074 /** File name or URL the probe was taken from. */
0075 name: string;
0076 entries: ContentEntry[];
0077 }
0078
0079 /** What the display hands a loader with each load. */
0080 export interface LoaderContext {
0081 /**
0082 * Turns a URL alias (`asset://`, a pack's alias such as `exp://`) or a path
0083 * the server serves into a URL `fetch()` can read. Absolute http(s) URLs
0084 * come back unchanged.
0085 */
0086 resolveUrl(url: string): string;
0087 /**
0088 * Aborted when a newer load of the same kind (geometry, or events)
0089 * replaces this one. Pass it to `fetch()`, stop work when it fires, and
0090 * reject with `signal.reason`; the display drops the result either way.
0091 */
0092 readonly signal: AbortSignal;
0093 }
0094
0095 /** What every loader declares, whatever it loads. */
0096 interface DataLoaderBase {
0097 readonly meta: DataLoaderMeta;
0098 /** Claims a source by its name alone (extension, scheme). */
0099 canLoad(source: DataSource): boolean;
0100 /**
0101 * Optional: claims a source by what is INSIDE it. Asked only when the name
0102 * was ambiguous. A loader that does not implement it is never selected by
0103 * content.
0104 */
0105 canLoadContent?(probe: FileContentProbe): boolean;
0106 }
0107
0108 /**
0109 * Opens detector geometry from a URL, path, or local file.
0110 * The first registered loader whose `canLoad()` returns true loads it.
0111 */
0112 export interface GeometryDataLoader extends DataLoaderBase {
0113 /**
0114 * Millimeters per length unit of the geometry `load()` returns: 10 for
0115 * ROOT TGeo (centimeters). The display scales the geometry container by
0116 * it, so detector and event data (millimeters) line up. Default 1.
0117 */
0118 readonly millimetersPerUnit?: number;
0119 /**
0120 * Loads the geometry and resolves to its root object; the display adds it
0121 * to the scene, replacing the previous geometry. Rejects with the reason
0122 * when the source cannot be loaded.
0123 */
0124 load(source: DataSource, context: LoaderContext): Promise<Object3D>;
0125 }
0126
0127 /**
0128 * Opens event data from a URL, path, or local file.
0129 * The first registered loader whose `canLoad()` returns true loads it.
0130 */
0131 export interface EventDataLoader extends DataLoaderBase {
0132 /**
0133 * Loads the events and resolves to the parsed DEX container
0134 * (`DataExchange.fromDexObj()`); the display shows its first event.
0135 * Rejects with the reason (HTTP status, unsupported DEX version, entries
0136 * outside the file) when the source cannot be loaded.
0137 */
0138 loadEvents(source: DataSource, context: LoaderContext): Promise<DataExchange>;
0139 }
0140
0141 /** The name to match against: the URL itself, or a picked file's name. */
0142 export function sourceName(source: DataSource): string {
0143 return typeof source === 'string' ? source : source.name;
0144 }
0145
0146 /**
0147 * True when the last path segment of a URL or path carries a file extension.
0148 * The host of a URL is not a path segment: `root://host.org` has none.
0149 */
0150 function hasFileExtension(path: string): boolean {
0151 const schemeEnd = path.indexOf('://');
0152 const afterScheme = schemeEnd >= 0 ? path.slice(schemeEnd + 3) : path;
0153 const pathStart = schemeEnd >= 0 ? afterScheme.indexOf('/') : 0;
0154 if (pathStart < 0) return false;
0155 const lastSegment = afterScheme.slice(pathStart).split('/').pop() ?? '';
0156 return lastSegment.lastIndexOf('.') > 0;
0157 }
0158
0159 /**
0160 * Shared helper: does the loader claim the source by its name? A source
0161 * matches when its path ends with one of `meta.fileExtensions`, when it
0162 * starts with one of `meta.urlSchemes` and has no file extension, or when a
0163 * query parameter value (download-style URLs) ends with one of the
0164 * extensions.
0165 */
0166 export function matchesFileExtensions(source: DataSource, meta: DataLoaderMeta): boolean {
0167 const name = sourceName(source);
0168 const matchesPath = (path: string) =>
0169 meta.fileExtensions.some(ext => path.toLowerCase().endsWith(ext.toLowerCase()));
0170
0171 const [path, query] = name.split('?');
0172 if (matchesPath(path)) return true;
0173 // A scheme never overrides an extension: 'asset://data/x.root' belongs to
0174 // a .root loader even when another loader claims every asset:// URL
0175 const matchesScheme = (meta.urlSchemes ?? []).some(scheme => path.toLowerCase().startsWith(scheme.toLowerCase()));
0176 if (matchesScheme && !hasFileExtension(path)) {
0177 return true;
0178 }
0179
0180 // Download-style URLs carry the file name in a query parameter
0181 // (pyrobird: /api/v1/download?f=events.firebird.zip) — match those values too
0182 if (query) {
0183 for (const pair of query.split('&')) {
0184 const value = pair.split('=')[1];
0185 if (!value) continue;
0186 try {
0187 if (matchesPath(decodeURIComponent(value))) return true;
0188 } catch {
0189 // Malformed percent-encoding: not a file name worth matching
0190 }
0191 }
0192 }
0193 return false;
0194 }