# frozen_string_literal: true require "active_support/test_case" require "fileutils" require "json" require "nokogiri" require "open-uri" require "file_helper" EMOJI_GROUPS_PATH = "lib/emoji/groups.json" EMOJI_DB_PATH = "lib/emoji/db.json" EMOJI_IMAGES_PATH = "public/images/emoji" EMOJI_ORDERING_URL = "http://www.unicode.org/emoji/charts/emoji-ordering.html" # emoji aliases are actually created as images # eg: "right_anger_bubble" => [ "anger_right" ] # your app will physically have right_anger_bubble.png and anger_right.png EMOJI_ALIASES = { "right_anger_bubble" => ["anger_right"], "ballot_box" => ["ballot_box_with_ballot"], "basketball_man" => %w[basketball_player person_with_ball], "beach_umbrella" => %w[umbrella_on_ground beach beach_with_umbrella], "parasol_on_ground" => ["umbrella_on_ground"], "bellhop_bell" => ["bellhop"], "biohazard" => ["biohazard_sign"], "bow_and_arrow" => ["archery"], "spiral_calendar" => %w[calendar_spiral spiral_calendar_pad], "card_file_box" => ["card_box"], "champagne" => ["bottle_with_popping_cork"], "cheese" => ["cheese_wedge"], "city_sunset" => ["city_dusk"], "couch_and_lamp" => ["couch"], "crayon" => ["lower_left_crayon"], "cricket_bat_and_ball" => ["cricket_bat_ball"], "latin_cross" => ["cross"], "dagger" => ["dagger_knife"], "desktop_computer" => ["desktop"], "card_index_dividers" => ["dividers"], "dove" => ["dove_of_peace"], "footprints" => ["feet"], "fire" => ["flame"], "black_flag" => %w[flag_black waving_black_flag], "cn" => ["flag_cn"], "de" => ["flag_de"], "es" => ["flag_es"], "fr" => ["flag_fr"], "uk" => %w[gb flag_gb], "it" => ["flag_it"], "jp" => ["flag_jp"], "kr" => ["flag_kr"], "ru" => ["flag_ru"], "us" => ["flag_us"], "white_flag" => %w[flag_white waving_white_flag], "plate_with_cutlery" => %w[fork_knife_plate fork_and_knife_with_plate], "framed_picture" => %w[frame_photo frame_with_picture], "hammer_and_pick" => ["hammer_pick"], "heavy_heart_exclamation" => %w[heart_exclamation heavy_heart_exclamation_mark_ornament], "houses" => %w[homes house_buildings], "hotdog" => ["hot_dog"], "derelict_house" => %w[house_abandoned derelict_house_building], "desert_island" => ["island"], "old_key" => ["key2"], "laughing" => ["satisfied"], "business_suit_levitating" => %w[levitate man_in_business_suit_levitating], "weight_lifting_man" => %w[lifter weight_lifter], "medal_sports" => %w[medal sports_medal], "metal" => ["sign_of_the_horns"], "fu" => %w[middle_finger reversed_hand_with_middle_finger_extended], "motorcycle" => ["racing_motorcycle"], "mountain_snow" => ["snow_capped_mountain"], "newspaper_roll" => %w[newspaper2 rolled_up_newspaper], "spiral_notepad" => %w[notepad_spiral spiral_note_pad], "oil_drum" => ["oil"], "older_woman" => ["grandma"], "paintbrush" => ["lower_left_paintbrush"], "paperclips" => ["linked_paperclips"], "pause_button" => ["double_vertical_bar"], "peace_symbol" => ["peace"], "fountain_pen" => %w[pen_fountain lower_left_fountain_pen], "ping_pong" => ["table_tennis"], "place_of_worship" => ["worship_symbol"], "poop" => %w[shit hankey poo], "radioactive" => ["radioactive_sign"], "railway_track" => ["railroad_track"], "robot" => ["robot_face"], "skull" => ["skeleton"], "skull_and_crossbones" => ["skull_crossbones"], "speaking_head" => ["speaking_head_in_silhouette"], "male_detective" => %w[spy sleuth_or_spy], "thinking" => ["thinking_face"], "-1" => ["thumbsdown"], "+1" => ["thumbsup"], "cloud_with_lightning_and_rain" => %w[thunder_cloud_rain thunder_cloud_and_rain], "tickets" => ["admission_tickets"], "next_track_button" => %w[track_next next_track], "previous_track_button" => %w[track_previous previous_track], "unicorn" => ["unicorn_face"], "funeral_urn" => ["urn"], "sun_behind_large_cloud" => %w[white_sun_cloud white_sun_behind_cloud], "sun_behind_rain_cloud" => %w[white_sun_rain_cloud white_sun_behind_cloud_with_rain], "partly_sunny" => %w[white_sun_small_cloud white_sun_with_small_cloud], "open_umbrella" => ["umbrella2"], "hammer_and_wrench" => ["tools"], "face_with_thermometer" => ["thermometer_face"], "timer_clock" => ["timer"], "keycap_ten" => ["ten"], "memo" => ["pencil"], "rescue_worker_helmet" => %w[helmet_with_cross helmet_with_white_cross], "slightly_smiling_face" => %w[slightly_smiling slight_smile], "construction_worker_man" => ["construction_worker"], "upside_down_face" => ["upside_down"], "money_mouth_face" => ["money_mouth"], "nerd_face" => ["nerd"], "hugs" => %w[hugging hugging_face], "roll_eyes" => %w[rolling_eyes face_with_rolling_eyes], "slightly_frowning_face" => %w[frowning slight_frown], "frowning_face" => %w[frowning2 white_frowning_face], "zipper_mouth_face" => ["zipper_mouth"], "face_with_head_bandage" => ["head_bandage"], "raised_hand_with_fingers_splayed" => ["hand_splayed"], "raised_hand" => ["hand"], "vulcan_salute" => %w[vulcan raised_hand_with_part_between_middle_and_ring_fingers], "policeman" => ["cop"], "running_man" => ["runner"], "walking_man" => ["walking"], "bowing_man" => ["bow"], "no_good_woman" => ["no_good"], "raising_hand_woman" => ["raising_hand"], "pouting_woman" => ["person_with_pouting_face"], "frowning_woman" => ["person_frowning"], "haircut_woman" => ["haircut"], "massage_woman" => ["massage"], "tshirt" => ["shirt"], "biking_man" => ["bicyclist"], "mountain_biking_man" => ["mountain_bicyclist"], "passenger_ship" => ["cruise_ship"], "motor_boat" => %w[motorboat boat], "flight_arrival" => ["airplane_arriving"], "flight_departure" => ["airplane_departure"], "small_airplane" => ["airplane_small"], "racing_car" => ["race_car"], "family_man_woman_boy_boy" => ["family_man_woman_boys"], "family_man_woman_girl_girl" => ["family_man_woman_girls"], "family_woman_woman_boy" => ["family_women_boy"], "family_woman_woman_girl" => ["family_women_girl"], "family_woman_woman_girl_boy" => ["family_women_girl_boy"], "family_woman_woman_boy_boy" => ["family_women_boys"], "family_woman_woman_girl_girl" => ["family_women_girls"], "family_man_man_boy" => ["family_men_boy"], "family_man_man_girl" => ["family_men_girl"], "family_man_man_girl_boy" => ["family_men_girl_boy"], "family_man_man_boy_boy" => ["family_men_boys"], "family_man_man_girl_girl" => ["family_men_girls"], "cloud_with_lightning" => ["cloud_lightning"], "tornado" => %w[cloud_tornado cloud_with_tornado], "cloud_with_rain" => ["cloud_rain"], "cloud_with_snow" => ["cloud_snow"], "asterisk" => ["keycap_star"], "studio_microphone" => ["microphone2"], "medal_military" => ["military_medal"], "couple_with_heart_woman_woman" => ["female_couple_with_heart"], "couple_with_heart_man_man" => ["male_couple_with_heart"], "couplekiss_woman_woman" => ["female_couplekiss"], "couplekiss_man_man" => ["male_couplekiss"], "honeybee" => ["bee"], "lion" => ["lion_face"], "artificial_satellite" => ["satellite_orbital"], "computer_mouse" => %w[mouse_three_button three_button_mouse], "hocho" => ["knife"], "swimming_man" => ["swimmer"], "wind_face" => ["wind_blowing_face"], "golfing_man" => ["golfer"], "facepunch" => ["punch"], "building_construction" => ["construction_site"], "family_man_woman_girl_boy" => ["family"], "ice_hockey" => ["hockey"], "snowman_with_snow" => ["snowman2"], "play_or_pause_button" => ["play_pause"], "film_projector" => ["projector"], "shopping" => ["shopping_bags"], "open_book" => ["book"], "national_park" => ["park"], "world_map" => ["map"], "pen" => %w[pen_ballpoint lower_left_ballpoint_pen], "email" => %w[envelope e-mail], "phone" => ["telephone"], "atom_symbol" => ["atom"], "mantelpiece_clock" => ["clock"], "camera_flash" => ["camera_with_flash"], "film_strip" => ["film_frames"], "balance_scale" => ["scales"], "surfing_man" => ["surfer"], "couplekiss_man_woman" => ["couplekiss"], "couple_with_heart_woman_man" => ["couple_with_heart"], "clamp" => ["compression"], "dancing_women" => ["dancers"], "blonde_man" => ["person_with_blond_hair"], "sleeping_bed" => ["sleeping_accommodation"], "om" => ["om_symbol"], "tipping_hand_woman" => ["information_desk_person"], "rowing_man" => ["rowboat"], "new_moon" => ["moon"], "oncoming_automobile" => %w[car automobile], "fleur_de_lis" => ["fleur-de-lis"], "face_vomiting" => ["puke"], "smile" => ["grinning_face_with_smiling_eyes"], "frowning_with_open_mouth" => ["frowning_face_with_open_mouth"], } EMOJI_GROUPS = [ { "name" => "smileys_&_emotion", "tabicon" => "grinning" }, { "name" => "people_&_body", "tabicon" => "wave" }, { "name" => "animals_&_nature", "tabicon" => "evergreen_tree" }, { "name" => "food_&_drink", "tabicon" => "hamburger" }, { "name" => "travel_&_places", "tabicon" => "airplane" }, { "name" => "activities", "tabicon" => "soccer" }, { "name" => "objects", "tabicon" => "eyeglasses" }, { "name" => "symbols", "tabicon" => "white_check_mark" }, { "name" => "flags", "tabicon" => "checkered_flag" }, ] FITZPATRICK_SCALE = %w[1f3fb 1f3fc 1f3fd 1f3fe 1f3ff] DEFAULT_SET = "twitter" # Replace the platform by another when downloading the image (accepts names or categories) EMOJI_IMAGES_PATCH = { "apple" => { "snowboarder" => "twitter", }, "windows" => { "country-flag" => "twitter", }, } EMOJI_SETS = { "apple" => "apple", "google" => "google", "google_blob" => "google_classic", "facebook" => "facebook_messenger", "twitter" => "twitter", "windows" => "win10", } EMOJI_DB_REPO = "git@github.com:xfalcox/emoji-db.git" EMOJI_DB_REPO_PATH = File.join("tmp", "emoji-db") GENERATED_PATH = File.join(EMOJI_DB_REPO_PATH, "generated") def search_aliases(emojis) # Format is search pattern => associated emojis # eg: "cry" => [ "sob" ] # for a "cry" query should return: cry and sob @aliases ||= begin aliases = { "sad" => %w[frowning_face slightly_frowning_face sob crying_cat_face cry], "cry" => ["sob"], } emojis.each do |_, config| next if config["search_aliases"].blank? config["search_aliases"].each do |name| aliases[name] ||= [] aliases[name] << config["name"] end end aliases.map { |_, names| names.uniq! } aliases end end desc "update emoji images" task "emoji:update" do abort("This task can't be run on production.") if Rails.env.production? copy_emoji_db json_db = File.read(File.join(GENERATED_PATH, "db.json")) db = JSON.parse(json_db) write_db_json(db["emojis"], db["translations"], search_aliases(db["emojis"])) fix_incomplete_sets(db["emojis"]) write_aliases groups = generate_emoji_groups(db["emojis"], db["sections"]) write_js_groups(db["emojis"], groups) optimize_images(Dir.glob(File.join(Rails.root, EMOJI_IMAGES_PATH, "/**/*.png"))) TestEmojiUpdate.run_and_summarize FileUtils.rm_rf(EMOJI_DB_REPO_PATH) end desc "test the emoji generation script" task "emoji:test" do ENV["EMOJI_TEST"] = "1" Rake::Task["emoji:update"].invoke end def optimize_images(images) images.each do |filename| FileHelper.image_optim(allow_pngquant: true, strip_image_metadata: true).optimize_image!( filename, ) end end def copy_emoji_db `rm -rf tmp/emoji-db && git clone -b unicodeorg-as-source-of-truth --depth 1 #{EMOJI_DB_REPO} tmp/emoji-db` path = "#{EMOJI_IMAGES_PATH}/**/*" confirm_overwrite(path) puts "Cleaning emoji folder..." emoji_assets = Dir.glob(path) emoji_assets.delete_if { |x| x == "#{EMOJI_IMAGES_PATH}/emoji_one" } FileUtils.rm_rf(emoji_assets) EMOJI_SETS.each do |set_name, set_destination| origin = File.join(GENERATED_PATH, set_name) destination = File.join(EMOJI_IMAGES_PATH, set_destination) FileUtils.mv(origin, destination) end end def fix_incomplete_sets(emojis) emojis.each do |code, config| EMOJI_SETS.each do |set_name, set_destination| patch_set = EMOJI_SETS[EMOJI_IMAGES_PATCH.dig(set_name, config["name"])] || EMOJI_SETS[EMOJI_IMAGES_PATCH.dig(set_name, config["category"])] if patch_set || !File.exist?(File.join(EMOJI_IMAGES_PATH, set_destination, "#{config["name"]}.png")) origin = File.join(EMOJI_IMAGES_PATH, patch_set || EMOJI_SETS[DEFAULT_SET], config["name"]) FileUtils.cp( "#{origin}.png", File.join(EMOJI_IMAGES_PATH, set_destination, "#{config["name"]}.png"), ) if File.directory?(origin) FileUtils.cp_r(origin, File.join(EMOJI_IMAGES_PATH, set_destination, config["name"])) end end end end end def generate_emoji_groups(keywords, sections) puts "Generating groups..." list = URI.parse(EMOJI_ORDERING_URL).read doc = Nokogiri.HTML5(list) table = doc.css("table")[0] EMOJI_GROUPS.map do |group| group["icons"] ||= [] sub_sections = sections[group["name"]]["sub_sections"] sub_sections.each do |section| title_section = table.css("tr th a[@name='#{section}']") emoji_list_section = title_section.first.parent.parent.next_element emoji_list_section .css("a.plain img") .each do |link| emoji_code = link .attr("title") .scan(/U\+(.{4,5})\b/) .flatten .map { |code| code.downcase.strip } .join("_") emoji_char = code_to_emoji(emoji_code) if emoji = keywords[emoji_char] group["icons"] << { name: emoji["name"], diversity: emoji["fitzpatrick_scale"] } end end end group.delete("sections") group end end def write_aliases EMOJI_ALIASES.each do |original, aliases| aliases.each do |emoji_alias| EMOJI_SETS.each do |set_name, set_destination| origin_file = File.join(EMOJI_IMAGES_PATH, set_destination, "#{original}.png") origin_dir = File.join(EMOJI_IMAGES_PATH, set_destination, original) FileUtils.cp( origin_file, File.join(EMOJI_IMAGES_PATH, set_destination, "#{emoji_alias}.png"), ) if File.directory?(origin_dir) FileUtils.cp_r(origin_dir, File.join(EMOJI_IMAGES_PATH, set_destination, emoji_alias)) end end end end end def write_db_json(emojis, translations, search_aliases) puts "Writing #{EMOJI_DB_PATH}..." confirm_overwrite(EMOJI_DB_PATH) FileUtils.mkdir_p(File.expand_path("..", EMOJI_DB_PATH)) # skin tones variations of emojis shouldn’t appear in autocomplete emojis_without_tones = emojis .select do |char, config| !FITZPATRICK_SCALE.any? do |scale| codepoints_to_code(char.codepoints, config["fitzpatrick_scale"])[scale] end end .map do |char, config| { "code" => codepoints_to_code(char.codepoints, config["fitzpatrick_scale"]).tr("_", "-"), "name" => config["name"], } end emoji_with_tones = emojis .select { |code, config| config["fitzpatrick_scale"] } .map { |code, config| config["name"] } db = { "emojis" => emojis_without_tones, "tonableEmojis" => emoji_with_tones, "aliases" => EMOJI_ALIASES, "searchAliases" => search_aliases, "translations" => translations, } File.write(EMOJI_DB_PATH, JSON.pretty_generate(db)) end def write_js_groups(emojis, groups) puts "Writing #{EMOJI_GROUPS_PATH}..." confirm_overwrite(EMOJI_GROUPS_PATH) template = JSON.pretty_generate(groups) FileUtils.mkdir_p(File.expand_path("..", EMOJI_GROUPS_PATH)) File.write(EMOJI_GROUPS_PATH, template) end def code_to_emoji(code) code.split("_").map { |e| e.to_i(16) }.pack "U*" end def codepoints_to_code(codepoints, fitzpatrick_scale) codepoints = codepoints.map { |c| c.to_s(16).rjust(4, "0") }.join("_").downcase codepoints.gsub!(/_fe0f\z/, "") if !fitzpatrick_scale codepoints end def confirm_overwrite(path) return if ENV["EMOJI_TEST"] STDOUT.puts( "[!] You are about to overwrite #{path}, are you sure? [CTRL+c] to cancel, [ENTER] to continue", ) STDIN.gets.chomp end class TestEmojiUpdate def self.run_and_summarize puts "Running tests..." instance = TestEmojiUpdate.new instance.public_methods.each do |method| next unless method.to_s.start_with? "test_" print "Running #{method}..." instance.public_send(method) puts " ✅" rescue StandardError => e puts " ❌" puts e.message.indent(2) end end def assert_equal(a, b) raise "Expected #{a.inspect} to equal #{b.inspect}" if a != b end def assert(a) raise "Expected #{a.inspect} to be truthy" if !a end def image_path(style, name) File.join("public", "images", "emoji", style, "#{name}.png") end def test_code_to_emoji assert_equal "😎", code_to_emoji("1f60e") end def test_codepoints_to_code assert_equal "1f6b5_200d_2640", codepoints_to_code([128_693, 8205, 9792, 65_039], false) end def test_codepoints_to_code_with_scale assert_equal "1f6b5_200d_2640_fe0f", codepoints_to_code([128_693, 8205, 9792, 65_039], true) end def test_groups_js_es6_creation assert File.exist?(EMOJI_GROUPS_PATH) assert File.size?(EMOJI_GROUPS_PATH) end def test_db_json_creation assert File.exist?(EMOJI_DB_PATH) assert File.size?(EMOJI_DB_PATH) end def test_alias_creation original_image = image_path("apple", "right_anger_bubble") alias_image = image_path("apple", "anger_right") assert_equal File.size(original_image), File.size(alias_image) end def test_cell_index_patch original_image = image_path("apple", "snowboarder") alias_image = image_path("twitter", "snowboarder") assert_equal File.size(original_image), File.size(alias_image) end def test_scales original_image = image_path("apple", "blonde_woman") assert File.exist?(original_image) assert File.size?(original_image) (2..6).each do |scale| image = image_path("apple", "blonde_woman/#{scale}") assert File.exist?(image) assert File.size?(image) end end def test_default_set original_image = image_path("twitter", "snowboarder") alias_image = image_path("apple", "snowboarder") assert_equal File.size(original_image), File.size(alias_image) original_image = image_path("twitter", "macau") alias_image = image_path("win10", "macau") assert_equal File.size(original_image), File.size(alias_image) end end