diff --git a/.github/workflows/triage_issue.yml b/.github/workflows/triage_issue.yml new file mode 100644 index 0000000..6446b43 --- /dev/null +++ b/.github/workflows/triage_issue.yml @@ -0,0 +1,58 @@ +name: Add Issue to Board + +on: + issues: + types: [opened] + +jobs: + track_issue: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + projects: write + steps: + - name: Get project data + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api graphql -f query=' + query { + organization(login: "revoltchat"){ + projectV2(number: 3) { + id + fields(first:20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + }' > project_data.json + + echo 'PROJECT_ID='$(jq -r '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV + echo 'STATUS_FIELD_ID='$(jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV + echo 'TODO_OPTION_ID='$(jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV + + - name: Add issue to project + env: + GH_TOKEN: ${{ github.token }} + ISSUE_ID: ${{ github.event.issue.node_id }} + run: | + item_id="$( gh api graphql -f query=' + mutation($project:ID!, $issue:ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) { + item { + id + } + } + }' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')" + + echo 'ITEM_ID='$item_id >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/workflows/triage_pr.yml b/.github/workflows/triage_pr.yml new file mode 100644 index 0000000..6607ccf --- /dev/null +++ b/.github/workflows/triage_pr.yml @@ -0,0 +1,89 @@ +name: Add PR to Board + +on: + pull_request_target: + types: [opened, synchronize, ready_for_review, review_requested] + +permissions: + contents: read + pull-requests: write + issues: write + projects: write + +jobs: + track_pr: + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Authenticate GitHub CLI + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token + + - name: Get project data + run: | + set -eo pipefail + gh api graphql -f query=' + query { + organization(login: "revoltchat"){ + projectV2(number: 5) { + id + fields(first:20) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + }' > project_data.json + + echo 'PROJECT_ID='$(jq -r '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV + echo 'STATUS_FIELD_ID='$(jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV + echo 'INCOMING_OPTION_ID='$(jq -r '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") | .id' project_data.json) >> $GITHUB_ENV + + - name: Add PR to project + env: + PR_ID: ${{ github.event.pull_request.node_id }} + run: | + set -eo pipefail + item_id="$( gh api graphql -f query=' + mutation($project:ID!, $pr:ID!) { + addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) { + item { + id + } + } + }' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')" + + echo 'ITEM_ID='$item_id >> $GITHUB_ENV + + - name: Set fields + run: | + set -eo pipefail + gh api graphql -f query=' + mutation ( + $project: ID! + $item: ID! + $status_field: ID! + $status_value: String! + ) { + set_status: updateProjectV2ItemFieldValue(input: { + projectId: $project + itemId: $item + fieldId: $status_field + value: { + singleSelectOptionId: $status_value + } + }) { + projectV2Item { + id + } + } + }' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent diff --git a/.gitmodules b/.gitmodules index 6afdcbf..40eadb7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,7 @@ url = https://github.com/microsoft/fluentui-emoji [submodule "packs/twemoji"] path = packs/twemoji - url = https://github.com/twitter/twemoji + url = https://github.com/jdecked/twemoji [submodule "packs/noto"] path = packs/noto url = https://github.com/googlefonts/noto-emoji diff --git a/README.md b/README.md index 8d1e686..64827da 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ genemoji is a small CLI tool to generate Revolt's emoji asset folder. It transfo - [Fluent](https://github.com/microsoft/fluentui-emoji) - [Twemoji](https://twemoji.twitter.com) -- [Mutant](https://mutant.revolt.chat) +- [Mutant Remix](https://mutant.revolt.chat) - [Noto Color Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) ## Submodule Hint diff --git a/packs/fluent b/packs/fluent index 492a0ef..dfb5c3b 160000 --- a/packs/fluent +++ b/packs/fluent @@ -1 +1 @@ -Subproject commit 492a0ef1006c81fbcb4dae6b3a03c49815011fec +Subproject commit dfb5c3b7b10e20878a3fee6e3b05660e4d3bd9d5 diff --git a/packs/noto b/packs/noto index 3ee516e..934a570 160000 --- a/packs/noto +++ b/packs/noto @@ -1 +1 @@ -Subproject commit 3ee516e14f145661370e7a1b82aaf5e128935dc8 +Subproject commit 934a5706f1f3dd2605c4d9b5d9162fd7f89d8702 diff --git a/packs/twemoji b/packs/twemoji index ad3d3d6..dbb2a10 160000 --- a/packs/twemoji +++ b/packs/twemoji @@ -1 +1 @@ -Subproject commit ad3d3d669bb3697946577247ebb15818f09c6c91 +Subproject commit dbb2a105307932399402c5333001e82ba67af016 diff --git a/src/extractors/fluent-generic.ts b/src/extractors/fluent-generic.ts index cf2a76d..9ead600 100644 --- a/src/extractors/fluent-generic.ts +++ b/src/extractors/fluent-generic.ts @@ -2,6 +2,7 @@ import { existsSync } from "fs" import { copyFile, mkdir, readdir, readFile, writeFile } from "fs/promises" import { join as joinPath } from "path" import { packsDir } from "../app.js" +import { sortEmojis } from "../sorting.js" import { FLUENT_TONE_DIRS, FLUENT_TONE_DIR_TO_CODEPOINT, @@ -96,10 +97,21 @@ const copySingle = async ( export const copyFluent = async (flavorName: string, toPath: string) => { const fluentEmojiDir = joinPath(packsDir, "fluent", "assets") - const emojis = await readdir(fluentEmojiDir) + let emojis = await readdir(fluentEmojiDir) if (!existsSync(toPath)) await mkdir(toPath) + // Process all emojis to get their filenames + const emojiFilenames: string[] = [] + const emojiMetadata: Map< + string, + { + emojiPath: string + codepoints: string[] + hasSkinTones: boolean + } + > = new Map() + for (let emoji of emojis) { const emojiPath = joinPath(fluentEmojiDir, emoji) const emojiDirContents = (await readdir(emojiPath)).filter( @@ -111,12 +123,43 @@ export const copyFluent = async (flavorName: string, toPath: string) => { ) const codepoints = metadataFile.unicode.split(" ") - const hasSkinTones = emojiDirContents.includes("Medium-Dark") // name unlikely to be reused + const hasSkinTones = emojiDirContents.includes("Medium-Dark") + + const filename = hasSkinTones + ? codepoints.join("-") + ".svg" + : codepoints + .filter((x) => x !== VARIANT_SELECTOR_EMOJI) + .join("-") + ".svg" + + emojiFilenames.push(filename) + emojiMetadata.set(filename, { + emojiPath, + codepoints, + hasSkinTones, + }) + } - if (hasSkinTones) { - copyWithSkinTones(toPath, flavorName, emojiPath, codepoints) + // Sort emojis according to Emoji 15 ordering + const sortedFilenames = await sortEmojis(emojiFilenames) + + // Copy emojis in sorted order + for (const filename of sortedFilenames) { + const metadata = emojiMetadata.get(filename)! + + if (metadata.hasSkinTones) { + await copyWithSkinTones( + toPath, + flavorName, + metadata.emojiPath, + metadata.codepoints + ) } else { - copySingle(toPath, flavorName, emojiPath, codepoints) + await copySingle( + toPath, + flavorName, + metadata.emojiPath, + metadata.codepoints + ) } } } diff --git a/src/extractors/mutant.ts b/src/extractors/mutant.ts index 33482e7..f2cde09 100644 --- a/src/extractors/mutant.ts +++ b/src/extractors/mutant.ts @@ -2,6 +2,7 @@ import { existsSync } from "fs" import { copyFile, mkdir, readdir } from "fs/promises" import { join as joinPath } from "path" import { packsDir } from "../app.js" +import { sortEmojis } from "../sorting.js" import { VARIANT_SELECTOR_EMOJI } from "../constants.js" export const copyMutantTo = async (outDir: string) => { @@ -9,11 +10,10 @@ export const copyMutantTo = async (outDir: string) => { if (!existsSync(outDir)) await mkdir(outDir) - const mutantSvgs = await readdir(mutantDir) - - for (const emoji of mutantSvgs) { - const inPath = joinPath(mutantDir, emoji) + let mutantSvgs = await readdir(mutantDir) + // Normalize filenames first + const normalizedSvgs = mutantSvgs.map((emoji) => { const codepoints = emoji.split("-") const normalizedFilename = codepoints.length === 2 @@ -21,7 +21,23 @@ export const copyMutantTo = async (outDir: string) => { .filter((x) => x !== VARIANT_SELECTOR_EMOJI) .join("-") : codepoints.join("-") - const outPath = joinPath(outDir, normalizedFilename) + return { original: emoji, normalized: normalizedFilename } + }) + + // Sort by normalized filename + const sortedNormalized = await sortEmojis( + normalizedSvgs.map((x) => x.normalized) + ) + + // Create reverse mapping + const normalizedToOriginal = new Map( + normalizedSvgs.map((x) => [x.normalized, x.original]) + ) + + for (const normalized of sortedNormalized) { + const original = normalizedToOriginal.get(normalized)! + const inPath = joinPath(mutantDir, original) + const outPath = joinPath(outDir, normalized) await copyFile(inPath, outPath) } diff --git a/src/extractors/noto.ts b/src/extractors/noto.ts index 5ab4987..3f40580 100644 --- a/src/extractors/noto.ts +++ b/src/extractors/noto.ts @@ -2,21 +2,36 @@ import { existsSync } from "fs" import { copyFile, mkdir, readdir } from "fs/promises" import { join as joinPath } from "path" import { packsDir } from "../app.js" +import { sortEmojis } from "../sorting.js" export const copyNotoTo = async (outDir: string) => { const notoDir = joinPath(packsDir, "noto", "svg") if (!existsSync(outDir)) await mkdir(outDir) - const notoSvgs = await readdir(notoDir) - - for (const emoji of notoSvgs) { - const inPath = joinPath(notoDir, emoji) + let notoSvgs = await readdir(notoDir) + // Sort emojis according to Emoji 15 ordering + const normalizedSvgs = notoSvgs.map((emoji) => { const normalizedFilename = emoji .replace("emoji_u", "") .replaceAll("_", "-") - const outPath = joinPath(outDir, normalizedFilename) + return { original: emoji, normalized: normalizedFilename } + }) + + const sortedNormalized = await sortEmojis( + normalizedSvgs.map((x) => x.normalized) + ) + + // Create reverse mapping + const normalizedToOriginal = new Map( + normalizedSvgs.map((x) => [x.normalized, x.original]) + ) + + for (const normalized of sortedNormalized) { + const original = normalizedToOriginal.get(normalized)! + const inPath = joinPath(notoDir, original) + const outPath = joinPath(outDir, normalized) await copyFile(inPath, outPath) } diff --git a/src/extractors/twemoji.ts b/src/extractors/twemoji.ts index 2638f3d..4f0c66b 100644 --- a/src/extractors/twemoji.ts +++ b/src/extractors/twemoji.ts @@ -2,6 +2,7 @@ import { copyFile, mkdir, readdir } from "fs/promises" import { existsSync } from "fs" import { join as joinPath } from "path" import { packsDir } from "../app.js" +import { sortEmojis } from "../sorting.js" export const getAllExistingTwemoji = async () => { let out: string[] = [] @@ -22,7 +23,10 @@ export const copyTwemojiTo = async (outDir: string) => { if (!existsSync(outDir)) await mkdir(outDir) - const twemojiSvgs = await readdir(twemojiDir) + let twemojiSvgs = await readdir(twemojiDir) + + // Sort emojis according to Emoji 15 ordering + twemojiSvgs = await sortEmojis(twemojiSvgs) for (const emoji of twemojiSvgs) { const inPath = joinPath(twemojiDir, emoji) diff --git a/src/sorting.ts b/src/sorting.ts new file mode 100644 index 0000000..ad3abb4 --- /dev/null +++ b/src/sorting.ts @@ -0,0 +1,145 @@ +import https from "https" +import { existsSync } from "fs" +import { readFile } from "fs/promises" +import { join as joinPath } from "path" +import { cwd } from "./app.js" + +export type Emoji15Ordering = { + [key: string]: number +} + +let cachedOrdering: Emoji15Ordering | null = null + +/** + * Download and cache the Emoji 15 ordering from Google Fonts + */ +const downloadEmoji15Ordering = async (): Promise => { + return new Promise((resolve, reject) => { + const url = + "https://raw.githubusercontent.com/googlefonts/noto-emoji/main/ordering/emoji_15_0_ordering.json" + + https + .get(url, (response) => { + let data = "" + + response.on("data", (chunk) => { + data += chunk + }) + + response.on("end", () => { + try { + const ordering = JSON.parse(data) + resolve(ordering) + } catch (error) { + reject(error) + } + }) + }) + .on("error", (error) => { + reject(error) + }) + }) +} + +/** + * Get the Emoji 15 ordering, either from cache, local file, or remote + */ +export const getEmoji15Ordering = async (): Promise => { + if (cachedOrdering) { + return cachedOrdering + } + + const orderingFilePath = joinPath(cwd, ".emoji15-ordering.json") + + // Try to load from local cache first + if (existsSync(orderingFilePath)) { + try { + const fileContent = await readFile(orderingFilePath, "utf-8") + cachedOrdering = JSON.parse(fileContent) + return cachedOrdering + } catch (error) { + console.warn( + "Warning: Failed to load cached Emoji 15 ordering, fetching from remote..." + ) + } + } + + // Download from remote + try { + console.log("Fetching Emoji 15 ordering from Google Fonts...") + const ordering = await downloadEmoji15Ordering() + cachedOrdering = ordering + + // Cache locally for future runs + try { + const fs = await import("fs/promises") + await fs.writeFile( + orderingFilePath, + JSON.stringify(ordering, null, 2) + ) + } catch (error) { + console.warn("Warning: Failed to cache Emoji 15 ordering locally") + } + + return ordering + } catch (error) { + console.error( + "Error fetching Emoji 15 ordering:", + error instanceof Error ? error.message : error + ) + throw error + } +} + +/** + * Convert emoji codepoints to a format that can be looked up in the ordering + */ +export const codepointsToOrderingKey = (codepoints: string[]): string => { + // Convert codepoint array like ["1f600"] to format used in ordering + // Filter out variant selectors (fe0f) + const filtered = codepoints + .filter((cp) => cp !== "fe0f") + .map((cp) => `U+${cp.toUpperCase()}`) + + return filtered.join(" ") +} + +/** + * Get the sort order value for a given emoji codepoint + */ +export const getEmojiSortOrder = async ( + codepoints: string[] +): Promise => { + const ordering = await getEmoji15Ordering() + const key = codepointsToOrderingKey(codepoints) + + // Return the order from the Emoji 15 ordering, or a high number if not found + return ordering[key] ?? Infinity +} + +/** + * Sort emoji filenames according to Emoji 15 ordering + * Filenames are expected to be in format: "xxxx-yyyy-zzzz.svg" + */ +export const sortEmojis = async (filenames: string[]): Promise => { + const ordering = await getEmoji15Ordering() + + // Create a map of filename to sort order + const filenameToOrder: Map = new Map() + + for (const filename of filenames) { + // Extract codepoints from filename (remove .svg extension) + const codepoints = filename.replace(/\.svg$/, "").split("-") + const key = codepointsToOrderingKey(codepoints) + const order = ordering[key] ?? Infinity + + filenameToOrder.set(filename, order) + } + + // Sort filenames by their order value, maintaining original order for ties + return [...filenames].sort((a, b) => { + const orderA = filenameToOrder.get(a) ?? Infinity + const orderB = filenameToOrder.get(b) ?? Infinity + return orderA - orderB + }) +}