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 }