~cadence/cloudtube

db7ccabb3bfa7723d41f78b097b742be1f92e3ae — Cadence Ember 6 months ago aa953dc
Implement video filters
A api/filters.js => api/filters.js +160 -0
@@ 0,0 1,160 @@
const constants = require("../utils/constants")
const db = require("../utils/db")
const {render} = require("pinski/plugins")
const {getUser, getToken} = require("../utils/getuser")
const validate = require("../utils/validate")
const V = validate.V
const {Matcher, PatternCompileError} = require("../utils/matcher")

const filterMaxLength = 160
const regexpEnabledText = constants.server_setup.allow_regexp_filters ? "" : "not"

function getCategories(req) {
	const user = getUser(req)
	const filters = user.getFilters()

	// Sort filters into categories for display. Titles are already sorted.
	const categories = {
		title: {name: "Title", filters: []},
		channel: {name: "Channel", filters: []}
	}
	for (const filter of filters) {
		if (filter.type === "title") {
			categories.title.filters.push(filter)
		} else { // filter.type is some kind of channel
			categories.channel.filters.push(filter)
		}
	}
	categories.channel.filters.sort((a, b) => {
		if (a.label && b.label) {
			if (a.label < b.label) return -1
			else if (a.label > b.label) return 1
		}
		return 0
	})

	return categories
}

module.exports = [
	{
		route: "/filters", methods: ["GET"], code: async ({req, url}) => {
			const categories = getCategories(req)
			let referrer = url.searchParams.get("referrer") || null

			let type = null
			let contents = ""
			let label = null
			if (url.searchParams.has("title")) {
				type = "title"
				contents = url.searchParams.get("title")
			} else if (url.searchParams.has("channel-id")) {
				type = "channel-id"
				contents = url.searchParams.get("channel-id")
				label = url.searchParams.get("label")
			}

			return render(200, "pug/filters.pug", {categories, type, contents, label, referrer, filterMaxLength, regexpEnabledText})
		}
	},
	{
		route: "/filters", methods: ["POST"], upload: true, code: async ({req, body}) => {
			return new V()
				.with(validate.presetLoad({body}))
				.with(validate.presetURLParamsBody())
				.with(validate.presetEnsureParams(["filter-type", "new-filter"]))
				.check(state => {
					// Extract fields
					state.type = state.params.get("filter-type")
					state.contents = state.params.get("new-filter").slice(0, filterMaxLength)
					state.label = state.params.get("label")
					if (state.label) {
						state.label = state.label.slice(0, filterMaxLength)
					} else {
						state.label = null
					}
					state.referrer = state.params.get("referrer")
					// Check type
					return ["title", "channel-name", "channel-id"].includes(state.type)
				}, () => ({
					statusCode: 400,
					contentType: "application/json",
					content: {
						error: "type parameter is not in the list of filter types."
					}
				}))
				.check(state => {
					// If title, check that pattern compiles
					if (state.type === "title") {
						try {
							const matcher = new Matcher(state.contents)
							matcher.compilePattern()
						} catch (e) {
							if (e instanceof PatternCompileError) {
								state.compileError = e
								return false
							}
							throw e
						}
					}
					return true
				}, state => {
					const {type, contents, label, compileError} = state
					const categories = getCategories(req)
					return render(400, "pug/filters.pug", {categories, type, contents, label, compileError, filterMaxLength, regexpEnabledText})
				})
				.last(state => {
					const {type, contents, label} = state
					const responseHeaders = {
						Location: state.referrer || "/filters"
					}
					const token = getToken(req, responseHeaders)

					db.prepare("INSERT INTO Filters (token, type, data, label) VALUES (?, ?, ?, ?)").run(token, type, contents, label)

					return {
						statusCode: 303,
						headers: responseHeaders,
						contentType: "text/html",
						content: "Redirecting..."
					}
				})
				.go()
			}
		},
		{
			route: "/filters/delete", methods: ["POST"], upload: true, code: async ({req, body}) => {
				return new V()
				.with(validate.presetLoad({body}))
				.with(validate.presetURLParamsBody())
				.with(validate.presetEnsureParams(["delete-id"]))
				.check(state => {
					state.deleteID = +state.params.get("delete-id")
					return !!state.deleteID
				}, () => ({
					statusCode: 400,
					contentType: "application/json",
					content: {
						error: "delete-id parameter must be a number"
					}
				}))
				.last(state => {
					const {deleteID} = state
					const token = getToken(req)

					// the IDs are unique, but can likely be guessed, so also use the token for actual authentication
					db.prepare("DELETE FROM Filters WHERE token = ? and id = ?").run(token, deleteID)

					return {
						statusCode: 303,
						headers: {
							Location: "/filters"
						},
						contentType: "text/html",
						content: "Redirecting..."
					}
				})
				.go()
		}
	}
]

M api/search.js => api/search.js +9 -2
@@ 7,10 7,14 @@ module.exports = [
	{
		route: "/(?:search|results)", methods: ["GET"], code: async ({req, url}) => {
			const query = url.searchParams.get("q") || url.searchParams.get("search_query")
			const instanceOrigin = getUser(req).getSettingsOrDefaults().instance
			const user = getUser(req)
			const settings = user.getSettingsOrDefaults()
			const instanceOrigin = settings.instance

			const fetchURL = new URL(`${instanceOrigin}/api/v1/search`)
			fetchURL.searchParams.set("q", query)
			const results = await request(fetchURL.toString()).then(res => res.json())

			let results = await request(fetchURL.toString()).then(res => res.json())
			const error = results.error || results.message || results.code

			if (error) throw new Error(`Instance said: ${error}`)


@@ 19,6 23,9 @@ module.exports = [
				converters.normaliseVideoInfo(video)
			}

			const filters = user.getFilters()
			results = converters.applyVideoFilters(results, filters).videos

			return render(200, "pug/search.pug", {query, results, instanceOrigin})
		}
	}

M api/subscriptions.js => api/subscriptions.js +5 -4
@@ 1,13 1,12 @@
const {render} = require("pinski/plugins")
const db = require("../utils/db")
const {fetchChannelLatest} = require("../utils/youtube")
const {getUser} = require("../utils/getuser")
const {timeToPastText, rewriteVideoDescription} = require("../utils/converters")
const {timeToPastText, rewriteVideoDescription, applyVideoFilters} = require("../utils/converters")
const {refresher} = require("../background/feed-update")

module.exports = [
	{
		route: `/subscriptions`, methods: ["GET"], code: async ({req}) => {
		route: `/subscriptions`, methods: ["GET"], code: async ({req, url}) => {
			const user = getUser(req)
			let hasSubscriptions = false
			let videos = []


@@ 36,10 35,12 @@ module.exports = [
							return video
						})
				}
				const filters = user.getFilters()
				;({videos} = applyVideoFilters(videos, filters))
			}
			const settings = user.getSettingsOrDefaults()
			const instanceOrigin = settings.instance
			return render(200, "pug/subscriptions.pug", {settings, hasSubscriptions, videos, channels, refreshed, timeToPastText, instanceOrigin})
			return render(200, "pug/subscriptions.pug", {url, settings, hasSubscriptions, videos, channels, refreshed, timeToPastText, instanceOrigin})
		}
	}
]

M api/video.js => api/video.js +4 -0
@@ 145,6 145,10 @@ module.exports = [
					converters.normaliseVideoInfo(rec)
				}

				// filter list
				const {videos, filteredCount} = converters.applyVideoFilters(video.recommendedVideos, user.getFilters())
				video.recommendedVideos = videos

				// get subscription data
				const subscribed = user.isSubscribed(video.authorId)


A html/static/js/filters.js => html/static/js/filters.js +16 -0
@@ 0,0 1,16 @@
import {ElemJS, q} from "./elemjs/elemjs.js"

class FilterType extends ElemJS {
	constructor(element) {
		super(element)
		this.notice = q("#title-pattern-matching")
		this.on("input", this.updateNotice.bind(this))
		this.updateNotice()
	}

	updateNotice() {
		this.notice.style.display = this.element.value !== "title" ? "none" : ""
	}
}

new FilterType(q("#filter-type"))

A pug/filters.pug => pug/filters.pug +77 -0
@@ 0,0 1,77 @@
extends includes/layout

mixin filter_type_option(label, value)
  option(value=value selected=(value === type))= label

block head
  title Filters - CloudTube
  script(type="module" src=getStaticURL("html", "static/js/filters.js"))

block content
  main.filters-page
    h1 Filters
    details(open=!!type)
      summary New filter
      form(method="post")
        if label
          input(type="hidden" name="label" value=label)
        if referrer
          input(type="hidden" name="referrer" value=referrer)
        .field-row
          label.field-row__label(for="filter-type") Type
          select(id="filter-type" name="filter-type").border-look.field-row__input
            +filter_type_option("Title", "title")
            +filter_type_option("Channel name", "channel-name")
            +filter_type_option("Channel ID", "channel-id")
        .field-row.max-width-input
          label.field-row__label(for="new-filter") Contents
          input(type="text" id="new-filter" name="new-filter" value=contents required maxlength=filterMaxLength).border-look.field-row__input
          .field-row__description(style=(type !== "title" ? "display: none" : ""))#title-pattern-matching
            | For titles, pattern matching is supported. Regular expressions are #{regexpEnabledText} enabled.
            |
            a(href="https://git.sr.ht/~cadence/tube-docs/tree/main/item/docs/cloudtube/Filters.md") For help, see the documentation.
        if compileError
          section.filter-compile-error
            header.filter-compile-error__header Your pattern failed to compile.
            pre.filter-compile-error__trace
              = contents + "\n"
              = " ".repeat(compileError.position) + "^ " + compileError.message
            div: a(href="https://git.sr.ht/~cadence/tube-docs/tree/main/item/docs/cloudtube/Filters.md") For help, see the documentation.
        else
          if type
            .filter-confirmation-notice.
              You can refine the filter further if you need to.
              When you're happy, click Save.
        .save-filter
          button.border-look
            if referrer
              | Save & return
            else
              | Save


    .filter-list
      - let someFiltersDisplayed = false
      each category in categories
        if category.filters.length
          - someFiltersDisplayed = true
          h2.filter-category-header= category.name
        div
          each filter in category.filters
            .filter
              .filter__details
                - let type = `type: ${filter.type}`
                - let content = filter.data
                if filter.type === "channel-id" && filter.label
                  - type += `, id: ${filter.data}`
                  - content = filter.label
                .filter__type= type
                .filter__content= content
              form.filter__remove(method="post" action="/filters/delete")
                input(type="hidden" name="delete-id" value=filter.id)
                button.border-look Remove
      if !someFiltersDisplayed
        .no-filters
          h2 You haven't created any filters.
          p Create one now and cleanse your mind.
          p You can add filters using the button on video thumbnails.

M pug/includes/video-list-item.pug => pug/includes/video-list-item.pug +9 -0
@@ 8,6 8,15 @@ mixin video_list_item(className, video, instanceOrigin, options = {})
        img(src=`/vi/${video.videoId}/mqdefault.jpg` width=320 height=180 alt="").image
      if video.second__lengthText != undefined
        span.duration= video.second__lengthText
      details.thumbnail__more
        summary.thumbnail__show-more ×
        .thumbnail__options-container
          .thumbnail__options-list
            - const paramsBase = {}
            - if (url) paramsBase.referrer = url.pathname + (url.search && "?" + url.search)
            a(href=`/filters?${new URLSearchParams({"channel-id": video.authorId, label: video.author, ...paramsBase})}`).menu-look Hide this channel
            a(href=`/filters?${new URLSearchParams({title: video.title, ...paramsBase})}`).menu-look Hide by title
            a(href="/filters").menu-look Edit all filters
    .info
      div.title: a(href=link).title-link= video.title
      div.author-line

M pug/settings.pug => pug/settings.pug +6 -0
@@ 81,6 81,12 @@ block content
      .save-settings
        button.border-look Save

    h2.more-settings-header More settings

    section.more-settings
      ul.more-settings__list
        li.more-settings__list-item: a(href="/filters") Edit filters

    if user.token
      details.data-management
        summary Sync data

M sass/includes/buttons.sass => sass/includes/buttons.sass +25 -1
@@ 55,4 55,28 @@
.border-look
  @include border-button
  @include button-size
  @include button-hover
  @at-root #{selector.unify(&, "a, button")}
    @include button-hover

.menu-look
  @include button-size
  -webkit-appearance: none
  -moz-appearance: none
  color: c.$fg-bright
  text-decoration: none
  line-height: 1.25
  margin: 0
  padding: 8px 20px
  background: c.$bg-accent
  border: solid c.$bg-darker
  border-width: 1px 0px 0px
  text-align: left

  &:last-child
    border-width: 1px 0px 1px

  &:hover
    background: c.$bg-accent-x

  &:active
    background: c.$bg-darker

A sass/includes/filters-page.sass => sass/includes/filters-page.sass +64 -0
@@ 0,0 1,64 @@
@use "colors.sass" as c

@mixin filter-notice
  margin-top: 24px
  padding: 12px
  border-radius: 8px
  background-color: c.$bg-darker
  white-space: pre-line

.filters-page
  max-width: 600px
  margin: 0 auto

  .filter-list
    margin-top: 24px

  .no-filters
    padding: 4px
    text-align: center

  .filter-confirmation-notice
    @include filter-notice
    color: c.$fg-warning

  .filter-compile-error
    @include filter-notice

    &__header
      color: c.$fg-warning

    &__trace
      background-color: c.$bg-darkest
      padding: 6px

  .save-filter
    margin-top: 12px

    .border-look
      background-color: c.$bg-darker
      font-size: 22px
      padding: 7px 16px 8px
      font-size: 24px

  .filter-category-header
    font-size: 1.25em
    margin-bottom: 4px

  .filter
    display: flex
    padding: 5px 0
    border-top: 1px solid c.$edge-grey

    &:last-child
      border-bottom: 1px solid c.$edge-grey

    &__details
      flex: 1

    &__type
      font-size: 15px
      color: c.$fg-dim

    &__remove
      flex-shrink: 0

M sass/includes/settings-page.sass => sass/includes/settings-page.sass +17 -0
@@ 13,6 13,23 @@
    font-size: 22px
    padding: 7px 16px 8px

.more-settings-header
  margin-top: 36px

.more-settings
  margin-top: 24px
  padding: 12px
  border-radius: 8px
  background-color: c.$bg-accent-x

  &__list
    margin: 0
    padding-left: 1em
    line-height: 1

  &__list-item:not(:last-child)
    margin-bottom: 0.4em // emulate line-height

.data-management
  margin-top: 24px


M sass/includes/video-list-item.sass => sass/includes/video-list-item.sass +66 -1
@@ 1,6 1,68 @@
@use "colors.sass" as c
@use "_dimensions.sass" as dimensions

.thumbnail
  $more-size: 24px

  &__more
    position: absolute
    top: 4px
    right: 4px
    width: $more-size
    height: $more-size
    border-radius: 50%
    background-color: rgba(20, 20, 20, 0.85)
    padding: 0px
    color: #fff

    visibility: hidden

    @at-root .thumbnail:hover &, &[open]
      visibility: visible

  &__show-more
    display: block
    height: $more-size
    line-height: 16px
    font-size: 25px
    text-align: center

    &::-webkit-details-marker
      display: none

  &__options-container
    position: absolute
    z-index: 1
    top: $more-size
    left: -1000px
    right: 0
    padding-top: 4px
    display: flex
    justify-content: flex-end
    pointer-events: none

  &__options-list
    pointer-events: auto
    display: grid
    background-color: c.$bg-accent
    padding: 8px 0px
    border-radius: 8px
    box-shadow: 0 2px 6px 2px #000

    &::before
      content: ""
      display: block
      height: 12px
      width: 12px
      position: absolute
      top: 0
      right: 0
      transform: translate(-6px, -1px) rotate(-45deg)
      clip-path: polygon(-5% -20%, 120% -20%, 120% 125%)
      background-color: c.$bg-accent
      box-shadow: 0px 0px 4px 0px #000
      pointer-events: none

@mixin video-list-item
  display: grid
  grid-template-columns: 160px 1fr


@@ 78,7 140,6 @@
    grid-gap: 16px
    grid-template-columns: auto 1fr
    margin-bottom: 20px
    overflow: hidden
    max-height: 150px

    @at-root .video-list-item--watched#{&}


@@ 98,6 159,10 @@
      right: 5px
      bottom: 5px

    .info
      overflow: hidden
      max-height: 150px

    .title
      font-size: 24px


M sass/main.sass => sass/main.sass +1 -0
@@ 10,6 10,7 @@
@use "includes/cant-think-page.sass"
@use "includes/privacy-page.sass"
@use "includes/js-licenses-page.sass"
@use "includes/filters-page.sass"
@use "includes/forms.sass"
@use "includes/nav.sass"
@use "includes/footer.sass"

M utils/constants.js => utils/constants.js +3 -1
@@ 26,7 26,9 @@ let constants = {
	// Settings for the server to use internally.
	server_setup: {
		// The URL of the local NewLeaf instance, which is always used for subscription updates.
		local_instance_origin: "http://localhost:3000"
		local_instance_origin: "http://localhost:3000",
		// Whether users may filter videos by regular expressions. Unlike square patterns, regular expressions are _not_ bounded in complexity, so this can be used for denial of service attacks. Only enable if this is a private instance and you trust all the members.
		allow_regexp_filters: false
	},

	// ***                                                 ***

M utils/converters.js => utils/converters.js +20 -0
@@ 1,5 1,6 @@
const constants = require("./constants")
const pug = require("pug")
const {Matcher} = require("./matcher")

function timeToPastText(timestamp) {
	const difference = Date.now() - timestamp


@@ 162,6 163,24 @@ function subscriberCountToText(count) {
	return preroundedCountToText(count) + " subscribers"
}

function applyVideoFilters(videos, filters) {
	const originalCount = videos.length
	for (const filter of filters) {
		if (filter.type === "channel-id") {
			videos = videos.filter(v => v.authorId !== filter.data)
		} else if (filter.type === "channel-name") {
			videos = videos.filter(v => v.author !== filter.data)
		} else if (filter.type === "title") {
			const matcher = new Matcher(filter.data)
			matcher.compilePattern()
			videos = videos.filter(v => !matcher.match(v.title))
		}
	}
	const filteredCount = originalCount - videos.length
	//TODO: actually display if things were filtered, and give the option to disable filters one time
	return {videos, filteredCount}
}

module.exports.timeToPastText = timeToPastText
module.exports.lengthSecondsToLengthText = lengthSecondsToLengthText
module.exports.normaliseVideoInfo = normaliseVideoInfo


@@ 169,3 188,4 @@ module.exports.rewriteVideoDescription = rewriteVideoDescription
module.exports.tToMediaFragment = tToMediaFragment
module.exports.viewCountToText = viewCountToText
module.exports.subscriberCountToText = subscriberCountToText
module.exports.applyVideoFilters = applyVideoFilters

M utils/getuser.js => utils/getuser.js +8 -0
@@ 80,6 80,14 @@ class User {
			db.prepare("INSERT OR IGNORE INTO WatchedVideos (token, videoID) VALUES (?, ?)").run([this.token, videoID])
		}
	}

	getFilters() {
		if (this.token) {
			return db.prepare("SELECT * FROM Filters WHERE token = ? ORDER BY data ASC").all(this.token)
		} else {
			return []
		}
	}
}

/**

A utils/matcher.js => utils/matcher.js +120 -0
@@ 0,0 1,120 @@
const {Parser} = require("./parser")
const constants = require("./constants")

class PatternCompileError extends Error {
	constructor(position, message) {
		super(message)
		this.position = position
	}
}

class PatternRuntimeError extends Error {
}

class Matcher {
	constructor(pattern) {
		this.pattern = pattern
		this.compiled = null
		this.anchors = null
	}

	compilePattern() {
		// Calculate anchors (starts or ends with -- to allow more text)
		this.anchors = {start: true, end: true}
		if (this.pattern.startsWith("--")) {
			this.anchors.start = false
			this.pattern = this.pattern.slice(2)
		}
		if (this.pattern.endsWith("--")) {
			this.anchors.end = false
			this.pattern = this.pattern.slice(0, -2)
		}

		this.compiled = []

		// Check if the pattern is a regular expression, only if regexp filters are enabled by administrator
		if (this.pattern.match(/^\/.*\/$/) && constants.server_setup.allow_regexp_filters) {
			this.compiled.push({
				type: "regexp",
				expr: new RegExp(this.pattern.slice(1, -1), "i")
			})
			return // do not proceed to step-by-step
		}

		// Step-by-step pattern compilation
		const patternParser = new Parser(this.pattern.toLowerCase())

		while (patternParser.hasRemaining()) {
			if (patternParser.swallow("[") > 0) { // there is a special command
				let index = patternParser.seek("]")
				if (index === -1) {
					throw new PatternCompileError(patternParser.cursor, "Command is missing closing square bracket")
				}
				let command = patternParser.get({split: "]"})
				let args = command.split("|")
				if (args[0] === "digits") {
					this.compiled.push({type: "regexp", expr: /\d+/})
				} else if (args[0] === "choose") {
					this.compiled.push({type: "choose", choices: args.slice(1).sort((a, b) => (b.length - a.length))})
				} else {
					throw new PatternCompileError(patternParser.cursor - command.length - 1 + args[0].length, `Unknown command name: \`${args[0]}\``)
				}
			} else { // no special command
				let next = patternParser.get({split: "["})
				this.compiled.push({type: "text", text: next})
				if (patternParser.hasRemaining()) patternParser.cursor-- // rewind to before the [
			}
		}
	}

	match(string) {
		if (this.compiled === null) {
			throw new Error("Pattern was not compiled before matching. Compiling must be done explicitly.")
		}

		const stringParser = new Parser(string.toLowerCase())

		let flexibleStart = !this.anchors.start

		for (const fragment of this.compiled) {
			if (fragment.type === "text") {
				let index = stringParser.seek(fragment.text, {moveToMatch: true}) // index, and move to, start of match
				if (index === -1) return false
				if (index !== 0 && !flexibleStart) return false // allow matching anywhere if flexible start
				stringParser.cursor += fragment.text.length // move to end of match.
			}
			else if (fragment.type === "regexp") {
				const match = stringParser.remaining().match(fragment.expr)
				if (!match) return false
				if (match.index !== 0 && !flexibleStart) return false // allow matching anywhere if flexible start
				stringParser.cursor += match.index + match[0].length
			}
			else if (fragment.type === "choose") {
				const ok = fragment.choices.some(choice => {
					let index = stringParser.seek(choice)
					if (index === -1) return false // try next choice
					if (index !== 0 && !flexibleStart) return false // try next choice
					// otherwise, good enough for us! /shrug
					stringParser.cursor += index + choice.length
					return true
				})
				if (!ok) return false
			}
			else {
				throw new PatternRuntimeError(`Unknown fragment type ${fragment.type}`)
			}

			flexibleStart = false // all further sequences must be anchored to the end of the last one.
		}

		if (stringParser.hasRemaining() && this.anchors.end) {
			return false // pattern did not end when expected
		}

		return true
	}
}

module.exports.Matcher = Matcher
module.exports.PatternCompileError = PatternCompileError
module.exports.PatternRuntimeError = PatternRuntimeError

A utils/parser.js => utils/parser.js +175 -0
@@ 0,0 1,175 @@
/**
 * @typedef GetOptions
 * @property {string} [split] Characters to split on
 * @property {string} [mode] "until" or "between"; choose where to get the content from
 * @property {function} [transform] Transformation to apply to result before returning
 */

const tf = {
	lc: s => s.toLowerCase()
}

class Parser {
	constructor(string) {
		this.string = string;
		this.substore = [];
		this.cursor = 0;
		this.cursorStore = [];
		this.mode = "until";
		this.transform = s => s;
		this.split = " ";
	}

	/**
	 * Return all the remaining text from the buffer, without updating the cursor
	 * @return {string}
	 */
	remaining() {
		return this.string.slice(this.cursor);
	}

	/**
	 * Have we reached the end of the string yet?
	 * @return {boolean}
	 */
	hasRemaining() {
		return this.cursor < this.string.length
	}

	/**
	 * Get the next element from the buffer, either up to a token or between two tokens, and update the cursor.
	 * @param {GetOptions} [options]
	 * @returns {string}
	 */
	get(options = {}) {
		["mode", "split", "transform"].forEach(o => {
			if (!options[o]) options[o] = this[o];
		});
		if (options.mode == "until") {
			let next = this.string.indexOf(options.split, this.cursor+options.split.length-1);
			if (next == -1) {
				let result = this.remaining();
				this.cursor = this.string.length;
				return result;
			} else {
				let result = this.string.slice(this.cursor, next);
				this.cursor = next + options.split.length;
				return options.transform(result);
			}
		} else if (options.mode == "between") {
			let start = this.string.indexOf(options.split, this.cursor);
			let end = this.string.indexOf(options.split, start+options.split.length);
			let result = this.string.slice(start+options.split.length, end);
			this.cursor = end + options.split.length;
			return options.transform(result);
		}
	}

	/**
	 * Get a number of chars from the buffer.
	 * @param {number} length Number of chars to get
	 * @param {boolean} [move] Whether to update the cursor
	 */
	slice(length, move = false) {
		let result = this.string.slice(this.cursor, this.cursor+length);
		if (move) this.cursor += length;
		return result;
	}

	/**
	 * Repeatedly swallow a character.
	 * @param {string} char
	 */
	swallow(char) {
		let before = this.cursor;
		while (this.string[this.cursor] == char) this.cursor++;
		return this.cursor - before;
	}

	/**
	 * Push the current cursor position to the store
	 */
	store() {
		this.cursorStore.push(this.cursor);
	}

	/**
	 * Pop the previous cursor position from the store
	 */
	restore() {
		this.cursor = this.cursorStore.pop();
	}

	/**
	 * Run a get operation, test against an input, return success or failure, and restore the cursor.
	 * @param {string} value The value to test against
	 * @param {object} options Options for get
	 */
	test(value, options) {
		this.store();
		let next = this.get(options);
		let result = next == value;
		this.restore();
		return result;
	}

	/**
	 * Run a get operation, test against an input, return success or failure, and restore the cursor on failure.
	 * @param {string} value The value to test against
	 * @param {object} options Options for get
	 */
	has(value, options) {
		this.store();
		let next = this.get(options);
		let result = next == value;
		if (!result) this.restore();
		return result;
	}

	/**
	 * Run a get operation, test against an input, and throw an error if it doesn't match.
	 * @param {string} value
	 * @param {GetOptions} [options]
	 */
	expect(value, options = {}) {
		let next = this.get(options);
		if (next != value) throw new Error("Expected "+value+", got "+next);
	}

	/**
	 * Seek to or past the next occurance of the string.
	 * @param {string} toFind
	 * @param {{moveToMatch?: boolean, useEnd?: boolean}} options both default to false
	 */
	seek(toFind, options = {}) {
		if (options.moveToMatch === undefined) options.moveToMatch = false
		if (options.useEnd === undefined) options.useEnd = false
		let index = this.string.indexOf(toFind, this.cursor)
		if (index !== -1) {
			index -= this.cursor
			if (options.useEnd) index += toFind.length
			if (options.moveToMatch) this.cursor += index
		}
		return index
	}

	/**
	 * Replace the current string, adding the old one to the substore.
	 * @param {string} string
	 */
	pushSubstore(string) {
		this.substore.push({string: this.string, cursor: this.cursor, cursorStore: this.cursorStore})
		this.string = string
		this.cursor = 0
		this.cursorStore = []
	}

	/**
	 * Replace the current string with the first entry from the substore.
	 */
	popSubstore() {
		Object.assign(this, this.substore.pop())
	}
}

module.exports.Parser = Parser

M utils/upgradedb.js => utils/upgradedb.js +5 -0
@@ 43,6 43,11 @@ const deltas = [
	function() {
		db.prepare("ALTER TABLE Settings ADD COLUMN quality INTEGER DEFAULT 0")
			.run()
	},
	// 6: +Filters
	function() {
		db.prepare("CREATE TABLE Filters (id INTEGER, token TEXT NOT NULL, type TEXT NOT NULL, data TEXT NOT NULL, label TEXT, PRIMARY KEY (id))")
			.run()
	}
]