Brazen Framework - Tag Query Engine

Tokenize, merge, and subtract space-separated booru tag queries

Bu script direkt olarak kurulamaz. Başka scriptler için bir kütüphanedir ve meta yönergeleri içerir // @require https://update.greasyfork.org/scripts/583965/1880215/Brazen%20Framework%20-%20Tag%20Query%20Engine.js

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         Brazen Framework - Tag Query Engine
// @namespace    brazenvoid
// @version      1.1.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Tokenize, merge, and subtract space-separated booru tag queries; includes Gelbooru-family search adapter (more adapters may be added later)
// @run-at       document-end
// ==/UserScript==

/**
 * Core tag-query engine: parenthesis-aware tokenize, idempotent merge, subtract for display.
 * Site-specific URL/param handling lives in adapters (e.g. GelbooruFamilySearchAdapter) in this same module.
 */
class BrazenTagQueryEngine
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * @param {string} token
   * @return {string|null}
   */
  static defaultMetatagKey(token)
  {
    let subject = token.startsWith('-') ? token.slice(1) : token
    if (subject.startsWith('(')) {
      return null
    }
    let colon = subject.indexOf(':')
    return colon > 0 ? subject.slice(0, colon) : null
  }

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {{singleInstanceKeys?: Set<string>, metatagKey?: function(string): (string|null)}} [options]
   */
  constructor(options = {})
  {
    this._singleInstanceKeys = options.singleInstanceKeys ?? new Set()
    this._metatagKey = options.metatagKey ?? BrazenTagQueryEngine.defaultMetatagKey
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * @param {string} query
   * @return {string[]}
   */
  tokenize(query)
  {
    let tokens = []
    if (!query) {
      return tokens
    }
    let index = 0
    let length = query.length

    while (index < length) {

      while (index < length && /\s/.test(query[index])) {
        index++
      }
      if (index >= length) {
        break
      }

      let start = index
      if (query[index] === '(') {

        let depth = 0
        while (index < length) {
          if (query[index] === '(') {
            depth++
          } else if (query[index] === ')') {
            depth--
            if (depth === 0) {
              index++
              break
            }
          }
          index++
        }

      } else {
        while (index < length && !/\s/.test(query[index])) {
          index++
        }
      }
      tokens.push(query.slice(start, index))
    }
    return tokens
  }

  /**
   * @param {string} token
   * @return {string|null}
   */
  metatagKey(token)
  {
    return this._metatagKey(token)
  }

  /**
   * @param {string} userQuery
   * @param {string[]} defaultTokens
   * @return {string[]}
   */
  merge(userQuery, defaultTokens)
  {
    let userTokens = this.tokenize(userQuery)
    let userKeys = new Set()
    let present = new Set(userTokens)

    for (let token of userTokens) {
      let key = this.metatagKey(token)
      if (key) {
        userKeys.add(key)
      }
    }

    let merged = [...userTokens]
    for (let token of defaultTokens) {

      let key = this.metatagKey(token)
      if (key && this._singleInstanceKeys.has(key) && userKeys.has(key)) {
        continue
      }
      if (present.has(token)) {
        continue
      }
      merged.push(token)
      present.add(token)
    }
    return merged
  }

  /**
   * @param {string[]} tokens
   * @param {string[]} defaultTokens
   * @return {string[]}
   */
  subtract(tokens, defaultTokens)
  {
    let defaults = new Set(defaultTokens)
    return tokens.filter((token) => !defaults.has(token))
  }
}

/** Metatags Gelbooru cannot combine more than once per query. */
const GELBOORU_SINGLE_INSTANCE_METATAGS = new Set([
  'sort', 'rating', 'user', 'md5', 'parent',
  'aspectratio', 'aspectratiof', 'sourcedomains', 'width', 'height',
])

/**
 * @typedef {object} GelbooruFamilySearchAdapterOptions
 * @property {string} [queryParam]
 * @property {string[]} [emptySentinels]
 * @property {string} [listPath]
 * @property {Set<string>} [singleInstanceKeys]
 */

/**
 * Gelbooru 0.2 HTML search adapter (rule34.xxx, gelbooru.com list pages).
 * Per-host URL variants (e.g. rule34.us `r=posts/index&q=`) override `listPath` / `queryParam`.
 * Future site-family drivers stay as separate classes in this module.
 */
class GelbooruFamilySearchAdapter
{
  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {GelbooruFamilySearchAdapterOptions} [options]
   */
  constructor(options = {})
  {
    this.queryParam = options.queryParam ?? 'tags'
    this.emptySentinels = options.emptySentinels ?? ['all']
    this.listPath = options.listPath ?? '/index.php?page=post&s=list'
    this.singleInstanceKeys = options.singleInstanceKeys ?? GELBOORU_SINGLE_INSTANCE_METATAGS
    this.engine = new BrazenTagQueryEngine({singleInstanceKeys: this.singleInstanceKeys})
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * @param {Location|string} [locationRef]
   * @return {string}
   */
  readQuery(locationRef = location)
  {
    let params = new URLSearchParams(typeof locationRef === 'string' ? new URL(locationRef).search : locationRef.search)
    return this.normalizeQueryParam(params.get(this.queryParam) ?? '')
  }

  /**
   * Treats empty sentinels (e.g. `tags=all`) as an empty user query for default merging.
   *
   * @param {string|null} tags
   * @return {string}
   */
  normalizeQueryParam(tags)
  {
    if (!tags) {
      return ''
    }
    let joined = this.engine.tokenize(tags).join(' ')
    if (this.emptySentinels.includes(joined)) {
      return ''
    }
    return tags
  }

  /**
   * @param {string[]} tokens
   * @return {string}
   */
  serializeQuery(tokens)
  {
    return tokens.join(' ')
  }

  /**
   * @param {string} serialized
   * @param {string} [origin]
   * @return {string}
   */
  buildListUrl(serialized, origin = location.origin)
  {
    return origin + this.listPath + '&' + this.queryParam + '=' + encodeURIComponent(serialized)
  }

  /**
   * Lowercase tag atoms; preserve metatag keys and OR-group syntax.
   *
   * @param {string} tag
   * @return {string}
   */
  normalizeAtom(tag)
  {
    tag = tag.trim()
    if (!tag) {
      return tag
    }

    let negated = false
    if (tag.startsWith('-')) {
      negated = true
      tag = tag.slice(1).trimStart()
    }

    if (tag.startsWith('(') && tag.endsWith(')')) {
      let inner = tag.slice(1, -1)
      let parts = inner.split('~').map((part) => this.normalizeAtom(part.trim()))
      tag = '(' + parts.join(' ~ ') + ')'
      return negated ? '-' + tag : tag
    }

    let colonIdx = tag.indexOf(':')
    if (colonIdx > 0) {
      let key = tag.slice(0, colonIdx)
      if (this.singleInstanceKeys.has(key)) {
        return negated ? '-' + tag : tag
      }
      tag = key + ':' + tag.slice(colonIdx + 1).trim().replace(/\s+/g, '_').toLowerCase()
      return negated ? '-' + tag : tag
    }

    tag = tag.replace(/\s+/g, '_').toLowerCase()
    return negated ? '-' + tag : tag
  }
}