Skip to content
Snippets Groups Projects
util.ts 11.5 KiB
Newer Older
  • Learn to ignore specific revisions
  • import { computedAsync, useNow } from '@vueuse/core'
    
    import { formatDistanceToNow, formatISO, parseISO } from 'date-fns'
    import DateFnEnUsLocale from 'date-fns/locale/en-US'
    import DOMPurify from 'dompurify'
    
    import {
      computed,
      ComputedGetter,
      ComputedRef,
    
      MaybeRefOrGetter,
    
      shallowReadonly,
    
      watch,
      watchEffect,
    } from 'vue'
    
    import { useI18n } from '@/i18n'
    
    import { SteeringUser } from '@/stores/auth'
    
    
    const dateFnLocales = {
      de: () => import('date-fns/locale/de'),
    }
    
    
    export function computedIter<T>(fn: ComputedGetter<Iterable<T>>): ComputedRef<T[]> {
      return computed(() => Array.from(fn()))
    }
    
    
    export const useId = (() => {
      let _id = 0
      return function useId(prefix = 'component') {
        return readonly(ref(`${prefix}-${_id++}`))
      }
    })()
    
    export function useAsyncFunction<F extends (...args: never[]) => Promise<unknown>>(fn: F) {
    
      const isProcessing = ref(false)
    
      const error = ref<Error | undefined>()
    
      async function wrapper(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> {
    
        isProcessing.value = true
    
          return (await fn(...args)) as Awaited<ReturnType<F>>
    
        } catch (e) {
          if (e instanceof Error) {
            error.value = e
          } else {
            error.value = new Error(String(e))
          }
          throw error.value
    
        } finally {
    
          isProcessing.value = false
    
      return { fn: wrapper, error, isProcessing }
    
    type UseFormattedISODateOptions = {
      representation?: 'date' | 'time' | 'complete' | undefined
      stripOffset?: boolean | undefined
    }
    
    export function useFormattedISODate(
      date: Ref<Date>,
      defaultValue?: MaybeRefOrGetter<Date>,
      options?: UseFormattedISODateOptions | undefined,
    ): ComputedRef<string>
    export function useFormattedISODate(
      date: Ref<Date | null | undefined>,
      defaultValue?: MaybeRefOrGetter<Date>,
      options?: UseFormattedISODateOptions | undefined,
    ): ComputedRef<string | undefined>
    export function useFormattedISODate(
      date: Ref<Date | null | undefined>,
      defaultValue?: MaybeRefOrGetter<Date> | undefined,
      options?: UseFormattedISODateOptions | undefined,
    ) {
      const offsetPattern = /(Z|\+\d\d:\d\d)$/
      function maybeStripOffset(isoDate: string) {
        return options?.stripOffset ? isoDate.replace(offsetPattern, '') : isoDate
      }
    
      function maybeAddOffset(isoDate: string, currentDate: Date | undefined) {
        if (!options?.stripOffset) return isoDate
        const match = isoDate.match(offsetPattern)
        if (match) return isoDate
        const _currentDate = currentDate ?? new Date()
        const offset = (formatISO(_currentDate).match(offsetPattern) as RegExpMatchArray)[0]
        return isoDate + offset
      }
    
    
      return computed({
        get() {
    
          return date.value
            ? maybeStripOffset(
                formatISO(date.value, { representation: options?.representation ?? 'complete' }),
              )
            : null
    
        set(dateValue: string | null | undefined) {
          const _defaultValue = toValue(defaultValue)
          const offsetRefDate = date.value ?? _defaultValue
          date.value = dateValue
            ? parseISO(maybeAddOffset(dateValue, offsetRefDate))
            : _defaultValue ?? null
    
    
    export function getClosestSlot(slotDurationMinutes: number, date?: Date): Date {
      date = date ?? new Date()
      const slotDurationMillis = slotDurationMinutes * 60 * 1000
      const slots = Math.floor(date.getTime() / slotDurationMillis)
      const closestSlotTimestamp = slots * slotDurationMillis
      return new Date(closestSlotTimestamp)
    }
    
    export function getNextAvailableSlot(slotDurationMinutes: number, date?: Date): Date {
      date = date ?? new Date()
      const slotDurationMillis = slotDurationMinutes * 60 * 1000
      const slots = Math.ceil(date.getTime() / slotDurationMillis)
      const nextSlotTimestamp = slots * slotDurationMillis
      return new Date(nextSlotTimestamp)
    }
    
    export function ensureDate(date: Date | string) {
      return typeof date === 'string' ? parseISO(date) : date
    }
    
    export function stripSecondsFromTimeString(time: string) {
      return /\d+:\d+:\d+$/.test(time) ? time.replace(/:\d+$/, '') : time
    }
    
    export function calculateDurationSeconds(start: Date | string, end: Date | string): number {
      return Math.abs(ensureDate(end).getTime() - ensureDate(start).getTime()) / 1000
    
    export function sanitizeHTML(
      html: string,
      preset?: 'inline-noninteractive' | 'safe-html' | 'strip',
    ): string {
      const inlineElements = [
        'b',
        'big',
        'i',
        'small',
        'tt',
        'abbr',
        'acronym',
        'cite',
        'code',
        'dfn',
        'em',
        'kbd',
        'strong',
        'samp',
        'var',
        'br',
        'q',
        's',
        'span',
        'sub',
        'sup',
      ]
    
      if (preset === 'inline-noninteractive') {
        return DOMPurify.sanitize(html, {
          ALLOWED_TAGS: inlineElements,
          ALLOWED_ATTR: ['style', 'title'],
        })
      }
    
      if (preset === 'safe-html') {
        return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
      }
    
      return DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
    }
    
    export function secondsToDurationString(seconds: number): string {
    
      const isNegative = seconds < 0
      const _seconds = Math.abs(Math.round(seconds))
      if (_seconds === 0) return `0s`
      const h = Math.floor(_seconds / 3600)
      const m = Math.floor((_seconds % 3600) / 60)
      const s = Math.round(_seconds % 60)
      const representation = `${h ? h + 'h ' : ''}${m ? m + 'min ' : ''}${s ? s + 's' : ''}`.trim()
      return isNegative ? `-${representation}` : representation
    
    
    export function clamp(value: number, min: number, max: number): number {
      return Math.min(Math.max(value, min), max)
    }
    
    export function mapToDomain(
      value: number,
      inputDomain: [number, number],
      outputDomain: [number, number],
    ) {
      const [x1, y1] = inputDomain
      const [x2, y2] = outputDomain
      value = clamp(value, x1, y1)
      return ((value - x1) * (y2 - x2)) / (y1 - x1) + x2
    }
    
    
    export function asyncWritableComputed<T>(
      initialValue: T,
      config: {
        get: () => Promise<T>
        set: (value: T) => Promise<void> | void
      },
    ) {
      const data = shallowRef(initialValue)
    
      watchEffect(async () => {
        data.value = await config.get()
      })
    
      return computed<T>({
        get: () => data.value,
        set: (value: T) => {
          config.set(value)
        },
      })
    }
    
    
    export function matchesSearch(sourceString: string, searchString: string) {
      const source = sourceString.toLowerCase()
      const terms = searchString
        .toLowerCase()
        .split(/\s+/)
        .map((s) => s.trim())
        .filter((s) => !!s)
      for (const term of terms) {
        if (!source.includes(term)) return false
      }
      return true
    }
    
    
    export function computedDebounced<T>(
      value: Ref<T> | (() => T),
      delayBySeconds: MaybeRefOrGetter<number> | ((newValue: T, oldValue: T) => number),
    ) {
      const debouncedValue = ref<T>(toValue(value)) as Ref<T>
      let timer: ReturnType<typeof setTimeout> | undefined
    
      watch(toRef(value), (newValue, oldValue) => {
        if (timer) clearTimeout(timer)
        const delay =
          typeof delayBySeconds === 'function'
            ? delayBySeconds(newValue, oldValue)
            : toValue(delayBySeconds)
        if (delay === 0) {
          debouncedValue.value = newValue
        } else {
          timer = setTimeout(() => {
            debouncedValue.value = newValue
          }, delay * 1000)
        }
      })
    
      return shallowReadonly(debouncedValue)
    }
    
    
    export function useDateFnLocale(language: MaybeRefOrGetter<string>) {
      return computedAsync(async () => {
        const localeString = toValue(language) as keyof typeof dateFnLocales
        let locale: Locale = DateFnEnUsLocale
        if (localeString in dateFnLocales) {
          locale = (await dateFnLocales[localeString]()).default
        }
        return locale
      }, DateFnEnUsLocale)
    }
    
    export function useRelativeDistanceToNow(
    
      date: MaybeRefOrGetter<Date | string | null | undefined>,
    
      options: { includeSeconds?: boolean; addSuffix?: boolean } = {},
    ) {
      const { locale: appLocale } = useI18n()
      const dateFnLocale = useDateFnLocale(appLocale)
      return computedAsync(async () => {
        const dateValue = toValue(date)
        if (!dateValue) return ''
    
        return formatDistanceToNow(ensureDate(dateValue), { locale: dateFnLocale.value, ...options })
    
    
    export function usePersonName(user: MaybeRefOrGetter<SteeringUser>) {
      return computed(() => {
        const _user = toValue(user)
        const firstName = (_user?.firstName ?? '').trim()
        const lastName = (_user.lastName ?? '').trim()
        const username = _user.username.trim()
        const name = [firstName, lastName].join(' ')
        const initials =
          firstName && lastName
            ? [firstName[0], lastName[0]]
            : lastName
            ? [lastName[0]]
            : firstName
            ? [firstName[0]]
            : [username[0]]
        return {
          name,
          username,
          initials: initials.join('').toUpperCase(),
        }
      })
    }
    
    
    export function useIsToday(
      date: MaybeRefOrGetter<Date | string>,
      today?: MaybeRefOrGetter<Date | string>,
    ) {
      const datetime = computed(() => ensureDate(toValue(date)))
      const now =
        typeof today === 'undefined'
          ? useNow({ interval: 60 * 1000 })
          : computed(() => ensureDate(toValue(today)))
      return computed(() => {
        return (
          now.value.getDate() === datetime.value.getDate() &&
          now.value.getMonth() === datetime.value.getMonth() &&
          now.value.getFullYear() === datetime.value.getFullYear()
        )
      })
    }
    
    
    export function areSetsEqual<T extends string | number | symbol>(
      firstIterable: Iterable<T>,
      secondIterable: Iterable<T>,
    ): boolean {
      const set1 = new Set(firstIterable)
      const set2 = new Set(secondIterable)
    
      if (set1.size !== set2.size) return false
    
      for (const item of set1) {
        if (!set2.has(item)) return false
      }
    
      return true
    }
    
    export function getFilenameFromURL(url: string, defaultValue?: string) {
      const basename = (url.split(/[\\/]/).pop() as string).trim()
      return !basename && defaultValue ? defaultValue : basename
    }
    
    
    function arrayPadLeft<T>(array: T[], maxLength: number, fillValue: T): T[] {
      return Array(Math.max(0, maxLength - array.length))
        .fill(fillValue)
        .concat(array)
    }
    
    export function parseTime(time: string): number {
      let [days, hours, minutes, seconds] = Array(4).fill(0)
    
      if (time.includes(':')) {
        ;[days, hours, minutes, seconds] = arrayPadLeft<number | string>(time.split(':'), 4, 0)
      } else {
        // parses strings like: "3m32", "3 h 2s ", "9d2h3min1"
        const format =
          /^\s*(?:(?<days>\d+)\s*d)?\s*(?:(?<hours>\d+)\s*h)?\s*(?:(?<minutes>\d+)\s*m(?:in)?)?\s*(?:(?<seconds>\d+)\s*s?)?\s*$/
        ;({ days, hours, minutes, seconds } = time.match(format)?.groups ?? {})
      }
    
      return (
        parseInt(days || 0) * 24 * 60 * 60 +
        parseInt(hours || 0) * 60 * 60 +
        parseInt(minutes || 0) * 60 +
        parseInt(seconds || 0)
      )
    }
    
    
    export function useForeignSlotNames() {
      const slots = useSlots()
      return computed(() => {
        return Object.entries(slots)
          .filter(([, slot]) => slot && slot.name === '')
          .map(([key]) => key)
      })
    }
    
    
    export function humanJoin(iterable: Iterable<string>, glue: string, lastGlue: string) {
      const array = Array.from(iterable)
    
      if (array.length === 0) return ''
      if (array.length === 1) return array[0]
      if (array.length === 2) return array.join(lastGlue)
    
      const lastValue = array.pop() as string
      return `${array.join(glue)}${lastGlue}${lastValue}`
    }
    
    
    export function useQuery(
      query: MaybeRefOrGetter<Record<string, string | number | null | undefined>>,
    ) {
      return computed(() => {
        const _query: Record<string, string> = {}
        for (const [key, value] of Object.entries(toValue(query))) {
          if (value === null || value === undefined) continue
          _query[key] = value.toString()
        }
        return new URLSearchParams(_query)
      })
    }