Back to home page

EIC code displayed by LXR

 
 

    


Warning, /firebird/packages/root2dex/src/entries.ts is written in an unsupported language. File is not indexed.

0001 /**
0002  * Entry-number selections such as '3', '1-5' or '1,2-5,8'.
0003  *
0004  * The parser keeps ranges as [start, end] pairs, so a request like
0005  * '0-20000000' costs nothing until its size and bounds are checked. Expand a
0006  * selection only through `selectEntries()`, which checks the size and clamps
0007  * the ranges to the file first. The rules and the messages match
0008  * `pyrobird/entries.py`, so the browser converter and the pyrobird convert
0009  * endpoint treat the same selections the same way, with the same words.
0010  */
0011 
0012 /** An inclusive range of entry numbers. */
0013 export type EntryRange = readonly [start: number, end: number];
0014 
0015 /** How many offending ranges an out-of-range message lists before it truncates. */
0016 export const MAX_LISTED_RANGES = 5;
0017 
0018 /** The most entries one conversion may name; pyrobird's default cap too. */
0019 export const DEFAULT_MAX_ENTRIES = 1000;
0020 
0021 /** A selection names entries that the file does not hold, or too many entries. */
0022 export class EntrySelectionError extends RangeError {
0023   override name = 'EntrySelectionError';
0024 }
0025 
0026 function invalidFormat(value: string): Error {
0027   return new Error(`Invalid entry format: '${value}'. Expected integers or ranges like '1-5'.`);
0028 }
0029 
0030 /**
0031  * Parses entry numbers written as '3', '1-5', or '1,2-5,8' into inclusive
0032  * ranges, in the order given. A single entry becomes [n, n]. Nothing is
0033  * expanded.
0034  *
0035  * @throws Error when the text does not follow the grammar, or a range has
0036  *   start > end.
0037  */
0038 export function parseEntryRanges(value: string): EntryRange[] {
0039   const parseInteger = (text: string): number => {
0040     const trimmed = text.trim();
0041     if (!/^[+-]?\d+$/.test(trimmed)) throw invalidFormat(value);
0042     return Number(trimmed);
0043   };
0044 
0045   const ranges: EntryRange[] = [];
0046   for (const part of value.split(',')) {
0047     const text = part.trim();
0048     if (!text) continue;
0049     // A hyphen after the first character is a range separator; a leading one is a sign
0050     const separator = text.indexOf('-', 1);
0051     if (separator > 0) {
0052       const start = parseInteger(text.slice(0, separator));
0053       const end = parseInteger(text.slice(separator + 1));
0054       if (start > end) throw new Error(`Invalid range '${text}': start must be <= end.`);
0055       ranges.push([start, end]);
0056     } else {
0057       const entry = parseInteger(text);
0058       ranges.push([entry, entry]);
0059     }
0060   }
0061   if (ranges.length === 0) throw invalidFormat(value);
0062   return ranges;
0063 }
0064 
0065 /** How many entries the ranges name, counting repeats, without expanding them. */
0066 export function countEntries(ranges: readonly EntryRange[]): number {
0067   return ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
0068 }
0069 
0070 /** Formats ranges as '3, 7-9, 12'; lists at most `limit` items, then '(and N more)'. */
0071 export function formatRanges(ranges: readonly EntryRange[], limit = MAX_LISTED_RANGES): string {
0072   const items = ranges.map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`));
0073   if (items.length > limit) {
0074     return `${items.slice(0, limit).join(', ')} (and ${items.length - limit} more)`;
0075   }
0076   return items.join(', ');
0077 }
0078 
0079 /** The parts of `ranges` outside 0..entryCount-1, without expanding. */
0080 export function outOfRangeParts(ranges: readonly EntryRange[], entryCount: number): EntryRange[] {
0081   const last = entryCount - 1;
0082   const outside: EntryRange[] = [];
0083   for (const [start, end] of ranges) {
0084     if (start < 0) outside.push([start, Math.min(end, -1)]);
0085     if (end > last) outside.push([Math.max(start, last + 1, 0), end]);
0086   }
0087   return outside;
0088 }
0089 
0090 /** The parts of `ranges` inside 0..entryCount-1, in the order given, without expanding. */
0091 export function clampRanges(ranges: readonly EntryRange[], entryCount: number): EntryRange[] {
0092   const inside: EntryRange[] = [];
0093   for (const [start, end] of ranges) {
0094     const clampedStart = Math.max(start, 0);
0095     const clampedEnd = Math.min(end, entryCount - 1);
0096     if (clampedStart <= clampedEnd) inside.push([clampedStart, clampedEnd]);
0097   }
0098   return inside;
0099 }
0100 
0101 /** The entries a selection converts, and the warning about entries the file does not hold. */
0102 export interface EntrySelection {
0103   /** Entry numbers in the order requested, repeats included. */
0104   entries: number[];
0105   /** One summary of the requested entries outside the file, or null when all exist. */
0106   warning: string | null;
0107 }
0108 
0109 /**
0110  * Checks ranges against a file, clamps them to its entries and expands them.
0111  *
0112  * Requested entries outside 0..entryCount-1 are dropped, and one warning
0113  * lists them with what remains. A selection with no entry in the file is an
0114  * error that names the file's entry count. `maxCount` applies to the
0115  * selection as requested, before clamping, so an oversized request fails
0116  * before anything is expanded.
0117  *
0118  * @param ranges Inclusive ranges, as returned by `parseEntryRanges()`.
0119  * @param entryCount Number of entries the file holds.
0120  * @param maxCount Upper bound on the number of requested entries; undefined means none.
0121  * @returns The entry numbers in the order requested, and the warning when
0122  *   some requested entries are outside the file.
0123  * @throws EntrySelectionError when the selection is too large or names no
0124  *   entry in the file.
0125  */
0126 export function selectEntries(
0127   ranges: readonly EntryRange[],
0128   entryCount: number,
0129   maxCount?: number,
0130 ): EntrySelection {
0131   const count = countEntries(ranges);
0132   if (maxCount !== undefined && count > maxCount) {
0133     throw new EntrySelectionError(
0134       `The selection names ${count} entries; at most ${maxCount} are allowed per request.`,
0135     );
0136   }
0137   const outside = outOfRangeParts(ranges, entryCount);
0138   const inside = clampRanges(ranges, entryCount);
0139   const holds = entryCount > 0
0140     ? `the file holds ${entryCount} events (0..${entryCount - 1})`
0141     : 'the file holds no events';
0142   if (outside.length && inside.length === 0) {
0143     throw new EntrySelectionError(`Event ${formatRanges(outside)} is out of range: ${holds}`);
0144   }
0145   const warning = outside.length
0146     ? `Event ${formatRanges(outside)} is out of range: ${holds}; converting ${formatRanges(inside)}`
0147     : null;
0148   const entries: number[] = [];
0149   for (const [start, end] of inside) {
0150     for (let entry = start; entry <= end; entry++) entries.push(entry);
0151   }
0152   return { entries, warning };
0153 }