helpers.ts 13 KB

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