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,
+ };
+ }
+}