From bdbc2f5b7a5031bf1484c57df3aae72f13bf261e Mon Sep 17 00:00:00 2001 From: arabcoders Date: Wed, 23 Jul 2025 19:34:08 +0300 Subject: [PATCH] migrate more code to ts --- .vscode/settings.json | 14 +- app/routes/api/conditions.py | 7 +- ui/components/ConditionForm.vue | 41 +- ui/components/NewDownload.vue | 8 +- ui/components/Settings.vue | 4 +- ui/utils/cache.js | 94 ---- ui/utils/cache.ts | 64 +++ ui/utils/{embedable.js => embedable.ts} | 51 +- ui/utils/index.js | 502 -------------------- ui/utils/index.ts | 599 ++++++++++++++++++++++++ ui/utils/utils.ts | 45 -- ui/utils/ytdlp.ts | 133 ++++-- 12 files changed, 838 insertions(+), 724 deletions(-) delete mode 100644 ui/utils/cache.js create mode 100644 ui/utils/cache.ts rename ui/utils/{embedable.js => embedable.ts} (54%) delete mode 100644 ui/utils/index.js create mode 100644 ui/utils/index.ts delete mode 100644 ui/utils/utils.ts 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: + +
-
{{ show_data() }}
- @@ -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 }