Warning, /firebird/packages/firebird-ng/display/src/lib/services/data-model.service.ts is written in an unsupported language. File is not indexed.
0001 import {Injectable, linkedSignal, signal} from "@angular/core";
0002 import { Event, DataExchange } from "@dexvis/firebird-core";
0003 import { HttpClient } from "@angular/common/http";
0004 import { UrlService } from "@dexvis/firebird-ng/api";
0005 import { fetchTextFile, loadJSONFileEvents, loadZipFileEvents, readDexFile } from "../utils/data-fetching.utils";
0006
0007 /**
0008 * Service for loading and managing event/entry data in Firebird.
0009 *
0010 * This service fetches ROOT events converted by the server and Firebird DEX
0011 * data from JSON or ZIP, and stores the list of entries and the currently
0012 * selected entry as Angular signals.
0013 *
0014 * Fetching and parsing never change the stored entries: `adoptEvents()` does,
0015 * and EventDisplayService calls it only for the latest requested load, so a
0016 * slow earlier load cannot replace a newer one.
0017 */
0018 @Injectable({
0019 providedIn: 'root'
0020 })
0021 export class DataModelService {
0022
0023 /**
0024 * Signal holding the list of loaded entries (events).
0025 * Each Entry corresponds to one event's data in Firebird Dex format.
0026 */
0027 public entries = signal<Event[]>([]);
0028
0029 /**
0030 * Signal holding the currently selected entry (event).
0031 */
0032 public currentEntry = linkedSignal(() => {
0033 if(this.entries().length > 0 ) {
0034 return this.entries()[0]
0035 }
0036 return null;
0037 });
0038
0039 /**
0040 * Constructor that injects services needed for resolving URLs and making HTTP requests.
0041 * @param urlService - Service used for building/transforming URLs
0042 * @param http - Angular HttpClient for making network requests (currently not used directly here)
0043 */
0044 constructor(
0045 private urlService: UrlService,
0046 private http: HttpClient
0047 ) {}
0048
0049 /**
0050 * Checks if an unknown object is valid "Firebird DEX" format by
0051 * verifying if it has a `"type": "firebird-dex-json"` property.
0052 *
0053 * @param obj - The object to inspect.
0054 * @returns True if the object has a `"type"` property equal to `"firebird-dex-json"`, otherwise false.
0055 */
0056 public isFirebirdDex(obj: unknown): boolean {
0057 return (
0058 typeof obj === "object" &&
0059 obj !== null &&
0060 "type" in obj &&
0061 (obj as any)["type"] === "firebird-dex-json"
0062 );
0063 }
0064
0065 /**
0066 * Converts ROOT events through the pyrobird convert endpoint and parses the
0067 * result. The server detects the data model (EDM4eic or EDM4hep) itself.
0068 * Does not change the loaded entries; see `adoptEvents()`.
0069 *
0070 * @param url - Location of the ROOT file: a `root://` URL, an http(s) URL,
0071 * or a path the server serves.
0072 * @param entryNames - Entry numbers as typed: '0', '0-4', '1,3'. The server
0073 * converts the entries the file holds and rejects a range with none.
0074 * @param collections - Collection groups to convert (server `collections`
0075 * query parameter, same names as `pyrobird convert --collections`).
0076 * Empty means all.
0077 * @param signal - Aborts the request.
0078 * @returns The parsed DEX document.
0079 * @throws Error with the reason: no backend, HTTP status and server message,
0080 * not DEX, unsupported DEX version.
0081 */
0082 async fetchRootConversion(url: string, entryNames: string = "0", collections?: string[], signal?: AbortSignal): Promise<DataExchange> {
0083 const finalUrl = this.urlService.resolveConvertUrl(url, "auto", entryNames, collections);
0084 console.log(`[DataModelService.fetchRootConversion] Fetching: ${finalUrl}`);
0085 const text = await fetchTextFile(finalUrl, signal);
0086 let dexData: unknown;
0087 try {
0088 dexData = JSON.parse(text);
0089 } catch (error) {
0090 throw new Error(`The server's conversion of '${url}' is not JSON: ${error instanceof Error ? error.message : error}`);
0091 }
0092 return this.parseDex(dexData, url);
0093 }
0094
0095 /**
0096 * Fetches a Firebird DEX JSON or ZIP file and parses it. Does not change
0097 * the loaded entries; see `adoptEvents()`.
0098 *
0099 * @param url - A URL `fetch()` reads: resolve aliases and server paths
0100 * first (`LoaderContext.resolveUrl`).
0101 * @param options.name - The source as the user named it, for messages.
0102 * @param options.signal - Aborts the download.
0103 * @returns The parsed DEX document.
0104 * @throws Error with the reason: HTTP status, unreadable zip, not DEX,
0105 * unsupported DEX version (with the upgrade command).
0106 */
0107 async fetchDex(url: string, options: { name?: string; signal?: AbortSignal } = {}): Promise<DataExchange> {
0108 console.log(`[DataModelService.fetchDex] Loading: ${url}`);
0109 // The name decides the format: a resolved download URL carries the file
0110 // name in its query (api/v1/download?f=run.firebird.zip)
0111 const name = options.name ?? url;
0112 const dexData = name.endsWith("zip") || url.endsWith("zip")
0113 ? await loadZipFileEvents(url, options.signal)
0114 : await loadJSONFileEvents(url, options.signal);
0115 return this.parseDex(dexData, name);
0116 }
0117
0118 /**
0119 * Reads a DEX JSON or ZIP file the user picked or dropped, in place (never
0120 * uploaded), and parses it.
0121 *
0122 * @throws Error with the reason: unreadable zip, not JSON, not DEX,
0123 * unsupported DEX version.
0124 */
0125 async readDexFile(file: File): Promise<DataExchange> {
0126 return this.parseDex(await readDexFile(file), file.name);
0127 }
0128
0129 /**
0130 * Parses a DEX document that is already in memory.
0131 *
0132 * @param dexData - The parsed JSON object.
0133 * @param sourceName - File name or URL, for the error message.
0134 * @throws Error when the object is not a Firebird DEX document or has an
0135 * unsupported version.
0136 */
0137 parseDex(dexData: unknown, sourceName = 'the document'): DataExchange {
0138 if (!this.isFirebirdDex(dexData)) {
0139 throw new Error(`'${sourceName}' is not a Firebird DEX document (it lacks "type": "firebird-dex-json")`);
0140 }
0141 return DataExchange.fromDexObj(dexData);
0142 }
0143
0144 /** Publishes loaded events to the signals and selects the first one. */
0145 adoptEvents(data: DataExchange): void {
0146 this.entries.set(data.events);
0147 if (this.entries().length > 0) {
0148 // Explicitly the first entry, not setNextEntry(): `currentEntry` is a
0149 // linkedSignal that has already recomputed to entries[0], so "next"
0150 // would step to entries[1]. With a single event that wrapped back around
0151 // and looked right; loading several events showed the second one while
0152 // the painter drew the first.
0153 this.setCurrentEntry(this.entries()[0]);
0154 }
0155 }
0156
0157 /**
0158 * Parses and adopts a DEX document that is already in memory.
0159 *
0160 * @param dexData - A parsed Firebird DEX document.
0161 * @returns The DataExchange, or null when the object is not DEX.
0162 */
0163 loadDexObject(dexData: unknown): DataExchange | null {
0164 if (!this.isFirebirdDex(dexData)) {
0165 console.error("[DataModelService.loadDexObject] The object does not conform to Firebird DEX JSON format.");
0166 return null;
0167 }
0168 const data = this.parseDex(dexData);
0169 this.adoptEvents(data);
0170 return data;
0171 }
0172
0173 /**
0174 * Sets the currently selected entry (event) to the provided `Entry`.
0175 *
0176 * @param entry - The Entry object to be marked as current.
0177 */
0178 setCurrentEntry(entry: Event): void {
0179 console.log(`[DataModelService.setCurrentEntry] Setting event: ${entry.id}`);
0180 this.currentEntry.set(entry);
0181 }
0182
0183 /**
0184 * Finds and sets the current entry by its name (i.e. `entry.id`).
0185 *
0186 * @param name - The string name or ID of the entry to set as current.
0187 */
0188 setCurrentEntryByName(name: string): void {
0189 // Look up the first Entry whose 'id' matches the provided name
0190 const found = this.entries().find((entry) => entry.id === name);
0191
0192 // If found, update currentEntry; otherwise log a warning.
0193 if (found) {
0194 this.setCurrentEntry(found);
0195 } else {
0196 console.warn(`[DataModelService] setCurrentEntryByName: Entry with id='${name}' not found.`);
0197 }
0198 }
0199
0200 setNextEntry() {
0201 const allEntries = this.entries();
0202 const current = this.currentEntry();
0203
0204 // If no entries available, return
0205 if (allEntries.length === 0) {
0206 console.warn('[DataModelService.setNextEntry] No entries available');
0207 return;
0208 }
0209
0210 // If no current entry, set the first one
0211 if (!current) {
0212 this.setCurrentEntry(allEntries[0]);
0213 return;
0214 }
0215
0216 // Find the current entry index
0217 const currentIndex = allEntries.findIndex(entry => entry === current);
0218
0219 // If current entry not found in the list (shouldn't happen), set first entry
0220 if (currentIndex === -1) {
0221 console.warn('[DataModelService.setNextEntry] Current entry not found in entries list');
0222 this.setCurrentEntry(allEntries[0]);
0223 return;
0224 }
0225
0226 // Calculate the next index with wrapping
0227 const nextIndex = (currentIndex + 1) % allEntries.length;
0228
0229 // Set the next entry
0230 this.setCurrentEntry(allEntries[nextIndex]);
0231 console.log(`[DataModelService.setNextEntry] Switching from entry ${currentIndex} to ${nextIndex} (${allEntries[nextIndex].id})`);
0232 }
0233 }