diff --git a/package-lock.json b/package-lock.json index a25e3aba..4eba87a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "fast-json-stable-stringify": "^2.1.0", "file-loader": "^6.2.0", "file-replace-loader": "^1.4.2", - "filtrex": "^3.1.0", "glob": "^11.0.1", "globals": "^16.0.0", "html-loader": "^5.1.0", @@ -2048,11 +2047,6 @@ "node": ">=8" } }, - "node_modules/filtrex": { - "version": "3.1.0", - "dev": true, - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "dev": true, diff --git a/package.json b/package.json index b6cfae93..e42c8f9c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "fast-json-stable-stringify": "^2.1.0", "file-loader": "^6.2.0", "file-replace-loader": "^1.4.2", - "filtrex": "^3.1.0", "glob": "^11.0.1", "globals": "^16.0.0", "html-loader": "^5.1.0", diff --git a/src/lib/components/filter/filter_creator.ts b/src/lib/components/filter/filter_creator.ts index 17abe5ea..f84a2715 100644 --- a/src/lib/components/filter/filter_creator.ts +++ b/src/lib/components/filter/filter_creator.ts @@ -1,4 +1,4 @@ -import {css, html, HTMLTemplateResult, nothing} from 'lit'; +import {css, html, nothing} from 'lit'; import {styleMap} from 'lit-html/directives/style-map.js'; import {state, query} from 'lit/decorators.js'; diff --git a/src/lib/components/market/react/filter_panel.ts b/src/lib/components/market/react/filter_panel.ts new file mode 100644 index 00000000..14cb29c5 --- /dev/null +++ b/src/lib/components/market/react/filter_panel.ts @@ -0,0 +1,61 @@ +import {css, html, nothing, HTMLTemplateResult} from 'lit'; +import {CustomElement, InjectBefore, InjectionMode} from '../../injectors'; +import {FloatElement} from '../../custom'; +import {isReactSteamMarket} from '../mode'; + +import '../../filter/filter_container'; + +/** + * Steam Market Beta equivalent of {@link UtilityBelt}. + */ +@CustomElement() +@InjectBefore( + 'div:has(> [style*="grid-columns:repeat(auto-fill, minmax(260px"])', + InjectionMode.CONTINUOUS, + isReactSteamMarket +) +export class BetaFilterPanel extends FloatElement { + static styles = [ + ...FloatElement.styles, + css` + .panel { + margin-bottom: 16px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + color: #c7d5e0; + font-family: 'Motiva Sans', sans-serif; + } + + .panel-title { + font-family: 'Motiva Sans', sans-serif; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #ebebeb; + margin: 0 0 12px; + } + `, + ]; + + private get key(): string { + return marketHashName() ?? ''; + } + + protected render(): HTMLTemplateResult | typeof nothing { + if (!this.key) return nothing; + return html` +
+

CSFloat Filters

+ +
+ `; + } +} + +/** Use the title of the page to get the market hash name */ +function marketHashName(): string | undefined { + return document.title.match(/^(.+?) - Steam Community Market$/)?.[1] ?? undefined; +} diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts index 3f1825c5..a6b1c76a 100644 --- a/src/lib/components/market/react/listing.ts +++ b/src/lib/components/market/react/listing.ts @@ -1,10 +1,13 @@ import {css, nothing} from 'lit'; +import type {Subscription} from 'rxjs'; import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; import {FloatElement} from '../../custom'; import {CustomElement, InjectAppend, InjectionMode} from '../../injectors'; import {isReactSteamMarket} from '../mode'; import {gFloatFetcher} from '../../../services/float_fetcher'; +import {gFilterService} from '../../../services/filter'; +import {pickTextColour} from '../../../utils/colours'; import {BetaListingRank} from './rank'; import {BetaListingSeedInfo} from './seed_info'; @@ -23,6 +26,7 @@ import type {MarketListing, MarketListingProps} from './types'; export class BetaListingEnhancer extends FloatElement { private rankInjected = false; private seedInfoInjected = false; + private filterSubscription?: Subscription; static styles = [ css` @@ -84,6 +88,15 @@ export class BetaListingEnhancer extends FloatElement { return Number(rawFloat); } + get convertedPrice(): number | undefined { + const listing = this.listing; + if (!listing || !listing.unPrice) { + return; + } + + return (listing.unPrice + listing.unFee) / 100; + } + connectedCallback(): void { super.connectedCallback(); @@ -94,6 +107,8 @@ export class BetaListingEnhancer extends FloatElement { disconnectedCallback(): void { super.disconnectedCallback(); + this.filterSubscription?.unsubscribe(); + this.filterSubscription = undefined; } protected render(): typeof nothing { @@ -112,6 +127,18 @@ export class BetaListingEnhancer extends FloatElement { this.injectRank(info); this.injectSeedInfo(info); + this.applyFilterColour(info); + } + + private applyFilterColour(info: ItemInfo): void { + this.filterSubscription?.unsubscribe(); + this.filterSubscription = gFilterService.onUpdate$.subscribe(() => { + if (!this.isConnected) return; + + const colour = gFilterService.matchColour(info, this.convertedPrice) || ''; + this.card.style.backgroundColor = colour; + this.card.style.color = colour ? pickTextColour(colour, '#8F98A0', '#484848') : ''; + }); } private injectRank(info: ItemInfo): void { diff --git a/src/lib/filter/compile_expression.ts b/src/lib/filter/compile_expression.ts new file mode 100644 index 00000000..1cfabb80 --- /dev/null +++ b/src/lib/filter/compile_expression.ts @@ -0,0 +1,665 @@ +import type {InternalInputVars} from './types'; + +/* + * A tiny AST-based expression engine for user-authored filters. + * + * Produces either a number, string, boolean, or an error, but never throws. + * The result is a Runner function that can be called with a set of variables to produce a single result. + */ + +export type FilterValue = number | string | boolean | Error; +type Callable = (...args: any[]) => FilterValue; +type Runner = (vars: InternalInputVars) => FilterValue; + +interface CompileOptions { + extraFunctions?: Record; +} + +type TokenType = 'number' | 'string' | 'identifier' | 'operator' | 'paren' | 'comma' | 'eof'; + +interface Token { + type: TokenType; + value: string; + /** Index of the token's first character in the source string, used for error messages. */ + position: number; +} + +type AstNode = + | {type: 'literal'; value: number | string | boolean} + | {type: 'identifier'; name: string} + | {type: 'unary'; operator: string; argument: AstNode} + | {type: 'binary'; operator: string; left: AstNode; right: AstNode} + | {type: 'call'; name: string; args: AstNode[]} + | {type: 'list'; values: AstNode[]}; + +const DEFAULT_FUNCTIONS: Record = { + abs: Math.abs, + ceil: Math.ceil, + floor: Math.floor, + log: Math.log, + max: Math.max, + min: Math.min, + random: Math.random, + round: Math.round, + sqrt: Math.sqrt, +}; + +/** + * Compiles an expression once into a reusable {@link Runner}. + * This is the expensive part, so it can be cached and reused. + */ +export function compileExpression(expression: string, options: CompileOptions = {}): Runner { + // Null prototype prevents user identifiers from being callable. + const functions: Record = Object.assign( + Object.create(null), + DEFAULT_FUNCTIONS, + options.extraFunctions + ); + + let ast: AstNode; + try { + const tokens = tokenize(expression); + ast = new Parser(tokens).parse(); + } catch (e) { + return errorRunner(e); + } + + return (vars: InternalInputVars) => { + try { + return evaluate(ast, vars, functions); + } catch (e: any) { + return e instanceof Error ? e : new Error(String(e)); + } + }; +} + +/** Builds a runner that ignores its input and always returns the given error. */ +function errorRunner(e: unknown): Runner { + const error = e instanceof Error ? e : new Error(String(e)); + return () => error; +} + +/** + * Stage 1: turns the raw source into a flat token list. A single forward scan with no backtracking; + * each branch consumes one token's worth of characters and advances `pos`. Throws on malformed input + * (unterminated string, stray character, ...). A trailing `eof` token lets the parser peek safely. + */ +function tokenize(expression: string): Token[] { + const tokens: Token[] = []; + let pos = 0; + + while (pos < expression.length) { + const char = expression[pos]; + + if (/\s/.test(char)) { + pos++; + continue; + } + + // String literal: supports both quote styles and backslash escapes (see readEscapedCharacter). + if (char === '"' || char === "'") { + const start = pos; + const quote = char; + let value = ''; + pos++; + + while (pos < expression.length) { + const current = expression[pos++]; + + if (current === quote) { + tokens.push({type: 'string', value, position: start}); + break; + } + + if (current === '\\') { + if (pos >= expression.length) { + throw new Error(`unterminated escape sequence at ${start}`); + } + + value += readEscapedCharacter(expression[pos++]); + } else { + value += current; + } + } + + // We only pushed a token above if the closing quote was found. Since each token's position + // is unique, a missing token at `start` means we ran off the end without closing the string. + if (tokens[tokens.length - 1]?.position !== start) { + throw new Error(`unterminated string at ${start}`); + } + + continue; + } + + if (isNumberStart(expression, pos)) { + const start = pos; + pos = readNumber(expression, pos); + tokens.push({type: 'number', value: expression.slice(start, pos), position: start}); + continue; + } + + // A word: either a reserved word operator or an identifier. `true`/`false` stay identifiers here and become literals later. + if (/[A-Za-z_]/.test(char)) { + const start = pos; + pos++; + + while (pos < expression.length && /[A-Za-z0-9_]/.test(expression[pos])) { + pos++; + } + + const value = expression.slice(start, pos); + const normalized = value.toLowerCase(); + if (['and', 'or', 'not', 'in'].includes(normalized)) { + tokens.push({type: 'operator', value: normalized, position: start}); + } else { + tokens.push({type: 'identifier', value, position: start}); + } + + continue; + } + + const twoCharOperator = expression.slice(pos, pos + 2); + if (['>=', '<=', '==', '!='].includes(twoCharOperator)) { + tokens.push({type: 'operator', value: twoCharOperator, position: pos}); + pos += 2; + continue; + } + + if (['>', '<', '+', '-', '*', '/', '%', '^', '='].includes(char)) { + tokens.push({type: 'operator', value: char, position: pos}); + pos++; + continue; + } + + if (char === '(' || char === ')') { + tokens.push({type: 'paren', value: char, position: pos}); + pos++; + continue; + } + + if (char === ',') { + tokens.push({type: 'comma', value: char, position: pos}); + pos++; + continue; + } + + throw new Error(`unexpected character "${char}" at ${pos}`); + } + + tokens.push({type: 'eof', value: '', position: expression.length}); + return tokens; +} + +/** Maps the character after a backslash to its value. Unknown escapes are kept literal (e.g. `\"` -> `"`). */ +function readEscapedCharacter(char: string): string { + switch (char) { + case 'n': + return '\n'; + case 'r': + return '\r'; + case 't': + return '\t'; + default: + return char; + } +} + +/** True if a number token starts here: a digit, or a leading dot immediately followed by a digit (`.5`). */ +function isNumberStart(expression: string, pos: number): boolean { + const char = expression[pos]; + const next = expression[pos + 1]; + return /[0-9]/.test(char) || (char === '.' && /[0-9]/.test(next)); +} + +/** + * Scans a numeric literal (integer, decimal, or scientific notation) and returns the index just past it. + * The actual numeric conversion is deferred to the parser; here we only delimit the token. Throws if an + * `e` exponent has no digits (e.g. `1e`). + */ +function readNumber(expression: string, pos: number): number { + const start = pos; + + // Integer part. + while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + + // Optional fractional part. + if (expression[pos] === '.') { + pos++; + while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + } + + // Optional exponent, e.g. `e-10`. + if (expression[pos]?.toLowerCase() === 'e') { + const exponentStart = pos; + pos++; + if (expression[pos] === '+' || expression[pos] === '-') pos++; + + const digitsStart = pos; + while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + + if (digitsStart === pos) { + throw new Error(`invalid number at ${start}: ${expression.slice(start, exponentStart + 1)}`); + } + } + + return pos; +} + +/** + * Stage 2: recursive-descent parser. Each `parseX` method handles one precedence level and delegates + * to the next-tighter-binding level, so the call chain itself encodes precedence from loosest to + * tightest: + * + * or -> and -> not -> comparison/in -> additive -> multiplicative -> power -> unary -> primary + * + * Most binary operators are left-associative (loop and fold left). `^` is right-associative and `not` + * is a prefix unary, so those recurse into themselves instead of looping. + */ +class Parser { + private position = 0; + + constructor(private tokens: Token[]) {} + + /** Parses the whole token stream and asserts nothing is left over after the expression. */ + parse(): AstNode { + const expression = this.parseOr(); + this.expect('eof'); + return expression; + } + + /** Lowest precedence: `a or b or c`, left-associative. */ + private parseOr(): AstNode { + let node = this.parseAnd(); + + while (this.matchOperator('or')) { + node = {type: 'binary', operator: 'or', left: node, right: this.parseAnd()}; + } + + return node; + } + + /** `a and b`, binds tighter than `or`, left-associative. */ + private parseAnd(): AstNode { + let node = this.parseNot(); + + while (this.matchOperator('and')) { + node = {type: 'binary', operator: 'and', left: node, right: this.parseNot()}; + } + + return node; + } + + /** Prefix `not`. Recurses so `not not x` chains; binds looser than comparisons (`not a == b`). */ + private parseNot(): AstNode { + if (this.matchOperator('not')) { + return {type: 'unary', operator: 'not', argument: this.parseNot()}; + } + + return this.parseComparison(); + } + + /** + * Comparisons and membership tests. The loop allows folding (`a < b < c` parses as `(a < b) < c`) + * rather than rejecting it. `in` / `not in` take a parenthesized list on the right; everything else + * takes an arithmetic operand. + */ + private parseComparison(): AstNode { + let node = this.parseAdditive(); + + while (true) { + const operator = this.peek(); + + if ( + operator.type === 'operator' && + ['==', '=', '!=', '<', '<=', '>', '>=', 'in'].includes(operator.value) + ) { + this.advance(); + node = { + type: 'binary', + operator: operator.value, + left: node, + right: operator.value === 'in' ? this.parseList() : this.parseAdditive(), + }; + continue; + } + + // `not` appearing here can only be the start of the two-word `not in` operator. + if (this.matchOperator('not')) { + this.expect('operator', 'in'); + node = {type: 'binary', operator: 'not in', left: node, right: this.parseList()}; + continue; + } + + return node; + } + } + + /** `+` and `-`, left-associative. */ + private parseAdditive(): AstNode { + let node = this.parseMultiplicative(); + + while (this.peek().type === 'operator' && ['+', '-'].includes(this.peek().value)) { + const operator = this.advance().value; + node = {type: 'binary', operator, left: node, right: this.parseMultiplicative()}; + } + + return node; + } + + /** `*`, `/`, `%`, left-associative; binds tighter than `+`/`-`. */ + private parseMultiplicative(): AstNode { + let node = this.parsePower(); + + while (this.peek().type === 'operator' && ['*', '/', '%'].includes(this.peek().value)) { + const operator = this.advance().value; + node = {type: 'binary', operator, left: node, right: this.parsePower()}; + } + + return node; + } + + /** Exponentiation `^`, right-associative: `2 ^ 3 ^ 2` parses as `2 ^ (3 ^ 2)`. */ + private parsePower(): AstNode { + const node = this.parseUnary(); + + if (this.matchOperator('^')) { + return {type: 'binary', operator: '^', left: node, right: this.parsePower()}; + } + + return node; + } + + /** Prefix sign `+x` / `-x`. Binds tighter than `^`, so `-2 ^ 2` is `(-2) ^ 2`. */ + private parseUnary(): AstNode { + if (this.peek().type === 'operator' && ['+', '-'].includes(this.peek().value)) { + const operator = this.advance().value; + return {type: 'unary', operator, argument: this.parseUnary()}; + } + + return this.parsePrimary(); + } + + /** + * Tightest level: literals, `true`/`false`, parenthesized sub-expressions, variable references, and + * function calls (an identifier immediately followed by `(`). + */ + private parsePrimary(): AstNode { + const token = this.peek(); + + if (token.type === 'number') { + this.advance(); + return {type: 'literal', value: Number(token.value)}; + } + + if (token.type === 'string') { + this.advance(); + return {type: 'literal', value: token.value}; + } + + if (token.type === 'identifier') { + this.advance(); + + // `name(...)` -> function call. Arguments are full expressions, comma-separated. + if (this.match('paren', '(')) { + const args: AstNode[] = []; + + if (!this.match('paren', ')')) { + do { + args.push(this.parseOr()); + } while (this.match('comma')); + + this.expect('paren', ')'); + } + + return {type: 'call', name: token.value, args}; + } + + // Bare `true`/`false` are boolean literals; anything else is a variable reference. + const lowerName = token.value.toLowerCase(); + if (lowerName === 'true' || lowerName === 'false') { + return {type: 'literal', value: lowerName === 'true'}; + } + + return {type: 'identifier', name: token.value}; + } + + // Parenthesized grouping, e.g. `(a + b) * c`. + if (this.match('paren', '(')) { + const node = this.parseOr(); + this.expect('paren', ')'); + return node; + } + + throw new Error(`unexpected token "${token.value}" at ${token.position}`); + } + + /** Parses the `(...)` operand of `in` / `not in` into a list node. Allows an empty list `()`. */ + private parseList(): AstNode { + this.expect('paren', '('); + const values: AstNode[] = []; + + if (!this.match('paren', ')')) { + do { + values.push(this.parseOr()); + } while (this.match('comma')); + + this.expect('paren', ')'); + } + + return {type: 'list', values}; + } + + /** Consumes the next token and returns true if it matches the given type (and value, if provided). */ + private match(type: TokenType, value?: string): boolean { + const token = this.peek(); + if (token.type !== type || (value !== undefined && token.value !== value)) return false; + this.advance(); + return true; + } + + private matchOperator(value: string): boolean { + return this.match('operator', value); + } + + /** Similar to {@link match} but throws a positioned error when the expected token isn't present. */ + private expect(type: TokenType, value?: string): Token { + const token = this.peek(); + if (token.type !== type || (value !== undefined && token.value !== value)) { + const expected = value === undefined ? type : `${type} "${value}"`; + throw new Error(`expected ${expected} at ${token.position}`); + } + + return this.advance(); + } + + /** Get the current token without consuming it */ + private peek(): Token { + return this.tokens[this.position]; + } + + /** Like {@link peek} but consumes the token. */ + private advance(): Token { + return this.tokens[this.position++]; + } +} + +/** + * Stage 3: recursively evaluates an AST node to a value. Mirrors the node shapes produced by the parser. + * Errors thrown here (and by the helpers below) are caught by the runner in {@link compileExpression}. + */ +function evaluate(node: AstNode, vars: InternalInputVars, functions: Record): FilterValue { + switch (node.type) { + case 'literal': + return node.value; + case 'identifier': + return readVariable(vars, node.name); + case 'unary': + return evaluateUnary(node.operator, evaluate(node.argument, vars, functions)); + case 'binary': + return evaluateBinary(node, vars, functions); + case 'call': + return evaluateCall(node, vars, functions); + case 'list': + throw new Error('list is only valid with the in operator'); + } +} + +/** + * Resolves a variable reference. Missing or `undefined` variables throw + */ +function readVariable(vars: InternalInputVars, name: string): FilterValue { + if (!Object.prototype.hasOwnProperty.call(vars, name) || (vars as any)[name] === undefined) { + throw new Error(`unknown variable "${name}"`); + } + + const value = (vars as any)[name]; + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value; + + throw new Error(`unsupported variable "${name}"`); +} + +function evaluateUnary(operator: string, value: FilterValue): FilterValue { + assertValue(value); + + switch (operator) { + case '+': + return toNumber(value); + case '-': + return -toNumber(value); + case 'not': + return !isTruthy(value); + default: + throw new Error(`unknown unary operator "${operator}"`); + } +} + +function evaluateBinary( + node: Extract, + vars: InternalInputVars, + functions: Record +): FilterValue { + // `and` / `or` short-circuit: the right side is only evaluated when the left doesn't already decide the result. + if (node.operator === 'and') { + const left = evaluate(node.left, vars, functions); + assertValue(left); + return isTruthy(left) && isTruthy(evaluate(node.right, vars, functions)); + } + + if (node.operator === 'or') { + const left = evaluate(node.left, vars, functions); + assertValue(left); + return isTruthy(left) || isTruthy(evaluate(node.right, vars, functions)); + } + + const left = evaluate(node.left, vars, functions); + assertValue(left); + + // Membership: the right side is a list node, so check the left against each element by equality. + if (node.operator === 'in' || node.operator === 'not in') { + if (node.right.type !== 'list') throw new Error(`${node.operator} requires a list`); + const found = node.right.values.some((item) => valuesEqual(left, evaluate(item, vars, functions))); + return node.operator === 'in' ? found : !found; + } + + const right = evaluate(node.right, vars, functions); + assertValue(right); + + switch (node.operator) { + case '==': + case '=': + return valuesEqual(left, right); + case '!=': + return !valuesEqual(left, right); + case '<': + return compare(left, right) < 0; + case '<=': + return compare(left, right) <= 0; + case '>': + return compare(left, right) > 0; + case '>=': + return compare(left, right) >= 0; + case '+': + // `+` is overloaded: string concatenation if either side is a string, numeric addition otherwise. + if (typeof left === 'string' || typeof right === 'string') return String(left) + String(right); + return toNumber(left) + toNumber(right); + case '-': + return toNumber(left) - toNumber(right); + case '*': + return toNumber(left) * toNumber(right); + case '/': + return toNumber(left) / toNumber(right); + case '%': + return toNumber(left) % toNumber(right); + case '^': + return Math.pow(toNumber(left), toNumber(right)); + default: + throw new Error(`unknown operator "${node.operator}"`); + } +} + +function evaluateCall( + node: Extract, + vars: InternalInputVars, + functions: Record +): FilterValue { + const func = functions[node.name]; + if (!func) throw new Error(`unknown function "${node.name}"`); + + // Evaluate every argument eagerly (no per-function short-circuiting) before invoking. + const args = node.args.map((arg) => { + const value = evaluate(arg, vars, functions); + assertValue(value); + return value; + }); + + return func(...args); +} + +/** Equality for `==` / `!=`: strict, so `5 == "5"` is false. No cross-type coercion. */ +function valuesEqual(left: FilterValue, right: FilterValue): boolean { + assertValue(left); + assertValue(right); + + return left === right; +} + +/** + * Three-way comparison for `<`, `<=`, `>`, `>=`. Numbers compare numerically and strings + * lexicographically; cross-type comparison throws an error. + */ +function compare(left: FilterValue, right: FilterValue): number { + assertValue(left); + assertValue(right); + + if (typeof left === 'number' && typeof right === 'number') return left - right; + if (typeof left === 'string' && typeof right === 'string') return left.localeCompare(right); + + throw new Error(`cannot compare ${typeof left} with ${typeof right}`); +} + +/** Coerces to a number for arithmetic, or throws. Deliberately strict: strings are not auto-parsed. */ +function toNumber(value: FilterValue): number { + assertValue(value); + + if (typeof value !== 'number') { + throw new Error(`expected number, got ${typeof value}`); + } + + return value; +} + +/** Truthiness for boolean contexts: false-y are `false`, `0`, `NaN`, and `""`. */ +function isTruthy(value: FilterValue): boolean { + assertValue(value); + + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0 && !Number.isNaN(value); + return value.length > 0; +} + +/** + * Narrows away the `Error` arm of {@link FilterValue}: if an `Error` reached here it means a sub-result failed, so re-throw it to abort evaluation. + */ +function assertValue(value: FilterValue): asserts value is number | string | boolean { + if (value instanceof Error) throw value; +} diff --git a/src/lib/filter/filter.ts b/src/lib/filter/filter.ts index 5b856f9f..6f41c582 100644 --- a/src/lib/filter/filter.ts +++ b/src/lib/filter/filter.ts @@ -1,8 +1,8 @@ import {InternalInputVars, SerializedFilter} from './types'; import {match, percentile, percentileRange} from './custom_functions'; -import {compileExpression} from 'filtrex'; +import {compileExpression, type FilterValue} from './compile_expression'; -type ExpressionRunner = (data: InternalInputVars) => boolean; +type ExpressionRunner = (data: InternalInputVars) => FilterValue; /** * Encapsulates a filter, with mechanisms for running expressions @@ -69,7 +69,7 @@ export class Filter { }; } - run(vars: InternalInputVars): any { + run(vars: InternalInputVars): FilterValue { // Update vars in use for the functions this.currentVars = vars; @@ -86,12 +86,9 @@ export class Filter { /** * Whether the return value from {@link run} is "valid" or usable * for comparison purposes. - * - * For instance, will return false if `result` is an error indicating - * a property is undefined. */ - static isValidReturnValue(result: any): boolean { - return typeof result === 'boolean' || result === 0 || result === 1; + static isValidReturnValue(result: FilterValue): boolean { + return result !== undefined && result !== null && !(result instanceof Error); } /** diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index 55b425b8..aba68349 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -2,6 +2,7 @@ import {init} from './utils'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; import '../components/market/react/listing'; +import '../components/market/react/filter_panel'; init('src/lib/page_scripts/market_listing.js', main);