digitalfabrik/integreat-app

View on GitHub
native/src/utils/DatabaseConnector.ts

Summary

Maintainability
F
4 days
Test Coverage
B
89%
import { BBox } from 'geojson'
import { map, mapValues } from 'lodash'
import { DateTime } from 'luxon'
import BlobUtil from 'react-native-blob-util'
import { rrulestr } from 'rrule'

import {
  CategoriesMapModel,
  CategoryModel,
  CityModel,
  DateModel,
  EventModel,
  FeaturedImageModel,
  LanguageModel,
  LocalNewsModel,
  LocationModel,
  OpeningHoursModel,
  PoiModel,
  PoiCategoryModel,
  OrganizationModel,
  OfferModel,
} from 'shared/api'

import DatabaseContext from '../models/DatabaseContext'
import {
  CityResourceCacheStateType,
  LanguageResourceCacheStateType,
  PageResourceCacheEntryStateType,
  PageResourceCacheStateType,
} from './DataContainer'
import { deleteIfExists } from './helpers'
import { log, reportError } from './sentry'

export const CONTENT_VERSION = 'v8'
export const RESOURCE_CACHE_VERSION = 'v1'

// Our pdf view can only load from DocumentDir. Therefore we need to use that
export const CACHE_DIR_PATH = BlobUtil.fs.dirs.DocumentDir
export const UNVERSIONED_CONTENT_DIR_PATH = `${CACHE_DIR_PATH}/content`
// Offline saved content like categories, events and pois
export const CONTENT_DIR_PATH = `${UNVERSIONED_CONTENT_DIR_PATH}/${CONTENT_VERSION}`
export const UNVERSIONED_RESOURCE_CACHE_DIR_PATH = `${CACHE_DIR_PATH}/resource-cache`
// Offline saved resources like pictures and pdf documents
export const RESOURCE_CACHE_DIR_PATH = `${UNVERSIONED_RESOURCE_CACHE_DIR_PATH}/${RESOURCE_CACHE_VERSION}`

const MAX_STORED_CITIES = 3

type ContentCategoryJsonType = {
  root: boolean
  path: string
  title: string
  content: string
  last_update: string
  thumbnail: string | null
  available_languages: Record<string, string>
  parent_path: string
  children: Array<string>
  order: number
  organization: {
    name: string
    logo: string
    url: string
  } | null
  embedded_offers: OfferJsonType[]
}

type OfferJsonType = {
  alias: string
  title: string
  path: string
  thumbnail: string
}
type LocationJsonType<T> = {
  id: number
  address: string
  town: string
  postcode: string
  latitude: T
  longitude: T
  country: string
  name: string
}
type FeaturedImageInstanceJsonType = {
  url: string
  width: number
  height: number
}
type FeaturedImageJsonType = {
  description: string | null | undefined
  thumbnail: FeaturedImageInstanceJsonType
  medium: FeaturedImageInstanceJsonType
  large: FeaturedImageInstanceJsonType
  full: FeaturedImageInstanceJsonType
}
type ContentEventJsonType = {
  path: string
  title: string
  content: string
  last_update: string
  thumbnail: string | null
  available_languages: Record<string, string>
  excerpt: string
  date: {
    start_date: string
    end_date: string
    all_day: boolean
    recurrence_rule: string | null
    offset: number
  }
  location: LocationJsonType<number | null> | null
  featured_image: FeaturedImageJsonType | null | undefined
  poi_path: string | null
}
type ContentLanguageJsonType = {
  code: string
  name: string
}
type ContentCityJsonType = {
  name: string
  live: boolean
  code: string
  languages: ContentLanguageJsonType[]
  prefix: string | null
  events_enabled: boolean
  chat_enabled: boolean
  pois_enabled: boolean
  sorting_name: string
  longitude: number
  latitude: number
  aliases: Record<string, { longitude: number; latitude: number }> | null
  pushNotificationsEnabled: boolean
  tunewsEnabled: boolean
  bounding_box: BBox
}
type ContentPoiJsonType = {
  path: string
  title: string
  content: string
  thumbnail: string | null
  website: string | null
  phoneNumber: string | null
  email: string | null
  availableLanguages: Record<string, string>
  excerpt: string
  location: LocationJsonType<number>
  lastUpdate: string
  category: { id: number; name: string; color: string; icon: string; iconName: string }
  openingHours:
    | { allDay: boolean; closed: boolean; timeSlots: { start: string; end: string }[]; appointmentOnly: boolean }[]
    | null
  temporarilyClosed: boolean
  appointmentUrl: string | null
}
type ContentLocalNewsJsonType = {
  id: number
  timestamp: string
  title: string
  content: string
  available_languages: Record<string, number> | undefined
}
type CityCodeType = string
type LanguageCodeType = string
type MetaCitiesEntryType = {
  languages: Record<
    LanguageCodeType,
    {
      lastUpdate: DateTime
    }
  >
  lastUsage: DateTime
}
type MetaCitiesJsonType = Record<
  CityCodeType,
  {
    languages: Record<
      LanguageCodeType,
      {
        last_update: string
      }
    >
    last_usage: string
  }
>
type CityLastUsageType = {
  city: CityCodeType
  lastUsage: DateTime
}
type MetaCitiesType = Record<CityCodeType, MetaCitiesEntryType>
type PageResourceCacheEntryJsonType = {
  file_path: string
  hash: string
}
type PageResourceCacheJsonType = Record<string, PageResourceCacheEntryJsonType>
type LanguageResourceCacheJsonType = Record<string, PageResourceCacheJsonType>
type CityResourceCacheJsonType = Record<LanguageCodeType, LanguageResourceCacheJsonType>

class DatabaseConnector {
  constructor() {
    this.migrationRoutine().catch(reportError)
  }

  async migrationRoutine(): Promise<void> {
    const contentDirExists = await BlobUtil.fs.isDir(CONTENT_DIR_PATH)
    const baseContentDirExists = await BlobUtil.fs.isDir(UNVERSIONED_CONTENT_DIR_PATH)
    const resourceCacheDirExists = await BlobUtil.fs.isDir(RESOURCE_CACHE_DIR_PATH)
    const baseResourceCacheDirExists = await BlobUtil.fs.isDir(UNVERSIONED_RESOURCE_CACHE_DIR_PATH)

    // Delete old content if version is upgraded (if the base dir exists but the current content doesn't, the old content is still there)
    if (!contentDirExists && baseContentDirExists) {
      await BlobUtil.fs.unlink(UNVERSIONED_CONTENT_DIR_PATH)
    }

    // Delete old resource cache if version is upgraded (if the base dir exists but the current resource cache doesn't, the old resource cache is still there)
    if (!resourceCacheDirExists && baseResourceCacheDirExists) {
      await BlobUtil.fs.unlink(UNVERSIONED_RESOURCE_CACHE_DIR_PATH)
    }
  }

  getContentPath(key: string, context: DatabaseContext): string {
    if (!key) {
      throw Error("Key mustn't be empty")
    } else if (!context.cityCode) {
      throw Error("cityCode mustn't be empty")
    }

    if (!context.languageCode) {
      return `${CONTENT_DIR_PATH}/${context.cityCode}/${key}.json`
    }

    return `${CONTENT_DIR_PATH}/${context.cityCode}/${context.languageCode}/${key}.json`
  }

  getResourceCachePath(context: DatabaseContext): string {
    if (!context.cityCode) {
      throw Error("cityCode mustn't be empty")
    }

    return `${RESOURCE_CACHE_DIR_PATH}/${context.cityCode}/files.json`
  }

  getMetaCitiesPath(): string {
    return `${CACHE_DIR_PATH}/cities-meta.json`
  }

  getCitiesPath(): string {
    return `${CACHE_DIR_PATH}/cities.json`
  }

  async deleteAllFiles(): Promise<void> {
    await BlobUtil.fs.unlink(CACHE_DIR_PATH)
  }

  async storeLastUpdate(lastUpdate: DateTime | null, context: DatabaseContext): Promise<void> {
    if (lastUpdate === null) {
      // Prior to storing lastUpdate, there needs to be a lastUsage of the city.
      throw Error('cannot set lastUsage to null')
    }

    const { cityCode, languageCode } = context

    if (!cityCode) {
      throw Error("cityCode mustn't be empty")
    } else if (!languageCode) {
      throw Error("languageCode mustn't be empty")
    }

    const metaData = await this._loadMetaCities()
    const cityMetaData = metaData[cityCode]

    if (!cityMetaData) {
      log(`Did not find city '${cityCode}' im metaData '${JSON.stringify(metaData)}'`, 'warning')
      throw Error('cannot store last update for unused city')
    }

    cityMetaData.languages[languageCode] = {
      lastUpdate,
    }

    this._storeMetaCities(metaData)
  }

  async _deleteMetaOfCities(cities: Array<string>): Promise<void> {
    const metaCities = await this._loadMetaCities()
    cities.forEach(city => delete metaCities[city])
    await this._storeMetaCities(metaCities)
  }

  async loadLastUpdate(context: DatabaseContext): Promise<DateTime | null> {
    const { cityCode } = context
    const { languageCode } = context

    if (!cityCode) {
      throw new Error('City is not set in DatabaseContext!')
    } else if (!languageCode) {
      throw new Error('Language is not set in DatabaseContext!')
    }

    const metaData = await this._loadMetaCities()
    return metaData[cityCode]?.languages[languageCode]?.lastUpdate || null
  }

  async _loadMetaCities(): Promise<MetaCitiesType> {
    const path = this.getMetaCitiesPath()
    const fileExists = await BlobUtil.fs.exists(path)

    if (!fileExists) {
      return {}
    }

    const mapCitiesMetaJson = (json: MetaCitiesJsonType) =>
      mapValues(json, cityMeta => ({
        languages: mapValues(
          cityMeta.languages,
          ({
            last_update: jsonLastUpdate,
          }): {
            lastUpdate: DateTime
          } => ({
            lastUpdate: DateTime.fromISO(jsonLastUpdate),
          }),
        ),
        lastUsage: DateTime.fromISO(cityMeta.last_usage),
      }))
    return this.readFile(path, mapCitiesMetaJson)
  }

  async _storeMetaCities(metaCities: MetaCitiesType): Promise<void> {
    const path = this.getMetaCitiesPath()
    const citiesMetaJson: MetaCitiesJsonType = mapValues(metaCities, cityMeta => ({
      languages: mapValues(
        cityMeta.languages,
        ({
          lastUpdate,
        }): {
          last_update: string
        } => ({
          last_update: lastUpdate.toISO(),
        }),
      ),
      last_usage: cityMeta.lastUsage.toISO(),
    }))
    await this.writeFile(path, JSON.stringify(citiesMetaJson))
  }

  async loadLastUsages(): Promise<Array<CityLastUsageType>> {
    const metaData = await this._loadMetaCities()
    return map<MetaCitiesType, CityLastUsageType>(metaData, (value, key) => ({
      city: key,
      lastUsage: value.lastUsage,
    }))
  }

  async storeLastUsage(context: DatabaseContext): Promise<void> {
    const city = context.cityCode

    if (!city) {
      throw Error("cityCode mustn't be null")
    }

    const metaData = await this._loadMetaCities().catch(() => ({}) as MetaCitiesType)
    metaData[city] = {
      lastUsage: DateTime.now(),
      languages: metaData[city]?.languages || {},
    }
    await this._storeMetaCities(metaData)
    await this.deleteOldFiles(context)
  }

  async storeCategories(categoriesMap: CategoriesMapModel, context: DatabaseContext): Promise<void> {
    const categoryModels = categoriesMap.toArray()
    const jsonModels = categoryModels.map(
      (category: CategoryModel): ContentCategoryJsonType => ({
        root: category.isRoot(),
        path: category.path,
        title: category.title,
        content: category.content,
        last_update: category.lastUpdate.toISO(),
        thumbnail: category.thumbnail,
        available_languages: category.availableLanguages,
        parent_path: category.parentPath,
        children: categoriesMap.getChildren(category).map(category => category.path),
        order: category.order,
        organization: category.organization
          ? {
              name: category.organization.name,
              logo: category.organization.logo,
              url: category.organization.url,
            }
          : null,
        embedded_offers: category.embeddedOffers.map(offer => ({
          title: offer.title,
          alias: offer.alias,
          thumbnail: offer.thumbnail,
          path: offer.path,
        })),
      }),
    )
    await this.writeFile(this.getContentPath('categories', context), JSON.stringify(jsonModels))
  }

  async loadCategories(context: DatabaseContext): Promise<CategoriesMapModel> {
    const path = this.getContentPath('categories', context)
    const mapCategoriesJson = (json: ContentCategoryJsonType[]) =>
      new CategoriesMapModel(
        json.map(
          jsonObject =>
            new CategoryModel({
              root: jsonObject.root,
              path: jsonObject.path,
              title: jsonObject.title,
              content: jsonObject.content,
              thumbnail: jsonObject.thumbnail,
              parentPath: jsonObject.parent_path,
              order: jsonObject.order,
              availableLanguages: jsonObject.available_languages,
              lastUpdate: DateTime.fromISO(jsonObject.last_update),
              organization: jsonObject.organization
                ? new OrganizationModel({
                    name: jsonObject.organization.name,
                    logo: jsonObject.organization.logo,
                    url: jsonObject.organization.url,
                  })
                : null,
              embeddedOffers: jsonObject.embedded_offers.map(
                jsonOffer =>
                  new OfferModel({
                    title: jsonOffer.title,
                    alias: jsonOffer.alias,
                    thumbnail: jsonOffer.thumbnail,
                    path: jsonOffer.path,
                  }),
              ),
            }),
        ),
      )

    return this.readFile(path, mapCategoriesJson)
  }

  async storePois(pois: Array<PoiModel>, context: DatabaseContext): Promise<void> {
    const jsonModels = pois.map(
      (poi: PoiModel): ContentPoiJsonType => ({
        path: poi.path,
        title: poi.title,
        content: poi.content,
        thumbnail: poi.thumbnail,
        availableLanguages: poi.availableLanguages,
        excerpt: poi.excerpt,
        website: poi.website,
        phoneNumber: poi.phoneNumber,
        email: poi.email,
        location: {
          id: poi.location.id,
          address: poi.location.address,
          town: poi.location.town,
          postcode: poi.location.postcode,
          latitude: poi.location.latitude,
          longitude: poi.location.longitude,
          country: poi.location.country,
          name: poi.location.name,
        },
        lastUpdate: poi.lastUpdate.toISO(),
        category: {
          id: poi.category.id,
          name: poi.category.name,
          icon: poi.category.icon,
          iconName: poi.category.iconName,
          color: poi.category.color,
        },
        openingHours:
          poi.openingHours?.map(hours => ({
            allDay: hours.allDay,
            closed: hours.closed,
            timeSlots: hours.timeSlots.map(timeslot => ({
              start: timeslot.start,
              end: timeslot.end,
            })),
            appointmentOnly: hours.appointmentOnly,
          })) ?? null,
        temporarilyClosed: poi.temporarilyClosed,
        appointmentUrl: poi.appointmentUrl,
      }),
    )
    await this.writeFile(this.getContentPath('pois', context), JSON.stringify(jsonModels))
  }

  async loadPois(context: DatabaseContext): Promise<Array<PoiModel>> {
    const path = this.getContentPath('pois', context)
    const mapPoisJson = (json: ContentPoiJsonType[]) =>
      json.map(jsonObject => {
        const jsonLocation = jsonObject.location
        return new PoiModel({
          path: jsonObject.path,
          title: jsonObject.title,
          content: jsonObject.content,
          thumbnail: jsonObject.thumbnail,
          availableLanguages: jsonObject.availableLanguages,
          metaDescription: null, // not used in native
          excerpt: jsonObject.excerpt,
          website: jsonObject.website,
          phoneNumber: jsonObject.phoneNumber,
          email: jsonObject.email,
          location: new LocationModel({
            id: jsonLocation.id,
            name: jsonLocation.name,
            country: jsonLocation.country,
            address: jsonLocation.address,
            latitude: jsonLocation.latitude,
            longitude: jsonLocation.longitude,
            postcode: jsonLocation.postcode,
            town: jsonLocation.town,
          }),
          lastUpdate: DateTime.fromISO(jsonObject.lastUpdate),
          category: new PoiCategoryModel({
            id: jsonObject.category.id,
            name: jsonObject.category.name,
            color: jsonObject.category.color,
            icon: jsonObject.category.icon,
            iconName: jsonObject.category.iconName,
          }),
          openingHours:
            jsonObject.openingHours?.map(
              hours =>
                new OpeningHoursModel({
                  allDay: hours.allDay,
                  closed: hours.closed,
                  timeSlots: hours.timeSlots.map(timeslot => ({
                    start: timeslot.start,
                    end: timeslot.end,
                  })),
                  appointmentOnly: hours.appointmentOnly,
                }),
            ) ?? null,
          temporarilyClosed: jsonObject.temporarilyClosed,
          appointmentUrl: jsonObject.appointmentUrl,
        })
      })

    return this.readFile(path, mapPoisJson)
  }

  async storeLocalNews(localNews: LocalNewsModel[], context: DatabaseContext): Promise<void> {
    const jsonModels = localNews.map(
      (it: LocalNewsModel): ContentLocalNewsJsonType => ({
        id: it.id,
        timestamp: it.timestamp.toISO(),
        title: it.title,
        content: it.content,
        available_languages: it.availableLanguages,
      }),
    )
    await this.writeFile(this.getContentPath('localNews', context), JSON.stringify(jsonModels))
  }

  async loadLocalNews(context: DatabaseContext): Promise<LocalNewsModel[]> {
    const path = this.getContentPath('localNews', context)
    const mapLocalNewsJson = (json: ContentLocalNewsJsonType[]) =>
      json.map(
        jsonObject =>
          new LocalNewsModel({
            id: jsonObject.id,
            timestamp: DateTime.fromISO(jsonObject.timestamp),
            title: jsonObject.title,
            content: jsonObject.content,
            availableLanguages: jsonObject.available_languages ?? {},
          }),
      )

    return this.readFile(path, mapLocalNewsJson)
  }

  async storeCities(cities: Array<CityModel>): Promise<void> {
    const jsonModels = cities.map(
      (city: CityModel): ContentCityJsonType => ({
        name: city.name,
        live: city.live,
        code: city.code,
        languages: city.languages.map(it => ({ code: it.code, name: it.name })),
        prefix: city.prefix,
        events_enabled: city.eventsEnabled,
        chat_enabled: city.chatEnabled,
        pois_enabled: city.poisEnabled,
        pushNotificationsEnabled: city.localNewsEnabled,
        tunewsEnabled: city.tunewsEnabled,
        sorting_name: city.sortingName,
        longitude: city.longitude,
        latitude: city.latitude,
        aliases: city.aliases,
        bounding_box: city.boundingBox,
      }),
    )
    await this.writeFile(this.getCitiesPath(), JSON.stringify(jsonModels))
  }

  async loadCities(): Promise<Array<CityModel>> {
    const path = this.getCitiesPath()
    const mapCityJson = (json: ContentCityJsonType[]) =>
      json.map(
        jsonObject =>
          new CityModel({
            name: jsonObject.name,
            code: jsonObject.code,
            live: jsonObject.live,
            languages: jsonObject.languages.map(it => new LanguageModel(it.code, it.name)),
            eventsEnabled: jsonObject.events_enabled,
            localNewsEnabled: jsonObject.pushNotificationsEnabled,
            tunewsEnabled: jsonObject.tunewsEnabled,
            poisEnabled: jsonObject.pois_enabled,
            sortingName: jsonObject.sorting_name,
            prefix: jsonObject.prefix,
            longitude: jsonObject.longitude,
            latitude: jsonObject.latitude,
            aliases: jsonObject.aliases,
            boundingBox: jsonObject.bounding_box,
            chatEnabled: jsonObject.chat_enabled,
          }),
      )

    return this.readFile(path, mapCityJson)
  }

  async storeEvents(events: Array<EventModel>, context: DatabaseContext): Promise<void> {
    const jsonModels = events.map(
      (event: EventModel): ContentEventJsonType => ({
        path: event.path,
        title: event.title,
        content: event.content,
        last_update: event.lastUpdate.toISO(),
        thumbnail: event.thumbnail,
        available_languages: event.availableLanguages,
        excerpt: event.excerpt,
        date: {
          start_date: event.date.startDate.toISO(),
          end_date: event.date.endDate.toISO(),
          all_day: event.date.allDay,
          recurrence_rule: event.date.recurrenceRule?.toString() ?? null,
          offset: event.date.offset,
        },
        location: event.location
          ? {
              id: event.location.id,
              address: event.location.address,
              town: event.location.town,
              postcode: event.location.postcode,
              latitude: event.location.latitude,
              longitude: event.location.longitude,
              country: event.location.country,
              name: event.location.name,
            }
          : null,
        featured_image: event.featuredImage
          ? {
              description: event.featuredImage.description,
              thumbnail: event.featuredImage.thumbnail,
              medium: event.featuredImage.medium,
              large: event.featuredImage.large,
              full: event.featuredImage.full,
            }
          : null,
        poi_path: event.poiPath,
      }),
    )
    await this.writeFile(this.getContentPath('events', context), JSON.stringify(jsonModels))
  }

  async loadEvents(context: DatabaseContext): Promise<Array<EventModel>> {
    const path = this.getContentPath('events', context)
    const mapEventsJson = (json: ContentEventJsonType[]) =>
      json.map(jsonObject => {
        const jsonDate = jsonObject.date
        return new EventModel({
          path: jsonObject.path,
          title: jsonObject.title,
          content: jsonObject.content,
          thumbnail: jsonObject.thumbnail,
          featuredImage: jsonObject.featured_image
            ? new FeaturedImageModel({
                description: jsonObject.featured_image.description,
                thumbnail: jsonObject.featured_image.thumbnail,
                medium: jsonObject.featured_image.medium,
                large: jsonObject.featured_image.large,
                full: jsonObject.featured_image.full,
              })
            : null,
          availableLanguages: jsonObject.available_languages,
          lastUpdate: DateTime.fromISO(jsonObject.last_update),
          excerpt: jsonObject.excerpt,
          date: new DateModel({
            startDate: DateTime.fromISO(jsonDate.start_date),
            endDate: DateTime.fromISO(jsonDate.end_date),
            offset: jsonDate.offset,
            allDay: jsonDate.all_day,
            recurrenceRule: jsonDate.recurrence_rule ? rrulestr(jsonDate.recurrence_rule) : null,
          }),
          location: jsonObject.location
            ? new LocationModel({
                id: jsonObject.location.id,
                name: jsonObject.location.name,
                country: jsonObject.location.country,
                address: jsonObject.location.address,
                latitude: jsonObject.location.latitude,
                longitude: jsonObject.location.longitude,
                postcode: jsonObject.location.postcode,
                town: jsonObject.location.town,
              })
            : null,
          poiPath: jsonObject.poi_path,
        })
      })

    return this.readFile(path, mapEventsJson)
  }

  async loadResourceCache(context: DatabaseContext): Promise<CityResourceCacheStateType> {
    const path = this.getResourceCachePath(context)
    const fileExists: boolean = await BlobUtil.fs.exists(path)

    if (!fileExists) {
      return {}
    }

    const mapResourceCacheJson = (json: CityResourceCacheJsonType) =>
      mapValues(json, languageResourceCache =>
        mapValues(languageResourceCache, (fileResourceCache: PageResourceCacheJsonType) =>
          mapValues(
            fileResourceCache,
            (entry: PageResourceCacheEntryJsonType): PageResourceCacheEntryStateType => ({
              filePath: entry.file_path,
              hash: entry.hash,
            }),
          ),
        ),
      )
    return this.readFile(path, mapResourceCacheJson)
  }

  async storeResourceCache(resourceCache: CityResourceCacheStateType, context: DatabaseContext): Promise<void> {
    const path = this.getResourceCachePath(context)
    const json: CityResourceCacheJsonType = mapValues(
      resourceCache,
      (languageResourceCache: LanguageResourceCacheStateType) =>
        mapValues(languageResourceCache, (fileResourceCache: PageResourceCacheStateType) =>
          mapValues(
            fileResourceCache,
            (entry: PageResourceCacheEntryStateType): PageResourceCacheEntryJsonType => ({
              file_path: entry.filePath,
              hash: entry.hash,
            }),
          ),
        ),
    )
    await this.writeFile(path, JSON.stringify(json))
  }

  /**
   * Deletes the resource caches and files of all but the latest used cities
   * @return {Promise<void>}
   */
  async deleteOldFiles(context: DatabaseContext): Promise<void> {
    const city = context.cityCode

    if (!city) {
      throw Error("cityCode mustn't be null")
    }

    const lastUsages = await this.loadLastUsages()
    const cachesToDelete = lastUsages
      .filter(it => it.city !== city) // Sort last usages chronological, from oldest to newest
      .sort((a, b) => {
        if (a.lastUsage < b.lastUsage) {
          return -1
        }
        return a.lastUsage.equals(b.lastUsage) ? 0 : 1
      }) // We only have to remove MAX_STORED_CITIES - 1 since we already filtered for the current resource cache
      .slice(0, -(MAX_STORED_CITIES - 1))
    await this.deleteCities(cachesToDelete.map(it => it.city))
  }

  async deleteCities(cityCodes: string[]): Promise<void> {
    await Promise.all([
      ...cityCodes.map(city => {
        log(`Deleting content and resource cache of city '${city}'`)
        const cityResourceCachePath = `${RESOURCE_CACHE_DIR_PATH}/${city}`
        const cityContentPath = `${CONTENT_DIR_PATH}/${city}`
        return Promise.all([deleteIfExists(cityResourceCachePath), deleteIfExists(cityContentPath)])
      }),
      this._deleteMetaOfCities(cityCodes),
    ])
  }

  isPoisPersisted(context: DatabaseContext): Promise<boolean> {
    return this._isPersisted(this.getContentPath('pois', context))
  }

  isCitiesPersisted(): Promise<boolean> {
    return this._isPersisted(this.getCitiesPath())
  }

  isCategoriesPersisted(context: DatabaseContext): Promise<boolean> {
    return this._isPersisted(this.getContentPath('categories', context))
  }

  isEventsPersisted(context: DatabaseContext): Promise<boolean> {
    return this._isPersisted(this.getContentPath('events', context))
  }

  isLocalNewsPersisted(context: DatabaseContext): Promise<boolean> {
    return this._isPersisted(this.getContentPath('localNews', context))
  }

  _isPersisted(path: string): Promise<boolean> {
    return BlobUtil.fs.exists(path)
  }

  async readFile<R, T>(path: string, mapJson: (json: R) => T, isRetry = false): Promise<T> {
    const fileExists = await BlobUtil.fs.exists(path)

    if (!fileExists) {
      throw Error(`File ${path} does not exist`)
    }

    const jsonString = await BlobUtil.fs.readFile(path, 'utf8')

    try {
      const json: R = JSON.parse(jsonString)
      return mapJson(json)
    } catch (e) {
      if (!isRetry) {
        log(
          `An error occurred while trying to parse or map json '${jsonString}' from path '${path}', retrying.`,
          'warning',
        )
        return this.readFile(path, mapJson, true)
      }
      log(
        `An error occurred while trying to parse or map json '${jsonString}' from path '${path}', deleting file.`,
        'warning',
      )
      await deleteIfExists(path)
      throw e
    }
  }

  async writeFile(path: string, data: string): Promise<void> {
    return BlobUtil.fs.writeFile(path, data, 'utf8')
  }
}

export default DatabaseConnector