Implement parser and configToText, remove old parser

This commit is contained in:
mschae23
2025-09-23 20:03:33 +02:00
parent 2004f6bf1b
commit 2a2d9de817
3 changed files with 471 additions and 391 deletions

View File

@@ -1,9 +1,8 @@
import * as React from "react"; import * as React from "react";
import * as CompileConfig from "../../../config.json";
import Config from "../../config"; import Config from "../../config";
import {AdvancedSkipRuleSet, compileConfigNew, SkipRuleAttribute, SkipRuleOperator} from "../../utils/skipRule"; import {AdvancedSkipPredicate, AdvancedSkipRule, parseConfig, PredicateOperator,} from "../../utils/skipRule";
import { ActionType, ActionTypes, CategorySkipOption } from "../../types"; import {CategorySkipOption} from "../../types";
let configSaveTimeout: NodeJS.Timeout | null = null; let configSaveTimeout: NodeJS.Timeout | null = null;
@@ -64,206 +63,43 @@ export function AdvancedSkipOptionsComponent() {
); );
} }
function compileConfig(config: string): AdvancedSkipRuleSet[] | null { function compileConfig(config: string): AdvancedSkipRule[] | null {
// Debug const { rules, errors } = parseConfig(config);
compileConfigNew(config);
const ruleSets: AdvancedSkipRuleSet[] = []; for (const error of errors) {
console.log(`Error on line ${error.span.start.line}: ${error.message}`);
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 (errors.length === 0) {
if (comment) { return rules;
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 { } 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
}
}
}
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
}
} else {
const numValue = Number(value);
if (!isNaN(numValue)) {
return numValue;
}
return null; return null;
} }
} }
function configToText(config: AdvancedSkipRuleSet[]): string { function configToText(config: AdvancedSkipRule[]): string {
let result = ""; let result = "";
for (const ruleSet of config) { for (const rule of config) {
if (ruleSet.comment) { for (const comment of rule.comments) {
result += "// " + ruleSet.comment + "\n"; result += "// " + comment + "\n";
} }
result += "if "; result += "if ";
let firstRule = true; result += predicateToText(rule.predicate, PredicateOperator.Or);
for (const rule of ruleSet.rules) {
if (!firstRule) {
result += " and ";
}
result += `${rule.attribute} ${rule.operator} ${JSON.stringify(rule.value)}`; switch (rule.skipOption) {
firstRule = false;
}
switch (ruleSet.skipOption) {
case CategorySkipOption.Disabled: case CategorySkipOption.Disabled:
result += "\nDisabled"; result += "\nDisabled";
break; break;
case CategorySkipOption.ShowOverlay: case CategorySkipOption.ShowOverlay:
result += "\nShow Overlay"; result += "\nShow overlay";
break; break;
case CategorySkipOption.ManualSkip: case CategorySkipOption.ManualSkip:
result += "\nManual Skip"; result += "\nManual skip";
break; break;
case CategorySkipOption.AutoSkip: case CategorySkipOption.AutoSkip:
result += "\nAuto Skip"; result += "\nAuto skip";
break; break;
default: default:
return null; // Invalid skip option return null; // Invalid skip option
@@ -274,3 +110,16 @@ function configToText(config: AdvancedSkipRuleSet[]): string {
return result.trim(); 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;
}
}
}

View File

@@ -3,7 +3,7 @@ import * as invidiousList from "../ci/invidiouslist.json";
import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorTime, VideoID, SponsorHideType } from "./types"; import { Category, CategorySelection, CategorySkipOption, NoticeVisibilityMode, PreviewBarOption, SponsorTime, VideoID, SponsorHideType } from "./types";
import { Keybind, ProtoConfig, keybindEquals } from "../maze-utils/src/config"; import { Keybind, ProtoConfig, keybindEquals } from "../maze-utils/src/config";
import { HashedValue } from "../maze-utils/src/hash"; import { HashedValue } from "../maze-utils/src/hash";
import { Permission, AdvancedSkipRuleSet } from "./utils/skipRule"; import { Permission, AdvancedSkipRule } from "./utils/skipRule";
interface SBConfig { interface SBConfig {
userID: string; userID: string;
@@ -166,7 +166,7 @@ interface SBStorage {
skipProfileTemp: { time: number; configID: ConfigurationID } | null; skipProfileTemp: { time: number; configID: ConfigurationID } | null;
skipProfiles: Record<ConfigurationID, CustomConfiguration>; skipProfiles: Record<ConfigurationID, CustomConfiguration>;
skipRules: AdvancedSkipRuleSet[]; skipRules: AdvancedSkipRule[];
} }
class ConfigClass extends ProtoConfig<SBConfig, SBStorage> { class ConfigClass extends ProtoConfig<SBConfig, SBStorage> {

View File

@@ -1,9 +1,10 @@
import { getCurrentPageTitle } from "../../maze-utils/src/elements"; import { getCurrentPageTitle } from "../../maze-utils/src/elements";
import { getChannelIDInfo, getVideoDuration } from "../../maze-utils/src/video"; import { getChannelIDInfo, getVideoDuration } from "../../maze-utils/src/video";
import Config from "../config"; 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 { getSkipProfile, getSkipProfileBool } from "./skipProfiles";
import { VideoLabelsCacheData } from "./videoLabels"; import { VideoLabelsCacheData } from "./videoLabels";
import * as CompileConfig from "../../config.json";
export interface Permission { export interface Permission {
canSubmit: boolean; canSubmit: boolean;
@@ -41,23 +42,38 @@ export enum SkipRuleOperator {
NotRegexIgnoreCase = "!~i=" NotRegexIgnoreCase = "!~i="
} }
export interface AdvancedSkipRule { export interface AdvancedSkipCheck {
kind: "check";
attribute: SkipRuleAttribute; attribute: SkipRuleAttribute;
operator: SkipRuleOperator; operator: SkipRuleOperator;
value: string | number; value: string | number;
} }
export interface AdvancedSkipRuleSet { export enum PredicateOperator {
rules: AdvancedSkipRule[]; 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; skipOption: CategorySkipOption;
comment: string; comments: string[];
} }
export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData): CategorySelection { export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData): CategorySelection {
// First check skip rules // First check skip rules
for (const ruleSet of Config.local.skipRules) { for (const rule of Config.local.skipRules) {
if (ruleSet.rules.every((rule) => isSkipRulePassing(segment, rule))) { if (isSkipPredicatePassing(segment, rule.predicate)) {
return { name: segment.category, option: ruleSet.skipOption } as CategorySelection; 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; 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) { switch (rule.attribute) {
case SkipRuleAttribute.StartTime: case SkipRuleAttribute.StartTime:
return (segment as SponsorTime).segment?.[0]; 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 { function isSkipCheckPassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): boolean {
const value = getSkipRuleValue(segment, rule); const value = getSkipCheckValue(segment, rule);
switch (rule.operator) { switch (rule.operator) {
case SkipRuleOperator.Less: 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 { export function getCategoryDefaultSelection(category: string): CategorySelection {
for (const selection of Config.config.categorySelections) { for (const selection of Config.config.categorySelections) {
if (selection.name === category) { if (selection.name === category) {
@@ -188,19 +217,19 @@ export function getCategoryDefaultSelection(category: string): CategorySelection
type TokenType = type TokenType =
| "if" // Keywords | "if" // Keywords
| "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option | "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option
| keyof typeof SkipRuleAttribute // Segment attributes | `${SkipRuleAttribute}` // Segment attributes
| keyof typeof SkipRuleOperator // Segment attribute operators | `${SkipRuleOperator}` // Segment attribute operators
| "and" | "or" // Expression operators | "and" | "or" // Expression operators
| "(" | ")" // Syntax | "(" | ")" | "comment" // Syntax
| "string" | "number" // Literal values | "string" | "number" // Literal values
| "eof" | "error"; // Sentinel and special tokens | "eof" | "error"; // Sentinel and special tokens
interface SourcePos { export interface SourcePos {
line: number; line: number;
// column: number; // column: number;
} }
interface Span { export interface Span {
start: SourcePos; start: SourcePos;
end: SourcePos; end: SourcePos;
} }
@@ -341,7 +370,6 @@ function nextToken(state: LexerState): Token {
state.start_pos = state.current_pos; state.start_pos = state.current_pos;
} }
for (;;) {
skipWhitespace(); skipWhitespace();
resetToCurrent(); resetToCurrent();
@@ -365,37 +393,38 @@ function nextToken(state: LexerState): Token {
case "(": return makeToken("("); case "(": return makeToken("(");
case ")": return makeToken(")"); case ")": return makeToken(")");
case "time.start": return makeToken("StartTime"); case "time.start": return makeToken("time.start");
case "time.end": return makeToken("EndTime"); case "time.end": return makeToken("time.end");
case "time.duration": return makeToken("Duration"); case "time.duration": return makeToken("time.duration");
case "time.startPercent": return makeToken("StartTimePercent"); case "time.startPercent": return makeToken("time.startPercent");
case "time.endPercent": return makeToken("EndTimePercent"); case "time.endPercent": return makeToken("time.endPercent");
case "time.durationPercent": return makeToken("DurationPercent"); case "time.durationPercent": return makeToken("time.durationPercent");
case "category": return makeToken("Category"); case "category": return makeToken("category");
case "actionType": return makeToken("ActionType"); case "actionType": return makeToken("actionType");
case "chapter.name": return makeToken("Description"); case "chapter.name": return makeToken("chapter.name");
case "chapter.source": return makeToken("Source"); case "chapter.source": return makeToken("chapter.source");
case "channel.id": return makeToken("ChannelID"); case "channel.id": return makeToken("channel.id");
case "channel.name": return makeToken("ChannelName"); case "channel.name": return makeToken("channel.name");
case "video.duration": return makeToken("VideoDuration"); case "video.duration": return makeToken("video.duration");
case "video.title": return makeToken("Title"); case "video.title": return makeToken("video.title");
case "<": return makeToken("Less"); case "<": return makeToken("<");
case "<=": return makeToken("LessOrEqual"); case "<=": return makeToken("<=");
case ">": return makeToken("Greater"); case ">": return makeToken(">");
case ">=": return makeToken("GreaterOrEqual"); case ">=": return makeToken(">=");
case "==": return makeToken("Equal"); case "==": return makeToken("==");
case "!=": return makeToken("NotEqual"); case "!=": return makeToken("!=");
case "*=": return makeToken("Contains"); case "*=": return makeToken("*=");
case "!*=": return makeToken("NotContains"); case "!*=": return makeToken("!*=");
case "~=": return makeToken("Regex"); case "~=": return makeToken("~=");
case "~i=": return makeToken("RegexIgnoreCase"); case "~i=": return makeToken("~i=");
case "!~=": return makeToken("NotRegex"); case "!~=": return makeToken("!~=");
case "!~i=": return makeToken("NotRegexIgnoreCase"); case "!~i=": return makeToken("!~i=");
case "//": case "//":
resetToCurrent();
skipLine(); skipLine();
continue; return makeToken("comment");
default: default:
} }
@@ -549,10 +578,14 @@ function nextToken(state: LexerState): Token {
} }
return makeToken("error"); 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() // Mutated by calls to nextToken()
const lexerState: LexerState = { const lexerState: LexerState = {
source: config, source: config,
@@ -563,14 +596,212 @@ export function compileConfigNew(config: string): AdvancedSkipRuleSet[] | null {
current_pos: { line: 1 }, current_pos: { line: 1 },
}; };
let token = nextToken(lexerState); let previous: Token = null;
let current: Token = nextToken(lexerState);
while (token.type !== "eof") { const rules: AdvancedSkipRule[] = [];
console.log(token); const errors: ParseError[] = [];
let erroring = false;
let panicMode = false;
token = nextToken(lexerState); function errorAt(span: Span, message: string, panic: boolean) {
if (!panicMode) {
errors.push({span, message,});
} }
// TODO panicMode ||= panic;
erroring = true;
}
function error(message: string, panic: boolean) {
errorAt(previous.span, message, panic);
}
function errorAtCurrent(message: string, panic: boolean) {
errorAt(current.span, message, panic);
}
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; 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,
};
}
} }