diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2346790f..92184457 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -25,6 +25,7 @@
"copyts",
"creationflags",
"cronsim",
+ "dailymotion",
"datas",
"dateparser",
"daterange",
@@ -40,10 +41,12 @@
"forceprint",
"fribidi",
"getpid",
+ "gibibytes",
"gpac",
"hiddenimports",
"hookspath",
"httpx",
+ "kibibytes",
"levelno",
"libcurl",
"libjavascriptcoregtk",
@@ -53,6 +56,7 @@
"matroska",
"mbed",
"mccabe",
+ "mebibytes",
"MEIPASS",
"Microformat",
"microformats",
@@ -60,6 +64,7 @@
"movflags",
"mpegts",
"msvideo",
+ "mult",
"multidict",
"muxdelay",
"nodesc",
@@ -77,6 +82,7 @@
"pypi",
"pyside",
"pywebview",
+ "qstr",
"qtpy",
"quicktime",
"rejecttitle",
@@ -85,17 +91,23 @@
"SIGUSR",
"smhd",
"socketio",
+ "sstr",
+ "tebibytes",
+ "tiktok",
"timespec",
"tmpfilename",
"ungroup",
+ "unnegated",
"upgrader",
"urandom",
"urlsafe",
"usegmt",
+ "ustr",
"vcodec",
"vconvert",
"writedescription",
- "xerror"
+ "xerror",
+ "youtu"
],
"css.styleSheets": [
"ui/assets/css/*.css"
diff --git a/app/routes/api/conditions.py b/app/routes/api/conditions.py
index 9895cd45..f9550f61 100644
--- a/app/routes/api/conditions.py
+++ b/app/routes/api/conditions.py
@@ -1,5 +1,6 @@
import logging
import uuid
+from collections import OrderedDict
from aiohttp import web
from aiohttp.web import Request, Response
@@ -185,7 +186,11 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
)
return web.json_response(
- data={"status": status, "condition": cond, "data": data},
+ data={
+ "status": status,
+ "condition": cond,
+ "data": OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))),
+ },
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
diff --git a/ui/components/ConditionForm.vue b/ui/components/ConditionForm.vue
index 9ba0f309..83b23b5b 100644
--- a/ui/components/ConditionForm.vue
+++ b/ui/components/ConditionForm.vue
@@ -181,23 +181,22 @@
- Filter Status: {{ test_data?.data?.status === null ? 'Not tested' : test_data?.data?.status ?
- 'Matched' : 'Not matched' }}
+ Filter Status:
+ Not tested
+ {{ logic_test ? 'Matched' : 'Not matched' }}
-
-
@@ -209,6 +208,7 @@
diff --git a/ui/components/NewDownload.vue b/ui/components/NewDownload.vue
index 83813fbe..2636aabf 100644
--- a/ui/components/NewDownload.vue
+++ b/ui/components/NewDownload.vue
@@ -232,7 +232,7 @@
import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core'
import type { item_request } from '~/types/item'
-import { getSeparatorsName, separators } from '~/utils/utils'
+import { getSeparatorsName, separators } from '~/utils/index'
const props = defineProps<{ item?: Partial }>()
const emitter = defineEmits<{
@@ -245,7 +245,7 @@ const socket = useSocketStore()
const toast = useNotification()
const showAdvanced = useStorage('show_advanced', false)
-const separator = useStorage('url_separator', separators[0].value)
+const separator = useStorage('url_separator', separators[0]?.value ?? ',')
const auto_start = useStorage('auto_start', true)
const show_description = useStorage('show_description', true)
@@ -414,7 +414,7 @@ onMounted(async () => {
await nextTick()
if (!separators.some(s => s.value === separator.value)) {
- separator.value = separators[0].value
+ separator.value = separators[0]?.value ?? '.'
}
})
@@ -422,7 +422,7 @@ const hasFormatInConfig = computed((): boolean => !!form.value.cli?.match(/(? config.presets.filter(item => item.default === flag)
const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
-const expand_description = (e: Event) => toggleClass(e.target, ['is-ellipsis', 'is-pre-wrap'])
+const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
const removeFromArchive = async (url: string) => {
try {
diff --git a/ui/components/Settings.vue b/ui/components/Settings.vue
index 658556dc..9b55d3da 100644
--- a/ui/components/Settings.vue
+++ b/ui/components/Settings.vue
@@ -142,7 +142,7 @@
diff --git a/ui/utils/cache.js b/ui/utils/cache.js
deleted file mode 100644
index 4fbf1214..00000000
--- a/ui/utils/cache.js
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Cache class
- *
- * @class
- * @param {string} engine - The engine to use for caching. Either 'session' or 'local'.
- * @throws {Error} If the engine is not supported.
- */
-class Cache {
- supportedEngines = ['session', 'local']
-
- constructor(engine = 'session') {
- if (!this.supportedEngines.includes(engine)) {
- throw new Error(`Engine '${engine}' not supported.`)
- }
-
- if ('session' === engine) {
- this.storage = window.sessionStorage
- } else {
- this.storage = window.localStorage
- }
- }
-
- /**
- * Set a value in the cache
- *
- * @param key {string}
- * @param value {*}
- * @param ttl {number|null} - Time to live in milliseconds. If null, the value will not expire.
- */
- set(key, value, ttl = null) {
- this.storage.setItem(key, JSON.stringify({value, ttl, time: Date.now()}))
- }
-
- /**
- * Get a value from the cache
- *
- * @param key {string}
- * @returns {any}
- */
- get(key) {
- const item = this.storage.getItem(key)
- if (null === item) {
- return null
- }
-
- try {
- const {value, ttl, time} = JSON.parse(item)
- if (null !== ttl && Date.now() - time > ttl) {
- this.remove(key)
- return null
- }
- return value
- } catch (e) {
- return item
- }
- }
-
- /**
- * Remove a value from the cache
- *
- * @param key {string}
- */
- remove(key) {
- this.storage.removeItem(key)
- }
-
- /**
- * Clear the cache
- *
- * @param {function|null} filter - A filter function to remove only specific keys.
- */
- clear(filter = null) {
- if (null !== filter) {
- Object.keys(this.storage ?? {}).filter(filter).forEach(k => this.storage.removeItem(k))
- } else {
- this.storage.clear()
- }
- }
-
- /**
- * Check if a key is in the cache
- *
- * @param key {string}
- * @returns {boolean}
- */
- has(key) {
- return null !== this.get(key)
- }
-}
-
-const useSessionCache = () => new Cache('session')
-const useLocalCache = () => new Cache('local')
-
-export {useSessionCache, useLocalCache}
diff --git a/ui/utils/cache.ts b/ui/utils/cache.ts
new file mode 100644
index 00000000..d24bd8f3
--- /dev/null
+++ b/ui/utils/cache.ts
@@ -0,0 +1,64 @@
+type CacheEngine = 'session' | 'local'
+
+interface CachedItem {
+ value: T
+ ttl: number | null
+ time: number
+}
+
+class Cache {
+ private storage: Storage
+ private supportedEngines: CacheEngine[] = ['session', 'local']
+
+ constructor(engine: CacheEngine = 'session') {
+ if (!this.supportedEngines.includes(engine)) {
+ throw new Error(`Engine '${engine}' not supported.`)
+ }
+
+ this.storage = 'session' === engine ? window.sessionStorage : window.localStorage
+ }
+
+ set(key: string, value: T, ttl: number | null = null): void {
+ const item: CachedItem = { value, ttl, time: Date.now() }
+ this.storage.setItem(key, JSON.stringify(item))
+ }
+
+ get(key: string): T | null {
+ const item = this.storage.getItem(key)
+ if (null === item) {
+ return null
+ }
+
+ try {
+ const parsed: CachedItem = JSON.parse(item)
+ if (null !== parsed.ttl && Date.now() - parsed.time > parsed.ttl) {
+ this.remove(key)
+ return null
+ }
+ return parsed.value
+ } catch {
+ return item as unknown as T
+ }
+ }
+
+ remove(key: string): void {
+ this.storage.removeItem(key)
+ }
+
+ clear(filter: ((key: string) => boolean) | null = null): void {
+ if (null !== filter) {
+ Object.keys(this.storage).filter(filter).forEach(k => this.storage.removeItem(k))
+ return
+ }
+ this.storage.clear()
+ }
+
+ has(key: string): boolean {
+ return null !== this.get(key)
+ }
+}
+
+const useLocalCache = (): Cache => new Cache('local')
+const useSessionCache = (): Cache => new Cache('session')
+
+export { useSessionCache, useLocalCache, Cache }
diff --git a/ui/utils/embedable.js b/ui/utils/embedable.ts
similarity index 54%
rename from ui/utils/embedable.js
rename to ui/utils/embedable.ts
index 6873b9f7..52f51543 100644
--- a/ui/utils/embedable.js
+++ b/ui/utils/embedable.ts
@@ -1,79 +1,86 @@
-const sources = [
+export type EmbedSource = {
+ name: string
+ url: string
+ regex: RegExp
+}
+
+const sources: EmbedSource[] = [
{
name: 'youtube',
- url: 'https://www.youtube-nocookie.com/embed/',
+ url: 'https://www.youtube-nocookie.com/embed/{id}',
regex: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:shorts\/|[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)(?[a-zA-Z0-9_-]{11})/,
},
{
name: "instagram_post",
- url: "https://www.instagram.com/p/",
+ url: "https://www.instagram.com/p/{id}",
regex: /https?:\/\/(?:www\.)?instagram\.com\/p\/(?[^/?#&]+)/,
},
{
name: "instagram_story",
- url: "https://www.instagram.com/stories/highlights/",
+ url: "https://www.instagram.com/stories/highlights/{id}",
regex: /https?:\/\/(?:www\.)?instagram\.com\/stories\/highlights\/(?[^/?#&]+)/,
},
{
name: "twitter",
- url: "https://twitter.com/i/status/",
+ url: "https://twitter.com/i/status/{id}",
regex: /https?:\/\/(?:www\.)?(twitter\.com|x\.com)\/.+?\/status\/(?[^/?#&]+)/,
},
{
name: "facebook",
- url: "https://www.facebook.com/plugins/post.php?href=",
+ url: "https://www.facebook.com/plugins/post.php?href={id}",
regex: /https?:\/\/(?:www\.)?facebook\.com\/(?:[^/?#&]+)\/posts\/(?[^/?#&]+)/,
},
{
name: "tiktok",
- url: "https://www.tiktok.com/embed/",
+ url: "https://www.tiktok.com/embed/{id}",
regex: /https?:\/\/(?:www\.)?tiktok\.com\/(?:[^/?#&]+)\/video\/(?[^/?#&]+)/,
},
{
name: "vimeo",
- url: "https://player.vimeo.com/video/",
+ url: "https://player.vimeo.com/video/{id}",
regex: /https?:\/\/(?:www\.)?vimeo\.com(?:\/[^/?#&]+)?\/(?[^/?#&]+)/,
},
{
name: "spotify",
- url: "https://open.spotify.com/embed/",
+ url: "https://open.spotify.com/embed/{id}",
regex: /https?:\/\/(?:open\.)?spotify\.com\/(?:[^/?#&]+)\/(?[^/?#&]+)/,
},
{
name: "twitch_vod",
- url: "https://player.twitch.tv/?parent={origin}&video=",
+ url: "https://player.twitch.tv/?parent={origin}&video={id}",
regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/?#&]+)\/(?[^/?#&]+)/,
},
{
name: "twitch_clip",
- url: "https://clips.twitch.tv/embed?parent={origin}&clip=",
+ url: "https://clips.twitch.tv/embed?parent={origin}&clip={id}",
regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?:[^/?#&]+)\/clip\/(?[^/?#&]+)/,
},
{
name: "twitch_channel",
- url: "https://player.twitch.tv/?parent={origin}&channel=",
+ url: "https://player.twitch.tv/?parent={origin}&channel={id}",
regex: /https?:\/\/(?:www\.)?twitch\.tv\/(?[^/?#&]+)/,
},
{
name: "dailymotion",
- url: "https://www.dailymotion.com/embed/video/",
+ url: "https://www.dailymotion.com/embed/video/{id}",
regex: /^.+dailymotion.com\/(video|hub)\/(?[^_]+)[^#]*(#video=([^_&]+))?/,
},
]
-const isEmbedable = url => {
- return sources.some(source => source.regex.test(url));
-}
+const isEmbedable = (url: string): boolean => sources.some(source => source.regex.test(url))
-const getEmbedable = url => {
- const source = sources.find(source => source.regex.test(url));
+const getEmbedable = (url: string): string | null => {
+ const source = sources.find(source => source.regex.test(url))
if (!source) {
- return null;
+ return null
}
- source.url.replace(/\{origin\}/g, window.location.origin);
+ const match = source.regex.exec(url)
+ if (!match || !match.groups?.id) {
+ return null
+ }
- return source.url + source.regex.exec(url)['groups'].id;
+ return source.url.replace(/\{origin\}/g, window.location.origin).replace(/\{id\}/g, match.groups.id)
}
-export { isEmbedable, getEmbedable };
+export { isEmbedable, getEmbedable }
diff --git a/ui/utils/index.js b/ui/utils/index.js
deleted file mode 100644
index a92eab6c..00000000
--- a/ui/utils/index.js
+++ /dev/null
@@ -1,502 +0,0 @@
-const toast = useNotification()
-const runtimeConfig = useRuntimeConfig()
-const AG_SEPARATOR = '.'
-
-/**
- * Get value from object or function
- *
- * @param {Function|*} obj
- * @returns {*}
- */
-const getValue = (obj) => 'function' === typeof obj ? obj() : obj
-
-/**
- * Get value from object or function and return default value if it's undefined or null
- *
- * @param {Object|Array|any} obj The object to get the value from.
- * @param {string} path The path to the value.
- * @param {*} defaultValue The default value to return if the path is not found.
- *
- * @returns {*} The value at the path or the default value.
- */
-const ag = (obj, path, defaultValue = null) => {
- const keys = path.split(AG_SEPARATOR)
- let at = obj
-
- for (let key of keys) {
- if (typeof at === 'object' && null !== at && key in at) {
- at = at[key]
- } else {
- return getValue(defaultValue)
- }
- }
-
- return getValue(null === at ? defaultValue : at)
-}
-
-/**
- * Set value in object by path
- *
- * @param {Object} obj The object to set the value in.
- * @param {string} path The path to the value.
- * @param {*} value The value to set.
- *
- * @returns {Object} The object with the value set.
- */
-const ag_set = (obj, path, value) => {
- const keys = path.split(AG_SEPARATOR)
- let at = obj
-
- while (keys.length > 0) {
- if (keys.length === 1) {
- if (typeof at === 'object' && at !== null) {
- at[keys.shift()] = value
- } else {
- throw new Error(`Cannot set value at this path (${path}) because it's not an object.`)
- }
- } else {
- const key = keys.shift()
- if (!at[key]) {
- at[key] = {}
- }
- at = at[key]
- }
- }
-
- return obj
-}
-
-/**
- * Wait for an element to be loaded in the DOM
- *
- * @param {string} sel The selector of the element.
- * @param {Function} callback The callback function.
- *
- * @returns {void}
- */
-const awaitElement = (sel, callback) => {
- let interval = undefined
- let $elm = document.querySelector(sel)
-
- if ($elm) {
- callback($elm, sel)
- return
- }
-
- interval = setInterval(() => {
- let $elm = document.querySelector(sel)
- if ($elm) {
- clearInterval(interval)
- callback($elm, sel)
- }
- }, 200)
-}
-
-/**
- * Replace tags in text with values from context
- *
- * @param {string} text The text with tags
- * @param {object} context The context with values
- *
- * @returns {string} The text with replaced tags
- */
-const r = (text, context = {}) => {
- const tagLeft = '{'
- const tagRight = '}'
-
- if (!text.includes(tagLeft) || !text.includes(tagRight)) {
- return text
- }
-
- const pattern = new RegExp(`${tagLeft}([\\w_.]+)${tagRight}`, 'g')
- const matches = text.match(pattern)
-
- if (!matches) {
- return text
- }
-
- let replacements = {}
-
- matches.forEach(match => replacements[match] = ag(context, match.slice(1, -1), ''))
-
- for (let key in replacements) {
- text = text.replace(new RegExp(key, 'g'), replacements[key])
- }
-
- return text
-}
-
-const copyText = (str, notify = true, store = false) => {
- if (navigator.clipboard) {
- navigator.clipboard.writeText(str).then(() => {
- if (notify) {
- toast.success('Text copied to clipboard.')
- }
- }).catch((error) => {
- console.error('Failed to copy.', error)
- if (notify) {
- toast.error('Failed to copy to clipboard.')
- }
- })
- return
- }
-
- const el = document.createElement('textarea')
- el.value = str
- document.body.appendChild(el)
- el.select()
- document.execCommand('copy')
- document.body.removeChild(el)
-
- if (notify) {
- toast.success('Text copied to clipboard.', { store: store })
- }
-}
-
-/**
- * Dispatch custom event.
- *
- * @param {string} eventName The name of the event.
- * @param {object} detail The detail object.
- *
- * @returns {Boolean} The return value of dispatchEvent.
- */
-const dEvent = (eventName, detail = {}) => window.dispatchEvent(new CustomEvent(eventName, { detail }))
-
-/**
- * Make pagination
- *
- * @param {number} current The current page.
- * @param {number} last The last page.
- * @param {number} delta The delta.
- *
- * @returns {Array} The pagination array.
- */
-const makePagination = (current, last, delta = 5) => {
- let pagination = []
-
- if (last < 2) {
- return pagination
- }
-
- const strR = '-'.repeat(9 + `${last}`.length)
-
- const left = current - delta, right = current + delta + 1
-
- for (let i = 1; i <= last; i++) {
- if (i === 1 || i === last || (i >= left && i < right)) {
- if (i === left && i > 2) {
- pagination.push({
- page: 0, text: strR, selected: false,
- })
- }
-
- pagination.push({
- page: i, text: `Page #${i}`, selected: i === current
- })
-
- if (i === right - 1 && i < last - 1) {
- pagination.push({
- page: 0, text: strR, selected: false,
- })
- }
- }
- }
-
- return pagination
-}
-
-/**
- * Encodes a path string to be used in a URL
- *
- * @param {string} item - The path string to encode
- *
- * @returns {string} - The encoded path string
- */
-const encodePath = item => {
- // -- manually encode #
- if (!item) {
- return item
- }
-
- item = item.replace(/#/g, '%23')
- try {
- return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
- } catch (e) {
- console.error('Error encoding path:', e, item)
- return item
- }
-}
-
-/**
- * Request content from the API. This function will automatically add the API token to the request headers.
- * And prefix the URL with the API URL and path.
- *
- * @param {string} url - The URL to request
- * @param {RequestInit} options - The request options
- *
- * @returns {Promise} - The response from the API
- */
-const request = (url, options = {}) => {
- options = options || {}
- options.method = options.method || 'GET'
- options.headers = options.headers || {}
- options.withCredentials = true
-
- if (undefined === options.headers['Content-Type']) {
- if (!(options?.body instanceof FormData)) {
- options.headers['Content-Type'] = 'application/json'
- }
- }
-
- if (undefined === options.headers['Accept']) {
- options.headers['Accept'] = 'application/json'
- }
-
- if (url.startsWith('/')) {
- options.credentials = 'same-origin'
- }
-
- return fetch(url.startsWith('/') ? uri(url) : url, options)
-}
-
-/**
- * Remove the ANSI colors from the text
- *
- * @param {string} text - The text to remove the colors from
- *
- * @returns {string} - The text without the colors.
- */
-const removeANSIColors = text => text?.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') ?? text
-
-const dec2hex = dec => dec.toString(16).padStart(2, "0")
-const makeId = len => Array.from(window.crypto.getRandomValues(new Uint8Array((len || 40) / 2)), dec2hex).join('')
-
-const basename = (path, ext = '') => {
- if (!path) {
- return ''
- }
- const segments = path.replace(/\\/g, '/').split('/')
- let base = segments.pop()
- while (segments.length && base === '') {
- base = segments.pop()
- }
- if (ext && base.endsWith(ext) && base !== ext) {
- base = base.substring(0, base.length - ext.length)
- }
- return base
-}
-
-const dirname = filePath => {
- const lastIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
- if (-1 === lastIndex) {
- return '.'
- }
- if (0 === lastIndex) {
- return filePath[0]
- }
- return filePath.substring(0, lastIndex)
-}
-
-const iTrim = (str, delim, position = 'both') => {
- if (!str) {
- return str
- }
-
- if (!delim) {
- throw new Error('Delimiter is required')
- }
-
- if ("]" === delim) {
- delim = "\\]"
- }
- if ("\\" === delim) {
- delim = "\\\\"
- }
-
- if (['both', 'start'].includes(position)) {
- str = str.replace(new RegExp("^[" + delim + "]+", "g"), "")
- }
-
- if (['both', 'end'].includes(position)) {
- str = str.replace(new RegExp("[" + delim + "]+$", "g"), "")
- }
-
- return str
-}
-
-const eTrim = (str, delim) => iTrim(str, delim, 'end')
-const sTrim = (str, delim) => iTrim(str, delim, 'start')
-
-const ucFirst = str => (!str) ? str : str.charAt(0).toUpperCase() + str.slice(1)
-
-/**
- * Get query parameters from the URL
- *
- * @param {string} url - The URL to get the query parameters from
- *
- * @returns {Object} - The query parameters
- */
-const getQueryParams = (url = window.location.search) => Object.fromEntries(new URLSearchParams(url).entries())
-
-/**
- * Make download URL.
- *
- * @param {Object} config
- * @param {Object} item
- * @param {string} base
- *
- * @returns {string} The download URL
- */
-const makeDownload = (config, item, base = 'api/download') => {
- let baseDir = 'api/player/m3u8/video/';
- if ('m3u8' !== base) {
- baseDir = `${base}/`;
- }
-
- if (item.folder) {
- item.folder = item.folder.replace(/#/g, '%23');
- baseDir += item.folder + '/';
- }
-
- let url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`;
-
- return uri(('m3u8' === base) ? url + '.m3u8' : url);
-}
-
-/**
- * Format a file size
- *
- * @param {number} bytes
- * @param {number} decimals
- *
- * @returns {string} The formatted file size
- */
-const formatBytes = (bytes, decimals = 2) => {
- if (!+bytes) return '0 Bytes'
-
- const k = 1024
- const dm = decimals < 0 ? 0 : decimals
- const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
-
- const i = Math.floor(Math.log(bytes) / Math.log(k))
-
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
-}
-
-
-
-/**
- * Check if array or object has data.
- *
- * @param {string} item - The item to check
- * @returns {boolean} - True if the item has data, false otherwise.
- */
-const has_data = item => {
- if (!item) {
- return false
- }
-
- if (typeof item === 'string') {
- try {
- item = JSON.parse(item)
- } catch (e) {
- return true
- }
- }
-
- try {
- if (typeof item === 'object') {
- return Object.keys(item).length > 0
- }
- return item.length > 0
- } catch (e) {
- console.error(e)
- }
-
- return false
-}
-
-const toggleClass = (target, className) => {
- if (Array.isArray(className)) {
- className.forEach(name => toggleClass(target, name))
- return;
- }
-
- if (target.classList.contains(className)) {
- target.classList.remove(className)
- return;
- }
- target.classList.add(className)
-}
-
-const cleanObject = (item, fields = []) => {
- if (!item || typeof item !== 'object' || fields.length < 1) {
- return item
- }
-
- const cleaned = {}
-
- for (const key of Object.keys(item)) {
- if (false === fields.includes(key)) {
- cleaned[key] = item[key]
- }
- }
-
- return cleaned
-}
-
-const uri = u => {
- if (!u || '/' === runtimeConfig.app.baseURL || !u.startsWith('/')) {
- return u
- }
-
- if (true === u.startsWith(runtimeConfig.app.baseURL)) {
- return u
- }
-
- return eTrim(runtimeConfig.app.baseURL, '/') + '/' + sTrim(u, '/');
-}
-
-const formatTime = seconds => {
- const hrs = Math.floor(seconds / 3600)
- const mins = Math.floor((seconds % 3600) / 60)
- const secs = Math.floor(seconds % 60)
-
- const pad = (n) => n.toString().padStart(2, '0')
-
- if (hrs > 0) return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
- if (mins > 0) return `${pad(mins)}:${pad(secs)}`
- return `${secs}`
-}
-
-const sleep = (seconds) => new Promise(resolve => setTimeout(resolve, seconds * 1000))
-
-export {
- ag_set,
- ag,
- awaitElement,
- copyText,
- dEvent,
- makePagination,
- request,
- r,
- encodePath,
- removeANSIColors,
- makeId,
- basename,
- iTrim,
- eTrim,
- sTrim,
- ucFirst,
- dirname,
- getQueryParams,
- makeDownload,
- formatBytes,
- has_data,
- toggleClass,
- cleanObject,
- uri,
- formatTime,
- sleep,
-}
diff --git a/ui/utils/index.ts b/ui/utils/index.ts
new file mode 100644
index 00000000..ffdf1b8c
--- /dev/null
+++ b/ui/utils/index.ts
@@ -0,0 +1,599 @@
+import type { convert_args_response } from "~/types/responses";
+
+const runtimeConfig = useRuntimeConfig()
+const toast = useNotification()
+
+const AG_SEPARATOR = '.'
+
+const separators = [
+ { name: 'Comma', value: ',', },
+ { name: 'Semicolon', value: ';', },
+ { name: 'Colon', value: ':', },
+ { name: 'Pipe', value: '|', },
+ { name: 'Space', value: ' ', }
+]
+
+/**
+ * Get value from object or function.
+ *
+ * @param obj - A function or value.
+ * @returns The result of calling the function if it's a function, otherwise the value.
+ */
+const getValue = (obj: (() => T) | T): T => {
+ return 'function' === typeof obj ? (obj as () => T)() : obj
+}
+
+/**
+ * Safely get a value from a nested object using a path string. Returns default value if not found.
+ *
+ * @param obj - The object to get the value from.
+ * @param path - Dot-delimited string path (e.g. 'a.b.c').
+ * @param defaultValue - The fallback value if path is not found or value is null/undefined.
+ * @returns The value at the path or the default value.
+ */
+const ag = (obj: any, path: string, defaultValue: T | null = null): T | null => {
+ const keys = path.split(AG_SEPARATOR)
+ let at = obj
+
+ for (const key of keys) {
+ if ('object' === typeof at && null !== at && key in at) {
+ at = at[key]
+ } else {
+ return getValue(defaultValue)
+ }
+ }
+
+ return getValue(null === at ? defaultValue : at)
+}
+
+/**
+ * Set a value in a nested object using a dot-delimited path.
+ *
+ * @param obj - The object to modify.
+ * @param path - Dot-delimited string path (e.g. 'a.b.c').
+ * @param value - The value to set.
+ * @returns The original object with the updated value.
+ * @throws Error if a path segment is not an object.
+ */
+const ag_set = (obj: Record, path: string, value: any): Record => {
+ const keys = path.split(AG_SEPARATOR)
+ let at: any = obj
+
+ while (keys.length > 0) {
+ if (1 === keys.length) {
+ if ('object' === typeof at && null !== at) {
+ at[keys.shift()!] = value
+ } else {
+ throw new Error(`Cannot set value at this path (${path}) because it's not an object.`)
+ }
+ } else {
+ const key = keys.shift()!
+ if (!at[key] || 'object' !== typeof at[key]) {
+ at[key] = {}
+ }
+ at = at[key]
+ }
+ }
+
+ return obj
+}
+
+/**
+ * Wait for an element to appear in the DOM, then invoke a callback.
+ *
+ * @param sel - CSS selector for the target element.
+ * @param callback - Function to execute when the element is found.
+ */
+const awaitElement = (sel: string, callback: (el: Element, selector: string) => void): void => {
+ const $elm = document.querySelector(sel)
+ if ($elm) {
+ callback($elm, sel)
+ return
+ }
+
+ const interval = setInterval(() => {
+ const $elm = document.querySelector(sel)
+ if ($elm) {
+ clearInterval(interval)
+ callback($elm, sel)
+ }
+ }, 200)
+}
+
+/**
+ * Replace template tags in a string with values from a context object.
+ *
+ * @param text - The input string containing tags in `{key}` format.
+ * @param context - An object whose keys are used for tag replacement.
+ * @returns The string with all matching tags replaced.
+ */
+const r = (text: string, context: Record = {}): string => {
+ const tagLeft = '{'
+ const tagRight = '}'
+
+ if (!text.includes(tagLeft) || !text.includes(tagRight)) {
+ return text
+ }
+
+ const pattern = new RegExp(`${tagLeft}([\\w_.]+)${tagRight}`, 'g')
+ const matches = text.match(pattern)
+ if (!matches) return text
+
+ const replacements: Record = {}
+ matches.forEach(match => {
+ const key = match.slice(1, -1)
+ replacements[match] = String(ag(context, key, ''))
+ })
+
+ for (const key in replacements) {
+ text = text.replace(new RegExp(key, 'g'), String(replacements[key]))
+ }
+
+ return text
+}
+
+/**
+ * Dispatch a custom event on the global window object.
+ *
+ * @param eventName - The name of the custom event.
+ * @param detail - Optional detail payload to include in the event.
+ * @returns Whether the event was successfully dispatched.
+ */
+const dEvent = (eventName: string, detail: Record = {}): boolean => {
+ return window.dispatchEvent(new CustomEvent(eventName, { detail }))
+}
+
+/**
+ * Generate a pagination list based on current page, total pages, and delta range.
+ *
+ * @param current - The current active page number.
+ * @param last - The last page number.
+ * @param delta - How many pages to show before/after the current page.
+ * @returns An array of pagination entries including optional gaps.
+ */
+const makePagination = (current: number, last: number, delta: number = 5): Array<{ page: number; text: string; selected: boolean }> => {
+ const pagination: Array<{ page: number; text: string; selected: boolean }> = []
+ if (last < 2) {
+ return pagination
+ }
+
+ const strR = '-'.repeat(9 + `${last}`.length)
+ const left = current - delta
+ const right = current + delta + 1
+
+ for (let i = 1; i <= last; i++) {
+ if (1 === i || last === i || (i >= left && i < right)) {
+ if (i === left && i > 2) {
+ pagination.push({ page: 0, text: strR, selected: false })
+ }
+
+ pagination.push({ page: i, text: `Page #${i}`, selected: i === current })
+
+ if (i === right - 1 && i < last - 1) {
+ pagination.push({ page: 0, text: strR, selected: false })
+ }
+ }
+ }
+
+ return pagination
+}
+
+/**
+ * Safely encode a path string for use in a URL.
+ * Will manually encode '#' and ensure valid URI segments.
+ *
+ * @param item - The input path string.
+ * @returns The URL-encoded path.
+ */
+const encodePath = (item: string): string => {
+ if (!item) {
+ return item
+ }
+
+ item = item.replace(/#/g, '%23')
+
+ try {
+ return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
+ } catch (e) {
+ console.error('Error encoding path:', e, item)
+ return item
+ }
+}
+
+/**
+ * Request content from the API with automatic token handling and URL prefixing.
+ *
+ * @param url - The URL to request. If relative, it will be passed through the `uri` helper.
+ * @param options - Optional fetch options, automatically extended with common headers and credentials.
+ * @returns The fetch Response promise.
+ */
+const request = (url: string, options: RequestInit = {}): Promise => {
+ options.method = options.method || 'GET'
+ options.headers = options.headers || {}; (options as any).withCredentials = true
+
+ if (undefined === (options.headers as Record)['Content-Type']) {
+ if (!(options?.body instanceof FormData)) {
+ ; (options.headers as Record)['Content-Type'] = 'application/json'
+ }
+ }
+
+ if (undefined === (options.headers as Record)['Accept']) {
+ ; (options.headers as Record)['Accept'] = 'application/json'
+ }
+
+ if (url.startsWith('/')) {
+ options.credentials = 'same-origin'
+ }
+
+ return fetch(url.startsWith('/') ? uri(url) : url, options)
+}
+
+/**
+ * Remove ANSI color codes from a string.
+ *
+ * @param text - The text potentially containing ANSI codes.
+ * @returns A string without ANSI escape sequences.
+ */
+const removeANSIColors = (text: string): string => {
+ return text?.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') ?? text
+}
+
+/**
+ * Convert a decimal value to a 2-character hex string.
+ *
+ * @param dec - Decimal value between 0-255.
+ * @returns The hex string.
+ */
+const dec2hex = (dec: number): string => dec.toString(16).padStart(2, '0')
+
+/**
+ * Generate a random ID using crypto.
+ *
+ * @param len - The length of the ID in characters (must be even).
+ * @returns A random hex ID string.
+ */
+const makeId = (len: number = 40): string => Array.from(window.crypto.getRandomValues(new Uint8Array(len / 2)), dec2hex).join('')
+
+/**
+ * Return the basename of a given path.
+ *
+ * @param path - The input path.
+ * @param ext - Optional extension to strip from the basename.
+ * @returns The last segment of the path, minus the extension if matched.
+ */
+const basename = (path: string, ext: string = ''): string => {
+ if (!path) return ''
+ const segments = path.replace(/\\/g, '/').split('/')
+ let base = segments.pop() || ''
+ while (segments.length && base === '') {
+ base = segments.pop() || ''
+ }
+ if (ext && base.endsWith(ext) && base !== ext) {
+ base = base.substring(0, base.length - ext.length)
+ }
+ return base
+}
+
+/**
+ * Return the directory portion of a path.
+ *
+ * @param filePath - The input file path.
+ * @returns The directory part of the path.
+ */
+const dirname = (filePath: string): string => {
+ const lastIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
+ if (-1 === lastIndex) {
+ return '.'
+ }
+
+ if (0 === lastIndex) {
+ return filePath[0] ?? '.'
+ }
+
+ return filePath.substring(0, lastIndex)
+}
+
+/**
+ * Copy text to clipboard with optional notification and storage flag.
+ *
+ * @param str - The string to copy.
+ * @param notify - Whether to show a toast notification (default: true).
+ * @param store - Whether to persist the notification (optional).
+ */
+const copyText = (str: string, notify: boolean = true, store: boolean = false): void => {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(str).then(() => {
+ if (notify) toast.success('Text copied to clipboard.')
+ }).catch((error) => {
+ console.error('Failed to copy.', error)
+ if (notify) toast.error('Failed to copy to clipboard.')
+ })
+ return
+ }
+
+ const el = document.createElement('textarea')
+ el.value = str
+ document.body.appendChild(el)
+ el.select()
+ document.execCommand('copy')
+ document.body.removeChild(el)
+
+ if (notify) {
+ toast.success('Text copied to clipboard.', { store })
+ }
+}
+
+/**
+ * Trim delimiter characters from a string at specified positions.
+ *
+ * @param str - The input string to trim.
+ * @param delim - The delimiter character to trim.
+ * @param position - Where to trim the delimiter from: 'start', 'end', or 'both'. Defaults to 'both'.
+ * @returns The trimmed string.
+ * @throws Will throw an error if `delim` is not provided.
+ */
+const iTrim = (str: string, delim: string, position: 'start' | 'end' | 'both' = 'both'): string => {
+ if (!str) {
+ return str
+ }
+
+ if (!delim) {
+ throw new Error('Delimiter is required')
+ }
+
+ if (']' === delim) {
+ delim = '\\]'
+ }
+
+ if ('\\' === delim) {
+ delim = '\\\\'
+ }
+
+ if (['both', 'start'].includes(position)) {
+ str = str.replace(new RegExp(`^[${delim}]+`, 'g'), '')
+ }
+
+ if (['both', 'end'].includes(position)) {
+ str = str.replace(new RegExp(`[${delim}]+$`, 'g'), '')
+ }
+
+ return str
+}
+
+/**
+ * Trim delimiter characters from the end of a string.
+ *
+ * @param str - The input string to trim.
+ * @param delim - The delimiter character to trim.
+ * @returns The trimmed string.
+ */
+const eTrim = (str: string, delim: string): string => iTrim(str, delim, 'end')
+
+/**
+ * Trim delimiter characters from the start of a string.
+ *
+ * @param str - The input string to trim.
+ * @param delim - The delimiter character to trim.
+ * @returns The trimmed string.
+ */
+const sTrim = (str: string, delim: string): string => iTrim(str, delim, 'start')
+
+/**
+ * Uppercase the first character of the string.
+ *
+ * @param str - The input string.
+ * @returns The string with the first character capitalized.
+ */
+const ucFirst = (str: string): string => (!str) ? str : str.charAt(0).toUpperCase() + str.slice(1)
+
+/**
+ * Get the name of a separator based on its value
+ *
+ * @param {string} value - The separator value
+ *
+ * @returns {string} The name of the separator, or 'Unknown' if not found
+ */
+const getSeparatorsName = (value: string): string => {
+ const sep = separators.find(s => s.value === value)
+ return sep ? `${sep.name} (${value})` : 'Unknown'
+}
+
+/**
+ * Convert options to JSON
+ *
+ * @param {string} opts
+ *
+ * @returns {Promise} The converted options
+ */
+const convertCliOptions = async (opts: string): Promise => {
+ const response = await request('/api/yt-dlp/convert', {
+ method: 'POST',
+ body: JSON.stringify({ args: opts }),
+ });
+
+ const data = await response.json()
+ if (200 !== response.status) {
+ throw new Error(`Error: (${response.status}): ${data.error}`)
+ }
+
+ return data
+}
+
+/**
+ * Get query parameters from a URL string or the current location.
+ *
+ * @param url - The full URL or search string (default: current URL's search).
+ * @returns A key-value map of query parameters.
+ */
+const getQueryParams = (url: string = window.location.search): Record => {
+ return Object.fromEntries(new URLSearchParams(url).entries())
+}
+
+/**
+ * Build a download URL based on config and item metadata.
+ *
+ * @param config - The application config object.
+ * @param item - The item containing filename/folder.
+ * @param base - The base endpoint type (default: 'api/download').
+ * @returns The fully constructed download URI.
+ */
+const makeDownload = (config: any, item: { folder?: string; filename: string }, base: string = 'api/download'): string => {
+ let baseDir = 'api/player/m3u8/video/'
+ if ('m3u8' !== base) {
+ baseDir = `${base}/`
+ }
+
+ if (item.folder) {
+ item.folder = item.folder.replace(/#/g, '%23')
+ baseDir += item.folder + '/'
+ }
+
+ const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`
+ return uri('m3u8' === base ? `${url}.m3u8` : url)
+}
+
+/**
+ * Format bytes to a human-readable string.
+ *
+ * @param bytes - The number of bytes.
+ * @param decimals - Number of decimal places to include.
+ * @returns A formatted size string (e.g., '2.00 MiB').
+ */
+const formatBytes = (bytes: number, decimals: number = 2): string => {
+ if (!+bytes) {
+ return '0 Bytes'
+ }
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
+}
+
+/**
+ * Check if input has non-empty data.
+ *
+ * @param item - The input (object, array, or JSON string).
+ * @returns True if it contains data, false otherwise.
+ */
+const has_data = (item: any): boolean => {
+ if (!item) {
+ return false
+ }
+
+ if ('string' === typeof item) {
+ try {
+ item = JSON.parse(item)
+ } catch {
+ return true
+ }
+ }
+
+ try {
+ if ('object' === typeof item) return Object.keys(item).length > 0
+ return item.length > 0
+ } catch (e) {
+ console.error(e)
+ return false
+ }
+}
+
+/**
+ * Toggle a class on a DOM element. Supports array of class names.
+ *
+ * @param target - The HTML element to toggle classes on.
+ * @param className - Class name or array of class names.
+ */
+const toggleClass = (target: HTMLElement, className: string | string[]): void => {
+ if (Array.isArray(className)) {
+ className.forEach(cls => toggleClass(target, cls))
+ return
+ }
+
+ if (target.classList.contains(className)) {
+ target.classList.remove(className)
+ } else {
+ target.classList.add(className)
+ }
+}
+
+/**
+ * Remove specified fields from an object.
+ *
+ * @param item - Input object.
+ * @param fields - Keys to exclude.
+ * @returns A new object without the excluded keys.
+ */
+const cleanObject = >(item: T, fields: string[] = []): Partial => {
+ if (!item || typeof item !== 'object' || fields.length < 1) return item
+ const cleaned: Partial = {}
+ for (const key of Object.keys(item)) {
+ if (!fields.includes(key)) {
+ cleaned[key as keyof T] = item[key]
+ }
+ }
+ return cleaned
+}
+
+/**
+ * Prefix URL with baseURL if needed.
+ *
+ * @param u - The input path.
+ * @returns The fully prefixed URI.
+ */
+const uri = (u: string): string => {
+ if (!u || '/' === runtimeConfig.app.baseURL || !u.startsWith('/')) {
+ return u
+ }
+
+ if (u.startsWith(runtimeConfig.app.baseURL)) {
+ return u
+ }
+
+ return `${eTrim(runtimeConfig.app.baseURL, '/')}/${sTrim(u, '/')}`
+}
+
+/**
+ * Format seconds into HH:MM:SS or MM:SS.
+ *
+ * @param seconds - Time in seconds.
+ * @returns A time string.
+ */
+const formatTime = (seconds: number): string => {
+ const hrs = Math.floor(seconds / 3600)
+ const mins = Math.floor((seconds % 3600) / 60)
+ const secs = Math.floor(seconds % 60)
+ const pad = (n: number): string => n.toString().padStart(2, '0')
+
+ if (hrs > 0) {
+ return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`
+ }
+
+ if (mins > 0) {
+ return `${pad(mins)}:${pad(secs)}`
+ }
+
+ return `${secs}`
+}
+
+/**
+ * Pause execution for a number of seconds.
+ *
+ * @param seconds - Number of seconds to sleep.
+ * @returns A promise that resolves after the delay.
+ */
+const sleep = (seconds: number): Promise => new Promise(resolve => setTimeout(resolve, seconds * 1000))
+
+export {
+ convertCliOptions, separators, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
+ getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
+ request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
+ makeDownload,
+ formatBytes,
+ has_data,
+ toggleClass,
+ cleanObject,
+ uri,
+ formatTime,
+ sleep
+}
diff --git a/ui/utils/utils.ts b/ui/utils/utils.ts
deleted file mode 100644
index 5e646cc6..00000000
--- a/ui/utils/utils.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { convert_args_response } from "~/types/responses";
-
-const separators = [
- { name: 'Comma', value: ',', },
- { name: 'Semicolon', value: ';', },
- { name: 'Colon', value: ':', },
- { name: 'Pipe', value: '|', },
- { name: 'Space', value: ' ', }
-]
-
-/**
- * Get the name of a separator based on its value
- *
- * @param {string} value - The separator value
- *
- * @returns {string} The name of the separator, or 'Unknown' if not found
- */
-const getSeparatorsName = (value: string): string => {
- const sep = separators.find(s => s.value === value)
- return sep ? `${sep.name} (${value})` : 'Unknown'
-}
-
-
-/**
- * Convert options to JSON
- *
- * @param {string} opts
- *
- * @returns {Promise} The converted options
- */
-const convertCliOptions = async (opts: string): Promise => {
- const response = await request('/api/yt-dlp/convert', {
- method: 'POST',
- body: JSON.stringify({ args: opts }),
- });
-
- const data = await response.json()
- if (200 !== response.status) {
- throw new Error(`Error: (${response.status}): ${data.error}`)
- }
-
- return data
-}
-
-export { convertCliOptions, separators, getSeparatorsName }
diff --git a/ui/utils/ytdlp.ts b/ui/utils/ytdlp.ts
index efbb7ac5..7308b6cc 100644
--- a/ui/utils/ytdlp.ts
+++ b/ui/utils/ytdlp.ts
@@ -1,6 +1,10 @@
const NUMBER_RE = /\d+(?:\.\d+)?/;
function match_str(filterStr: string, dct: Record, incomplete: boolean | Set = false): boolean {
+ if (!filterStr) {
+ return true;
+ }
+
return filterStr
.split(/(? _match_one(filterPart.replace(/\\&/g, '&'), dct, incomplete));
@@ -9,15 +13,22 @@ function match_str(filterStr: string, dct: Record, incomplete: bool
function lookup_unit_table(unitTable: Record, s: string, strict = false): number | null {
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
const unitsRe = Object.keys(unitTable).map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
- const re = new RegExp(`^(?${numRe.source})\s*(?${unitsRe})\b`, strict ? '' : 'i');
+ const re = new RegExp(`^(?${numRe.source})\\s*(?${unitsRe})\\b`, strict ? '' : 'i');
const m = s.match(re);
- if (!m || !m.groups) return null;
+ if (!m?.groups?.num || !m.groups.unit) {
+ return null;
- const num = parseFloat(m.groups.num.replace(',', '.'));
- const mult = unitTable[m.groups.unit];
+ }
- return Math.round(num * mult);
+ const numStr = m.groups.num.replace(',', '.');
+ const unit = m.groups.unit;
+ const mult = unitTable[unit];
+ if (undefined === mult) {
+ return null;
+ }
+
+ return Math.round(parseFloat(numStr) * mult);
}
function parse_filesize(s: string | null): number | null {
@@ -35,73 +46,109 @@ function parse_filesize(s: string | null): number | null {
}
function parse_duration(s: string): number | null {
- if (!s.trim()) return null;
+ if (!s.trim()) {
+ return null;
+ }
const durationRe = /^(?:(?:(?\d+):)?(?\d+):)?(?\d+):(?\d{1,2})(?[.:]\d+)?Z?$/;
const m = s.match(durationRe);
- if (!m || !m.groups) return null;
+ if (!m?.groups) {
+ return null;
+ }
const { days, hours, mins, secs, ms } = m.groups;
return (
- (parseFloat(days || '0') * 86400) +
- (parseFloat(hours || '0') * 3600) +
- (parseFloat(mins || '0') * 60) +
- parseFloat(secs || '0') +
- parseFloat((ms || '0').replace(':', '.'))
+ (parseFloat(days ?? '0') * 86400) +
+ (parseFloat(hours ?? '0') * 3600) +
+ (parseFloat(mins ?? '0') * 60) + parseFloat(secs ?? '0') + parseFloat((ms ?? '0').replace(':', '.'))
);
}
function _match_one(filterPart: string, dct: Record, incomplete: boolean | Set): boolean {
- const STRING_OPERATORS: Record = {
- '*=': (a: string, v: string) => a.includes(v),
- '^=': (a: string, v: string) => a.startsWith(v),
- '$=': (a: string, v: string) => a.endsWith(v),
- '~=': (a: string, v: string) => new RegExp(v).test(a),
+ const STRING_OPERATORS: Record boolean> = {
+ '*=': (a, b) => a.includes(b),
+ '^=': (a, b) => a.startsWith(b),
+ '$=': (a, b) => a.endsWith(b),
+ '~=': (a, b) => new RegExp(b).test(a),
};
- const COMPARISON_OPERATORS: Record = {
- ...STRING_OPERATORS,
- '<=': (a: any, b: any) => a <= b,
- '<': (a: any, b: any) => a < b,
- '>=': (a: any, b: any) => a >= b,
- '>': (a: any, b: any) => a > b,
- '=': (a: any, b: any) => a == b,
+ const COMPARISON_OPERATORS: Record boolean> = {
+ '*=': STRING_OPERATORS['*=']!,
+ '^=': STRING_OPERATORS['^=']!,
+ '$=': STRING_OPERATORS['$=']!,
+ '~=': STRING_OPERATORS['~=']!,
+ '<=': (a, b) => a <= b,
+ '<': (a, b) => a < b,
+ '>=': (a, b) => a >= b,
+ '>': (a, b) => a > b,
+ '=': (a, b) => a == b,
};
- const match = filterPart.trim().match(/^([a-z_]+)\s*(!)?\s*([*^$~=<>]+)\s*(?:"(.+)"|'(.+)'|(.+))$/);
+ const isIncomplete = typeof incomplete === 'boolean' ? (_: string) => incomplete : (k: string) => incomplete.has(k);
- if (match) {
- const [, key, negation, op, qstr, sstr, ustr] = match;
- const value = qstr || sstr || ustr;
- const actual = dct[key];
+ const cmpOps = Object.keys(COMPARISON_OPERATORS).map(op => op.replace(/([.*+?^${}()|\[\]\\])/g, '\\$1')).join('|');
+ const operatorRe = new RegExp(`^(?[a-z_]+)\\s*(?!\\s*)?(?${cmpOps})(?\\s*\\?)?\\s*(?:(?["'])(?.+?)(?:\\k)|(?.+))$`);
- const compValue = parse_filesize(value) || parse_duration(value) || value;
+ const m = filterPart.trim().match(operatorRe);
+ if (m?.groups) {
+ const { key, negation, op, noneInclusive, quote, quoted, plain } = m.groups;
+ const unnegatedOp = COMPARISON_OPERATORS[op!];
+ if (!unnegatedOp) {
+ throw new Error(`Invalid operator '${op}'`);
+ }
- const _cmp = COMPARISON_OPERATORS[op];
- if (actual === undefined) return incomplete === true || (incomplete instanceof Set && incomplete.has(key));
+ const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
- return negation ? !_cmp(actual, compValue) : _cmp(actual, compValue);
+ let value = quoted ?? plain ?? '';
+ if (quote) value = value.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
+
+ const actual = dct[key!];
+ let numeric: number | null = null;
+
+ if (typeof actual === 'number') {
+ numeric = Number(value);
+ if (Number.isNaN(numeric)) {
+ numeric = parse_filesize(value) ?? parse_filesize(`${value}B`) ?? parse_duration(value);
+ }
+ }
+
+ if (numeric !== null && op! in STRING_OPERATORS) {
+ throw new Error(`Operator ${op} only supports string values!`);
+ }
+
+ if (actual == null) {
+ return isIncomplete(key!) || Boolean(noneInclusive);
+ }
+
+ return opFn(actual, numeric !== null ? numeric : value);
}
- const UNARY_OPERATORS: Record = {
- '': (v: any) => v != null,
- '!': (v: any) => v == null,
+ const UNARY_OPERATORS: Record boolean> = {
+ '': v => typeof v === 'boolean' ? v === true : v != null,
+ '!': v => typeof v === 'boolean' ? v === false : v == null,
};
- const unaryMatch = filterPart.trim().match(/^(!)?([a-z_]+)$/);
+ const unaryRe = new RegExp(`^(?!?)\\s*(?[a-z_]+)$`);
+ const mu = filterPart.trim().match(unaryRe);
+ if (mu?.groups) {
+ const { op, key } = mu.groups;
+ const actual = dct[key!];
- if (unaryMatch) {
- const [, op, key] = unaryMatch;
- const actual = dct[key];
+ if (actual === undefined && isIncomplete(key!)) {
+ return true;
+ }
- if (actual === undefined && (incomplete === true || (incomplete instanceof Set && incomplete.has(key)))) return true;
+ const unaryOp = UNARY_OPERATORS[op!];
- return UNARY_OPERATORS[op || ''](actual);
+ if (!unaryOp) {
+ throw new Error(`Invalid unary operator '${op}'`);
+ }
+
+ return unaryOp(actual);
}
throw new Error(`Invalid filter part '${filterPart}'`);
}
-
export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one }