From 224810dcc3854363d053eee1b712e7c0ee5b4e4c Mon Sep 17 00:00:00 2001 From: DavidGracias Date: Fri, 5 Sep 2025 11:52:14 -0400 Subject: [PATCH] feat(core/formatters): improve and refactor base formatter - Strings can contain spaces in conditional checks (i.e. X in ~X can contain spaces) - (>=, >, <, <=)X conditionals can now be used with strings Co-authored-by: David Garcia --- packages/core/src/formatters/base.ts | 587 +++++++++------------ packages/core/src/formatters/predefined.ts | 6 +- 2 files changed, 267 insertions(+), 326 deletions(-) diff --git a/packages/core/src/formatters/base.ts b/packages/core/src/formatters/base.ts index 0ce4da72..49b5db04 100644 --- a/packages/core/src/formatters/base.ts +++ b/packages/core/src/formatters/base.ts @@ -107,6 +107,151 @@ export interface ParseValue { }; } +export const hardcodedParseValueKeysForRegexMatching: ParseValue = { + stream: { + filename: null, + folderName: null, + size: null, + folderSize: null, + library: null, + quality: null, + resolution: null, + languages: null, + uLanguages: null, + languageEmojis: null, + uLanguageEmojis: null, + languageCodes: null, + uLanguageCodes: null, + smallLanguageCodes: null, + uSmallLanguageCodes: null, + wedontknowwhatakilometeris: null, + uWedontknowwhatakilometeris: null, + visualTags: null, + audioTags: null, + releaseGroup: null, + regexMatched: null, + encode: null, + audioChannels: null, + indexer: null, + year: null, + title: null, + season: null, + seasons: null, + episode: null, + seasonEpisode: null, + seeders: null, + age: null, + duration: null, + infoHash: null, + type: null, + message: null, + proxied: null, + }, + service: { + id: null, + shortName: null, + name: null, + cached: null, + }, + addon: { + name: "", + presetId: "", + manifestUrl: "", + }, + config: { + addonName: null, + }, + debug: { + json: null, + jsonf: null, + }, +} + +export const stringModifiers = { + 'upper': (value: string) => value.toUpperCase(), + 'lower': (value: string) => value.toLowerCase(), + 'title': (value: string) => value + .split(' ') + .map((word) => word.toLowerCase()) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '), + 'length': (value: string) => value.length.toString(), + 'reverse': (value: string) => value.split('').reverse().join(''), + 'base64': (value: string) => btoa(value), + 'string': (value: string) => value, +} + +const arrayModifierGetOrDefault = (value: string[], i: number) => value.length > 0 ? String(value[i]) : ''; +export const arrayModifiers = { + 'join': (value: string[]) => value.join(", "), + 'length': (value: string[]) => value.length.toString(), + 'first': (value: string[]) => arrayModifierGetOrDefault(value, 0), + 'last': (value: string[]) => arrayModifierGetOrDefault(value, value.length - 1), + 'random': (value: string[]) => arrayModifierGetOrDefault(value, Math.floor(Math.random() * value.length)), + 'sort': (value: string[]) => [...value].sort(), + 'reverse': (value: string[]) => [...value].reverse(), +} + +export const numberModifiers = { + 'comma': (value: number) => value.toLocaleString(), + 'hex': (value: number) => value.toString(16), + 'octal': (value: number) => value.toString(8), + 'binary': (value: number) => value.toString(2), + 'bytes': (value: number) => formatBytes(value, 1000), + 'bytes10': (value: number) => formatBytes(value, 1000), + 'bytes2': (value: number) => formatBytes(value, 1024), + 'string': (value: number) => value.toString(), + 'time': (value: number) => formatDuration(value), +} + +export const conditionalModifiers = { + exact: { + 'istrue': (value: any) => value === true, + 'isfalse': (value: any) => value === false, + 'exists': (value: any) => { + // Handle null, undefined, empty strings, empty arrays + if (value === undefined || value === null) return false; + if (typeof value === 'string') return (value.trim().length > 0 && value != 'null' && value != 'undefined'); + if (Array.isArray(value)) return value.length > 0; + // For other types (numbers, booleans, objects), consider them as "existing" + return true; + }, + }, + + prefix: { + '$': (value: any, check: any) => value.toLowerCase().startsWith(check.toLowerCase()), + '^': (value: any, check: any) => value.toLowerCase().endsWith(check.toLowerCase()), + '~': (value: any, check: any) => value.includes(check), + '=': (value: any, check: any) => value == check, + '>=': (value: any, check: any) => value >= check, + '>': (value: any, check: any) => value > check, + '<=': (value: any, check: any) => value <= check, + '<': (value: any, check: any) => value < check, + }, +} + +const hardcodedModifiersForRegexMatching = { + "join('.*?')": null, + 'join(".*?")': null, + "$.*?": null, + "^.*?": null, + "~.*?": null, + "=.*?": null, + ">=.*?": null, + ">.*?": null, + "<=.*?": null, + "<.*?": null, +} + +const modifiers = { + ...stringModifiers, + ...numberModifiers, + ...arrayModifiers, + ...conditionalModifiers.exact, + ...conditionalModifiers.prefix, + ...hardcodedModifiersForRegexMatching, +} + export abstract class BaseFormatter { protected config: FormatterConfig; protected userData: UserData; @@ -257,6 +402,42 @@ export abstract class BaseFormatter { }; } + protected buildRegexExpression(): RegExp { + // Dynamically build the `variable` regex pattern from ParseValue keys + // Futureproof: if we add new keys to ParseValue interface, we must add them here too + const validVariables: (keyof ParseValue)[] = Object.keys(hardcodedParseValueKeysForRegexMatching) as (keyof ParseValue)[]; + // Get all valid properties (subkeys) from ParseValue structure + const validProperties = validVariables.flatMap(sectionKey => { + const section = hardcodedParseValueKeysForRegexMatching[sectionKey as keyof ParseValue]; + if (section && typeof section === 'object' && section !== null) { + return Object.keys(section); + } + return []; + }); + const variable = `(?${validVariables.join('|')})\\.(?${validProperties.join('|')})`; + + // Dynamically build the `modifier` regex pattern from modifier keys + // Sort by length (longest first) to ensure more specific patterns match before shorter ones + const validModifiers = Object.keys(modifiers) + .map(key => key.replace(/[\(\)\'\"\$\^\~\=\>\<]/g, '\\$&')) + .sort((a, b) => b.length - a.length); // Sort by length, longest first + + const modifier = `::(?${validModifiers.join('|')})`; + + // Build the conditional check pattern separately + // Use [^"]* to capture anything except quotes, making it non-greedy + const checkTrue = `"(?[^"]*)"`; + const checkFalse = `"(?[^"]*)"`; + const checkTF = `\\[(?${checkTrue}\\|\\|${checkFalse})\\]`; + + // TZ Locale pattern (e.g. 'UTC', 'GMT', 'EST', 'PST', 'en-US', 'en-GB', 'Europe/London', 'America/New_York') + const modTZLocale = `::(?[A-Za-z]{2,3}(?:-[A-Z]{2})?|[A-Za-z]+?/[A-Za-z_]+?)`; + + const regexPattern = `\\{${variable}(${modifier})?(${modTZLocale})?(${checkTF})?\\}`; + + return new RegExp(regexPattern, 'gi') + } + protected parseString(str: string, value: ParseValue): string | null { if (!str) return null; @@ -264,20 +445,13 @@ export abstract class BaseFormatter { return value; }; - const data = { - stream: value.stream, - service: value.service, - addon: value.addon, - config: value.config, - }; - value.debug = { - json: JSON.stringify(data, replacer), - jsonf: JSON.stringify(data, replacer, 2), + json: JSON.stringify({ ...value, debug: undefined }, replacer), + jsonf: JSON.stringify({ ...value, debug: undefined }, replacer, 2), }; - const re = - /\{(?stream|service|addon|config|debug)\.(?\w+)(::(?(\w+(\([^)]*\))?|<|<=|=|>=|>|\^|\$|~|\/)+))?((::(?\S+?))|(?\[(?".*?")\|\|(?".*?")\]))?\}/gi; + + const re = this.buildRegexExpression(); let matches: RegExpExecArray | null; while ((matches = re.exec(str))) { @@ -285,12 +459,12 @@ export abstract class BaseFormatter { const index = matches.index as number; - const getV = value[matches.groups.type as keyof ParseValue]; - - if (!getV) { + // Validate - variableName (exists in value) + const variableDict = value[matches.groups.variableName as keyof ParseValue]; + if (!variableDict) { str = this.replaceCharsFromString( str, - '{unknown_type}', + '{unknown_variableName}', index, re.lastIndex ); @@ -298,18 +472,12 @@ export abstract class BaseFormatter { continue; } - const v = - getV[ - matches.groups.prop as - | keyof ParseValue['stream'] - | keyof ParseValue['service'] - | keyof ParseValue['addon'] - ]; - - if (v === undefined) { + // Validate - property: variableDict[propertyName] + const property = variableDict[matches.groups.propertyName as keyof typeof variableDict]; + if (property === undefined) { str = this.replaceCharsFromString( str, - '{unknown_value}', + '{unknown_propertyName}', index, re.lastIndex ); @@ -317,15 +485,16 @@ export abstract class BaseFormatter { continue; } + // Validate - Modifier(s) if (matches.groups.mod) { str = this.replaceCharsFromString( str, this.modifier( matches.groups.mod, - v, - matches.groups.mod_tzlocale ?? undefined, - matches.groups.mod_check_true ?? undefined, - matches.groups.mod_check_false ?? undefined, + property as unknown, + matches.groups.mod_tzlocale ?? "", + matches.groups.mod_check_true ?? "", + matches.groups.mod_check_false ?? "", value ), index, @@ -335,7 +504,7 @@ export abstract class BaseFormatter { continue; } - str = this.replaceCharsFromString(str, v, index, re.lastIndex); + str = this.replaceCharsFromString(str, property, index, re.lastIndex); re.lastIndex = index; } @@ -357,310 +526,82 @@ export abstract class BaseFormatter { check_false?: string, _value?: ParseValue ): string { + const _mod = mod; mod = mod.toLowerCase(); - check_true = check_true?.slice(1, -1); - check_false = check_false?.slice(1, -1); - if (Array.isArray(value)) { + // CONDITIONAL MODIFIERS + const isExact = Object.keys(conditionalModifiers.exact).includes(mod); + const isPrefix = Object.keys(conditionalModifiers.prefix).some(key => mod.startsWith(key)); + if (isExact || isPrefix) { + if (typeof check_true !== 'string' || typeof check_false !== 'string') + return `{unknown_conditional_modifier_check_true_or_false(${mod})}`; + + // try to coerce true/false value from modifier + let conditional: boolean | undefined; + try { + if (isExact) { + const modAsKey = mod as keyof typeof conditionalModifiers.exact; + conditional = conditionalModifiers.exact[modAsKey](value); + } + + // PREFIX + else if (isPrefix) { + for (let key of Object.keys(conditionalModifiers.prefix)) { + if (mod.startsWith(key)) { + var checkKey = mod.substring(key.length); + if (typeof value !== 'string' || !value.includes(' ')) { + checkKey = checkKey.replace(/ /g, ''); + } + conditional = conditionalModifiers.prefix[key as keyof typeof conditionalModifiers.prefix](value, checkKey); + break; + } + } + } + } catch (error) { + conditional = false; + } + + if (conditional === undefined) return `{unknown_conditional_modifier(${mod})}`; + + if (_value) { + return conditional + ? this.parseString(check_true, _value) || check_true + : this.parseString(check_false, _value) || check_false; + } + return conditional ? check_true : check_false; + } + + // --- STRING MODIFIERS --- + if (typeof value === 'string') { + if (mod in stringModifiers) return stringModifiers[mod as keyof typeof stringModifiers](value); + return `{unknown_str_modifier(${mod})}`; + } + + // --- ARRAY MODIFIERS --- + else if (Array.isArray(value)) { + if (mod in arrayModifiers) { + const result = arrayModifiers[mod as keyof typeof arrayModifiers](value); + if (typeof result === 'string') return result; + return result.join(', '); + } + + // handle hardcoded modifiers here switch (true) { - case mod === 'join': - return value.join(', '); case mod.startsWith('join(') && mod.endsWith(')'): { // Extract the separator from join(separator) // e.g. join(' - ') - const separator = mod - .substring(5, mod.length - 1) - .replace(/^['"]|['"]$/g, ''); + const separator = _mod + .substring(6, _mod.length - 2) return value.join(separator); } - case mod == 'length': - return value.length.toString(); - case mod == 'first': - return value.length > 0 ? String(value[0]) : ''; - case mod == 'last': - return value.length > 0 ? String(value[value.length - 1]) : ''; - case mod == 'random': - return value.length > 0 - ? String(value[Math.floor(Math.random() * value.length)]) - : ''; - case mod == 'sort': - return [...value].sort().join(', '); - case mod == 'reverse': - return [...value].reverse().join(', '); - case mod.startsWith('~'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_array_modifier(${mod})}`; - - const check = mod.replace('~', '').replace('_', ' '); - - if (_value) { - return value.some((item) => item.toLowerCase().includes(check)) - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value.some((item) => item.toLowerCase().includes(check)) - ? check_true - : check_false; - } - case mod == 'exists': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_array_modifier(${mod})}`; - - if (_value) { - return value.length > 0 - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value.length > 0 ? check_true : check_false; - } - default: - return `{unknown_array_modifier(${mod})}`; - } - } else if (typeof value === 'string') { - switch (true) { - case mod == 'upper': - return value.toUpperCase(); - case mod == 'lower': - return value.toLowerCase(); - case mod == 'title': { - return value - .split(' ') - .map((word) => word.toLowerCase()) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - } - case mod == 'length': - return value.length.toString(); - case mod == 'reverse': - return value.split('').reverse().join(''); - case mod == 'base64': - return btoa(value); - case mod == 'string': - return value; - case mod == 'exists': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value != 'null' && value - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value != 'null' && value ? check_true : check_false; - } - case mod.startsWith('='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('=', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase() == check - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value.toLowerCase() == check ? check_true : check_false; - } - case mod.startsWith('$'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('$', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase().startsWith(check) - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value.toLowerCase().startsWith(check) - ? check_true - : check_false; - } - case mod.startsWith('^'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('^', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase().endsWith(check) - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value.toLowerCase().endsWith(check) ? check_true : check_false; - } - case mod.startsWith('~'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_str_modifier(${mod})}`; - - const check = mod.replace('~', ''); - - if (!check) return `{unknown_str_modifier(${mod})}`; - - if (_value) { - return value.toLowerCase().includes(check) - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value.toLowerCase().includes(check) ? check_true : check_false; - } - default: - return `{unknown_str_modifier(${mod})}`; - } - } else if (typeof value === 'number') { - switch (true) { - case mod == 'comma': - return value.toLocaleString(); - case mod == 'hex': - return value.toString(16); - case mod == 'octal': - return value.toString(8); - case mod == 'binary': - return value.toString(2); - case mod == 'bytes10' || mod == 'bytes': - return formatBytes(value, 1000); - case mod == 'bytes2': - return formatBytes(value, 1024); - case mod == 'string': - return value.toString(); - case mod == 'time': - return formatDuration(value); - case mod.startsWith('>='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('>=', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value >= check - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value >= check ? check_true : check_false; - } - case mod.startsWith('>'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('>', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value > check - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value > check ? check_true : check_false; - } - case mod.startsWith('='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('=', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value == check - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value == check ? check_true : check_false; - } - case mod.startsWith('<='): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('<=', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value <= check - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value <= check ? check_true : check_false; - } - case mod.startsWith('<'): { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_int_modifier(${mod})}`; - - const check = Number(mod.replace('<', '')); - - if (Number.isNaN(check)) return `{unknown_int_modifier(${mod})}`; - - if (_value) { - return value < check - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value < check ? check_true : check_false; - } - default: - return `{unknown_int_modifier(${mod})}`; - } - } else if (typeof value === 'boolean') { - switch (true) { - case mod == 'istrue': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_bool_modifier(${mod})}`; - - if (_value) { - return value - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return value ? check_true : check_false; - } - case mod == 'isfalse': { - if (typeof check_true !== 'string' || typeof check_false !== 'string') - return `{unknown_bool_modifier(${mod})}`; - - if (_value) { - return !value - ? this.parseString(check_true, _value) || check_true - : this.parseString(check_false, _value) || check_false; - } - - return !value ? check_true : check_false; - } - default: - return `{unknown_bool_modifier(${mod})}`; } + return `{unknown_array_modifier(${mod})}`; } - if ( - typeof check_false == 'string' && - (['>', '>=', '=', '<=', '<', '~', '$', '^'].some((modif) => - mod.startsWith(modif) - ) || - ['istrue', 'exists', 'isfalse'].includes(mod)) - ) { - if (_value) return this.parseString(check_false, _value) || check_false; - return check_false; + // --- NUMBER MODIFIERS --- + else if (typeof value === 'number') { + if (mod in numberModifiers) return numberModifiers[mod as keyof typeof numberModifiers](value); + return `{unknown_int_modifier(${mod})}`; } return `{unknown_modifier(${mod})}`; diff --git a/packages/core/src/formatters/predefined.ts b/packages/core/src/formatters/predefined.ts index 1db26068..754b0103 100644 --- a/packages/core/src/formatters/predefined.ts +++ b/packages/core/src/formatters/predefined.ts @@ -1,5 +1,5 @@ import { UserData } from '../db'; -import { BaseFormatter, FormatterConfig } from './base'; +import { BaseFormatter } from './base'; export class TorrentioFormatter extends BaseFormatter { constructor(userData: UserData) { @@ -14,7 +14,7 @@ export class TorrentioFormatter extends BaseFormatter { {stream.folderName::exists["{stream.folderName}"||""]} {stream.filename::exists["{stream.filename}"||""]} {stream.size::>0["💾{stream.size::bytes2} "||""]}{stream.folderSize::>0["/ 💾{stream.folderSize::bytes2}"||""]}{stream.seeders::>=0["👤{stream.seeders} "||""]}{stream.age::exists["📅{stream.age} "||""]}{stream.indexer::exists["⚙️{stream.indexer}"||""]} -{stream.languageEmojis::exists["{stream.languageEmojis::join( / ')}"||""]} +{stream.languageEmojis::exists["{stream.languageEmojis::join(' / ')}"||""]} `, }, userData @@ -95,4 +95,4 @@ export class MinimalisticGdriveFormatter extends BaseFormatter { userData ); } -} +} \ No newline at end of file