import { useStorage } from '@vueuse/core'; import type { convert_args_response, Paginated } from '~/types/responses'; import type { StoreItem } from '~/types/store'; const AG_SEPARATOR = '.'; const APP_TITLE = 'YTPTube'; 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 })); }; /** * Safely encode a path string for use in a URL. * * @param item - The input path string. * @returns The URL-encoded path. */ const encodePath = (item: string): string => { if (!item) { return item; } return item .split('/') .map((segment) => { try { const decoded = decodeURIComponent(segment); const reEncoded = encodeURIComponent(decoded); if (reEncoded === segment) { return segment; } } catch { // Decoding failed, segment has invalid encoding } const placeholders: string[] = []; const _PREFIX = `_YTP${Math.random().toString(36).substring(2, 8).toUpperCase()}_`; const _SUFFIX = `_YTP${Math.random().toString(36).substring(2, 8).toUpperCase()}_`; let processed = segment.replace(/%[0-9A-Fa-f]{2}/g, (match) => { const index = placeholders.length; placeholders.push(match); return `${_PREFIX}${index}${_SUFFIX}`; }); processed = encodeURIComponent(processed); const placeholderRegex = new RegExp( `${_PREFIX.replace(/_/g, '_')}(\\d+)${_SUFFIX.replace(/_/g, '_')}`, 'g', ); return processed.replace( placeholderRegex, (_match, index: string) => placeholders[parseInt(index)] || '', ); }) .join('/'); }; /** * 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 & { timeout?: number } = {}, ): Promise => { const { timeout, ...fetchOptions } = options; fetchOptions.method = fetchOptions.method || 'GET'; fetchOptions.headers = fetchOptions.headers || {}; (fetchOptions as any).withCredentials = true; if (undefined === (fetchOptions.headers as Record)['Content-Type']) { if (!(options?.body instanceof FormData)) { (fetchOptions.headers as Record)['Content-Type'] = 'application/json'; } } if (undefined === (fetchOptions.headers as Record)['Accept']) { (fetchOptions.headers as Record)['Accept'] = 'application/json'; } if (url.startsWith('/')) { fetchOptions.credentials = 'same-origin'; } let controller: AbortController | undefined; let timer: ReturnType | undefined; if (typeof timeout === 'number' && timeout > 0) { controller = new AbortController(); fetchOptions.signal = controller.signal; timer = setTimeout(() => controller!.abort(`Request timed out.`), timeout * 1000); } return fetch(url.startsWith('/') ? uri(url) : url, fetchOptions).finally(() => { if (timer) { clearTimeout(timer); } }); }; /** * 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( /* eslint-disable-next-line no-control-regex */ /[\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 => { const toast = useNotification(); 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) { const toast = useNotification(); 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'); } // Escape special regex characters for use in character class // Characters that need escaping in character classes: \ ] ^ - const escapedDelim = delim.replace(/[\\^\-\]]/g, '\\$&'); if (['both', 'start'].includes(position)) { str = str.replace(new RegExp(`^[${escapedDelim}]+`, 'g'), ''); } if (['both', 'end'].includes(position)) { str = str.replace(new RegExp(`[${escapedDelim}]+$`, '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)); const normalizePresetName = (name: string): string => name.trim().toLowerCase().replace(/\s+/g, '_'); const prettyName = (name: string): string => name.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); /** * 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'). * @param playlist - Whether to generate a playlist URL (default: false). * @returns The fully constructed download URI. */ const makeDownload = ( config: any, item: StoreItem | { folder?: string; filename: string }, base: string = 'api/download', playlist: boolean = false, ): string => { let baseDir = 'api/player/m3u8/video/'; if ('m3u8' !== base) { baseDir = `${base}/`; } if (item.folder) { baseDir += item.folder.replace(/#/g, '%23') + '/'; } if (!item.filename) { return ''; } const url = `/${sTrim(baseDir, '/')}${encodePath(item.filename)}`; return uri('m3u8' === base || true === playlist ? `${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 => { const runtimeConfig = useRuntimeConfig(); 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)); /** * Waits for the test function to return a truthy value. * * @param test - The function to test * @param timeout_ms - The maximum time to wait in milliseconds. * @param frequency - The frequency to check the test function in milliseconds. * * @returns The result of the test function. */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type const awaiter = async (test: Function, timeout_ms: number = 20 * 1000, frequency: number = 200) => { if (typeof test != 'function') { throw new Error('test should be a function in awaiter(test, [timeout_ms], [frequency])'); } const isNotTruthy = (val: any) => val === undefined || val === false || val === null || val.length === 0; const endTime: number = Date.now() + timeout_ms; let result = test(); while (isNotTruthy(result)) { if (Date.now() > endTime) { return false; } await sleep(frequency); result = test(); } return result; }; /** * Encode a JavaScript object into a URL-safe base64 string. * * @param obj - The object to encode. * @returns A URL-safe base64-encoded string. */ const encode = (obj: Record): string => { const jsonStr = JSON.stringify(obj); const utf8Bytes = new TextEncoder().encode(jsonStr); const binary = String.fromCharCode(...utf8Bytes); const base64 = btoa(binary); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }; /** * Decode a URL-safe base64 string into an object. * * @param str - The URL-safe base64-encoded string. * @returns The decoded JavaScript object. */ const decode = (str: string): object => { const base64 = str .replace(/-/g, '+') .replace(/_/g, '/') .padEnd(str.length + ((4 - (str.length % 4)) % 4), '='); const binary = atob(base64); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const jsonStr = new TextDecoder().decode(bytes); return JSON.parse(jsonStr); }; let opacityLockCount = 0; const getStorageValue = (key: string, defaultValue: T, missingValue: T = defaultValue): T => { const stored = useStorage(key, defaultValue); if (!stored || typeof stored !== 'object' || !('value' in stored)) { return missingValue; } return (stored.value === undefined ? defaultValue : stored.value) as T; }; const setBodyOpacity = (value: string): boolean => { const body = document.querySelector('body'); if (!body) { return false; } body.style.opacity = value; return true; }; const clearBodyOpacity = (): boolean => { const body = document.querySelector('body'); if (!body) { return false; } body.style.removeProperty('opacity'); return true; }; const syncOpacity = (): boolean => { if (!getStorageValue('random_bg', true, false)) { opacityLockCount = 0; return clearBodyOpacity(); } if (opacityLockCount > 0) { return setBodyOpacity('1.0'); } return setBodyOpacity(String(getStorageValue('random_bg_opacity', 0.95))); }; const disableOpacity = (): boolean => { if (!getStorageValue('random_bg', true, false)) { opacityLockCount = 0; return false; } opacityLockCount += 1; return setBodyOpacity('1.0'); }; const enableOpacity = (): boolean => { if (!getStorageValue('random_bg', true, false)) { opacityLockCount = 0; return false; } opacityLockCount = Math.max(0, opacityLockCount - 1); return syncOpacity(); }; const stripPath = (base_path: string, real_path: string): string => { if (!base_path) { return real_path; } return real_path.replace(base_path, '').replace(/^\//, ''); }; const shortPath = (path: string, prefix: string = '...'): string => { if (typeof path !== 'string') { return path; } const hasTrailingSlash = /\/$/.test(path); const clean = path.replace(/\/+$/, ''); const parts = clean.split('/').filter(Boolean); if (parts.length <= 1) { return path; } return `${prefix}/${parts.at(-1)}${hasTrailingSlash ? '/' : ''}`; }; const isDownloadSkipped = ( item: Pick | null | undefined, ): boolean => Boolean(item && item.status === 'finished' && item.download_skipped); /** * Recursively test if a value (including nested objects/arrays) contains a query string. * - Plain queries match keys or values (case-insensitive). * - key:value queries require the value to be under a matching key in the path. * * @param value - Value to search within. * @param query - Raw query string. * @param seen - Optional WeakSet to prevent circular reference loops. * @param kv - Internal: parsed key/value pair when using key:value mode. * @param keyMatched - Internal: whether current recursion path matched the key. */ const deepIncludes = ( value: unknown, query: string, seen: WeakSet = new WeakSet(), kv: { key: string; val: string } | null = null, keyMatched: boolean = false, ): boolean => { const normalized = query.trim().toLowerCase(); if (!normalized || null === value || undefined === value) { return false; } const pair = kv ?? (() => { const idx = normalized.indexOf(':'); if (idx <= 0 || idx >= normalized.length - 1) { return null; } const key = normalized.slice(0, idx).trim(); const val = normalized.slice(idx + 1).trim(); if (!key || !val) { return null; } return { key, val }; })(); const matchPrimitive = (val: unknown, q: string): boolean => String(val).toLowerCase().includes(q); if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { if (!pair) { return matchPrimitive(value, normalized); } return keyMatched && matchPrimitive(value, pair.val); } if (Array.isArray(value)) { return value.some((entry) => deepIncludes(entry, normalized, seen, pair, keyMatched)); } if ('object' === typeof value) { const obj = value as Record; if (seen.has(obj)) { return false; } seen.add(obj); for (const [key, val] of Object.entries(obj)) { const keyLower = key.toLowerCase(); if (!pair && keyLower.includes(normalized)) { return true; } const nextKeyMatched = pair ? keyMatched || keyLower.includes(pair.key) : keyMatched; if (deepIncludes(val, normalized, seen, pair, nextKeyMatched)) { return true; } } } return false; }; const getPath = (basePath: string, item: StoreItem): string => { if (!item.folder && ((!item.filename && item.download_dir === basePath) || !item.download_dir)) { return shortPath(basePath); } if (!item?.filename) { return stripPath( eTrim(basePath, '/'), '/' + sTrim(eTrim(item.download_dir || item.folder, '/'), '/'), ); } return stripPath( eTrim(basePath, '/'), '/' + eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/'), ); }; const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => { if (item?.extras?.thumbnail) { return uri(item.extras.thumbnail); } return fallback ? uri('/images/placeholder.png') : ''; }; const getHistoryImage = (item: StoreItem, fallback: boolean = true): string => { if (item._id && item.filename) { return uri(`/api/history/${encodeURIComponent(item._id)}/thumbnail`); } return getRemoteImage(item, fallback); }; const getImage = (basePath: string, item: StoreItem, fallback: boolean = true): string => { if (item.sidecar?.image && item.sidecar.image.length > 0) { return uri( '/api/download/' + encodeURIComponent(stripPath(basePath, item.sidecar.image[0]?.file || '')), ); } return getRemoteImage(item, fallback); }; const parse_list_response = async (json: unknown): Promise> => { if ('function' === typeof (json as any).then) { json = await (json as Promise); } if (!json || 'object' !== typeof json) { return { items: [], pagination: { page: 1, per_page: 20, total: 0, total_pages: 0, has_next: false, has_prev: false, }, }; } const payload = json as Paginated; const items = Array.isArray(payload.items) ? payload.items : []; const pagination = { page: Number(payload.pagination?.page ?? 1), per_page: Number(payload.pagination?.per_page ?? 20), total: Number(payload.pagination?.total ?? 0), total_pages: Number(payload.pagination?.total_pages ?? 0), has_next: Boolean(payload.pagination?.has_next ?? false), has_prev: Boolean(payload.pagination?.has_prev ?? false), }; return { items: items as T[], pagination }; }; const parse_api_response = async (json: unknown): Promise => { if ('function' === typeof (json as any).then) { json = await (json as Promise); } return json as T; }; const parse_api_error = async (json: unknown): Promise => { if ('function' === typeof (json as any).then) { json = await (json as Promise); } if (!json || 'object' !== typeof json) { return 'Unknown error occurred'; } const payload = json as { error?: string; message?: string; detail?: string | Array<{ loc: string[]; msg: string; type: string }>; }; let extra_detail = ''; if (Array.isArray(payload.detail)) { const errors = payload.detail.map((err: any) => { if ('object' === typeof err && err.loc && err.msg) { const field = Array.isArray(err.loc) ? err.loc[err.loc.length - 1] : 'unknown'; return `${field}: ${err.msg}`; } return String(err); }); extra_detail = errors.join(', '); } if (payload.error) { return String(payload.error + (extra_detail ? ` - ${extra_detail}` : '')); } if (payload.message) { return String(payload.message + (extra_detail ? ` - ${extra_detail}` : '')); } if (extra_detail) { return extra_detail; } if ('string' === typeof payload.detail) { return payload.detail; } return 'Unknown error occurred'; }; const formatPageTitle = (title?: string | null): string => { const normalized = title?.trim(); if (!normalized || normalized === APP_TITLE) { return APP_TITLE; } return `${normalized} | ${APP_TITLE}`; }; export { APP_TITLE, separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst, normalizePresetName, prettyName, getValue, ag, ag_set, awaitElement, r, copyText, dEvent, encodePath, request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams, makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime, sleep, awaiter, encode, decode, syncOpacity, disableOpacity, enableOpacity, stripPath, shortPath, isDownloadSkipped, deepIncludes, getPath, getImage, getHistoryImage, getRemoteImage, parse_list_response, parse_api_response, parse_api_error, formatPageTitle, };