|
|
#!/usr/bin/env xcrun --sdk macosx swift
|
|
|
|
|
|
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
|
|
|
//
|
|
|
// This script is used to generate/update the set of Emoji used for reactions
|
|
|
//
|
|
|
// stringlint:disable
|
|
|
|
|
|
import Foundation
|
|
|
|
|
|
// OWSAssertionError but for this script
|
|
|
|
|
|
enum EmojiError: Error {
|
|
|
case assertion(String)
|
|
|
init(_ string: String) {
|
|
|
self = .assertion(string)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - Remote Model
|
|
|
// These definitions are kept fairly lightweight since we don't control their format
|
|
|
// All processing of remote data is done by converting RemoteModel items to EmojiModel items
|
|
|
|
|
|
enum RemoteModel {
|
|
|
struct EmojiItem: Codable {
|
|
|
let name: String
|
|
|
let shortName: String
|
|
|
let unified: String
|
|
|
let sortOrder: UInt
|
|
|
let category: EmojiCategory
|
|
|
let skinVariations: [String: SkinVariation]?
|
|
|
let shortNames: [String]?
|
|
|
}
|
|
|
|
|
|
struct SkinVariation: Codable {
|
|
|
let unified: String
|
|
|
}
|
|
|
|
|
|
enum EmojiCategory: String, Codable, Equatable {
|
|
|
case smileys = "Smileys & Emotion"
|
|
|
case people = "People & Body"
|
|
|
|
|
|
// This category is not provided in the data set, but is actually
|
|
|
// a merger of the categories of `smileys` and `people`
|
|
|
case smileysAndPeople = "Smileys & People"
|
|
|
|
|
|
case animals = "Animals & Nature"
|
|
|
case food = "Food & Drink"
|
|
|
case activities = "Activities"
|
|
|
case travel = "Travel & Places"
|
|
|
case objects = "Objects"
|
|
|
case symbols = "Symbols"
|
|
|
case flags = "Flags"
|
|
|
case components = "Component"
|
|
|
|
|
|
var localizedKey: String = {
|
|
|
switch self {
|
|
|
case .smileys:
|
|
|
return "Smileys"
|
|
|
case .people:
|
|
|
return "People"
|
|
|
case .smileysAndPeople:
|
|
|
return "Smileys"
|
|
|
case .animals:
|
|
|
return "Animals"
|
|
|
case .food:
|
|
|
return "Food"
|
|
|
case .activities:
|
|
|
return "Activities"
|
|
|
case .travel:
|
|
|
return "Travel"
|
|
|
case .objects:
|
|
|
return "Objects"
|
|
|
case .symbols:
|
|
|
return "Symbols"
|
|
|
case .flags:
|
|
|
return "Flags"
|
|
|
case .components:
|
|
|
return "Component"
|
|
|
}
|
|
|
}()
|
|
|
}
|
|
|
|
|
|
static func fetchEmojiData() throws -> Data {
|
|
|
// let remoteSourceUrl = URL(string: "https://unicodey.com/emoji-data/emoji.json")!
|
|
|
// This URL has been unavailable the past couple of weeks. If you're seeing failures here, try this other one:
|
|
|
let remoteSourceUrl = URL(string: "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json")!
|
|
|
return try Data(contentsOf: remoteSourceUrl)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - Local Model
|
|
|
|
|
|
struct EmojiModel {
|
|
|
let definitions: [EmojiDefinition]
|
|
|
|
|
|
struct EmojiDefinition {
|
|
|
let category: RemoteModel.EmojiCategory
|
|
|
let rawName: String
|
|
|
let enumName: String
|
|
|
var shortNames: Set<String>
|
|
|
let variants: [Emoji]
|
|
|
var baseEmoji: Character { variants[0].base }
|
|
|
|
|
|
struct Emoji: Comparable {
|
|
|
let emojiChar: Character
|
|
|
|
|
|
let base: Character
|
|
|
let skintoneSequence: SkinToneSequence
|
|
|
|
|
|
static func <(lhs: Self, rhs: Self) -> Bool {
|
|
|
for (leftElement, rightElement) in zip(lhs.skintoneSequence, rhs.skintoneSequence) {
|
|
|
if leftElement.sortId != rightElement.sortId {
|
|
|
return leftElement.sortId < rightElement.sortId
|
|
|
}
|
|
|
}
|
|
|
if lhs.skintoneSequence.count != rhs.skintoneSequence.count {
|
|
|
return lhs.skintoneSequence.count < rhs.skintoneSequence.count
|
|
|
} else {
|
|
|
return false
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
init(parsingRemoteItem remoteItem: RemoteModel.EmojiItem) throws {
|
|
|
category = remoteItem.category
|
|
|
rawName = remoteItem.name
|
|
|
enumName = Self.parseEnumNameFromRemoteItem(remoteItem)
|
|
|
shortNames = Set((remoteItem.shortNames ?? []))
|
|
|
shortNames.insert(rawName.lowercased())
|
|
|
shortNames.insert(enumName.lowercased())
|
|
|
|
|
|
let baseEmojiChar = try Self.codePointsToCharacter(Self.parseCodePointString(remoteItem.unified))
|
|
|
let baseEmoji = Emoji(emojiChar: baseEmojiChar, base: baseEmojiChar, skintoneSequence: .none)
|
|
|
|
|
|
let toneVariants: [Emoji]
|
|
|
if let skinVariations = remoteItem.skinVariations {
|
|
|
toneVariants = try skinVariations.map { key, value in
|
|
|
let modifier = SkinTone.sequence(from: Self.parseCodePointString(key))
|
|
|
let parsedEmoji = try Self.codePointsToCharacter(Self.parseCodePointString(value.unified))
|
|
|
return Emoji(emojiChar: parsedEmoji, base: baseEmojiChar, skintoneSequence: modifier)
|
|
|
}.sorted()
|
|
|
} else {
|
|
|
toneVariants = []
|
|
|
}
|
|
|
|
|
|
variants = [baseEmoji] + toneVariants
|
|
|
try postInitValidation()
|
|
|
}
|
|
|
|
|
|
func postInitValidation() throws {
|
|
|
guard variants.count > 0 else {
|
|
|
throw EmojiError("Expecting at least one variant")
|
|
|
}
|
|
|
|
|
|
guard variants.allSatisfy({ $0.base == baseEmoji }) else {
|
|
|
// All emoji variants must have a common base emoji
|
|
|
throw EmojiError("Inconsistent base emoji: \(baseEmoji)")
|
|
|
}
|
|
|
|
|
|
let hasMultipleComponents = variants.first(where: { $0.skintoneSequence.count > 1 }) != nil
|
|
|
if hasMultipleComponents, skinToneComponents == nil {
|
|
|
// If you hit this, this means a new emoji was added where a skintone modifier sequence specifies multiple
|
|
|
// skin tones for multiple emoji components: e.g. 👫 -> 🧍♀️+🧍♂️
|
|
|
// These are defined in `skinToneComponents`. You'll need to add a new case.
|
|
|
throw EmojiError("\(baseEmoji):\(enumName) definition has variants with multiple skintone modifiers but no component emojis defined")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static func parseEnumNameFromRemoteItem(_ item: RemoteModel.EmojiItem) -> String {
|
|
|
// some names don't play nice with swift, so we special case them
|
|
|
switch item.shortName {
|
|
|
case "+1": return "plusOne"
|
|
|
case "-1": return "negativeOne"
|
|
|
case "8ball": return "eightBall"
|
|
|
case "repeat": return "`repeat`"
|
|
|
case "100": return "oneHundred"
|
|
|
case "1234": return "oneTwoThreeFour"
|
|
|
case "couplekiss": return "personKissPerson"
|
|
|
case "couple_with_heart": return "personHeartPerson"
|
|
|
default:
|
|
|
let uppperCamelCase = item.shortName
|
|
|
.replacingOccurrences(of: "-", with: " ")
|
|
|
.replacingOccurrences(of: "_", with: " ")
|
|
|
.titlecase
|
|
|
.replacingOccurrences(of: " ", with: "")
|
|
|
|
|
|
return uppperCamelCase.first!.lowercased() + uppperCamelCase.dropFirst()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
var skinToneComponents: String? {
|
|
|
// There's no great way to do this except manually. Some emoji have multiple skin tones.
|
|
|
// In the picker, we need to use one emoji to represent each person. For now, we manually
|
|
|
// specify this. Hopefully, in the future, the data set will contain this information.
|
|
|
switch enumName {
|
|
|
case "peopleHoldingHands": return "[.standingPerson, .standingPerson]"
|
|
|
case "twoWomenHoldingHands": return "[.womanStanding, .womanStanding]"
|
|
|
case "manAndWomanHoldingHands": return "[.womanStanding, .manStanding]"
|
|
|
case "twoMenHoldingHands": return "[.manStanding, .manStanding]"
|
|
|
case "personKissPerson": return "[.adult, .adult]"
|
|
|
case "womanKissMan": return "[.woman, .man]"
|
|
|
case "manKissMan": return "[.man, .man]"
|
|
|
case "womanKissWoman": return "[.woman, .woman]"
|
|
|
case "personHeartPerson": return "[.adult, .adult]"
|
|
|
case "womanHeartMan": return "[.woman, .man]"
|
|
|
case "manHeartMan": return "[.man, .man]"
|
|
|
case "womanHeartWoman": return "[.woman, .woman]"
|
|
|
case "handshake": return "[.rightwardsHand, .leftwardsHand]"
|
|
|
default:
|
|
|
return nil
|
|
|
}
|
|
|
}
|
|
|
|
|
|
var isNormalized: Bool { enumName == normalizedEnumName }
|
|
|
var normalizedEnumName: String {
|
|
|
switch enumName {
|
|
|
// flagUm (US Minor Outlying Islands) looks identical to the
|
|
|
// US flag. We don't present it as a sendable reaction option
|
|
|
// This matches the iOS keyboard behavior.
|
|
|
case "flagUm": return "us"
|
|
|
default: return enumName
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static func parseCodePointString(_ pointString: String) -> [UnicodeScalar] {
|
|
|
return pointString
|
|
|
.components(separatedBy: "-")
|
|
|
.map { Int($0, radix: 16)! }
|
|
|
.map { UnicodeScalar($0)! }
|
|
|
}
|
|
|
|
|
|
static func codePointsToCharacter(_ codepoints: [UnicodeScalar]) throws -> Character {
|
|
|
let result = codepoints.map { String($0) }.joined()
|
|
|
if result.count != 1 {
|
|
|
throw EmojiError("Invalid number of chars for codepoint sequence: \(codepoints)")
|
|
|
}
|
|
|
return result.first!
|
|
|
}
|
|
|
}
|
|
|
|
|
|
init(rawJSONData jsonData: Data) throws {
|
|
|
let jsonDecoder = JSONDecoder()
|
|
|
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
|
|
definitions = try jsonDecoder
|
|
|
.decode([RemoteModel.EmojiItem].self, from: jsonData)
|
|
|
.sorted { $0.sortOrder < $1.sortOrder }
|
|
|
.map { try EmojiDefinition(parsingRemoteItem: $0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
typealias SkinToneSequence = [EmojiModel.SkinTone]
|
|
|
enum SkinTone: UnicodeScalar, CaseIterable, Equatable {
|
|
|
case light = "🏻"
|
|
|
case mediumLight = "🏼"
|
|
|
case medium = "🏽"
|
|
|
case mediumDark = "🏾"
|
|
|
case dark = "🏿"
|
|
|
|
|
|
var sortId: Int { return SkinTone.allCases.firstIndex(of: self)! }
|
|
|
|
|
|
static func sequence(from codepoints: [UnicodeScalar]) -> SkinToneSequence {
|
|
|
codepoints
|
|
|
.map { SkinTone(rawValue: $0)! }
|
|
|
.reduce(into: [SkinTone]()) { result, skinTone in
|
|
|
guard !result.contains(skinTone) else { return }
|
|
|
result.append(skinTone)
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
extension EmojiModel.SkinToneSequence {
|
|
|
static var none: EmojiModel.SkinToneSequence = []
|
|
|
}
|
|
|
|
|
|
// MARK: - File Writers
|
|
|
|
|
|
extension EmojiGenerator {
|
|
|
static func writePrimaryFile(from emojiModel: EmojiModel) {
|
|
|
// Main enum: Create a string enum defining our enumNames equal to the baseEmoji string
|
|
|
// e.g. case grinning = "😀"
|
|
|
writeBlock(fileName: "Emoji.swift") { fileHandle in
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
fileHandle.writeLine("// stringlint:disable")
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("/// A sorted representation of all available emoji")
|
|
|
fileHandle.writeLine("enum Emoji: String, CaseIterable, Equatable {")
|
|
|
fileHandle.indent {
|
|
|
emojiModel.definitions.forEach {
|
|
|
fileHandle.writeLine("case \($0.enumName) = \"\($0.baseEmoji)\"")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
indirect enum Structure {
|
|
|
enum ChunkType {
|
|
|
case firstScalar
|
|
|
case scalarSum
|
|
|
|
|
|
func chunk(_ character: Character, into size: UInt32) -> UInt32 {
|
|
|
guard size > 0 else { return 0 }
|
|
|
|
|
|
let scalarValues: [UInt32] = character.unicodeScalars.map { $0.value }
|
|
|
|
|
|
switch self {
|
|
|
case .firstScalar: return (scalarValues.first.map { $0 / size } ?? 0)
|
|
|
case .scalarSum: return (scalarValues.reduce(0, +) / size)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func switchString(with variableName: String = "rawValue", size: UInt32) -> String {
|
|
|
switch self {
|
|
|
case .firstScalar: return "rawValue.unicodeScalars.map({ $0.value }).first.map({ $0 / \(size) })"
|
|
|
case .scalarSum: return "(rawValue.unicodeScalars.map({ $0.value }).reduce(0, +) / \(size))"
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
case ifElse // XCode 15 taking over 10 min with M1 Pro (gave up)
|
|
|
case switchStatement // XCode 15 taking over 10 min with M1 Pro (gave up)
|
|
|
case directLookup // XCode 15 taking 93 sec with M1 Pro
|
|
|
case chunked(UInt32, Structure, ChunkType) // XCode 15 taking <10 sec with M1 Pro (chunk by 100)
|
|
|
}
|
|
|
typealias ChunkedEmojiInfo = (
|
|
|
variant: EmojiModel.EmojiDefinition.Emoji,
|
|
|
baseName: String
|
|
|
)
|
|
|
|
|
|
static func writeStringConversionsFile(from emojiModel: EmojiModel) {
|
|
|
// This combination seems to have the smallest compile time (~2.2 sec out of all of the combinations)
|
|
|
let desiredStructure: Structure = .chunked(100, .directLookup, .scalarSum)
|
|
|
|
|
|
// Conversion from String: Creates an initializer mapping a single character emoji string to an EmojiWithSkinTones
|
|
|
// e.g.
|
|
|
// if rawValue == "😀" { self.init(baseEmoji: .grinning, skinTones: nil) }
|
|
|
// else if rawValue == "🦻🏻" { self.init(baseEmoji: .earWithHearingAid, skinTones: [.light])
|
|
|
writeBlock(fileName: "EmojiWithSkinTones+String.swift") { fileHandle in
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
fileHandle.writeLine("// stringlint:disable")
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("extension EmojiWithSkinTones {")
|
|
|
fileHandle.indent {
|
|
|
switch desiredStructure {
|
|
|
case .chunked(let chunkSize, let childStructure, let chunkType):
|
|
|
let chunkedEmojiInfo = emojiModel.definitions
|
|
|
.reduce(into: [UInt32: [ChunkedEmojiInfo]]()) { result, next in
|
|
|
next.variants.forEach { emoji in
|
|
|
let chunk: UInt32 = chunkType.chunk(emoji.emojiChar, into: chunkSize)
|
|
|
result[chunk] = ((result[chunk] ?? []) + [(emoji, next.enumName)])
|
|
|
.sorted { lhs, rhs in lhs.variant < rhs.variant }
|
|
|
}
|
|
|
}
|
|
|
.sorted { lhs, rhs in lhs.key < rhs.key }
|
|
|
|
|
|
fileHandle.writeLine("init?(rawValue: String) {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
|
|
|
fileHandle.writeLine("switch \(chunkType.switchString(size: chunkSize)) {")
|
|
|
fileHandle.indent {
|
|
|
chunkedEmojiInfo.forEach { chunk, _ in
|
|
|
fileHandle.writeLine("case \(chunk): self = EmojiWithSkinTones.emojiFrom\(chunk)(rawValue)")
|
|
|
}
|
|
|
fileHandle.writeLine("default: self = EmojiWithSkinTones(unsupportedValue: rawValue)")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
|
|
|
chunkedEmojiInfo.forEach { chunk, emojiInfo in
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("private static func emojiFrom\(chunk)(_ rawValue: String) -> EmojiWithSkinTones {")
|
|
|
fileHandle.indent {
|
|
|
switch emojiInfo.count {
|
|
|
case 0:
|
|
|
fileHandle.writeLine("return EmojiWithSkinTones(unsupportedValue: rawValue)")
|
|
|
|
|
|
default:
|
|
|
writeStructure(
|
|
|
childStructure,
|
|
|
for: emojiInfo,
|
|
|
using: fileHandle,
|
|
|
assignmentPrefix: "return "
|
|
|
)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
fileHandle.writeLine("init?(rawValue: String) {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("guard rawValue.isSingleEmoji else { return nil }")
|
|
|
writeStructure(
|
|
|
desiredStructure,
|
|
|
for: emojiModel.definitions
|
|
|
.flatMap { definition in
|
|
|
definition.variants.map { ($0, definition.enumName) }
|
|
|
},
|
|
|
using: fileHandle
|
|
|
)
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private static func writeStructure(
|
|
|
_ structure: Structure,
|
|
|
for emojiInfo: [ChunkedEmojiInfo],
|
|
|
using fileHandle: WriteHandle,
|
|
|
assignmentPrefix: String = "self = "
|
|
|
) {
|
|
|
func initItem(_ info: ChunkedEmojiInfo) -> String {
|
|
|
let skinToneString: String = {
|
|
|
guard !info.variant.skintoneSequence.isEmpty else { return "nil" }
|
|
|
return "[\(info.variant.skintoneSequence.map { ".\($0)" }.joined(separator: ", "))]"
|
|
|
}()
|
|
|
|
|
|
return "EmojiWithSkinTones(baseEmoji: .\(info.baseName), skinTones: \(skinToneString))"
|
|
|
}
|
|
|
|
|
|
switch structure {
|
|
|
case .ifElse:
|
|
|
emojiInfo.enumerated().forEach { index, info in
|
|
|
switch index {
|
|
|
case 0: fileHandle.writeLine("if rawValue == \"\(info.variant.emojiChar)\" {")
|
|
|
default: fileHandle.writeLine("} else if rawValue == \"\(info.variant.emojiChar)\" {")
|
|
|
}
|
|
|
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("\(assignmentPrefix)\(initItem(info))")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fileHandle.writeLine("} else {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("\(assignmentPrefix)EmojiWithSkinTones(unsupportedValue: rawValue)")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
|
|
|
case .switchStatement:
|
|
|
fileHandle.writeLine("switch rawValue {")
|
|
|
fileHandle.indent {
|
|
|
emojiInfo.forEach { info in
|
|
|
fileHandle.writeLine("case \"\(info.variant.emojiChar)\": \(assignmentPrefix)\(initItem(info))")
|
|
|
}
|
|
|
fileHandle.writeLine("default: \(assignmentPrefix)EmojiWithSkinTones(unsupportedValue: rawValue)")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
|
|
|
case .directLookup:
|
|
|
fileHandle.writeLine("let lookup: [String: EmojiWithSkinTones] = [")
|
|
|
fileHandle.indent {
|
|
|
emojiInfo.enumerated().forEach { index, info in
|
|
|
let isLast: Bool = (index == (emojiInfo.count - 1))
|
|
|
fileHandle.writeLine("\"\(info.variant.emojiChar)\": \(initItem(info))\(isLast ? "" : ",")")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("]")
|
|
|
fileHandle.writeLine("\(assignmentPrefix)(lookup[rawValue] ?? EmojiWithSkinTones(unsupportedValue: rawValue))")
|
|
|
|
|
|
case .chunked: break // Provide one of the other types
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static func writeSkinToneLookupFile(from emojiModel: EmojiModel) {
|
|
|
writeBlock(fileName: "Emoji+SkinTones.swift") { fileHandle in
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
fileHandle.writeLine("// stringlint:disable")
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("extension Emoji {")
|
|
|
fileHandle.indent {
|
|
|
// SkinTone enum
|
|
|
fileHandle.writeLine("enum SkinTone: String, CaseIterable, Equatable {")
|
|
|
fileHandle.indent {
|
|
|
for skinTone in EmojiModel.SkinTone.allCases {
|
|
|
fileHandle.writeLine("case \(skinTone) = \"\(skinTone.rawValue)\"")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// skin tone helpers
|
|
|
fileHandle.writeLine("var hasSkinTones: Bool { return emojiPerSkinTonePermutation != nil }")
|
|
|
fileHandle.writeLine("var allowsMultipleSkinTones: Bool { return hasSkinTones && skinToneComponentEmoji != nil }")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// Start skinToneComponentEmoji
|
|
|
fileHandle.writeLine("var skinToneComponentEmoji: [Emoji]? {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
emojiModel.definitions.forEach { emojiDef in
|
|
|
if let components = emojiDef.skinToneComponents {
|
|
|
fileHandle.writeLine("case .\(emojiDef.enumName): return \(components)")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
fileHandle.writeLine("default: return nil")
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// Start emojiPerSkinTonePermutation
|
|
|
fileHandle.writeLine("var emojiPerSkinTonePermutation: [[SkinTone]: String]? {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
emojiModel.definitions.forEach { emojiDef in
|
|
|
let skintoneVariants = emojiDef.variants.filter({ $0.skintoneSequence != .none})
|
|
|
if skintoneVariants.isEmpty {
|
|
|
// None of our variants have a skintone, nothing to do
|
|
|
return
|
|
|
}
|
|
|
|
|
|
fileHandle.writeLine("case .\(emojiDef.enumName):")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("return [")
|
|
|
fileHandle.indent {
|
|
|
skintoneVariants.forEach {
|
|
|
let skintoneSequenceKey = $0.skintoneSequence.map({ ".\($0)" }).joined(separator: ", ")
|
|
|
fileHandle.writeLine("[\(skintoneSequenceKey)]: \"\($0.emojiChar)\",")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("]")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("default: return nil")
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static func writeCategoryLookupFile(from emojiModel: EmojiModel) {
|
|
|
let outputCategories: [RemoteModel.EmojiCategory] = [
|
|
|
.smileysAndPeople,
|
|
|
.animals,
|
|
|
.food,
|
|
|
.activities,
|
|
|
.travel,
|
|
|
.objects,
|
|
|
.symbols,
|
|
|
.flags
|
|
|
]
|
|
|
|
|
|
writeBlock(fileName: "Emoji+Category.swift") { fileHandle in
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
fileHandle.writeLine("// stringlint:disable")
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("extension Emoji {")
|
|
|
fileHandle.indent {
|
|
|
|
|
|
// Category enum
|
|
|
fileHandle.writeLine("enum Category: String, CaseIterable, Equatable {")
|
|
|
fileHandle.indent {
|
|
|
// Declare cases
|
|
|
for category in outputCategories {
|
|
|
fileHandle.writeLine("case \(category) = \"\(category.rawValue)\"")
|
|
|
}
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// Localized name for category
|
|
|
fileHandle.writeLine("var localizedName: String {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
for category in outputCategories {
|
|
|
fileHandle.writeLine("case .\(category):")
|
|
|
fileHandle.indent {
|
|
|
let stringKey = "emojiCategory\(category.localizedKey)"
|
|
|
let stringComment = "The name for the emoji category '\(category.rawValue)'"
|
|
|
|
|
|
fileHandle.writeLine("return NSLocalizedString(\"\(stringKey)\", comment: \"\(stringComment)\")")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// Emoji lookup per category
|
|
|
fileHandle.writeLine("var normalizedEmoji: [Emoji] {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
|
|
|
let normalizedEmojiPerCategory: [RemoteModel.EmojiCategory: [EmojiModel.EmojiDefinition]]
|
|
|
normalizedEmojiPerCategory = emojiModel.definitions.reduce(into: [:]) { result, emojiDef in
|
|
|
if emojiDef.isNormalized {
|
|
|
var categoryList = result[emojiDef.category] ?? []
|
|
|
categoryList.append(emojiDef)
|
|
|
result[emojiDef.category] = categoryList
|
|
|
}
|
|
|
}
|
|
|
|
|
|
for category in outputCategories {
|
|
|
let emoji: [EmojiModel.EmojiDefinition] = {
|
|
|
switch category {
|
|
|
case .smileysAndPeople:
|
|
|
// Merge smileys & people. It's important we initially bucket these separately,
|
|
|
// because we want the emojis to be sorted smileys followed by people
|
|
|
return normalizedEmojiPerCategory[.smileys]! + normalizedEmojiPerCategory[.people]!
|
|
|
default:
|
|
|
return normalizedEmojiPerCategory[category]!
|
|
|
}
|
|
|
}()
|
|
|
|
|
|
fileHandle.writeLine("case .\(category):")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("return [")
|
|
|
fileHandle.indent {
|
|
|
emoji.compactMap { $0.enumName }.forEach { name in
|
|
|
fileHandle.writeLine(".\(name),")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("]")
|
|
|
}
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// Category lookup per emoji
|
|
|
fileHandle.writeLine("var category: Category {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
for emojiDef in emojiModel.definitions {
|
|
|
let category = [.smileys, .people].contains(emojiDef.category) ? .smileysAndPeople : emojiDef.category
|
|
|
if category != .components {
|
|
|
fileHandle.writeLine("case .\(emojiDef.enumName): return .\(category)")
|
|
|
}
|
|
|
}
|
|
|
// Write a default case, because this enum is too long for the compiler to validate it's exhaustive
|
|
|
fileHandle.writeLine("default: fatalError(\"Unexpected case \\(self)\")")
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
// Normalized variant mapping
|
|
|
fileHandle.writeLine("var isNormalized: Bool { normalized == self }")
|
|
|
fileHandle.writeLine("var normalized: Emoji {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
emojiModel.definitions.filter { !$0.isNormalized }.forEach {
|
|
|
fileHandle.writeLine("case .\($0.enumName): return .\($0.normalizedEnumName)")
|
|
|
}
|
|
|
fileHandle.writeLine("default: return self")
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static func writeNameLookupFile(from emojiModel: EmojiModel) {
|
|
|
// Name lookup: Create a computed property mapping an Emoji enum element to the raw Emoji name string
|
|
|
// e.g. case .grinning: return "GRINNING FACE"
|
|
|
writeBlock(fileName: "Emoji+Name.swift") { fileHandle in
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
fileHandle.writeLine("// stringlint:disable")
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("extension Emoji {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("var name: String {")
|
|
|
fileHandle.indent {
|
|
|
fileHandle.writeLine("switch self {")
|
|
|
emojiModel.definitions.forEach {
|
|
|
fileHandle.writeLine("case .\($0.enumName): return \"\($0.shortNames.sorted().joined(separator:", "))\"")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
}
|
|
|
fileHandle.writeLine("}")
|
|
|
fileHandle.writeLine("// swiftlint:disable all")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - File I/O Helpers
|
|
|
|
|
|
class WriteHandle {
|
|
|
static let emojiDirectory = URL(
|
|
|
fileURLWithPath: "../Session/Emoji",
|
|
|
isDirectory: true,
|
|
|
relativeTo: EmojiGenerator.pathToFolderContainingThisScript!)
|
|
|
|
|
|
let handle: FileHandle
|
|
|
|
|
|
var indentDepth: Int = 0
|
|
|
var hasBeenClosed = false
|
|
|
|
|
|
func indent(_ block: () -> Void) {
|
|
|
indentDepth += 1
|
|
|
block()
|
|
|
indentDepth -= 1
|
|
|
}
|
|
|
|
|
|
func writeLine(_ body: String) {
|
|
|
let spaces = indentDepth * 4
|
|
|
let prefix = String(repeating: " ", count: spaces)
|
|
|
let suffix = "\n"
|
|
|
|
|
|
let line = prefix + body + suffix
|
|
|
handle.write(line.data(using: .utf8)!)
|
|
|
}
|
|
|
|
|
|
init(fileName: String) {
|
|
|
// Create directory if necessary
|
|
|
if !FileManager.default.fileExists(atPath: Self.emojiDirectory.path) {
|
|
|
try! FileManager.default.createDirectory(at: Self.emojiDirectory, withIntermediateDirectories: true, attributes: nil)
|
|
|
}
|
|
|
|
|
|
// Delete old file and create anew
|
|
|
let url = URL(fileURLWithPath: fileName, relativeTo: Self.emojiDirectory)
|
|
|
if FileManager.default.fileExists(atPath: url.path) {
|
|
|
try! FileManager.default.removeItem(at: url)
|
|
|
}
|
|
|
FileManager.default.createFile(atPath: url.path, contents: nil, attributes: nil)
|
|
|
handle = try! FileHandle(forWritingTo: url)
|
|
|
}
|
|
|
|
|
|
deinit {
|
|
|
precondition(hasBeenClosed, "File handle still open at de-init")
|
|
|
}
|
|
|
|
|
|
func close() {
|
|
|
handle.closeFile()
|
|
|
hasBeenClosed = true
|
|
|
}
|
|
|
}
|
|
|
|
|
|
extension EmojiGenerator {
|
|
|
static func writeBlock(fileName: String, block: (WriteHandle) -> Void) {
|
|
|
let fileHandle = WriteHandle(fileName: fileName)
|
|
|
defer { fileHandle.close() }
|
|
|
|
|
|
fileHandle.writeLine("")
|
|
|
fileHandle.writeLine("// This file is generated by EmojiGenerator.swift, do not manually edit it.")
|
|
|
fileHandle.writeLine("")
|
|
|
|
|
|
block(fileHandle)
|
|
|
}
|
|
|
|
|
|
// from http://stackoverflow.com/a/31480534/255489
|
|
|
static var pathToFolderContainingThisScript: URL? = {
|
|
|
let cwd = FileManager.default.currentDirectoryPath
|
|
|
|
|
|
let script = CommandLine.arguments[0]
|
|
|
|
|
|
if script.hasPrefix("/") { // absolute
|
|
|
let path = (script as NSString).deletingLastPathComponent
|
|
|
return URL(fileURLWithPath: path)
|
|
|
} else { // relative
|
|
|
let urlCwd = URL(fileURLWithPath: cwd)
|
|
|
|
|
|
if let urlPath = URL(string: script, relativeTo: urlCwd) {
|
|
|
let path = (urlPath.path as NSString).deletingLastPathComponent
|
|
|
return URL(fileURLWithPath: path)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
}()
|
|
|
}
|
|
|
|
|
|
// MARK: - Misc
|
|
|
|
|
|
extension String {
|
|
|
var titlecase: String {
|
|
|
components(separatedBy: " ")
|
|
|
.map { $0.first!.uppercased() + $0.dropFirst().lowercased() }
|
|
|
.joined(separator: " ")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
|
|
class EmojiGenerator {
|
|
|
static func run() throws {
|
|
|
let remoteData = try RemoteModel.fetchEmojiData()
|
|
|
let model = try EmojiModel(rawJSONData: remoteData)
|
|
|
|
|
|
writePrimaryFile(from: model)
|
|
|
writeStringConversionsFile(from: model)
|
|
|
writeSkinToneLookupFile(from: model)
|
|
|
writeCategoryLookupFile(from: model)
|
|
|
writeNameLookupFile(from: model)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
do {
|
|
|
try EmojiGenerator.run()
|
|
|
} catch {
|
|
|
print("Failed to generate emoji data: \(error)")
|
|
|
let errorCode = (error as? CustomNSError)?.errorCode ?? -1
|
|
|
exit(Int32(errorCode))
|
|
|
}
|