From 3d84152fa4c6fa9bf754f4d4e5a4564bf389280d Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Mon, 22 Sep 2025 23:22:25 +0200 Subject: [PATCH 01/16] Begin implementing lexer --- .../options/AdvancedSkipOptionsComponent.tsx | 2 +- src/utils/skipRule.ts | 181 +++++++++++++++++- 2 files changed, 180 insertions(+), 3 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 71bbce6f35..9e2fca56ec 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -270,4 +270,4 @@ function configToText(config: AdvancedSkipRuleSet[]): string { } return result.trim(); -} \ No newline at end of file +} diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 17497b5044..c0b34ae7ed 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -145,7 +145,7 @@ function getSkipRuleValue(segment: SponsorTime | VideoLabelsCacheData, rule: Adv function isSkipRulePassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipRule): boolean { const value = getSkipRuleValue(segment, rule); - + switch (rule.operator) { case SkipRuleOperator.Less: return typeof value === "number" && value < (rule.value as number); @@ -183,4 +183,181 @@ export function getCategoryDefaultSelection(category: string): CategorySelection } } return { name: category, option: CategorySkipOption.Disabled} as CategorySelection; -} \ No newline at end of file +} + +type TokenType = + | "if" // Keywords + | "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option + | keyof typeof SkipRuleAttribute // Segment attributes + | keyof typeof SkipRuleOperator // Segment attribute operators + | "and" | "or" // Expression operators + | "string" // Literal values + | "eof" | "error"; // Sentinel and special tokens + +interface SourcePos { + line: number; + // column: number; +} + +interface Span { + start: SourcePos; + end: SourcePos; +} + +interface Token { + type: TokenType; + span: Span; + value: string; +} + +interface LexerState { + source: string; + start: number; + current: number; + + start_pos: SourcePos; + current_pos: SourcePos; +} + +function nextToken(state: LexerState): Token { + function makeToken(type: TokenType): Token { + return { + type, + span: { start: state.start_pos, end: state.current_pos, }, + value: state.source.slice(state.start, state.current), + }; + } + + /** + * Returns the UTF-16 value at the current position and advances it forward. + * If the end of the source string has been reached, returns {@code null}. + * + * @return current UTF-16 value, or {@code null} on EOF + */ + function consume(): string | null { + if (state.source.length > state.current) { + // The UTF-16 value at the current position, which could be either a Unicode code point or a lone surrogate. + // The check above this is also based on the UTF-16 value count, so this should not be able to fail on “weird” inputs. + const c = state.source[state.current]; + state.current++; + + if (c === "\n") { + state.current_pos.line++; + // state.current_pos.column = 1; + } else { + // // TODO This will be wrong on anything involving UTF-16 surrogate pairs or grapheme clusters with multiple code units + // // So just don't show column numbers on errors for now + // state.current_pos.column++; + } + + return c; + } else { + return null; + } + } + + /** + * Returns the UTF-16 value at the current position without advancing it. + * If the end of the source string has been reached, returns {@code null}. + * + * @return current UTF-16 value, or {@code null} on EOF + */ + function peek(): string | null { + if (state.source.length > state.current) { + // See comment in consume() for Unicode expectations here + return state.source[state.current]; + } else { + return null; + } + } + + /** + * Checks the current position against expected UTF-16 values. + * If any of them matches, advances the current position and returns + * {@code true}, otherwise {@code false}. + * + * @param expected the expected set of UTF-16 values at the current position + * @return whether the actual value matches and whether the position was advanced + */ + function expect(expected: string | readonly string[]): boolean { + const actual = peek(); + + if (actual === null) { + return false; + } + + if (typeof expected === "string") { + if (expected === actual) { + consume(); + return true; + } + } else if (expected.includes(actual)) { + consume(); + return true; + } + + return false; + } + + /** + * Skips a series of whitespace characters starting at the current + * position. May advance the current position multiple times, once, + * or not at all. + */ + function skipWhitespace() { + let c = peek(); + const whitespace = /s+/; + + while (c != null) { + if (!whitespace.test(c)) { + return; + } + + consume(); + c = peek(); + } + } + + /** + * Skips all characters until the next {@code "\n"} (line feed) + * character occurs (inclusive). Will always advance the current position + * at least once. + */ + function skipLine() { + let c = consume(); + while (c != null) { + if (c == '\n') { + return; + } + + c = consume(); + } + } + + function isEof(): boolean { + return state.current >= state.source.length; + } + + for (;;) { + skipWhitespace(); + state.start = state.current; + + if (isEof()) { + return makeToken("eof"); + } + + const c = consume(); + + switch (c) { + // TODO + default: + return makeToken("error"); + } + } +} + +export function compileConfig(config: string): AdvancedSkipRuleSet[] | null { + // TODO + const ruleSets: AdvancedSkipRuleSet[] = []; + return ruleSets; +} From 2370adb8b2dda2694e68ae7633dbb1d0f18c9325 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:45:29 +0200 Subject: [PATCH 02/16] Tokenize keywords --- .../options/AdvancedSkipOptionsComponent.tsx | 5 +- src/utils/skipRule.ts | 147 ++++++++++++++---- 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 9e2fca56ec..2f7665bd6a 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import * as CompileConfig from "../../../config.json"; import Config from "../../config"; -import { AdvancedSkipRuleSet, SkipRuleAttribute, SkipRuleOperator } from "../../utils/skipRule"; +import {AdvancedSkipRuleSet, compileConfigNew, SkipRuleAttribute, SkipRuleOperator} from "../../utils/skipRule"; import { ActionType, ActionTypes, CategorySkipOption } from "../../types"; let configSaveTimeout: NodeJS.Timeout | null = null; @@ -65,6 +65,9 @@ export function AdvancedSkipOptionsComponent() { } function compileConfig(config: string): AdvancedSkipRuleSet[] | null { + // Debug + compileConfigNew(config); + const ruleSets: AdvancedSkipRuleSet[] = []; let ruleSet: AdvancedSkipRuleSet = { diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index c0b34ae7ed..b1dc422237 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -191,6 +191,7 @@ type TokenType = | keyof typeof SkipRuleAttribute // Segment attributes | keyof typeof SkipRuleOperator // Segment attribute operators | "and" | "or" // Expression operators + | "(" | ")" // Syntax | "string" // Literal values | "eof" | "error"; // Sentinel and special tokens @@ -230,9 +231,9 @@ function nextToken(state: LexerState): Token { /** * Returns the UTF-16 value at the current position and advances it forward. - * If the end of the source string has been reached, returns {@code null}. + * If the end of the source string has been reached, returns null. * - * @return current UTF-16 value, or {@code null} on EOF + * @return current UTF-16 value, or null on EOF */ function consume(): string | null { if (state.source.length > state.current) { @@ -258,9 +259,9 @@ function nextToken(state: LexerState): Token { /** * Returns the UTF-16 value at the current position without advancing it. - * If the end of the source string has been reached, returns {@code null}. + * If the end of the source string has been reached, returns null. * - * @return current UTF-16 value, or {@code null} on EOF + * @return current UTF-16 value, or null on EOF */ function peek(): string | null { if (state.source.length > state.current) { @@ -272,31 +273,28 @@ function nextToken(state: LexerState): Token { } /** - * Checks the current position against expected UTF-16 values. - * If any of them matches, advances the current position and returns - * {@code true}, otherwise {@code false}. + * Checks the word at the current position against a list of + * expected keywords. The keyword can consist of multiple characters. + * If a match is found, the current position is advanced by the length + * of the keyword found. * - * @param expected the expected set of UTF-16 values at the current position - * @return whether the actual value matches and whether the position was advanced + * @param keywords the expected set of keywords at the current position + * @param caseSensitive whether to do a case-sensitive comparison + * @return the matching keyword, or null */ - function expect(expected: string | readonly string[]): boolean { - const actual = peek(); - - if (actual === null) { - return false; - } - - if (typeof expected === "string") { - if (expected === actual) { - consume(); - return true; + function expectKeyword(keywords: readonly string[], caseSensitive: boolean): string | null { + for (const keyword of keywords) { + // slice() clamps to string length, so cannot cause out of bounds errors + const actual = state.source.slice(state.current, state.current + keyword.length); + + if (caseSensitive && keyword === actual || !caseSensitive && keyword.toLowerCase() === actual.toLowerCase()) { + // Does not handle keywords containing line feeds, which shouldn't happen anyway + state.current += keyword.length; + return keyword; } - } else if (expected.includes(actual)) { - consume(); - return true; } - return false; + return null; } /** @@ -306,7 +304,7 @@ function nextToken(state: LexerState): Token { */ function skipWhitespace() { let c = peek(); - const whitespace = /s+/; + const whitespace = /\s+/; while (c != null) { if (!whitespace.test(c)) { @@ -319,7 +317,7 @@ function nextToken(state: LexerState): Token { } /** - * Skips all characters until the next {@code "\n"} (line feed) + * Skips all characters until the next "\n" (line feed) * character occurs (inclusive). Will always advance the current position * at least once. */ @@ -341,23 +339,108 @@ function nextToken(state: LexerState): Token { for (;;) { skipWhitespace(); state.start = state.current; + state.start_pos = state.current_pos; if (isEof()) { return makeToken("eof"); } + const keyword = expectKeyword([ + "if", "and", "or", + "(", ")", + "//", + ].concat(Object.values(SkipRuleAttribute)) + .concat(Object.values(SkipRuleOperator)), true); + + if (keyword !== null) { + switch (keyword) { + case "if": return makeToken("if"); + case "and": return makeToken("and"); + case "or": return makeToken("or"); + + case "(": return makeToken("("); + case ")": return makeToken(")"); + + case "time.start": return makeToken("StartTime"); + case "time.end": return makeToken("EndTime"); + case "time.duration": return makeToken("Duration"); + case "time.startPercent": return makeToken("StartTimePercent"); + case "time.endPercent": return makeToken("EndTimePercent"); + case "time.durationPercent": return makeToken("DurationPercent"); + case "category": return makeToken("Category"); + case "actionType": return makeToken("ActionType"); + case "chapter.name": return makeToken("Description"); + case "chapter.source": return makeToken("Source"); + case "channel.id": return makeToken("ChannelID"); + case "channel.name": return makeToken("ChannelName"); + case "video.duration": return makeToken("VideoDuration"); + case "video.title": return makeToken("Title"); + + case "<": return makeToken("Less"); + case "<=": return makeToken("LessOrEqual"); + case ">": return makeToken("Greater"); + case ">=": return makeToken("GreaterOrEqual"); + case "==": return makeToken("Equal"); + case "!=": return makeToken("NotEqual"); + case "*=": return makeToken("Contains"); + case "!*=": return makeToken("NotContains"); + case "~=": return makeToken("Regex"); + case "~i=": return makeToken("RegexIgnoreCase"); + case "!~=": return makeToken("NotRegex"); + case "!~i=": return makeToken("NotRegexIgnoreCase"); + + case "//": + skipLine(); + continue; + + default: + } + } + + const keyword2 = expectKeyword( + [ "disabled", "show overlay", "manual skip", "auto skip" ], false); + + if (keyword2 !== null) { + switch (keyword2) { + case "disabled": return makeToken("disabled"); + case "show overlay": return makeToken("show overlay"); + case "manual skip": return makeToken("manual skip"); + case "auto skip": return makeToken("auto skip"); + default: + } + } + const c = consume(); - switch (c) { + if (c === '"') { + // TODO + } else if (/[0-9.]/.test(c)) { // TODO - default: - return makeToken("error"); } + + return makeToken("error"); } } -export function compileConfig(config: string): AdvancedSkipRuleSet[] | null { +export function compileConfigNew(config: string): AdvancedSkipRuleSet[] | null { + // Mutated by calls to nextToken() + const lexerState: LexerState = { + source: config, + start: 0, + current: 0, + + start_pos: { line: 1 }, + current_pos: { line: 1 }, + }; + + let token = nextToken(lexerState); + + while (token.type !== "eof") { + console.log(token); + + token = nextToken(lexerState); + } + // TODO - const ruleSets: AdvancedSkipRuleSet[] = []; - return ruleSets; + return null; } From 2004f6bf1ba62b2e76211d7ec1d99eed38fe878e Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:59:59 +0200 Subject: [PATCH 03/16] Implement lexing strings and numbers --- src/utils/skipRule.ts | 144 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 137 insertions(+), 7 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index b1dc422237..5f2b35b9b5 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -192,7 +192,7 @@ type TokenType = | keyof typeof SkipRuleOperator // Segment attribute operators | "and" | "or" // Expression operators | "(" | ")" // Syntax - | "string" // Literal values + | "string" | "number" // Literal values | "eof" | "error"; // Sentinel and special tokens interface SourcePos { @@ -336,10 +336,14 @@ function nextToken(state: LexerState): Token { return state.current >= state.source.length; } - for (;;) { - skipWhitespace(); + function resetToCurrent() { state.start = state.current; state.start_pos = state.current_pos; + } + + for (;;) { + skipWhitespace(); + resetToCurrent(); if (isEof()) { return makeToken("eof"); @@ -410,12 +414,138 @@ function nextToken(state: LexerState): Token { } } - const c = consume(); + let c = consume(); if (c === '"') { - // TODO - } else if (/[0-9.]/.test(c)) { - // TODO + // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” + let output = ""; + let c = consume(); + let error = false; + + while (c !== null && c !== '"') { + if (c == '\\') { + c = consume(); + + switch (c) { + case '"': + output = output.concat('"'); + break; + case '\\': + output = output.concat('\\'); + break; + case '/': + output = output.concat('/'); + break; + case 'b': + output = output.concat('\b'); + break; + case 'f': + output = output.concat('\f'); + break; + case 'n': + output = output.concat('\n'); + break; + case 'r': + output = output.concat('\r'); + break; + case 't': + output = output.concat('\t'); + break; + case 'u': { + // UTF-16 value sequence + const digits = state.source.slice(state.current, state.current + 4); + + if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { + error = true; + output = output.concat(`\\u`); + c = consume(); + continue; + } + + const value = parseInt(digits, 16); + // fromCharCode() takes a UTF-16 value without performing validity checks, + // which is exactly what is needed here – in JSON, code units outside the + // BMP are represented by two Unicode escape sequences. + output = output.concat(String.fromCharCode(value)); + break; + } + default: + error = true; + output = output.concat(`\\${c}`); + break; + } + } else { + output = output.concat(c); + } + + c = consume(); + } + + return { + type: error || c !== '"' ? "error" : "string", + span: { start: state.start_pos, end: state.current_pos, }, + value: output, + }; + } else if (/[0-9-]/.test(c)) { + // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers” + if (c === '-') { + c = consume(); + + if (!/[0-9]/.test(c)) { + return makeToken("error"); + } + } + + const leadingZero = c === '0'; + let next = peek(); + let error = false; + + while (next !== null && /[0-9]/.test(next)) { + consume(); + next = peek(); + + if (leadingZero) { + error = true; + } + } + + + if (next !== null && next === '.') { + consume(); + next = peek(); + + if (next === null || !/[0-9]/.test(next)) { + return makeToken("error"); + } + + do { + consume(); + next = peek(); + } while (next !== null && /[0-9]/.test(next)); + } + + next = peek(); + + if (next != null && (next === 'e' || next === 'E')) { + consume(); + next = peek(); + + if (next === null) { + return makeToken("error"); + } + + if (next === '+' || next === '-') { + consume(); + next = peek(); + } + + while (next !== null && /[0-9]/.test(next)) { + consume(); + next = peek(); + } + } + + return makeToken(error ? "error" : "number"); } return makeToken("error"); From 2a2d9de817eaafc513698f73e5d7e45147d7d864 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:03:33 +0200 Subject: [PATCH 04/16] Implement parser and configToText, remove old parser --- .../options/AdvancedSkipOptionsComponent.tsx | 211 +----- src/config.ts | 10 +- src/utils/skipRule.ts | 627 ++++++++++++------ 3 files changed, 464 insertions(+), 384 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 2f7665bd6a..89b382a789 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -1,9 +1,8 @@ import * as React from "react"; -import * as CompileConfig from "../../../config.json"; import Config from "../../config"; -import {AdvancedSkipRuleSet, compileConfigNew, SkipRuleAttribute, SkipRuleOperator} from "../../utils/skipRule"; -import { ActionType, ActionTypes, CategorySkipOption } from "../../types"; +import {AdvancedSkipPredicate, AdvancedSkipRule, parseConfig, PredicateOperator,} from "../../utils/skipRule"; +import {CategorySkipOption} from "../../types"; let configSaveTimeout: NodeJS.Timeout | null = null; @@ -64,206 +63,43 @@ export function AdvancedSkipOptionsComponent() { ); } -function compileConfig(config: string): AdvancedSkipRuleSet[] | null { - // Debug - compileConfigNew(config); +function compileConfig(config: string): AdvancedSkipRule[] | null { + const { rules, errors } = parseConfig(config); - const ruleSets: AdvancedSkipRuleSet[] = []; - - let ruleSet: AdvancedSkipRuleSet = { - rules: [], - skipOption: null, - comment: "" - }; - - for (const line of config.split("\n")) { - if (line.trim().length === 0) { - // Skip empty lines - continue; - } - - const comment = line.match(/^\s*\/\/(.+)$/); - if (comment) { - if (ruleSet.rules.length > 0) { - // Rule has already been created, add it to list if valid - if (ruleSet.skipOption !== null && ruleSet.rules.length > 0) { - ruleSets.push(ruleSet); - - ruleSet = { - rules: [], - skipOption: null, - comment: "" - }; - } else { - return null; - } - } - - if (ruleSet.comment.length > 0) { - ruleSet.comment += "; "; - } - - ruleSet.comment += comment[1].trim(); - - // Skip comment lines - continue; - } else if (line.startsWith("if ")) { - if (ruleSet.rules.length > 0) { - // Rule has already been created, add it to list if valid - if (ruleSet.skipOption !== null && ruleSet.rules.length > 0) { - ruleSets.push(ruleSet); - - ruleSet = { - rules: [], - skipOption: null, - comment: "" - }; - } else { - return null; - } - } - - const ruleTexts = [...line.matchAll(/\S+ \S+ (?:"[^"\\]*(?:\\.[^"\\]*)*"|\d+)(?= and |$)/g)]; - for (const ruleText of ruleTexts) { - if (!ruleText[0]) return null; - - const ruleParts = ruleText[0].match(/(\S+) (\S+) ("[^"\\]*(?:\\.[^"\\]*)*"|\d+)/); - if (ruleParts.length !== 4) { - return null; // Invalid rule format - } - - const attribute = getSkipRuleAttribute(ruleParts[1]); - const operator = getSkipRuleOperator(ruleParts[2]); - const value = getSkipRuleValue(ruleParts[3]); - if (attribute === null || operator === null || value === null) { - return null; // Invalid attribute or operator - } - - if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { - if (attribute === SkipRuleAttribute.Category - && !CompileConfig.categoryList.includes(value as string)) { - return null; // Invalid category value - } else if (attribute === SkipRuleAttribute.ActionType - && !ActionTypes.includes(value as ActionType)) { - return null; // Invalid category value - } else if (attribute === SkipRuleAttribute.Source - && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { - return null; // Invalid category value - } - } - - ruleSet.rules.push({ - attribute, - operator, - value - }); - } - - // Make sure all rules were parsed - if (ruleTexts.length === 0 || !line.endsWith(ruleTexts[ruleTexts.length - 1][0])) { - return null; - } - } else { - // Only continue if a rule has been defined - if (ruleSet.rules.length === 0) { - return null; // No rules defined yet - } - - switch (line.trim().toLowerCase()) { - case "disabled": - ruleSet.skipOption = CategorySkipOption.Disabled; - break; - case "show overlay": - ruleSet.skipOption = CategorySkipOption.ShowOverlay; - break; - case "manual skip": - ruleSet.skipOption = CategorySkipOption.ManualSkip; - break; - case "auto skip": - ruleSet.skipOption = CategorySkipOption.AutoSkip; - break; - default: - return null; // Invalid skip option - } - } + for (const error of errors) { + console.log(`Error on line ${error.span.start.line}: ${error.message}`); } - if (ruleSet.rules.length > 0 && ruleSet.skipOption !== null) { - ruleSets.push(ruleSet); - } else if (ruleSet.rules.length > 0 || ruleSet.skipOption !== null) { - // Incomplete rule set - return null; - } - - return ruleSets; -} - -function getSkipRuleAttribute(attribute: string): SkipRuleAttribute | null { - if (attribute && Object.values(SkipRuleAttribute).includes(attribute as SkipRuleAttribute)) { - return attribute as SkipRuleAttribute; - } - - return null; -} - -function getSkipRuleOperator(operator: string): SkipRuleOperator | null { - if (operator && Object.values(SkipRuleOperator).includes(operator as SkipRuleOperator)) { - return operator as SkipRuleOperator; - } - - return null; -} - -function getSkipRuleValue(value: string): string | number | null { - if (!value) return null; - - if (value.startsWith('"')) { - try { - return JSON.parse(value); - } catch (e) { - return null; // Invalid JSON string - } + if (errors.length === 0) { + return rules; } else { - const numValue = Number(value); - if (!isNaN(numValue)) { - return numValue; - } - return null; } } -function configToText(config: AdvancedSkipRuleSet[]): string { +function configToText(config: AdvancedSkipRule[]): string { let result = ""; - for (const ruleSet of config) { - if (ruleSet.comment) { - result += "// " + ruleSet.comment + "\n"; + for (const rule of config) { + for (const comment of rule.comments) { + result += "// " + comment + "\n"; } result += "if "; - let firstRule = true; - for (const rule of ruleSet.rules) { - if (!firstRule) { - result += " and "; - } - - result += `${rule.attribute} ${rule.operator} ${JSON.stringify(rule.value)}`; - firstRule = false; - } + result += predicateToText(rule.predicate, PredicateOperator.Or); - switch (ruleSet.skipOption) { + switch (rule.skipOption) { case CategorySkipOption.Disabled: result += "\nDisabled"; break; case CategorySkipOption.ShowOverlay: - result += "\nShow Overlay"; + result += "\nShow overlay"; break; case CategorySkipOption.ManualSkip: - result += "\nManual Skip"; + result += "\nManual skip"; break; case CategorySkipOption.AutoSkip: - result += "\nAuto Skip"; + result += "\nAuto skip"; break; default: return null; // Invalid skip option @@ -274,3 +110,16 @@ function configToText(config: AdvancedSkipRuleSet[]): string { return result.trim(); } + +function predicateToText(predicate: AdvancedSkipPredicate, highestPrecedence: PredicateOperator): string { + if (predicate.kind === "check") { + return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`; + } else { + if (predicate.operator === PredicateOperator.And) { + return `${predicateToText(predicate.left, PredicateOperator.And)} and ${predicateToText(predicate.right, PredicateOperator.And)}`; + } else { // Or + const text = `${predicateToText(predicate.left, PredicateOperator.Or)} or ${predicateToText(predicate.right, PredicateOperator.Or)}`; + return highestPrecedence == PredicateOperator.And ? `(${text})` : text; + } + } +} diff --git a/src/config.ts b/src/config.ts index 4261f66fca..73f16048b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import * as invidiousList from "../ci/invidiouslist.json"; import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorTime, VideoID, SponsorHideType } from "./types"; import { Keybind, ProtoConfig, keybindEquals } from "../maze-utils/src/config"; import { HashedValue } from "../maze-utils/src/hash"; -import { Permission, AdvancedSkipRuleSet } from "./utils/skipRule"; +import { Permission, AdvancedSkipRule } from "./utils/skipRule"; interface SBConfig { userID: string; @@ -155,7 +155,7 @@ interface SBStorage { /* VideoID prefixes to UUID prefixes */ downvotedSegments: Record; navigationApiAvailable: boolean; - + // Used when sync storage disabled alreadyInstalled: boolean; @@ -166,7 +166,7 @@ interface SBStorage { skipProfileTemp: { time: number; configID: ConfigurationID } | null; skipProfiles: Record; - skipRules: AdvancedSkipRuleSet[]; + skipRules: AdvancedSkipRule[]; } class ConfigClass extends ProtoConfig { @@ -212,7 +212,7 @@ function migrateOldSyncFormats(config: SBConfig, local: SBStorage) { for (const channelID of whitelistedChannels) { local.channelSkipProfileIDs[channelID] = skipProfileID; } - local.channelSkipProfileIDs = local.channelSkipProfileIDs; + local.channelSkipProfileIDs = local.channelSkipProfileIDs; chrome.storage.sync.remove("whitelistedChannels"); } @@ -246,7 +246,7 @@ function migrateOldSyncFormats(config: SBConfig, local: SBStorage) { name: "chapter" as Category, option: CategorySkipOption.ShowOverlay }); - + config.categorySelections = config.categorySelections; } } diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 5f2b35b9b5..91a030145d 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -1,9 +1,10 @@ import { getCurrentPageTitle } from "../../maze-utils/src/elements"; import { getChannelIDInfo, getVideoDuration } from "../../maze-utils/src/video"; import Config from "../config"; -import { CategorySelection, CategorySkipOption, SponsorSourceType, SponsorTime } from "../types"; +import {ActionType, ActionTypes, CategorySelection, CategorySkipOption, SponsorSourceType, SponsorTime} from "../types"; import { getSkipProfile, getSkipProfileBool } from "./skipProfiles"; import { VideoLabelsCacheData } from "./videoLabels"; +import * as CompileConfig from "../../config.json"; export interface Permission { canSubmit: boolean; @@ -41,23 +42,38 @@ export enum SkipRuleOperator { NotRegexIgnoreCase = "!~i=" } -export interface AdvancedSkipRule { +export interface AdvancedSkipCheck { + kind: "check"; attribute: SkipRuleAttribute; operator: SkipRuleOperator; value: string | number; } -export interface AdvancedSkipRuleSet { - rules: AdvancedSkipRule[]; +export enum PredicateOperator { + And = "and", + Or = "or", +} + +export interface AdvancedSkipOperator { + kind: "operator"; + operator: PredicateOperator; + left: AdvancedSkipPredicate; + right: AdvancedSkipPredicate; +} + +export type AdvancedSkipPredicate = AdvancedSkipCheck | AdvancedSkipOperator; + +export interface AdvancedSkipRule { + predicate: AdvancedSkipPredicate; skipOption: CategorySkipOption; - comment: string; + comments: string[]; } export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData): CategorySelection { // First check skip rules - for (const ruleSet of Config.local.skipRules) { - if (ruleSet.rules.every((rule) => isSkipRulePassing(segment, rule))) { - return { name: segment.category, option: ruleSet.skipOption } as CategorySelection; + for (const rule of Config.local.skipRules) { + if (isSkipPredicatePassing(segment, rule.predicate)) { + return { name: segment.category, option: rule.skipOption } as CategorySelection; } } @@ -84,7 +100,7 @@ export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData return { name: segment.category, option: CategorySkipOption.Disabled} as CategorySelection; } -function getSkipRuleValue(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipRule): string | number | undefined { +function getSkipCheckValue(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): string | number | undefined { switch (rule.attribute) { case SkipRuleAttribute.StartTime: return (segment as SponsorTime).segment?.[0]; @@ -143,8 +159,8 @@ function getSkipRuleValue(segment: SponsorTime | VideoLabelsCacheData, rule: Adv } } -function isSkipRulePassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipRule): boolean { - const value = getSkipRuleValue(segment, rule); +function isSkipCheckPassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): boolean { + const value = getSkipCheckValue(segment, rule); switch (rule.operator) { case SkipRuleOperator.Less: @@ -176,6 +192,19 @@ function isSkipRulePassing(segment: SponsorTime | VideoLabelsCacheData, rule: Ad } } +function isSkipPredicatePassing(segment: SponsorTime | VideoLabelsCacheData, predicate: AdvancedSkipPredicate): boolean { + if (predicate.kind === "check") { + return isSkipCheckPassing(segment, predicate as AdvancedSkipCheck); + } else { // predicate.kind === "operator" + // TODO Is recursion fine to use here? + if (predicate.operator == PredicateOperator.And) { + return isSkipPredicatePassing(segment, predicate.left) && isSkipPredicatePassing(segment, predicate.right); + } else { // predicate.operator === PredicateOperator.Or + return isSkipPredicatePassing(segment, predicate.left) || isSkipPredicatePassing(segment, predicate.right); + } + } +} + export function getCategoryDefaultSelection(category: string): CategorySelection { for (const selection of Config.config.categorySelections) { if (selection.name === category) { @@ -188,19 +217,19 @@ export function getCategoryDefaultSelection(category: string): CategorySelection type TokenType = | "if" // Keywords | "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option - | keyof typeof SkipRuleAttribute // Segment attributes - | keyof typeof SkipRuleOperator // Segment attribute operators + | `${SkipRuleAttribute}` // Segment attributes + | `${SkipRuleOperator}` // Segment attribute operators | "and" | "or" // Expression operators - | "(" | ")" // Syntax + | "(" | ")" | "comment" // Syntax | "string" | "number" // Literal values | "eof" | "error"; // Sentinel and special tokens -interface SourcePos { +export interface SourcePos { line: number; // column: number; } -interface Span { +export interface Span { start: SourcePos; end: SourcePos; } @@ -341,218 +370,222 @@ function nextToken(state: LexerState): Token { state.start_pos = state.current_pos; } - for (;;) { - skipWhitespace(); - resetToCurrent(); - - if (isEof()) { - return makeToken("eof"); - } - - const keyword = expectKeyword([ - "if", "and", "or", - "(", ")", - "//", - ].concat(Object.values(SkipRuleAttribute)) - .concat(Object.values(SkipRuleOperator)), true); - - if (keyword !== null) { - switch (keyword) { - case "if": return makeToken("if"); - case "and": return makeToken("and"); - case "or": return makeToken("or"); - - case "(": return makeToken("("); - case ")": return makeToken(")"); - - case "time.start": return makeToken("StartTime"); - case "time.end": return makeToken("EndTime"); - case "time.duration": return makeToken("Duration"); - case "time.startPercent": return makeToken("StartTimePercent"); - case "time.endPercent": return makeToken("EndTimePercent"); - case "time.durationPercent": return makeToken("DurationPercent"); - case "category": return makeToken("Category"); - case "actionType": return makeToken("ActionType"); - case "chapter.name": return makeToken("Description"); - case "chapter.source": return makeToken("Source"); - case "channel.id": return makeToken("ChannelID"); - case "channel.name": return makeToken("ChannelName"); - case "video.duration": return makeToken("VideoDuration"); - case "video.title": return makeToken("Title"); - - case "<": return makeToken("Less"); - case "<=": return makeToken("LessOrEqual"); - case ">": return makeToken("Greater"); - case ">=": return makeToken("GreaterOrEqual"); - case "==": return makeToken("Equal"); - case "!=": return makeToken("NotEqual"); - case "*=": return makeToken("Contains"); - case "!*=": return makeToken("NotContains"); - case "~=": return makeToken("Regex"); - case "~i=": return makeToken("RegexIgnoreCase"); - case "!~=": return makeToken("NotRegex"); - case "!~i=": return makeToken("NotRegexIgnoreCase"); - - case "//": - skipLine(); - continue; + skipWhitespace(); + resetToCurrent(); - default: - } + if (isEof()) { + return makeToken("eof"); + } + + const keyword = expectKeyword([ + "if", "and", "or", + "(", ")", + "//", + ].concat(Object.values(SkipRuleAttribute)) + .concat(Object.values(SkipRuleOperator)), true); + + if (keyword !== null) { + switch (keyword) { + case "if": return makeToken("if"); + case "and": return makeToken("and"); + case "or": return makeToken("or"); + + case "(": return makeToken("("); + case ")": return makeToken(")"); + + case "time.start": return makeToken("time.start"); + case "time.end": return makeToken("time.end"); + case "time.duration": return makeToken("time.duration"); + case "time.startPercent": return makeToken("time.startPercent"); + case "time.endPercent": return makeToken("time.endPercent"); + case "time.durationPercent": return makeToken("time.durationPercent"); + case "category": return makeToken("category"); + case "actionType": return makeToken("actionType"); + case "chapter.name": return makeToken("chapter.name"); + case "chapter.source": return makeToken("chapter.source"); + case "channel.id": return makeToken("channel.id"); + case "channel.name": return makeToken("channel.name"); + case "video.duration": return makeToken("video.duration"); + case "video.title": return makeToken("video.title"); + + case "<": return makeToken("<"); + case "<=": return makeToken("<="); + case ">": return makeToken(">"); + case ">=": return makeToken(">="); + case "==": return makeToken("=="); + case "!=": return makeToken("!="); + case "*=": return makeToken("*="); + case "!*=": return makeToken("!*="); + case "~=": return makeToken("~="); + case "~i=": return makeToken("~i="); + case "!~=": return makeToken("!~="); + case "!~i=": return makeToken("!~i="); + + case "//": + resetToCurrent(); + skipLine(); + return makeToken("comment"); + + default: } + } - const keyword2 = expectKeyword( - [ "disabled", "show overlay", "manual skip", "auto skip" ], false); + const keyword2 = expectKeyword( + [ "disabled", "show overlay", "manual skip", "auto skip" ], false); - if (keyword2 !== null) { - switch (keyword2) { - case "disabled": return makeToken("disabled"); - case "show overlay": return makeToken("show overlay"); - case "manual skip": return makeToken("manual skip"); - case "auto skip": return makeToken("auto skip"); - default: - } + if (keyword2 !== null) { + switch (keyword2) { + case "disabled": return makeToken("disabled"); + case "show overlay": return makeToken("show overlay"); + case "manual skip": return makeToken("manual skip"); + case "auto skip": return makeToken("auto skip"); + default: } + } - let c = consume(); + let c = consume(); - if (c === '"') { - // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” - let output = ""; - let c = consume(); - let error = false; - - while (c !== null && c !== '"') { - if (c == '\\') { - c = consume(); - - switch (c) { - case '"': - output = output.concat('"'); - break; - case '\\': - output = output.concat('\\'); - break; - case '/': - output = output.concat('/'); - break; - case 'b': - output = output.concat('\b'); - break; - case 'f': - output = output.concat('\f'); - break; - case 'n': - output = output.concat('\n'); - break; - case 'r': - output = output.concat('\r'); - break; - case 't': - output = output.concat('\t'); - break; - case 'u': { - // UTF-16 value sequence - const digits = state.source.slice(state.current, state.current + 4); - - if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { - error = true; - output = output.concat(`\\u`); - c = consume(); - continue; - } - - const value = parseInt(digits, 16); - // fromCharCode() takes a UTF-16 value without performing validity checks, - // which is exactly what is needed here – in JSON, code units outside the - // BMP are represented by two Unicode escape sequences. - output = output.concat(String.fromCharCode(value)); - break; - } - default: - error = true; - output = output.concat(`\\${c}`); - break; - } - } else { - output = output.concat(c); - } + if (c === '"') { + // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” + let output = ""; + let c = consume(); + let error = false; + while (c !== null && c !== '"') { + if (c == '\\') { c = consume(); - } - return { - type: error || c !== '"' ? "error" : "string", - span: { start: state.start_pos, end: state.current_pos, }, - value: output, - }; - } else if (/[0-9-]/.test(c)) { - // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers” - if (c === '-') { - c = consume(); + switch (c) { + case '"': + output = output.concat('"'); + break; + case '\\': + output = output.concat('\\'); + break; + case '/': + output = output.concat('/'); + break; + case 'b': + output = output.concat('\b'); + break; + case 'f': + output = output.concat('\f'); + break; + case 'n': + output = output.concat('\n'); + break; + case 'r': + output = output.concat('\r'); + break; + case 't': + output = output.concat('\t'); + break; + case 'u': { + // UTF-16 value sequence + const digits = state.source.slice(state.current, state.current + 4); + + if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { + error = true; + output = output.concat(`\\u`); + c = consume(); + continue; + } - if (!/[0-9]/.test(c)) { - return makeToken("error"); + const value = parseInt(digits, 16); + // fromCharCode() takes a UTF-16 value without performing validity checks, + // which is exactly what is needed here – in JSON, code units outside the + // BMP are represented by two Unicode escape sequences. + output = output.concat(String.fromCharCode(value)); + break; + } + default: + error = true; + output = output.concat(`\\${c}`); + break; } + } else { + output = output.concat(c); } - const leadingZero = c === '0'; - let next = peek(); - let error = false; + c = consume(); + } - while (next !== null && /[0-9]/.test(next)) { - consume(); - next = peek(); + return { + type: error || c !== '"' ? "error" : "string", + span: { start: state.start_pos, end: state.current_pos, }, + value: output, + }; + } else if (/[0-9-]/.test(c)) { + // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers” + if (c === '-') { + c = consume(); - if (leadingZero) { - error = true; - } + if (!/[0-9]/.test(c)) { + return makeToken("error"); } + } + const leadingZero = c === '0'; + let next = peek(); + let error = false; - if (next !== null && next === '.') { - consume(); - next = peek(); - - if (next === null || !/[0-9]/.test(next)) { - return makeToken("error"); - } + while (next !== null && /[0-9]/.test(next)) { + consume(); + next = peek(); - do { - consume(); - next = peek(); - } while (next !== null && /[0-9]/.test(next)); + if (leadingZero) { + error = true; } + } + + if (next !== null && next === '.') { + consume(); next = peek(); - if (next != null && (next === 'e' || next === 'E')) { + if (next === null || !/[0-9]/.test(next)) { + return makeToken("error"); + } + + do { consume(); next = peek(); + } while (next !== null && /[0-9]/.test(next)); + } - if (next === null) { - return makeToken("error"); - } + next = peek(); - if (next === '+' || next === '-') { - consume(); - next = peek(); - } + if (next != null && (next === 'e' || next === 'E')) { + consume(); + next = peek(); - while (next !== null && /[0-9]/.test(next)) { - consume(); - next = peek(); - } + if (next === null) { + return makeToken("error"); + } + + if (next === '+' || next === '-') { + consume(); + next = peek(); } - return makeToken(error ? "error" : "number"); + while (next !== null && /[0-9]/.test(next)) { + consume(); + next = peek(); + } } - return makeToken("error"); + return makeToken(error ? "error" : "number"); } + + return makeToken("error"); } -export function compileConfigNew(config: string): AdvancedSkipRuleSet[] | null { +export interface ParseError { + span: Span; + message: string; +} + +export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors: ParseError[] } { // Mutated by calls to nextToken() const lexerState: LexerState = { source: config, @@ -563,14 +596,212 @@ export function compileConfigNew(config: string): AdvancedSkipRuleSet[] | null { current_pos: { line: 1 }, }; - let token = nextToken(lexerState); + let previous: Token = null; + let current: Token = nextToken(lexerState); - while (token.type !== "eof") { - console.log(token); + const rules: AdvancedSkipRule[] = []; + const errors: ParseError[] = []; + let erroring = false; + let panicMode = false; + + function errorAt(span: Span, message: string, panic: boolean) { + if (!panicMode) { + errors.push({span, message,}); + } + + panicMode ||= panic; + erroring = true; + } + + function error(message: string, panic: boolean) { + errorAt(previous.span, message, panic); + } - token = nextToken(lexerState); + function errorAtCurrent(message: string, panic: boolean) { + errorAt(current.span, message, panic); } - // TODO - return null; + function consume() { + previous = current; + current = nextToken(lexerState); + + while (current.type === "error") { + errorAtCurrent(`Unexpected token: ${JSON.stringify(current)}`, true); + current = nextToken(lexerState); + } + } + + function match(expected: readonly TokenType[]): boolean { + if (expected.includes(current.type)) { + consume(); + return true; + } else { + return false; + } + } + + function expect(expected: readonly TokenType[], message: string, panic: boolean) { + if (!match(expected)) { + errorAtCurrent(message.concat(`, got: \`${current.type}\``), panic); + } + } + + function synchronize() { + panicMode = false; + + while (!isEof()) { + if (current.type === "if") { + return; + } + + consume(); + } + } + + function isEof(): boolean { + return current.type === "eof"; + } + + while (!isEof()) { + erroring = false; + const rule = parseRule(); + + if (!erroring) { + rules.push(rule); + } + + if (panicMode) { + synchronize(); + } + } + + return { rules, errors, }; + + function parseRule(): AdvancedSkipRule { + const rule: AdvancedSkipRule = { + predicate: null, + skipOption: null, + comments: [], + }; + + while (match(["comment"])) { + rule.comments.push(previous.value.trim()); + } + + expect(["if"], "Expected `if`", true); + + rule.predicate = parsePredicate(); + + expect(["disabled", "show overlay", "manual skip", "auto skip"], "Expected skip option after predicate", true); + + switch (previous.type) { + case "disabled": + rule.skipOption = CategorySkipOption.Disabled; + break; + case "show overlay": + rule.skipOption = CategorySkipOption.ShowOverlay; + break; + case "manual skip": + rule.skipOption = CategorySkipOption.ManualSkip; + break; + case "auto skip": + rule.skipOption = CategorySkipOption.AutoSkip; + break; + default: + // Ignore, should have already errored + } + + return rule; + } + + function parsePredicate(): AdvancedSkipPredicate { + return parseOr(); + } + + function parseOr(): AdvancedSkipPredicate { + let left = parseAnd(); + + while (match(["or"])) { + const right = parseAnd(); + + left = { + kind: "operator", + operator: PredicateOperator.Or, + left, right, + }; + } + + return left; + } + + function parseAnd(): AdvancedSkipPredicate { + let left = parsePrimary(); + + while (match(["and"])) { + const right = parsePrimary(); + + left = { + kind: "operator", + operator: PredicateOperator.And, + left, right, + }; + } + + return left; + } + + function parsePrimary(): AdvancedSkipPredicate { + if (match(["("])) { + const predicate = parsePredicate(); + expect([")"], "Expected `)` after predicate", true); + return predicate; + } else { + return parseCheck(); + } + } + + function parseCheck(): AdvancedSkipCheck { + expect(Object.values(SkipRuleAttribute), "Expected attribute", true); + + if (erroring) { + return null; + } + + const attribute = previous.type as SkipRuleAttribute; + expect(Object.values(SkipRuleOperator), "Expected operator after attribute", true); + + if (erroring) { + return null; + } + + const operator = previous.type as SkipRuleOperator; + expect(["string", "number"], "Expected string or number after operator", true); + + if (erroring) { + return null; + } + + const value = previous.type === "number" ? Number(previous.value) : previous.value; + + if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { + if (attribute === SkipRuleAttribute.Category + && !CompileConfig.categoryList.includes(value as string)) { + error(`Unknown category: \`${value}\``, false); + return null; + } else if (attribute === SkipRuleAttribute.ActionType + && !ActionTypes.includes(value as ActionType)) { + error(`Unknown action type: \`${value}\``, false); + return null; + } else if (attribute === SkipRuleAttribute.Source + && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { + error(`Unknown chapter source: \`${value}\``, false); + return null; + } + } + + return { + kind: "check", + attribute, operator, value, + }; + } } From 8d0d71b0f3ea158387aefca03bc74f075b4acbed Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Tue, 23 Sep 2025 20:38:54 +0200 Subject: [PATCH 05/16] Always use parentheses in unclear nesting --- .../options/AdvancedSkipOptionsComponent.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 89b382a789..20a7ed47dd 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -86,7 +86,7 @@ function configToText(config: AdvancedSkipRule[]): string { } result += "if "; - result += predicateToText(rule.predicate, PredicateOperator.Or); + result += predicateToText(rule.predicate, null); switch (rule.skipOption) { case CategorySkipOption.Disabled: @@ -111,15 +111,18 @@ function configToText(config: AdvancedSkipRule[]): string { return result.trim(); } -function predicateToText(predicate: AdvancedSkipPredicate, highestPrecedence: PredicateOperator): string { +function predicateToText(predicate: AdvancedSkipPredicate, outerPrecedence: PredicateOperator | null): string { if (predicate.kind === "check") { return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`; } else { + let text: string; + if (predicate.operator === PredicateOperator.And) { - return `${predicateToText(predicate.left, PredicateOperator.And)} and ${predicateToText(predicate.right, PredicateOperator.And)}`; + text = `${predicateToText(predicate.left, PredicateOperator.And)} and ${predicateToText(predicate.right, PredicateOperator.And)}`; } else { // Or - const text = `${predicateToText(predicate.left, PredicateOperator.Or)} or ${predicateToText(predicate.right, PredicateOperator.Or)}`; - return highestPrecedence == PredicateOperator.And ? `(${text})` : text; + text = `${predicateToText(predicate.left, PredicateOperator.Or)} or ${predicateToText(predicate.right, PredicateOperator.Or)}`; } + + return outerPrecedence !== null && outerPrecedence !== predicate.operator ? `(${text})` : text; } } From f64a552bf73e237ff2dcf9bea26e1fe66d32365c Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Tue, 23 Sep 2025 22:10:19 +0200 Subject: [PATCH 06/16] Add missing documentation comments --- src/utils/skipRule.ts | 63 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 91a030145d..e86657db0f 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -361,10 +361,20 @@ function nextToken(state: LexerState): Token { } } + /** + * @return whether the lexer has reached the end of input + */ function isEof(): boolean { return state.current >= state.source.length; } + /** + * Sets the start position of the next token that will be emitted + * to the current position. + * + * More characters need to be consumed after calling this, as + * an empty token would be emitted otherwise. + */ function resetToCurrent() { state.start = state.current; state.start_pos = state.current_pos; @@ -604,6 +614,14 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors let erroring = false; let panicMode = false; + /** + * Adds an error message. The current skip rule will be marked as erroring. + * + * @param span the range of the error + * @param message the message to report + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ function errorAt(span: Span, message: string, panic: boolean) { if (!panicMode) { errors.push({span, message,}); @@ -613,14 +631,36 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors erroring = true; } + /** + * Adds an error message for an error occurring at the previous token + * (which was just consumed). + * + * @param message the message to report + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ function error(message: string, panic: boolean) { errorAt(previous.span, message, panic); } + /** + * Adds an error message for an error occurring at the current token + * (which has not been consumed yet). + * + * @param message the message to report + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ function errorAtCurrent(message: string, panic: boolean) { errorAt(current.span, message, panic); } + /** + * Consumes the current token, which can then be accessed at previous. + * The next token will be at current after this call. + * + * If a token of type error is found, issues an error message. + */ function consume() { previous = current; current = nextToken(lexerState); @@ -631,6 +671,12 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors } } + /** + * Checks the current token (that has not been consumed yet) against a set of expected token types. + * + * @param expected the set of expected token types + * @return whether the actual current token matches any expected token type + */ function match(expected: readonly TokenType[]): boolean { if (expected.includes(current.type)) { consume(); @@ -640,12 +686,26 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors } } + /** + * Checks the current token (that has not been consumed yet) against a set of expected token types. + * + * If there is no match, issues an error message which will be prepended to , got: . + * + * @param expected the set of expected token types + * @param message the error message to report in case the actual token doesn't match + * @param panic if true, all further errors will be silenced + * until panic mode is disabled again + */ function expect(expected: readonly TokenType[], message: string, panic: boolean) { if (!match(expected)) { errorAtCurrent(message.concat(`, got: \`${current.type}\``), panic); } } + /** + * Synchronize with the next rule block and disable panic mode. + * Skips all tokens until the if keyword is found. + */ function synchronize() { panicMode = false; @@ -658,6 +718,9 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors } } + /** + * @return whether the parser has reached the end of input + */ function isEof(): boolean { return current.type === "eof"; } From ebc323a83bffd253189d7dd041253a90758333b1 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 00:24:36 +0200 Subject: [PATCH 07/16] Fix some operators being shadowed, improve errors --- src/utils/skipRule.ts | 95 ++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 55 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index e86657db0f..7c21a8532c 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -28,20 +28,23 @@ export enum SkipRuleAttribute { } export enum SkipRuleOperator { - Less = "<", LessOrEqual = "<=", - Greater = ">", + Less = "<", GreaterOrEqual = ">=", - Equal = "==", + Greater = ">", NotEqual = "!=", - Contains = "*=", + Equal = "==", NotContains = "!*=", - Regex = "~=", - RegexIgnoreCase = "~i=", + Contains = "*=", NotRegex = "!~=", - NotRegexIgnoreCase = "!~i=" + Regex = "~=", + NotRegexIgnoreCase = "!~i=", + RegexIgnoreCase = "~i=" } +const SKIP_RULE_ATTRIBUTES = Object.values(SkipRuleAttribute); +const SKIP_RULE_OPERATORS = Object.values(SkipRuleOperator); + export interface AdvancedSkipCheck { kind: "check"; attribute: SkipRuleAttribute; @@ -272,12 +275,11 @@ function nextToken(state: LexerState): Token { state.current++; if (c === "\n") { - state.current_pos.line++; - // state.current_pos.column = 1; + state.current_pos = { line: state.current_pos.line + 1, /* column: 1 */ }; } else { // // TODO This will be wrong on anything involving UTF-16 surrogate pairs or grapheme clusters with multiple code units // // So just don't show column numbers on errors for now - // state.current_pos.column++; + // state.current_pos = { line: state.current_pos.line, /* column: state.current_pos.column + 1 */ }; } return c; @@ -391,10 +393,14 @@ function nextToken(state: LexerState): Token { "if", "and", "or", "(", ")", "//", - ].concat(Object.values(SkipRuleAttribute)) - .concat(Object.values(SkipRuleOperator)), true); + ].concat(SKIP_RULE_ATTRIBUTES) + .concat(SKIP_RULE_OPERATORS), true); if (keyword !== null) { + if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword) || (SKIP_RULE_OPERATORS as string[]).includes(keyword)) { + return makeToken(keyword as TokenType); + } + switch (keyword) { case "if": return makeToken("if"); case "and": return makeToken("and"); @@ -403,34 +409,6 @@ function nextToken(state: LexerState): Token { case "(": return makeToken("("); case ")": return makeToken(")"); - case "time.start": return makeToken("time.start"); - case "time.end": return makeToken("time.end"); - case "time.duration": return makeToken("time.duration"); - case "time.startPercent": return makeToken("time.startPercent"); - case "time.endPercent": return makeToken("time.endPercent"); - case "time.durationPercent": return makeToken("time.durationPercent"); - case "category": return makeToken("category"); - case "actionType": return makeToken("actionType"); - case "chapter.name": return makeToken("chapter.name"); - case "chapter.source": return makeToken("chapter.source"); - case "channel.id": return makeToken("channel.id"); - case "channel.name": return makeToken("channel.name"); - case "video.duration": return makeToken("video.duration"); - case "video.title": return makeToken("video.title"); - - case "<": return makeToken("<"); - case "<=": return makeToken("<="); - case ">": return makeToken(">"); - case ">=": return makeToken(">="); - case "==": return makeToken("=="); - case "!=": return makeToken("!="); - case "*=": return makeToken("*="); - case "!*=": return makeToken("!*="); - case "~=": return makeToken("~="); - case "~i=": return makeToken("~i="); - case "!~=": return makeToken("!~="); - case "!~i=": return makeToken("!~i="); - case "//": resetToCurrent(); skipLine(); @@ -587,6 +565,15 @@ function nextToken(state: LexerState): Token { return makeToken(error ? "error" : "number"); } + // Consume common characters up to a space for a more useful value in the error token + const common = /[a-zA-Z0-9<>=!~*.-]/; + if (c !== null && common.test(c)) { + do { + consume(); + c = peek(); + } while (c !== null && common.test(c)); + } + return makeToken("error"); } @@ -663,12 +650,10 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors */ function consume() { previous = current; + // Intentionally ignoring `error` tokens here; + // by handling those in later functions with more context (match(), expect(), ...), + // the user gets better errors current = nextToken(lexerState); - - while (current.type === "error") { - errorAtCurrent(`Unexpected token: ${JSON.stringify(current)}`, true); - current = nextToken(lexerState); - } } /** @@ -698,7 +683,7 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors */ function expect(expected: readonly TokenType[], message: string, panic: boolean) { if (!match(expected)) { - errorAtCurrent(message.concat(`, got: \`${current.type}\``), panic); + errorAtCurrent(message.concat(`, got: \`${current.type === "error" ? current.value : current.type}\``), panic); } } @@ -751,11 +736,11 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors rule.comments.push(previous.value.trim()); } - expect(["if"], "Expected `if`", true); + expect(["if"], rule.comments.length !== 0 ? "expected `if` after `comment`" : "expected `if`", true); rule.predicate = parsePredicate(); - expect(["disabled", "show overlay", "manual skip", "auto skip"], "Expected skip option after predicate", true); + expect(["disabled", "show overlay", "manual skip", "auto skip"], "expected skip option after predicate", true); switch (previous.type) { case "disabled": @@ -816,7 +801,7 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors function parsePrimary(): AdvancedSkipPredicate { if (match(["("])) { const predicate = parsePredicate(); - expect([")"], "Expected `)` after predicate", true); + expect([")"], "expected `)` after predicate", true); return predicate; } else { return parseCheck(); @@ -824,21 +809,21 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors } function parseCheck(): AdvancedSkipCheck { - expect(Object.values(SkipRuleAttribute), "Expected attribute", true); + expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \`${previous.type}\``, true); if (erroring) { return null; } const attribute = previous.type as SkipRuleAttribute; - expect(Object.values(SkipRuleOperator), "Expected operator after attribute", true); + expect(SKIP_RULE_OPERATORS, `expected operator after \`${attribute}\``, true); if (erroring) { return null; } const operator = previous.type as SkipRuleOperator; - expect(["string", "number"], "Expected string or number after operator", true); + expect(["string", "number"], `expected string or number after \`${operator}\``, true); if (erroring) { return null; @@ -849,15 +834,15 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { if (attribute === SkipRuleAttribute.Category && !CompileConfig.categoryList.includes(value as string)) { - error(`Unknown category: \`${value}\``, false); + error(`unknown category: \`${value}\``, false); return null; } else if (attribute === SkipRuleAttribute.ActionType && !ActionTypes.includes(value as ActionType)) { - error(`Unknown action type: \`${value}\``, false); + error(`unknown action type: \`${value}\``, false); return null; } else if (attribute === SkipRuleAttribute.Source && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { - error(`Unknown chapter source: \`${value}\``, false); + error(`unknown chapter source: \`${value}\``, false); return null; } } From 7c61c8b44e45280b6af1f7f7f5c5a33e3607b3e3 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 00:35:52 +0200 Subject: [PATCH 08/16] Use error log level, prefix messages --- src/components/options/AdvancedSkipOptionsComponent.tsx | 2 +- src/utils/skipRule.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 20a7ed47dd..00f4230b62 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -67,7 +67,7 @@ function compileConfig(config: string): AdvancedSkipRule[] | null { const { rules, errors } = parseConfig(config); for (const error of errors) { - console.log(`Error on line ${error.span.start.line}: ${error.message}`); + console.error(`[SB] Error on line ${error.span.start.line}: ${error.message}`); } if (errors.length === 0) { diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 7c21a8532c..12830b577a 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -737,11 +737,9 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors } expect(["if"], rule.comments.length !== 0 ? "expected `if` after `comment`" : "expected `if`", true); - rule.predicate = parsePredicate(); - expect(["disabled", "show overlay", "manual skip", "auto skip"], "expected skip option after predicate", true); - + expect(["disabled", "show overlay", "manual skip", "auto skip"], "expected skip option after condition", true); switch (previous.type) { case "disabled": rule.skipOption = CategorySkipOption.Disabled; @@ -801,7 +799,7 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors function parsePrimary(): AdvancedSkipPredicate { if (match(["("])) { const predicate = parsePredicate(); - expect([")"], "expected `)` after predicate", true); + expect([")"], "expected `)` after condition", true); return predicate; } else { return parseCheck(); From d165e06d2b3e8254aef8a807f889b6bfb51fdee7 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:46:21 +0200 Subject: [PATCH 09/16] Fix various tokenization issues - Keywords and operators were previously matching too eagerly. For example, `ifcategory` would be matched as two tokens `if` `category` and result in a valid file. This is now a single error token. - Strings previously allowed line breaks in them. This has been fixed, strings only consume up to the end of the line now. - the error message for error tokens has been improved by using JSON escape. --- src/utils/skipRule.ts | 79 +++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 12830b577a..82b651e1ee 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -395,40 +395,55 @@ function nextToken(state: LexerState): Token { "//", ].concat(SKIP_RULE_ATTRIBUTES) .concat(SKIP_RULE_OPERATORS), true); + let type: TokenType | null = null; + let kind: "word" | "operator" | null = null; if (keyword !== null) { - if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword) || (SKIP_RULE_OPERATORS as string[]).includes(keyword)) { - return makeToken(keyword as TokenType); - } + if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword)) { + kind = "word"; + type = keyword as TokenType; + } else if ((SKIP_RULE_OPERATORS as string[]).includes(keyword)) { + kind = "operator"; + type = keyword as TokenType; + } else { + switch (keyword) { + case "if": // Fallthrough + case "and": // Fallthrough + case "or": kind = "word"; type = keyword as TokenType; break; - switch (keyword) { - case "if": return makeToken("if"); - case "and": return makeToken("and"); - case "or": return makeToken("or"); + case "(": return makeToken("("); + case ")": return makeToken(")"); - case "(": return makeToken("("); - case ")": return makeToken(")"); + case "//": + resetToCurrent(); + skipLine(); + return makeToken("comment"); - case "//": - resetToCurrent(); - skipLine(); - return makeToken("comment"); + default: + } + } + } else { + const keyword2 = expectKeyword( + [ "disabled", "show overlay", "manual skip", "auto skip" ], false); - default: + if (keyword2 !== null) { + kind = "word"; + type = keyword2 as TokenType; } } - const keyword2 = expectKeyword( - [ "disabled", "show overlay", "manual skip", "auto skip" ], false); + if (type !== null) { + const more = kind == "operator" ? /[<>=!~*&|-]/ : kind == "word" ? /[a-zA-Z0-9.]/ : /[a-zA-Z0-9<>=!~*&|.-]/; - if (keyword2 !== null) { - switch (keyword2) { - case "disabled": return makeToken("disabled"); - case "show overlay": return makeToken("show overlay"); - case "manual skip": return makeToken("manual skip"); - case "auto skip": return makeToken("auto skip"); - default: + let c = peek(); + let error = false; + while (c !== null && more.test(c)) { + error = true; + consume(); + c = peek(); } + + return makeToken(error ? "error" : type); } let c = consume(); @@ -491,6 +506,11 @@ function nextToken(state: LexerState): Token { output = output.concat(`\\${c}`); break; } + } else if (c === '\n') { + // Unterminated / multi-line string, unsupported + error = true; + // Prevent unterminated strings from consuming the entire rest of the input + break; } else { output = output.concat(c); } @@ -566,12 +586,11 @@ function nextToken(state: LexerState): Token { } // Consume common characters up to a space for a more useful value in the error token - const common = /[a-zA-Z0-9<>=!~*.-]/; - if (c !== null && common.test(c)) { - do { - consume(); - c = peek(); - } while (c !== null && common.test(c)); + const common = /[a-zA-Z0-9<>=!~*&|.-]/; + c = peek(); + while (c !== null && common.test(c)) { + consume(); + c = peek(); } return makeToken("error"); @@ -683,7 +702,7 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors */ function expect(expected: readonly TokenType[], message: string, panic: boolean) { if (!match(expected)) { - errorAtCurrent(message.concat(`, got: \`${current.type === "error" ? current.value : current.type}\``), panic); + errorAtCurrent(message.concat(current.type === "error" ? `, got: ${JSON.stringify(current.value)}` : `, got: \`${current.type}\``), panic); } } From fa8db7c3b3517f1a41d8550ce918c22bf2211388 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 14:13:42 +0200 Subject: [PATCH 10/16] Fix some attributes being shadowed --- src/utils/skipRule.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 82b651e1ee..f69e9a9296 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -11,12 +11,12 @@ export interface Permission { } export enum SkipRuleAttribute { - StartTime = "time.start", - EndTime = "time.end", - Duration = "time.duration", StartTimePercent = "time.startPercent", + StartTime = "time.start", EndTimePercent = "time.endPercent", + EndTime = "time.end", DurationPercent = "time.durationPercent", + Duration = "time.duration", Category = "category", ActionType = "actionType", Description = "chapter.name", From 75caa40fe547395753632cf6d4156dadf8a1d38c Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:04:42 +0200 Subject: [PATCH 11/16] Add migration from old storage format --- src/config.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 73f16048b4..0a38bb690b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,9 @@ import * as CompileConfig from "../config.json"; import * as invidiousList from "../ci/invidiouslist.json"; -import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorTime, VideoID, SponsorHideType } from "./types"; -import { Keybind, ProtoConfig, keybindEquals } from "../maze-utils/src/config"; +import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorHideType, SponsorTime, VideoID } from "./types"; +import { Keybind, keybindEquals, ProtoConfig } from "../maze-utils/src/config"; import { HashedValue } from "../maze-utils/src/hash"; -import { Permission, AdvancedSkipRule } from "./utils/skipRule"; +import { AdvancedSkipCheck, AdvancedSkipPredicate, AdvancedSkipRule, Permission, PredicateOperator } from "./utils/skipRule"; interface SBConfig { userID: string; @@ -186,6 +186,43 @@ class ConfigClass extends ProtoConfig { } function migrateOldSyncFormats(config: SBConfig, local: SBStorage) { + if (local["skipRules"] && local["skipRules"].length !== 0 && local["skipRules"][0]["rules"]) { + const output: AdvancedSkipRule[] = []; + + for (const rule of local["skipRules"]) { + const rules: object[] = rule["rules"]; + + if (rules.length !== 0) { + let predicate: AdvancedSkipPredicate = { + kind: "check", + ...rules[0] as AdvancedSkipCheck, + }; + + for (let i = 1; i < rules.length; i++) { + predicate = { + kind: "operator", + operator: PredicateOperator.And, + left: predicate, + right: { + kind: "check", + ...rules[i] as AdvancedSkipCheck, + }, + }; + } + + const comment = rule["comment"] as string; + + output.push({ + predicate, + skipOption: rule.skipOption, + comments: comment.length === 0 ? [] : comment.split(/;\s*/), + }); + } + } + + local["skipRules"] = output; + } + if (config["whitelistedChannels"]) { // convert to skipProfiles const whitelistedChannels = config["whitelistedChannels"] as string[]; From f6109ace9973ad05ed3fac6094b34e3c1cacd271 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:32:49 +0200 Subject: [PATCH 12/16] =?UTF-8?q?Remove=20dead=20code=20and=20revert=20ski?= =?UTF-8?q?p=20action=20being=20changed=20to=20sentence=20case=20?= =?UTF-8?q?=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/options/AdvancedSkipOptionsComponent.tsx | 6 +++--- src/utils/skipRule.ts | 8 ++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 00f4230b62..0975e9aa54 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -93,13 +93,13 @@ function configToText(config: AdvancedSkipRule[]): string { result += "\nDisabled"; break; case CategorySkipOption.ShowOverlay: - result += "\nShow overlay"; + result += "\nShow Overlay"; break; case CategorySkipOption.ManualSkip: - result += "\nManual skip"; + result += "\nManual Skip"; break; case CategorySkipOption.AutoSkip: - result += "\nAuto skip"; + result += "\nAuto Skip"; break; default: return null; // Invalid skip option diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index f69e9a9296..ad4c2fe0e7 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -229,7 +229,6 @@ type TokenType = export interface SourcePos { line: number; - // column: number; } export interface Span { @@ -275,11 +274,8 @@ function nextToken(state: LexerState): Token { state.current++; if (c === "\n") { - state.current_pos = { line: state.current_pos.line + 1, /* column: 1 */ }; - } else { - // // TODO This will be wrong on anything involving UTF-16 surrogate pairs or grapheme clusters with multiple code units - // // So just don't show column numbers on errors for now - // state.current_pos = { line: state.current_pos.line, /* column: state.current_pos.column + 1 */ }; + // Cannot use state.current_pos.line++, because SourcePos is mutable and used in tokens without copying + state.current_pos = { line: state.current_pos.line + 1, }; } return c; From 1ae101405b9d42bd46382ad20fd52ed2269e2890 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Wed, 24 Sep 2025 22:00:21 +0200 Subject: [PATCH 13/16] Convert nested functions to classes --- src/utils/skipRule.ts | 636 ++++++++++++++++++++++-------------------- 1 file changed, 331 insertions(+), 305 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index ad4c2fe0e7..5bdd9774d6 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -242,21 +242,27 @@ interface Token { value: string; } -interface LexerState { - source: string; - start: number; - current: number; +class Lexer { + private readonly source: string; + private start: number; + private current: number; - start_pos: SourcePos; - current_pos: SourcePos; -} + private start_pos: SourcePos; + private current_pos: SourcePos; + + public constructor(source: string) { + this.source = source; + this.start = 0; + this.current = 0; + this.start_pos = { line: 1 }; + this.current_pos = { line: 1 }; + } -function nextToken(state: LexerState): Token { - function makeToken(type: TokenType): Token { + private makeToken(type: TokenType): Token { return { type, - span: { start: state.start_pos, end: state.current_pos, }, - value: state.source.slice(state.start, state.current), + span: { start: this.start_pos, end: this.current_pos, }, + value: this.source.slice(this.start, this.current), }; } @@ -266,16 +272,16 @@ function nextToken(state: LexerState): Token { * * @return current UTF-16 value, or null on EOF */ - function consume(): string | null { - if (state.source.length > state.current) { + private consume(): string | null { + if (this.source.length > this.current) { // The UTF-16 value at the current position, which could be either a Unicode code point or a lone surrogate. // The check above this is also based on the UTF-16 value count, so this should not be able to fail on “weird” inputs. - const c = state.source[state.current]; - state.current++; + const c = this.source[this.current]; + this.current++; if (c === "\n") { - // Cannot use state.current_pos.line++, because SourcePos is mutable and used in tokens without copying - state.current_pos = { line: state.current_pos.line + 1, }; + // Cannot use this.current_pos.line++, because SourcePos is mutable and used in tokens without copying + this.current_pos = { line: this.current_pos.line + 1, }; } return c; @@ -290,10 +296,10 @@ function nextToken(state: LexerState): Token { * * @return current UTF-16 value, or null on EOF */ - function peek(): string | null { - if (state.source.length > state.current) { + private peek(): string | null { + if (this.source.length > this.current) { // See comment in consume() for Unicode expectations here - return state.source[state.current]; + return this.source[this.current]; } else { return null; } @@ -309,14 +315,14 @@ function nextToken(state: LexerState): Token { * @param caseSensitive whether to do a case-sensitive comparison * @return the matching keyword, or null */ - function expectKeyword(keywords: readonly string[], caseSensitive: boolean): string | null { + private expectKeyword(keywords: readonly string[], caseSensitive: boolean): string | null { for (const keyword of keywords) { // slice() clamps to string length, so cannot cause out of bounds errors - const actual = state.source.slice(state.current, state.current + keyword.length); + const actual = this.source.slice(this.current, this.current + keyword.length); if (caseSensitive && keyword === actual || !caseSensitive && keyword.toLowerCase() === actual.toLowerCase()) { // Does not handle keywords containing line feeds, which shouldn't happen anyway - state.current += keyword.length; + this.current += keyword.length; return keyword; } } @@ -329,8 +335,8 @@ function nextToken(state: LexerState): Token { * position. May advance the current position multiple times, once, * or not at all. */ - function skipWhitespace() { - let c = peek(); + private skipWhitespace() { + let c = this.peek(); const whitespace = /\s+/; while (c != null) { @@ -338,8 +344,8 @@ function nextToken(state: LexerState): Token { return; } - consume(); - c = peek(); + this.consume(); + c = this.peek(); } } @@ -348,22 +354,22 @@ function nextToken(state: LexerState): Token { * character occurs (inclusive). Will always advance the current position * at least once. */ - function skipLine() { - let c = consume(); + private skipLine() { + let c = this.consume(); while (c != null) { if (c == '\n') { return; } - c = consume(); + c = this.consume(); } } /** * @return whether the lexer has reached the end of input */ - function isEof(): boolean { - return state.current >= state.source.length; + private isEof(): boolean { + return this.current >= this.source.length; } /** @@ -373,223 +379,225 @@ function nextToken(state: LexerState): Token { * More characters need to be consumed after calling this, as * an empty token would be emitted otherwise. */ - function resetToCurrent() { - state.start = state.current; - state.start_pos = state.current_pos; - } - - skipWhitespace(); - resetToCurrent(); - - if (isEof()) { - return makeToken("eof"); - } - - const keyword = expectKeyword([ - "if", "and", "or", - "(", ")", - "//", - ].concat(SKIP_RULE_ATTRIBUTES) - .concat(SKIP_RULE_OPERATORS), true); - let type: TokenType | null = null; - let kind: "word" | "operator" | null = null; - - if (keyword !== null) { - if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword)) { - kind = "word"; - type = keyword as TokenType; - } else if ((SKIP_RULE_OPERATORS as string[]).includes(keyword)) { - kind = "operator"; - type = keyword as TokenType; - } else { - switch (keyword) { - case "if": // Fallthrough - case "and": // Fallthrough - case "or": kind = "word"; type = keyword as TokenType; break; - - case "(": return makeToken("("); - case ")": return makeToken(")"); + private resetToCurrent() { + this.start = this.current; + this.start_pos = this.current_pos; + } + + public nextToken(): Token { + this.skipWhitespace(); + this.resetToCurrent(); + + if (this.isEof()) { + return this.makeToken("eof"); + } + + const keyword = this.expectKeyword([ + "if", "and", "or", + "(", ")", + "//", + ].concat(SKIP_RULE_ATTRIBUTES) + .concat(SKIP_RULE_OPERATORS), true); + let type: TokenType | null = null; + let kind: "word" | "operator" | null = null; + + if (keyword !== null) { + if ((SKIP_RULE_ATTRIBUTES as string[]).includes(keyword)) { + kind = "word"; + type = keyword as TokenType; + } else if ((SKIP_RULE_OPERATORS as string[]).includes(keyword)) { + kind = "operator"; + type = keyword as TokenType; + } else { + switch (keyword) { + case "if": // Fallthrough + case "and": // Fallthrough + case "or": kind = "word"; type = keyword as TokenType; break; - case "//": - resetToCurrent(); - skipLine(); - return makeToken("comment"); + case "(": return this.makeToken("("); + case ")": return this.makeToken(")"); - default: - } - } - } else { - const keyword2 = expectKeyword( - [ "disabled", "show overlay", "manual skip", "auto skip" ], false); - - if (keyword2 !== null) { - kind = "word"; - type = keyword2 as TokenType; - } - } - - if (type !== null) { - const more = kind == "operator" ? /[<>=!~*&|-]/ : kind == "word" ? /[a-zA-Z0-9.]/ : /[a-zA-Z0-9<>=!~*&|.-]/; - - let c = peek(); - let error = false; - while (c !== null && more.test(c)) { - error = true; - consume(); - c = peek(); - } - - return makeToken(error ? "error" : type); - } - - let c = consume(); - - if (c === '"') { - // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” - let output = ""; - let c = consume(); - let error = false; - - while (c !== null && c !== '"') { - if (c == '\\') { - c = consume(); - - switch (c) { - case '"': - output = output.concat('"'); - break; - case '\\': - output = output.concat('\\'); - break; - case '/': - output = output.concat('/'); - break; - case 'b': - output = output.concat('\b'); - break; - case 'f': - output = output.concat('\f'); - break; - case 'n': - output = output.concat('\n'); - break; - case 'r': - output = output.concat('\r'); - break; - case 't': - output = output.concat('\t'); - break; - case 'u': { - // UTF-16 value sequence - const digits = state.source.slice(state.current, state.current + 4); - - if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { - error = true; - output = output.concat(`\\u`); - c = consume(); - continue; - } + case "//": + this.resetToCurrent(); + this.skipLine(); + return this.makeToken("comment"); - const value = parseInt(digits, 16); - // fromCharCode() takes a UTF-16 value without performing validity checks, - // which is exactly what is needed here – in JSON, code units outside the - // BMP are represented by two Unicode escape sequences. - output = output.concat(String.fromCharCode(value)); - break; - } default: - error = true; - output = output.concat(`\\${c}`); - break; } - } else if (c === '\n') { - // Unterminated / multi-line string, unsupported - error = true; - // Prevent unterminated strings from consuming the entire rest of the input - break; - } else { - output = output.concat(c); } + } else { + const keyword2 = this.expectKeyword( + [ "disabled", "show overlay", "manual skip", "auto skip" ], false); - c = consume(); + if (keyword2 !== null) { + kind = "word"; + type = keyword2 as TokenType; + } } - return { - type: error || c !== '"' ? "error" : "string", - span: { start: state.start_pos, end: state.current_pos, }, - value: output, - }; - } else if (/[0-9-]/.test(c)) { - // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers” - if (c === '-') { - c = consume(); + if (type !== null) { + const more = kind == "operator" ? /[<>=!~*&|-]/ : kind == "word" ? /[a-zA-Z0-9.]/ : /[a-zA-Z0-9<>=!~*&|.-]/; - if (!/[0-9]/.test(c)) { - return makeToken("error"); + let c = this.peek(); + let error = false; + while (c !== null && more.test(c)) { + error = true; + this.consume(); + c = this.peek(); } - } - const leadingZero = c === '0'; - let next = peek(); - let error = false; + return this.makeToken(error ? "error" : type); + } + + let c = this.consume(); + + if (c === '"') { + // Parses string according to ECMA-404 2nd edition (JSON), section 9 “String” + let output = ""; + let c = this.consume(); + let error = false; + + while (c !== null && c !== '"') { + if (c == '\\') { + c = this.consume(); + + switch (c) { + case '"': + output = output.concat('"'); + break; + case '\\': + output = output.concat('\\'); + break; + case '/': + output = output.concat('/'); + break; + case 'b': + output = output.concat('\b'); + break; + case 'f': + output = output.concat('\f'); + break; + case 'n': + output = output.concat('\n'); + break; + case 'r': + output = output.concat('\r'); + break; + case 't': + output = output.concat('\t'); + break; + case 'u': { + // UTF-16 value sequence + const digits = this.source.slice(this.current, this.current + 4); + + if (digits.length < 4 || !/[0-9a-zA-Z]{4}/.test(digits)) { + error = true; + output = output.concat(`\\u`); + c = this.consume(); + continue; + } + + const value = parseInt(digits, 16); + // fromCharCode() takes a UTF-16 value without performing validity checks, + // which is exactly what is needed here – in JSON, code units outside the + // BMP are represented by two Unicode escape sequences. + output = output.concat(String.fromCharCode(value)); + break; + } + default: + error = true; + output = output.concat(`\\${c}`); + break; + } + } else if (c === '\n') { + // Unterminated / multi-line string, unsupported + error = true; + // Prevent unterminated strings from consuming the entire rest of the input + break; + } else { + output = output.concat(c); + } - while (next !== null && /[0-9]/.test(next)) { - consume(); - next = peek(); + c = this.consume(); + } - if (leadingZero) { - error = true; + return { + type: error || c !== '"' ? "error" : "string", + span: { start: this.start_pos, end: this.current_pos, }, + value: output, + }; + } else if (/[0-9-]/.test(c)) { + // Parses number according to ECMA-404 2nd edition (JSON), section 8 “Numbers” + if (c === '-') { + c = this.consume(); + + if (!/[0-9]/.test(c)) { + return this.makeToken("error"); + } } - } + const leadingZero = c === '0'; + let next = this.peek(); + let error = false; - if (next !== null && next === '.') { - consume(); - next = peek(); + while (next !== null && /[0-9]/.test(next)) { + this.consume(); + next = this.peek(); - if (next === null || !/[0-9]/.test(next)) { - return makeToken("error"); + if (leadingZero) { + error = true; + } } - do { - consume(); - next = peek(); - } while (next !== null && /[0-9]/.test(next)); - } - next = peek(); + if (next !== null && next === '.') { + this.consume(); + next = this.peek(); - if (next != null && (next === 'e' || next === 'E')) { - consume(); - next = peek(); + if (next === null || !/[0-9]/.test(next)) { + return this.makeToken("error"); + } - if (next === null) { - return makeToken("error"); + do { + this.consume(); + next = this.peek(); + } while (next !== null && /[0-9]/.test(next)); } - if (next === '+' || next === '-') { - consume(); - next = peek(); - } + next = this.peek(); - while (next !== null && /[0-9]/.test(next)) { - consume(); - next = peek(); + if (next != null && (next === 'e' || next === 'E')) { + this.consume(); + next = this.peek(); + + if (next === null) { + return this.makeToken("error"); + } + + if (next === '+' || next === '-') { + this.consume(); + next = this.peek(); + } + + while (next !== null && /[0-9]/.test(next)) { + this.consume(); + next = this.peek(); + } } + + return this.makeToken(error ? "error" : "number"); } - return makeToken(error ? "error" : "number"); - } + // Consume common characters up to a space for a more useful value in the error token + const common = /[a-zA-Z0-9<>=!~*&|.-]/; + c = this.peek(); + while (c !== null && common.test(c)) { + this.consume(); + c = this.peek(); + } - // Consume common characters up to a space for a more useful value in the error token - const common = /[a-zA-Z0-9<>=!~*&|.-]/; - c = peek(); - while (c !== null && common.test(c)) { - consume(); - c = peek(); + return this.makeToken("error"); } - - return makeToken("error"); } export interface ParseError { @@ -597,24 +605,29 @@ export interface ParseError { message: string; } -export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors: ParseError[] } { - // Mutated by calls to nextToken() - const lexerState: LexerState = { - source: config, - start: 0, - current: 0, +class Parser { + private lexer: Lexer; + + private previous: Token; + private current: Token; - start_pos: { line: 1 }, - current_pos: { line: 1 }, - }; + private readonly rules: AdvancedSkipRule[]; + private readonly errors: ParseError[]; - let previous: Token = null; - let current: Token = nextToken(lexerState); + private erroring: boolean; + private panicMode: boolean; + + public constructor(lexer: Lexer) { + this.lexer = lexer; + this.previous = null; + this.current = lexer.nextToken(); + this.rules = []; + this.errors = []; + this.erroring = false; + this.panicMode = false; + } - const rules: AdvancedSkipRule[] = []; - const errors: ParseError[] = []; - let erroring = false; - let panicMode = false; + // Helper functions /** * Adds an error message. The current skip rule will be marked as erroring. @@ -624,13 +637,13 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * @param panic if true, all further errors will be silenced * until panic mode is disabled again */ - function errorAt(span: Span, message: string, panic: boolean) { - if (!panicMode) { - errors.push({span, message,}); + private errorAt(span: Span, message: string, panic: boolean) { + if (!this.panicMode) { + this.errors.push({span, message,}); } - panicMode ||= panic; - erroring = true; + this.panicMode ||= panic; + this.erroring = true; } /** @@ -641,8 +654,8 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * @param panic if true, all further errors will be silenced * until panic mode is disabled again */ - function error(message: string, panic: boolean) { - errorAt(previous.span, message, panic); + private error(message: string, panic: boolean) { + this.errorAt(this.previous.span, message, panic); } /** @@ -653,8 +666,8 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * @param panic if true, all further errors will be silenced * until panic mode is disabled again */ - function errorAtCurrent(message: string, panic: boolean) { - errorAt(current.span, message, panic); + private errorAtCurrent(message: string, panic: boolean) { + this.errorAt(this.current.span, message, panic); } /** @@ -663,12 +676,12 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * * If a token of type error is found, issues an error message. */ - function consume() { - previous = current; + private consume() { + this.previous = this.current; // Intentionally ignoring `error` tokens here; - // by handling those in later functions with more context (match(), expect(), ...), + // by handling those in later privates with more context (match(), expect(), ...), // the user gets better errors - current = nextToken(lexerState); + this.current = this.lexer.nextToken(); } /** @@ -677,9 +690,9 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * @param expected the set of expected token types * @return whether the actual current token matches any expected token type */ - function match(expected: readonly TokenType[]): boolean { - if (expected.includes(current.type)) { - consume(); + private match(expected: readonly TokenType[]): boolean { + if (expected.includes(this.current.type)) { + this.consume(); return true; } else { return false; @@ -696,9 +709,9 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * @param panic if true, all further errors will be silenced * until panic mode is disabled again */ - function expect(expected: readonly TokenType[], message: string, panic: boolean) { - if (!match(expected)) { - errorAtCurrent(message.concat(current.type === "error" ? `, got: ${JSON.stringify(current.value)}` : `, got: \`${current.type}\``), panic); + private expect(expected: readonly TokenType[], message: string, panic: boolean) { + if (!this.match(expected)) { + this.errorAtCurrent(message.concat(this.current.type === "error" ? `, got: ${JSON.stringify(this.current.value)}` : `, got: \`${this.current.type}\``), panic); } } @@ -706,56 +719,64 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors * Synchronize with the next rule block and disable panic mode. * Skips all tokens until the if keyword is found. */ - function synchronize() { - panicMode = false; + private synchronize() { + this.panicMode = false; - while (!isEof()) { - if (current.type === "if") { + while (!this.isEof()) { + if (this.current.type === "if") { return; } - consume(); + this.consume(); } } /** * @return whether the parser has reached the end of input */ - function isEof(): boolean { - return current.type === "eof"; + private isEof(): boolean { + return this.current.type === "eof"; } - while (!isEof()) { - erroring = false; - const rule = parseRule(); + // Parsing functions - if (!erroring) { - rules.push(rule); - } + /** + * Parse the config. Should only ever be called once on a given + * Parser instance. + */ + public parse(): { rules: AdvancedSkipRule[]; errors: ParseError[] } { + while (!this.isEof()) { + this.erroring = false; + const rule = this.parseRule(); - if (panicMode) { - synchronize(); + if (!this.erroring) { + this.rules.push(rule); + } + + if (this.panicMode) { + this.synchronize(); + } } - } - return { rules, errors, }; + return { rules: this.rules, errors: this.errors, }; + } - function parseRule(): AdvancedSkipRule { + private parseRule(): AdvancedSkipRule { const rule: AdvancedSkipRule = { predicate: null, skipOption: null, comments: [], }; - while (match(["comment"])) { - rule.comments.push(previous.value.trim()); + while (this.match(["comment"])) { + rule.comments.push(this.previous.value.trim()); } - expect(["if"], rule.comments.length !== 0 ? "expected `if` after `comment`" : "expected `if`", true); - rule.predicate = parsePredicate(); + this.expect(["if"], rule.comments.length !== 0 ? "expected `if` after `comment`" : "expected `if`", true); + rule.predicate = this.parsePredicate(); - expect(["disabled", "show overlay", "manual skip", "auto skip"], "expected skip option after condition", true); - switch (previous.type) { + this.expect(["disabled", "show overlay", "manual skip", "auto skip"], "expected skip option after condition", true); + switch (this.previous.type) { case "disabled": rule.skipOption = CategorySkipOption.Disabled; break; @@ -769,21 +790,21 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors rule.skipOption = CategorySkipOption.AutoSkip; break; default: - // Ignore, should have already errored + // Ignore, should have already errored } return rule; } - function parsePredicate(): AdvancedSkipPredicate { - return parseOr(); + private parsePredicate(): AdvancedSkipPredicate { + return this.parseOr(); } - function parseOr(): AdvancedSkipPredicate { - let left = parseAnd(); + private parseOr(): AdvancedSkipPredicate { + let left = this.parseAnd(); - while (match(["or"])) { - const right = parseAnd(); + while (this.match(["or"])) { + const right = this.parseAnd(); left = { kind: "operator", @@ -795,11 +816,11 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors return left; } - function parseAnd(): AdvancedSkipPredicate { - let left = parsePrimary(); + private parseAnd(): AdvancedSkipPredicate { + let left = this.parsePrimary(); - while (match(["and"])) { - const right = parsePrimary(); + while (this.match(["and"])) { + const right = this.parsePrimary(); left = { kind: "operator", @@ -811,51 +832,51 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors return left; } - function parsePrimary(): AdvancedSkipPredicate { - if (match(["("])) { - const predicate = parsePredicate(); - expect([")"], "expected `)` after condition", true); + private parsePrimary(): AdvancedSkipPredicate { + if (this.match(["("])) { + const predicate = this.parsePredicate(); + this.expect([")"], "expected `)` after condition", true); return predicate; } else { - return parseCheck(); + return this.parseCheck(); } } - function parseCheck(): AdvancedSkipCheck { - expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \`${previous.type}\``, true); + private parseCheck(): AdvancedSkipCheck { + this.expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \`${this.previous.type}\``, true); - if (erroring) { + if (this.erroring) { return null; } - const attribute = previous.type as SkipRuleAttribute; - expect(SKIP_RULE_OPERATORS, `expected operator after \`${attribute}\``, true); + const attribute = this.previous.type as SkipRuleAttribute; + this.expect(SKIP_RULE_OPERATORS, `expected operator after \`${attribute}\``, true); - if (erroring) { + if (this.erroring) { return null; } - const operator = previous.type as SkipRuleOperator; - expect(["string", "number"], `expected string or number after \`${operator}\``, true); + const operator = this.previous.type as SkipRuleOperator; + this.expect(["string", "number"], `expected string or number after \`${operator}\``, true); - if (erroring) { + if (this.erroring) { return null; } - const value = previous.type === "number" ? Number(previous.value) : previous.value; + const value = this.previous.type === "number" ? Number(this.previous.value) : this.previous.value; if ([SkipRuleOperator.Equal, SkipRuleOperator.NotEqual].includes(operator)) { if (attribute === SkipRuleAttribute.Category && !CompileConfig.categoryList.includes(value as string)) { - error(`unknown category: \`${value}\``, false); + this.error(`unknown category: \`${value}\``, false); return null; } else if (attribute === SkipRuleAttribute.ActionType && !ActionTypes.includes(value as ActionType)) { - error(`unknown action type: \`${value}\``, false); + this.error(`unknown action type: \`${value}\``, false); return null; } else if (attribute === SkipRuleAttribute.Source && !["local", "youtube", "autogenerated", "server"].includes(value as string)) { - error(`unknown chapter source: \`${value}\``, false); + this.error(`unknown chapter source: \`${value}\``, false); return null; } } @@ -866,3 +887,8 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors }; } } + +export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors: ParseError[] } { + const parser = new Parser(new Lexer(config)); + return parser.parse(); +} From 0eb222ae05c8fb9e18275baabc7456fc18ebd6e9 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Sat, 27 Sep 2025 21:30:50 +0200 Subject: [PATCH 14/16] Minor improvements --- src/utils/skipRule.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index 5bdd9774d6..a560fbb666 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -44,6 +44,9 @@ export enum SkipRuleOperator { const SKIP_RULE_ATTRIBUTES = Object.values(SkipRuleAttribute); const SKIP_RULE_OPERATORS = Object.values(SkipRuleOperator); +const WORD_EXTRA_CHARACTER = /[a-zA-Z0-9.]/; +const OPERATOR_EXTRA_CHARACTER = /[<>=!~*&|-]/; +const ANY_EXTRA_CHARACTER = /[a-zA-Z0-9<>=!~*&|.-]/; export interface AdvancedSkipCheck { kind: "check"; @@ -436,7 +439,7 @@ class Lexer { } if (type !== null) { - const more = kind == "operator" ? /[<>=!~*&|-]/ : kind == "word" ? /[a-zA-Z0-9.]/ : /[a-zA-Z0-9<>=!~*&|.-]/; + const more = kind == "operator" ? OPERATOR_EXTRA_CHARACTER : kind == "word" ? WORD_EXTRA_CHARACTER : ANY_EXTRA_CHARACTER; let c = this.peek(); let error = false; @@ -589,7 +592,7 @@ class Lexer { } // Consume common characters up to a space for a more useful value in the error token - const common = /[a-zA-Z0-9<>=!~*&|.-]/; + const common = ANY_EXTRA_CHARACTER; c = this.peek(); while (c !== null && common.test(c)) { this.consume(); @@ -749,7 +752,7 @@ class Parser { this.erroring = false; const rule = this.parseRule(); - if (!this.erroring) { + if (!this.erroring && rule) { this.rules.push(rule); } @@ -761,7 +764,7 @@ class Parser { return { rules: this.rules, errors: this.errors, }; } - private parseRule(): AdvancedSkipRule { + private parseRule(): AdvancedSkipRule | null { const rule: AdvancedSkipRule = { predicate: null, skipOption: null, @@ -796,11 +799,11 @@ class Parser { return rule; } - private parsePredicate(): AdvancedSkipPredicate { + private parsePredicate(): AdvancedSkipPredicate | null { return this.parseOr(); } - private parseOr(): AdvancedSkipPredicate { + private parseOr(): AdvancedSkipPredicate | null { let left = this.parseAnd(); while (this.match(["or"])) { @@ -816,7 +819,7 @@ class Parser { return left; } - private parseAnd(): AdvancedSkipPredicate { + private parseAnd(): AdvancedSkipPredicate | null { let left = this.parsePrimary(); while (this.match(["and"])) { @@ -832,7 +835,7 @@ class Parser { return left; } - private parsePrimary(): AdvancedSkipPredicate { + private parsePrimary(): AdvancedSkipPredicate | null { if (this.match(["("])) { const predicate = this.parsePredicate(); this.expect([")"], "expected `)` after condition", true); @@ -842,7 +845,7 @@ class Parser { } } - private parseCheck(): AdvancedSkipCheck { + private parseCheck(): AdvancedSkipCheck | null { this.expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \`${this.previous.type}\``, true); if (this.erroring) { From 510029af4093e59f17baa0bf7789e790acd14ebe Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Sat, 27 Sep 2025 14:29:30 +0200 Subject: [PATCH 15/16] Implement `not` operator --- src/utils/skipRule.ts | 52 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index a560fbb666..a43e4633fc 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -44,6 +44,20 @@ export enum SkipRuleOperator { const SKIP_RULE_ATTRIBUTES = Object.values(SkipRuleAttribute); const SKIP_RULE_OPERATORS = Object.values(SkipRuleOperator); +const INVERTED_SKIP_RULE_OPERATORS = { + "<=": SkipRuleOperator.Greater, + "<": SkipRuleOperator.GreaterOrEqual, + ">=": SkipRuleOperator.Less, + ">": SkipRuleOperator.LessOrEqual, + "!=": SkipRuleOperator.Equal, + "==": SkipRuleOperator.NotEqual, + "!*=": SkipRuleOperator.Contains, + "*=": SkipRuleOperator.NotContains, + "!~=": SkipRuleOperator.Regex, + "~=": SkipRuleOperator.NotRegex, + "!~i=": SkipRuleOperator.RegexIgnoreCase, + "~i=": SkipRuleOperator.NotRegexIgnoreCase, +}; const WORD_EXTRA_CHARACTER = /[a-zA-Z0-9.]/; const OPERATOR_EXTRA_CHARACTER = /[<>=!~*&|-]/; const ANY_EXTRA_CHARACTER = /[a-zA-Z0-9<>=!~*&|.-]/; @@ -225,7 +239,7 @@ type TokenType = | "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option | `${SkipRuleAttribute}` // Segment attributes | `${SkipRuleOperator}` // Segment attribute operators - | "and" | "or" // Expression operators + | "and" | "or" | "not" // Expression operators | "(" | ")" | "comment" // Syntax | "string" | "number" // Literal values | "eof" | "error"; // Sentinel and special tokens @@ -396,7 +410,7 @@ class Lexer { } const keyword = this.expectKeyword([ - "if", "and", "or", + "if", "and", "or", "not", "(", ")", "//", ].concat(SKIP_RULE_ATTRIBUTES) @@ -415,7 +429,8 @@ class Lexer { switch (keyword) { case "if": // Fallthrough case "and": // Fallthrough - case "or": kind = "word"; type = keyword as TokenType; break; + case "or": // Fallthrough + case "not": kind = "word"; type = keyword as TokenType; break; case "(": return this.makeToken("("); case ")": return this.makeToken(")"); @@ -820,10 +835,10 @@ class Parser { } private parseAnd(): AdvancedSkipPredicate | null { - let left = this.parsePrimary(); + let left = this.parseUnary(); while (this.match(["and"])) { - const right = this.parsePrimary(); + const right = this.parseUnary(); left = { kind: "operator", @@ -835,6 +850,33 @@ class Parser { return left; } + private static invertPredicate(predicate: AdvancedSkipPredicate): AdvancedSkipPredicate { + if (predicate.kind === "check") { + return { + ...predicate, + operator: INVERTED_SKIP_RULE_OPERATORS[predicate.operator], + }; + } else { + // not (a and b) == (not a or not b) + // not (a or b) == (not a and not b) + return { + kind: "operator", + operator: predicate.operator === "and" ? PredicateOperator.Or : PredicateOperator.And, + left: predicate.left ? Parser.invertPredicate(predicate.left) : null, + right: predicate.right ? Parser.invertPredicate(predicate.right) : null, + }; + } + } + + private parseUnary(): AdvancedSkipPredicate | null { + if (this.match(["not"])) { + const predicate = this.parseUnary(); + return predicate ? Parser.invertPredicate(predicate) : null; + } + + return this.parsePrimary(); + } + private parsePrimary(): AdvancedSkipPredicate | null { if (this.match(["("])) { const predicate = this.parsePredicate(); From 479c31d57a7463b08a52e2c3814d816cced5ad66 Mon Sep 17 00:00:00 2001 From: mschae23 <46165762+mschae23@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:00:44 +0200 Subject: [PATCH 16/16] Add `not` operator support in configToText --- .../options/AdvancedSkipOptionsComponent.tsx | 53 +--------- src/utils/skipRule.ts | 99 +++++++++++++++---- 2 files changed, 81 insertions(+), 71 deletions(-) diff --git a/src/components/options/AdvancedSkipOptionsComponent.tsx b/src/components/options/AdvancedSkipOptionsComponent.tsx index 0975e9aa54..f382314f58 100644 --- a/src/components/options/AdvancedSkipOptionsComponent.tsx +++ b/src/components/options/AdvancedSkipOptionsComponent.tsx @@ -1,8 +1,7 @@ import * as React from "react"; import Config from "../../config"; -import {AdvancedSkipPredicate, AdvancedSkipRule, parseConfig, PredicateOperator,} from "../../utils/skipRule"; -import {CategorySkipOption} from "../../types"; +import { AdvancedSkipRule, configToText, parseConfig, } from "../../utils/skipRule"; let configSaveTimeout: NodeJS.Timeout | null = null; @@ -76,53 +75,3 @@ function compileConfig(config: string): AdvancedSkipRule[] | null { return null; } } - -function configToText(config: AdvancedSkipRule[]): string { - let result = ""; - - for (const rule of config) { - for (const comment of rule.comments) { - result += "// " + comment + "\n"; - } - - result += "if "; - result += predicateToText(rule.predicate, null); - - switch (rule.skipOption) { - case CategorySkipOption.Disabled: - result += "\nDisabled"; - break; - case CategorySkipOption.ShowOverlay: - result += "\nShow Overlay"; - break; - case CategorySkipOption.ManualSkip: - result += "\nManual Skip"; - break; - case CategorySkipOption.AutoSkip: - result += "\nAuto Skip"; - break; - default: - return null; // Invalid skip option - } - - result += "\n\n"; - } - - return result.trim(); -} - -function predicateToText(predicate: AdvancedSkipPredicate, outerPrecedence: PredicateOperator | null): string { - if (predicate.kind === "check") { - return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`; - } else { - let text: string; - - if (predicate.operator === PredicateOperator.And) { - text = `${predicateToText(predicate.left, PredicateOperator.And)} and ${predicateToText(predicate.right, PredicateOperator.And)}`; - } else { // Or - text = `${predicateToText(predicate.left, PredicateOperator.Or)} or ${predicateToText(predicate.right, PredicateOperator.Or)}`; - } - - return outerPrecedence !== null && outerPrecedence !== predicate.operator ? `(${text})` : text; - } -} diff --git a/src/utils/skipRule.ts b/src/utils/skipRule.ts index a43e4633fc..b94961ddb2 100644 --- a/src/utils/skipRule.ts +++ b/src/utils/skipRule.ts @@ -10,6 +10,9 @@ export interface Permission { canSubmit: boolean; } +// Note that attributes that are prefixes of other attributes (like `time.start`) need to be ordered *after* +// the longer attributes, because these are matched sequentially. Using the longer attribute would otherwise result +// in an error token. export enum SkipRuleAttribute { StartTimePercent = "time.startPercent", StartTime = "time.start", @@ -27,6 +30,9 @@ export enum SkipRuleAttribute { Title = "video.title" } +// Note that operators that are prefixes of other attributes (like `<`) need to be ordered *after* the longer +// operators, because these are matched sequentially. Using the longer operator would otherwise result +// in an error token. export enum SkipRuleOperator { LessOrEqual = "<=", Less = "<", @@ -79,6 +85,7 @@ export interface AdvancedSkipOperator { operator: PredicateOperator; left: AdvancedSkipPredicate; right: AdvancedSkipPredicate; + displayInverted?: boolean; } export type AdvancedSkipPredicate = AdvancedSkipCheck | AdvancedSkipOperator; @@ -850,28 +857,10 @@ class Parser { return left; } - private static invertPredicate(predicate: AdvancedSkipPredicate): AdvancedSkipPredicate { - if (predicate.kind === "check") { - return { - ...predicate, - operator: INVERTED_SKIP_RULE_OPERATORS[predicate.operator], - }; - } else { - // not (a and b) == (not a or not b) - // not (a or b) == (not a and not b) - return { - kind: "operator", - operator: predicate.operator === "and" ? PredicateOperator.Or : PredicateOperator.And, - left: predicate.left ? Parser.invertPredicate(predicate.left) : null, - right: predicate.right ? Parser.invertPredicate(predicate.right) : null, - }; - } - } - private parseUnary(): AdvancedSkipPredicate | null { if (this.match(["not"])) { const predicate = this.parseUnary(); - return predicate ? Parser.invertPredicate(predicate) : null; + return predicate ? invertPredicate(predicate) : null; } return this.parsePrimary(); @@ -937,3 +926,75 @@ export function parseConfig(config: string): { rules: AdvancedSkipRule[]; errors const parser = new Parser(new Lexer(config)); return parser.parse(); } + +export function configToText(config: AdvancedSkipRule[]): string { + let result = ""; + + for (const rule of config) { + for (const comment of rule.comments) { + result += "// " + comment + "\n"; + } + + result += "if "; + result += predicateToText(rule.predicate, null); + + switch (rule.skipOption) { + case CategorySkipOption.Disabled: + result += "\nDisabled"; + break; + case CategorySkipOption.ShowOverlay: + result += "\nShow Overlay"; + break; + case CategorySkipOption.ManualSkip: + result += "\nManual Skip"; + break; + case CategorySkipOption.AutoSkip: + result += "\nAuto Skip"; + break; + default: + return null; // Invalid skip option + } + + result += "\n\n"; + } + + return result.trim(); +} + +function predicateToText(predicate: AdvancedSkipPredicate, outerPrecedence: "or" | "and" | "not" | null): string { + if (predicate.kind === "check") { + return `${predicate.attribute} ${predicate.operator} ${JSON.stringify(predicate.value)}`; + } else if (predicate.displayInverted) { + // Should always be fine, considering `not` has the highest precedence + return `not ${predicateToText(invertPredicate(predicate), "not")}`; + } else { + let text: string; + + if (predicate.operator === PredicateOperator.And) { + text = `${predicateToText(predicate.left, "and")} and ${predicateToText(predicate.right, "and")}`; + } else { // Or + text = `${predicateToText(predicate.left, "or")} or ${predicateToText(predicate.right, "or")}`; + } + + return outerPrecedence !== null && outerPrecedence !== predicate.operator ? `(${text})` : text; + } +} + +function invertPredicate(predicate: AdvancedSkipPredicate): AdvancedSkipPredicate { + if (predicate.kind === "check") { + return { + ...predicate, + operator: INVERTED_SKIP_RULE_OPERATORS[predicate.operator], + }; + } else { + // not (a and b) == (not a or not b) + // not (a or b) == (not a and not b) + return { + kind: "operator", + operator: predicate.operator === "and" ? PredicateOperator.Or : PredicateOperator.And, + left: predicate.left ? invertPredicate(predicate.left) : null, + right: predicate.right ? invertPredicate(predicate.right) : null, + displayInverted: !predicate.displayInverted, + }; + } +}