import { getCurrentPageTitle } from "../../maze-utils/src/elements";
import { getChannelIDInfo, getVideoDuration } from "../../maze-utils/src/video";
import Config from "../config";
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;
}
export enum SkipRuleAttribute {
StartTime = "time.start",
EndTime = "time.end",
Duration = "time.duration",
StartTimePercent = "time.startPercent",
EndTimePercent = "time.endPercent",
DurationPercent = "time.durationPercent",
Category = "category",
ActionType = "actionType",
Description = "chapter.name",
Source = "chapter.source",
ChannelID = "channel.id",
ChannelName = "channel.name",
VideoDuration = "video.duration",
Title = "video.title"
}
export enum SkipRuleOperator {
LessOrEqual = "<=",
Less = "<",
GreaterOrEqual = ">=",
Greater = ">",
NotEqual = "!=",
Equal = "==",
NotContains = "!*=",
Contains = "*=",
NotRegex = "!~=",
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;
operator: SkipRuleOperator;
value: string | number;
}
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;
comments: string[];
}
export function getCategorySelection(segment: SponsorTime | VideoLabelsCacheData): CategorySelection {
// First check skip rules
for (const rule of Config.local.skipRules) {
if (isSkipPredicatePassing(segment, rule.predicate)) {
return { name: segment.category, option: rule.skipOption } as CategorySelection;
}
}
// Action type filters
if ("actionType" in segment && (segment as SponsorTime).actionType === "mute" && !getSkipProfileBool("muteSegments")) {
return { name: segment.category, option: CategorySkipOption.Disabled } as CategorySelection;
}
// Then check skip profile
const profile = getSkipProfile();
if (profile) {
const profileSelection = profile.categorySelections.find(selection => selection.name === segment.category);
if (profileSelection) {
return profileSelection;
}
}
// Then fallback to default
for (const selection of Config.config.categorySelections) {
if (selection.name === segment.category) {
return selection;
}
}
return { name: segment.category, option: CategorySkipOption.Disabled} as CategorySelection;
}
function getSkipCheckValue(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): string | number | undefined {
switch (rule.attribute) {
case SkipRuleAttribute.StartTime:
return (segment as SponsorTime).segment?.[0];
case SkipRuleAttribute.EndTime:
return (segment as SponsorTime).segment?.[1];
case SkipRuleAttribute.Duration:
return (segment as SponsorTime).segment?.[1] - (segment as SponsorTime).segment?.[0];
case SkipRuleAttribute.StartTimePercent: {
const startTime = (segment as SponsorTime).segment?.[0];
if (startTime === undefined) return undefined;
return startTime / getVideoDuration() * 100;
}
case SkipRuleAttribute.EndTimePercent: {
const endTime = (segment as SponsorTime).segment?.[1];
if (endTime === undefined) return undefined;
return endTime / getVideoDuration() * 100;
}
case SkipRuleAttribute.DurationPercent: {
const startTime = (segment as SponsorTime).segment?.[0];
const endTime = (segment as SponsorTime).segment?.[1];
if (startTime === undefined || endTime === undefined) return undefined;
return (endTime - startTime) / getVideoDuration() * 100;
}
case SkipRuleAttribute.Category:
return segment.category;
case SkipRuleAttribute.ActionType:
return (segment as SponsorTime).actionType;
case SkipRuleAttribute.Description:
return (segment as SponsorTime).description || "";
case SkipRuleAttribute.Source:
switch ((segment as SponsorTime).source) {
case SponsorSourceType.Local:
return "local";
case SponsorSourceType.YouTube:
return "youtube";
case SponsorSourceType.Autogenerated:
return "autogenerated";
case SponsorSourceType.Server:
return "server";
default:
return undefined;
}
case SkipRuleAttribute.ChannelID:
return getChannelIDInfo().id;
case SkipRuleAttribute.ChannelName:
return getChannelIDInfo().author;
case SkipRuleAttribute.VideoDuration:
return getVideoDuration();
case SkipRuleAttribute.Title:
return getCurrentPageTitle() || "";
default:
return undefined;
}
}
function isSkipCheckPassing(segment: SponsorTime | VideoLabelsCacheData, rule: AdvancedSkipCheck): boolean {
const value = getSkipCheckValue(segment, rule);
switch (rule.operator) {
case SkipRuleOperator.Less:
return typeof value === "number" && value < (rule.value as number);
case SkipRuleOperator.LessOrEqual:
return typeof value === "number" && value <= (rule.value as number);
case SkipRuleOperator.Greater:
return typeof value === "number" && value > (rule.value as number);
case SkipRuleOperator.GreaterOrEqual:
return typeof value === "number" && value >= (rule.value as number);
case SkipRuleOperator.Equal:
return value === rule.value;
case SkipRuleOperator.NotEqual:
return value !== rule.value;
case SkipRuleOperator.Contains:
return String(value).toLocaleLowerCase().includes(String(rule.value).toLocaleLowerCase());
case SkipRuleOperator.NotContains:
return !String(value).toLocaleLowerCase().includes(String(rule.value).toLocaleLowerCase());
case SkipRuleOperator.Regex:
return new RegExp(rule.value as string).test(String(value));
case SkipRuleOperator.RegexIgnoreCase:
return new RegExp(rule.value as string, "i").test(String(value));
case SkipRuleOperator.NotRegex:
return !new RegExp(rule.value as string).test(String(value));
case SkipRuleOperator.NotRegexIgnoreCase:
return !new RegExp(rule.value as string, "i").test(String(value));
default:
return false;
}
}
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) {
return selection;
}
}
return { name: category, option: CategorySkipOption.Disabled} as CategorySelection;
}
type TokenType =
| "if" // Keywords
| "disabled" | "show overlay" | "manual skip" | "auto skip" // Skip option
| `${SkipRuleAttribute}` // Segment attributes
| `${SkipRuleOperator}` // Segment attribute operators
| "and" | "or" // Expression operators
| "(" | ")" | "comment" // Syntax
| "string" | "number" // Literal values
| "eof" | "error"; // Sentinel and special tokens
export interface SourcePos {
line: number;
// column: number;
}
export 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 null.
*
* @return current UTF-16 value, or 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.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 */ };
}
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 null.
*
* @return current UTF-16 value, or 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 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 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 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;
}
}
return null;
}
/**
* 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 "\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();
}
}
/**
* @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;
}
skipWhitespace();
resetToCurrent();
if (isEof()) {
return makeToken("eof");
}
const keyword = expectKeyword([
"if", "and", "or",
"(", ")",
"//",
].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");
case "or": return makeToken("or");
case "(": return makeToken("(");
case ")": return makeToken(")");
case "//":
resetToCurrent();
skipLine();
return makeToken("comment");
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:
}
}
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);
}
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");
}
// 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");
}
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,
start: 0,
current: 0,
start_pos: { line: 1 },
current_pos: { line: 1 },
};
let previous: Token = null;
let current: Token = nextToken(lexerState);
const rules: AdvancedSkipRule[] = [];
const errors: ParseError[] = [];
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,});
}
panicMode ||= panic;
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;
// Intentionally ignoring `error` tokens here;
// by handling those in later functions with more context (match(), expect(), ...),
// the user gets better errors
current = nextToken(lexerState);
}
/**
* 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();
return true;
} else {
return false;
}
}
/**
* 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 === "error" ? current.value : 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;
while (!isEof()) {
if (current.type === "if") {
return;
}
consume();
}
}
/**
* @return whether the parser has reached the end of input
*/
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"], 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 condition", 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 condition", true);
return predicate;
} else {
return parseCheck();
}
}
function parseCheck(): AdvancedSkipCheck {
expect(SKIP_RULE_ATTRIBUTES, `expected attribute after \`${previous.type}\``, true);
if (erroring) {
return null;
}
const attribute = previous.type as SkipRuleAttribute;
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);
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,
};
}
}