helpers.ts450 lines · main
| 1 | import { UIEvent } from 'react' |
| 2 | import { v4 as _uuidV4 } from 'uuid' |
| 3 | |
| 4 | import type { TablesData } from '../data/tables/tables-query' |
| 5 | |
| 6 | export const uuidv4 = () => { |
| 7 | return _uuidV4() |
| 8 | } |
| 9 | |
| 10 | export const isAtBottom = ({ currentTarget }: UIEvent<HTMLElement>): boolean => { |
| 11 | return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight |
| 12 | } |
| 13 | |
| 14 | export const tryParseJson = (jsonString: any) => { |
| 15 | try { |
| 16 | const parsed = JSON.parse(jsonString) |
| 17 | return parsed |
| 18 | } catch (error) { |
| 19 | return undefined |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | export const minifyJSON = (prettifiedJSON: string) => { |
| 24 | try { |
| 25 | if (prettifiedJSON.trim() === '') { |
| 26 | return null |
| 27 | } |
| 28 | const res = JSON.stringify(JSON.parse(prettifiedJSON)) |
| 29 | if (!isNaN(Number(res))) { |
| 30 | return Number(res) |
| 31 | } else { |
| 32 | return res |
| 33 | } |
| 34 | } catch (err) { |
| 35 | throw err |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | export const prettifyJSON = (minifiedJSON: string) => { |
| 40 | try { |
| 41 | if (minifiedJSON && minifiedJSON.length > 0) { |
| 42 | return JSON.stringify(JSON.parse(minifiedJSON), undefined, 2) |
| 43 | } else { |
| 44 | return minifiedJSON |
| 45 | } |
| 46 | } catch (err) { |
| 47 | // dont need to throw error, just return text value |
| 48 | // Users have to fix format if they want to save |
| 49 | return minifiedJSON |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | export const removeJSONTrailingComma = (jsonString: string) => { |
| 54 | /** |
| 55 | * Remove trailing commas: Delete any comma immediately preceding the closing brace '}' or |
| 56 | * bracket ']' using a regular expression. |
| 57 | */ |
| 58 | return jsonString.replace(/,\s*(?=[\}\]])/g, '') |
| 59 | } |
| 60 | |
| 61 | export const timeout = (ms: number) => { |
| 62 | return new Promise((resolve) => setTimeout(resolve, ms)) |
| 63 | } |
| 64 | |
| 65 | export const getURL = () => { |
| 66 | const url = |
| 67 | process?.env?.NEXT_PUBLIC_SITE_URL && process.env.NEXT_PUBLIC_SITE_URL !== '' |
| 68 | ? process.env.NEXT_PUBLIC_SITE_URL |
| 69 | : process?.env?.NEXT_PUBLIC_VERCEL_BRANCH_URL && |
| 70 | process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL !== '' |
| 71 | ? process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL |
| 72 | : 'https://supabase.com/dashboard' |
| 73 | return url.includes('http') ? url : `https://${url}` |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Generates a random string using alpha characters |
| 78 | */ |
| 79 | export const makeRandomString = (length: number) => { |
| 80 | var result = '' |
| 81 | var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' |
| 82 | var charactersLength = characters.length |
| 83 | for (var i = 0; i < length; i++) { |
| 84 | result += characters.charAt(Math.floor(Math.random() * charactersLength)) |
| 85 | } |
| 86 | return result.toString() |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Get a subset of fields from an object |
| 91 | * @param {object} model |
| 92 | * @param {array} fields a list of properties to pluck. eg: ['first_name', 'last_name'] |
| 93 | */ |
| 94 | export const pluckObjectFields = (model: any, fields: any[]) => { |
| 95 | let o: any = {} |
| 96 | fields.forEach((field) => { |
| 97 | o[field] = model[field] |
| 98 | }) |
| 99 | return o |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Returns undefined if the string isn't parse-able |
| 104 | */ |
| 105 | export const tryParseInt = (str: string) => { |
| 106 | try { |
| 107 | const int = parseInt(str, 10) |
| 108 | return isNaN(int) ? undefined : int |
| 109 | } catch (error) { |
| 110 | return undefined |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // Used as checker for memoized components |
| 115 | export const propsAreEqual = (prevProps: any, nextProps: any) => { |
| 116 | try { |
| 117 | Object.keys(prevProps).forEach((key) => { |
| 118 | if (typeof prevProps[key] !== 'function') { |
| 119 | if (prevProps[key] !== nextProps[key]) { |
| 120 | throw new Error() |
| 121 | } |
| 122 | } |
| 123 | }) |
| 124 | return true |
| 125 | } catch (e) { |
| 126 | return false |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | export const formatBytes = ( |
| 131 | bytes: any, |
| 132 | decimals = 2, |
| 133 | size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB' |
| 134 | ) => { |
| 135 | const k = 1024 |
| 136 | const dm = decimals < 0 ? 0 : decimals |
| 137 | const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] |
| 138 | |
| 139 | if (bytes === 0 || bytes === undefined) return size !== undefined ? `0 ${size}` : '0 bytes' |
| 140 | |
| 141 | // Handle negative values |
| 142 | const isNegative = bytes < 0 |
| 143 | const absBytes = Math.abs(bytes) |
| 144 | |
| 145 | const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(absBytes) / Math.log(k)) |
| 146 | const formattedValue = parseFloat((absBytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] |
| 147 | |
| 148 | return isNegative ? '-' + formattedValue : formattedValue |
| 149 | } |
| 150 | |
| 151 | export const formatBytesMinMB = (bytes: any, decimals = 2) => { |
| 152 | if (bytes === 0 || bytes === undefined) return '0 MB' |
| 153 | const MB = 1024 * 1024 |
| 154 | if (Math.abs(bytes) < MB) return formatBytes(bytes, decimals, 'MB') |
| 155 | return formatBytes(bytes, decimals) |
| 156 | } |
| 157 | |
| 158 | export const snakeToCamel = (str: string) => |
| 159 | str.replace(/([-_][a-z])/g, (group: string) => |
| 160 | group.toUpperCase().replace('-', '').replace('_', '') |
| 161 | ) |
| 162 | |
| 163 | export const detectBrowser = () => { |
| 164 | if (!navigator) return undefined |
| 165 | |
| 166 | if (navigator.userAgent.indexOf('Chrome') !== -1) { |
| 167 | return 'Chrome' |
| 168 | } else if (navigator.userAgent.indexOf('Firefox') !== -1) { |
| 169 | return 'Firefox' |
| 170 | } else if (navigator.userAgent.indexOf('Safari') !== -1) { |
| 171 | return 'Safari' |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | export const detectOS = () => { |
| 176 | if (typeof window === 'undefined' || !window) return undefined |
| 177 | if (typeof navigator === 'undefined' || !navigator) return undefined |
| 178 | |
| 179 | const userAgent = window.navigator.userAgent.toLowerCase() |
| 180 | const macosPlatforms = /(macintosh|macintel|macppc|mac68k|macos)/i |
| 181 | const windowsPlatforms = /(win32|win64|windows|wince)/i |
| 182 | |
| 183 | if (macosPlatforms.test(userAgent)) { |
| 184 | return 'macos' |
| 185 | } else if (windowsPlatforms.test(userAgent)) { |
| 186 | return 'windows' |
| 187 | } else { |
| 188 | return undefined |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | export const getModKeyLabel = () => { |
| 193 | const os = detectOS() |
| 194 | return os === 'macos' ? '⌘' : 'Ctrl+' |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Convert a list of tables to SQL |
| 199 | * @param t - The list of tables |
| 200 | * @returns The SQL string |
| 201 | */ |
| 202 | export function tablesToSQL(t: TablesData) { |
| 203 | if (!Array.isArray(t)) return '' |
| 204 | const warning = |
| 205 | '-- WARNING: This schema is for context only and is not meant to be run.\n-- Table order and constraints may not be valid for execution.\n\n' |
| 206 | const sql = t |
| 207 | .map((table) => { |
| 208 | if (!table || !Array.isArray((table as any).columns)) return '' |
| 209 | |
| 210 | const columns = (table as { columns?: any[] }).columns ?? [] |
| 211 | const columnLines = columns.map((c) => { |
| 212 | let line = ` ${c.name} ${c.data_type}` |
| 213 | if (c.is_identity) { |
| 214 | line += ' GENERATED ALWAYS AS IDENTITY' |
| 215 | } |
| 216 | if (c.is_nullable === false) { |
| 217 | line += ' NOT NULL' |
| 218 | } |
| 219 | if (c.default_value !== null && c.default_value !== undefined) { |
| 220 | line += ` DEFAULT ${c.default_value}` |
| 221 | } |
| 222 | if (c.is_unique) { |
| 223 | line += ' UNIQUE' |
| 224 | } |
| 225 | if (c.check) { |
| 226 | line += ` CHECK (${c.check})` |
| 227 | } |
| 228 | return line |
| 229 | }) |
| 230 | |
| 231 | const constraints: string[] = [] |
| 232 | |
| 233 | if (Array.isArray(table.primary_keys) && table.primary_keys.length > 0) { |
| 234 | const pkCols = table.primary_keys.map((pk: any) => pk.name).join(', ') |
| 235 | constraints.push(` CONSTRAINT ${table.name}_pkey PRIMARY KEY (${pkCols})`) |
| 236 | } |
| 237 | |
| 238 | if (Array.isArray(table.relationships)) { |
| 239 | table.relationships.forEach((rel: any) => { |
| 240 | if (rel && rel.source_table_name === table.name) { |
| 241 | constraints.push( |
| 242 | ` CONSTRAINT ${rel.constraint_name} FOREIGN KEY (${rel.source_column_name}) REFERENCES ${rel.target_table_schema}.${rel.target_table_name}(${rel.target_column_name})` |
| 243 | ) |
| 244 | } |
| 245 | }) |
| 246 | } |
| 247 | |
| 248 | const allLines = [...columnLines, ...constraints] |
| 249 | return `CREATE TABLE ${table.schema}.${table.name} (\n${allLines.join(',\n')}\n);` |
| 250 | }) |
| 251 | .join('\n') |
| 252 | return warning + sql |
| 253 | } |
| 254 | |
| 255 | /** |
| 256 | * Pluralize a word based on a count |
| 257 | */ |
| 258 | export function pluralize(count: number, singular: string, plural?: string) { |
| 259 | return count === 1 ? singular : plural || singular + 's' |
| 260 | } |
| 261 | |
| 262 | export const isValidHttpUrl = (value: string) => { |
| 263 | let url: URL |
| 264 | try { |
| 265 | url = new URL(value) |
| 266 | } catch (_) { |
| 267 | return false |
| 268 | } |
| 269 | return url.protocol === 'http:' || url.protocol === 'https:' |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Remove markdown code blocks (fenced and inline) from text |
| 274 | */ |
| 275 | export const stripMarkdownCodeBlocks = (text: string): string => { |
| 276 | // Remove fenced code blocks (```...```) |
| 277 | const withoutFenced = text.replace(/```[\s\S]*?```/g, '') |
| 278 | // Remove inline code (`...`) |
| 279 | return withoutFenced.replace(/`[^`]+`/g, '') |
| 280 | } |
| 281 | |
| 282 | interface ExtractUrlsOptions { |
| 283 | excludeCodeBlocks?: boolean |
| 284 | excludeTemplates?: boolean |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Extract URLs from text using regex for URL detection |
| 289 | * Matches URLs with protocols (http/https) and common domain patterns |
| 290 | * @param text - The text to extract URLs from |
| 291 | * @param options - Optional filtering options |
| 292 | * @returns Array of extracted URLs with trailing punctuation removed |
| 293 | */ |
| 294 | export const extractUrls = (text: string, options?: ExtractUrlsOptions): string[] => { |
| 295 | const { excludeCodeBlocks = false, excludeTemplates = false } = options ?? {} |
| 296 | |
| 297 | let processedText = text |
| 298 | if (excludeCodeBlocks) { |
| 299 | processedText = stripMarkdownCodeBlocks(processedText) |
| 300 | } |
| 301 | |
| 302 | // Regex matches URLs with protocols (http/https) |
| 303 | // Handles: domains, ports, paths, query params, and fragments |
| 304 | // Pattern: https?://domain(:port)?(/path)?(?query)?(#fragment)? |
| 305 | const urlRegex = /https?:\/\/(?:[-\w.])+(?::\d+)?(?:\/(?:[\w\/_.~!*'();:@&=+$,?#[\]%-])*)?/gi |
| 306 | |
| 307 | const urls: string[] = [] |
| 308 | let match |
| 309 | |
| 310 | while ((match = urlRegex.exec(processedText)) !== null) { |
| 311 | // Remove trailing punctuation that might have been captured (common in text) |
| 312 | const url = match[0].replace(/[.,;:!?)*]+$/, '') |
| 313 | |
| 314 | if (excludeTemplates) { |
| 315 | // Skip URLs that were truncated at an angle bracket (template URL) |
| 316 | const endPos = match.index + match[0].length |
| 317 | if (processedText[endPos] === '<') { |
| 318 | continue |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | urls.push(url) |
| 323 | } |
| 324 | |
| 325 | return urls |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Helper function to remove comments from SQL. |
| 330 | * Disclaimer: Doesn't work as intended for nested comments. |
| 331 | */ |
| 332 | export const removeCommentsFromSql = (sql: string) => { |
| 333 | // Removing single-line comments: |
| 334 | let cleanedSql = sql.replace(/--.*$/gm, '') |
| 335 | |
| 336 | // Removing multi-line comments: |
| 337 | cleanedSql = cleanedSql.replace(/\/\*[\s\S]*?\*\//gm, '') |
| 338 | |
| 339 | return cleanedSql |
| 340 | } |
| 341 | |
| 342 | const formatSemver = (version: string) => { |
| 343 | // e.g supabase-postgres-14.1.0.88 |
| 344 | // There's 4 segments instead so we can't use the semver package |
| 345 | const segments = version.split('supabase-postgres-') |
| 346 | const semver = segments[segments.length - 1] |
| 347 | |
| 348 | // e.g supabase-postgres-14.1.0.99-vault-rc1 |
| 349 | const formattedSemver = semver.split('-')[0] |
| 350 | |
| 351 | return formattedSemver |
| 352 | } |
| 353 | |
| 354 | export const getSemanticVersion = (version: string) => { |
| 355 | if (!version) return 0 |
| 356 | |
| 357 | const formattedSemver = formatSemver(version) |
| 358 | return Number(formattedSemver.split('.').join('')) |
| 359 | } |
| 360 | |
| 361 | export const getDatabaseMajorVersion = (version: string) => { |
| 362 | if (!version) return 0 |
| 363 | |
| 364 | const formattedSemver = formatSemver(version) |
| 365 | return Number(formattedSemver.split('.')[0]) |
| 366 | } |
| 367 | |
| 368 | const deg2rad = (deg: number) => { |
| 369 | return deg * (Math.PI / 180) |
| 370 | } |
| 371 | |
| 372 | export const getDistanceLatLonKM = (lat1: number, lon1: number, lat2: number, lon2: number) => { |
| 373 | const R = 6371 // Radius of the earth in kilometers |
| 374 | const dLat = deg2rad(lat2 - lat1) // deg2rad below |
| 375 | const dLon = deg2rad(lon2 - lon1) |
| 376 | const a = |
| 377 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + |
| 378 | Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2) |
| 379 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) |
| 380 | const d = R * c // Distance in KM |
| 381 | return d |
| 382 | } |
| 383 | |
| 384 | const currencyFormatterDefault = Intl.NumberFormat('en-US', { |
| 385 | style: 'currency', |
| 386 | currency: 'USD', |
| 387 | minimumFractionDigits: 2, |
| 388 | maximumFractionDigits: 2, |
| 389 | }) |
| 390 | |
| 391 | const currencyFormatterSmallValues = Intl.NumberFormat('en-US', { |
| 392 | style: 'currency', |
| 393 | currency: 'USD', |
| 394 | minimumFractionDigits: 0, |
| 395 | }) |
| 396 | |
| 397 | export const formatCurrency = (amount: number | undefined | null): string | null => { |
| 398 | if (amount === undefined || amount === null) { |
| 399 | return null |
| 400 | } else if (amount > 0 && amount < 0.01) { |
| 401 | return currencyFormatterSmallValues.format(amount) |
| 402 | } else { |
| 403 | return currencyFormatterDefault.format(amount) |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | /** |
| 408 | * [Joshen] This is to address an incredibly weird bug that's happening between Data Grid + Shadcn ContextMenu + Shadcn Overlay |
| 409 | * This trifecta is causing a pointer events none style getting left behind on the body element which makes the dashboard become |
| 410 | * unresponsive, hence the attempt to clean things up here |
| 411 | * |
| 412 | * Timeout is made configurable as I've observed it requires a higher timeout sometimes (e.g when closing the cron job sheet) |
| 413 | */ |
| 414 | export const cleanPointerEventsNoneOnBody = (timeoutMs: number = 300) => { |
| 415 | if (typeof window !== 'undefined') { |
| 416 | setTimeout(() => { |
| 417 | if (document.body.style.pointerEvents === 'none') { |
| 418 | document.body.style.pointerEvents = '' |
| 419 | } |
| 420 | }, timeoutMs) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | export const createWrappedSymbol = (name: string, display: string): Symbol => { |
| 425 | const sym = Symbol(name) |
| 426 | const wrapper = Object(sym) |
| 427 | |
| 428 | wrapper.toString = () => display |
| 429 | |
| 430 | Object.freeze(wrapper) |
| 431 | return wrapper |
| 432 | } |
| 433 | |
| 434 | // Intentional for generic use; does not affect type safety since this branch is |
| 435 | // unreachable. |
| 436 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 437 | export function neverGuard(_: never): any {} |
| 438 | |
| 439 | export function isObject( |
| 440 | maybeObject: unknown |
| 441 | ): maybeObject is Record<string | symbol | number, unknown> { |
| 442 | return maybeObject !== null && typeof maybeObject === 'object' && !Array.isArray(maybeObject) |
| 443 | } |
| 444 | |
| 445 | export function isObjectContainingKeys<T extends string | symbol | number>( |
| 446 | maybeObject: unknown, |
| 447 | keys: Array<T> |
| 448 | ): maybeObject is { [K in T]: unknown } { |
| 449 | return isObject(maybeObject) && keys.every((key) => key in maybeObject) |
| 450 | } |