digitalfabrik/integreat-app

View on GitHub
tools/manage-metadata.ts

Summary

Maintainability
C
7 hrs
Test Coverage
import { program } from 'commander'
import yaml from 'js-yaml'
import fs from 'node:fs'
import path from 'node:path'

import {
  GITKEEP_FILE,
  PLATFORM_ANDROID,
  PLATFORM_IOS,
  PLATFORM_WEB,
  RELEASE_NOTES_DIR,
  UNRELEASED_DIR,
} from './constants.js'

const loadStoreTranslations = (appName: string) =>
  JSON.parse(fs.readFileSync(`../translations/store-translations/${appName}.json`, 'utf-8'))

// Release notes
type Platform = 'ios' | 'android' | 'web'
type NoteType = {
  show_in_stores: boolean
  issue_key: string
  platforms: Platform
  de?: string
  en: string
}
type ParseOptions = {
  destination?: string
  source: string
  ios: boolean
  android: boolean
  web: boolean
  language: string
  production: boolean
  appName?: string
}
const MAX_RELEASE_NOTES_LENGTH = 500
const DEFAULT_NOTES_LANGUAGE = 'de'
const ISSUE_URL = 'https://github.com/digitalfabrik/integreat-app/issues'
const prepareDefaultReleaseNote = (language: string, production: boolean, appName?: string): string => {
  if (!production) {
    return ''
  }
  if (!appName) {
    throw new Error('No app name supplied while preparing notes for production!')
  }
  const common = loadStoreTranslations(appName).common
  return common[language]?.defaultReleaseNote ?? common[DEFAULT_NOTES_LANGUAGE].defaultReleaseNote
}

const formatNotes = (params: {
  notes: NoteType[]
  language: string
  production: boolean
  platformName?: string
  appName?: string
}) => {
  const { notes, language, production, platformName, appName } = params
  const defaultReleaseNote = prepareDefaultReleaseNote(language, production, appName)

  const formattedNotes = notes
    .map(note => {
      const localizedNote = language === 'en' || !note.de ? note.en : note.de
      // Double quotes make mattermost status alerts fail
      const escapedNote = localizedNote.replace(/"/g, "'")
      return production ? `* ${escapedNote}` : `* [[${note.issue_key}](${ISSUE_URL}/${note.issue_key})] ${escapedNote}`
    })
    .reduce((text, note) => {
      // Make sure release notes don't get longer than the maximal allowed length
      if (production && text.length + note.length >= MAX_RELEASE_NOTES_LENGTH) {
        return text
      }
      if (text.length === 0) {
        return note
      }
      return `${text}\n${note}`
    }, defaultReleaseNote)

  return platformName && formattedNotes ? `\n${platformName}:\n${formattedNotes}` : formattedNotes
}

const isNoteRelevant = ({ note, platforms }: { note: NoteType; platforms: string[] }) =>
  platforms.some(platform => note.platforms.includes(platform))
const isNoteCommon = ({ note, platforms }: { note: NoteType; platforms: string[] }) =>
  platforms.every(platform => note.platforms.includes(platform))

// Format the release notes for development purposes with all available information
const formatDevelopmentNotes = (params: { notes: NoteType[]; language: string; platforms: string[] }) => {
  const { notes, language, platforms } = params
  const emptyNotesMap = {
    common: [] as NoteType[],
    android: [] as NoteType[],
    ios: [] as NoteType[],
    web: [] as NoteType[],
  }
  // Group notes by platform
  const notesMap = notes.reduce((notesMap, note) => {
    if (isNoteCommon({ note, platforms })) {
      notesMap.common.push(note)
    } else if (isNoteRelevant({ note, platforms: [PLATFORM_ANDROID] })) {
      notesMap.android.push(note)
    } else if (isNoteRelevant({ note, platforms: [PLATFORM_IOS] })) {
      notesMap.ios.push(note)
    } else if (isNoteRelevant({ note, platforms: [PLATFORM_WEB] })) {
      notesMap.web.push(note)
    }
    return notesMap
  }, emptyNotesMap)

  const commonNotes = formatNotes({ notes: notesMap.common, language, production: false })
  const androidNotes = formatNotes({
    notes: notesMap.android,
    language,
    production: false,
    platformName: PLATFORM_ANDROID,
  })
  const iosNotes = formatNotes({ notes: notesMap.ios, language, production: false, platformName: PLATFORM_IOS })
  const webNotes = formatNotes({ notes: notesMap.web, language, production: false, platformName: PLATFORM_WEB })

  const releaseNotes = `${commonNotes}${androidNotes}${iosNotes}${webNotes}`
  return `Release Notes:\n${releaseNotes || 'No release notes found. Looks like nothing happened for a while.'}`
}

const parseReleaseNotes = ({ source, ios, android, web, production, language, appName }: ParseOptions): string => {
  const platforms: string[] = [
    android ? PLATFORM_ANDROID : undefined,
    ios ? PLATFORM_IOS : undefined,
    web ? PLATFORM_WEB : undefined,
  ].filter((platform): platform is string => !!platform)

  if (platforms.length === 0) {
    throw new Error('No platforms selected! Use --ios, --android and --web flags.')
  } else if (platforms.length > 1 && production) {
    // e.g. play store release notes should not contain ios release infos
    throw new Error('Usage of multiple platforms in production mode is not supported.')
  }

  const fileNames = fs.existsSync(source) ? fs.readdirSync(source) : []
  if (fileNames.length === 0) {
    console.warn(`No release notes found in source ${source}. Using default notes.`)
  }

  const asNoteType = (as: unknown): NoteType => as as NoteType

  // Load all notes not belonging to a release
  const relevantNotes = fileNames
    .filter(fileName => fileName !== GITKEEP_FILE)
    .map(fileName => asNoteType(yaml.load(fs.readFileSync(`${source}/${fileName}`, 'utf-8'))))
    .filter(note => isNoteRelevant({ note, platforms }))

  // If the production flag is set, hide information that is irrelevant for users
  if (production) {
    const productionNotes = relevantNotes.filter(note => note.show_in_stores)
    return formatNotes({ notes: productionNotes, language, production, appName })
  }

  return formatDevelopmentNotes({ notes: relevantNotes, language, platforms })
}

const parseNotesProgram = (options: ParseOptions) => {
  const { destination } = options
  try {
    const notes = parseReleaseNotes(options)

    if (destination) {
      fs.mkdirSync(path.dirname(destination), { recursive: true })
      fs.writeFileSync(destination, notes)
    }

    // Log to enable bash piping
    console.log(JSON.stringify(notes))
  } catch (e) {
    console.error(e)
    process.exit(1)
  }
}

program
  .command('parse-release-notes')
  .description(
    'parse the release notes and outputs the release notes as JSON string and writes them to the specified file',
  )
  .option('--ios', 'include release notes for ios')
  .option('--android', 'include release notes for android')
  .option('--web', 'include release notes for web.')
  .option(
    '--production',
    'whether to hide extra information, e.g. issue keys, hidden notes and platforms and prepare the notes for a store. may not be used with multiple platforms. If set to true, make sure to pass the app name as well.',
  )
  .option('--app-name <app-name>', 'the name of the app to prepare the notes for. Only used if production flag is set.')
  .option('--destination <destination>', 'if specified the parsed notes are saved to the directory')
  .requiredOption(
    '--source <source>',
    'the directory of the release notes to parse',
    `../${RELEASE_NOTES_DIR}/${UNRELEASED_DIR}`,
  )
  .requiredOption('--language <language>', 'the language of the release notes to parse', DEFAULT_NOTES_LANGUAGE)
  .action(parseNotesProgram)

// General store metadata
type StoreName = 'appstore' | 'playstore'

const APPSTORE_NAME_LENGTH = 30
const APPSTORE_SUBTITLE_LENGTH = 30
const APPSTORE_RELEASE_NOTE_LENGTH = 4000
const APPSTORE_PROMOTIONAL_TEXT_LENGTH = 170
const APPSTORE_DESCRIPTION_LENGTH = 4000
const APPSTORE_KEYWORDS_LENGTH = 60

const PLAYSTORE_NAME_LENGTH = 50
const PLAYSTORE_SHORT_DESCRIPTION_LENGTH = 80
const PLAYSTORE_FULL_DESCRIPTION_LENGTH = 4000

const metadataPath = (appName: string, storeName: StoreName, languageCode: string) =>
  `../native/${storeName === 'appstore' ? 'ios' : 'android'}/fastlane/${appName}/metadata/${languageCode}`

// Maps our translation keys to the right key used by the appstore
// Empty array means no translation in the store
// https://docs.fastlane.tools/actions/deliver/#available-language-codes
const appstoreLanguageMap: Record<string, string[]> = {
  am: [],
  ar: ['ar-SA'],
  bg: [],
  de: ['de-DE'],
  el: ['el'],
  en: ['en-US'],
  es: ['es-ES'],
  fr: ['fr-FR'],
  hr: ['hr'],
  hu: ['hu'],
  it: ['it'],
  ka: [],
  mk: [],
  pes: [],
  pl: ['pl'],
  prs: [],
  ro: ['ro'],
  ru: ['ru'],
  sq: [],
  tr: ['tr'],
  uk: ['uk'],
  ur: [],
  'zh-CN': ['zh-Hans'],
}

// Maps our translation keys to the right key used by the play store
// https://support.google.com/googleplay/android-developer/answer/9844778?hl=en#zippy=%2Cview-list-of-available-languages%2Cif-you-dont-add-or-purchase-translations
const playstoreLanguageMap: Record<string, string[]> = {
  am: ['am'],
  ar: ['ar'],
  bg: ['bg'],
  de: ['de-DE'],
  el: ['el-GR'],
  en: ['en-US', 'en-GB'],
  es: ['es-ES'],
  fr: ['fr-FR'],
  hr: ['hr'],
  hu: ['hu-HU'],
  it: ['it-IT'],
  ka: ['ka-GE'],
  mk: ['mk-MK'],
  pes: ['fa'],
  prs: ['fa-AF'],
  pl: ['pl-PL'],
  ro: ['ro'],
  ru: ['ru-RU'],
  sq: ['sq'],
  tr: ['tr-TR'],
  uk: ['uk'],
  ur: ['ur'],
  'zh-CN': ['zh-CN'],
}

// Record<storeName, Record<language, Record<metadataKey, metadataValue>>>
type StoreTranslationType = Record<string, Record<string, Record<string, string>>>

// Merges the metadata of the store with the common metadata in a specific language
const metadataFromTranslations = (
  storeName: StoreName,
  language: string,
  translations: StoreTranslationType,
): Record<string, string> => {
  /* eslint-disable @typescript-eslint/no-non-null-assertion */
  const commonTranslation = translations.common![language]!
  const name = commonTranslation.name!
  const description = commonTranslation.description!
  const storeTranslation = translations[storeName]?.[language]
  /* eslint-enable @typescript-eslint/no-non-null-assertion */

  return storeName === 'appstore'
    ? {
        name,
        description,
        ...storeTranslation,
      }
    : {
        title: name,
        full_description: description,
        ...storeTranslation,
      }
}

const languageMap = (storeName: StoreName): Record<string, string[]> =>
  storeName === 'appstore' ? appstoreLanguageMap : playstoreLanguageMap

const writeMetadata = (appName: string, storeName: string, overrideVersionName?: string) => {
  if (storeName !== 'appstore' && storeName !== 'playstore') {
    throw new Error(`Invalid store name ${storeName} passed!`)
  }

  const storeTranslations = loadStoreTranslations(appName)

  Object.keys(storeTranslations[storeName]).forEach(language => {
    const metadata = metadataFromTranslations(storeName, language, storeTranslations)
    const targetLanguages = languageMap(storeName)[language] ?? [language]

    targetLanguages.forEach(targetLanguage => {
      const path = metadataPath(appName, storeName, targetLanguage)
      fs.mkdirSync(path, {
        recursive: true,
      })

      Object.keys(metadata).forEach(metadataKey => {
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        fs.writeFileSync(`${path}/${metadataKey}.txt`, metadata[metadataKey]!)
      })

      // Prepare release notes
      const platforms = { ios: storeName === 'appstore', android: storeName === 'playstore', web: false }
      const source = `../${RELEASE_NOTES_DIR}/${overrideVersionName ?? UNRELEASED_DIR}`
      const releaseNotesPath = `${metadataPath(appName, storeName, targetLanguage)}${
        storeName === 'playstore' ? '/changelogs' : ''
      }`
      fs.mkdirSync(releaseNotesPath, { recursive: true })

      const destination = `${releaseNotesPath}/${storeName === 'appstore' ? 'release_notes.txt' : 'default.txt'}`
      parseNotesProgram({ ...platforms, production: true, language, destination, source, appName })

      console.warn(`${storeName} metadata for ${appName} successfully written in language ${targetLanguage}.`)
    })
  })
}

program
  .command('prepare-metadata <appName> <storeName>')
  .description('prepare metadata for store')
  .option(
    '--override-version-name <override-version-name>',
    'if specified the release notes will be generated from the specified version name instead of the unreleased notes',
  )
  .action((appName: string, storeName: string, options: { overrideVersionName: string }) => {
    try {
      const { overrideVersionName } = options
      writeMetadata(appName, storeName, overrideVersionName)
    } catch (e) {
      console.error(e)
      process.exit(1)
    }
  })
program.parse(process.argv)