From dc2b7299ff5535e0af9444d14f52ac46312a71c5 Mon Sep 17 00:00:00 2001 From: lvwmwm Date: Fri, 4 Sep 2026 13:20:42 -0300 Subject: [PATCH] refactor: dedupe diff logic, simplify runner, decompose canvas - extract 4 diff fns into generic diffEntries + diffEntriesCode - drop 120s polling loop; await gzip promise directly - filter-before-map in webhook, inline color cast helpers - decompose drawSections into measure/render helpers - validate colors VM eval result shape - chain git commits via lastCommit promise - drop unused Differs fields, trim types --- src/canvas/index.ts | 224 ++++++++++++------------- src/mock.ts | 11 +- src/types.ts | 7 +- src/update/git.ts | 55 +++--- src/update/runner.ts | 50 +++--- src/update/tasks/code.ts | 7 +- src/update/tasks/colors.ts | 31 +++- src/update/tasks/decompile.ts | 22 ++- src/update/tasks/diffs.ts | 304 +++++++++++++++------------------- src/update/tasks/webhook.ts | 78 +++++---- 10 files changed, 390 insertions(+), 399 deletions(-) diff --git a/src/canvas/index.ts b/src/canvas/index.ts index 16fa988499d..89f770ae98d 100644 --- a/src/canvas/index.ts +++ b/src/canvas/index.ts @@ -1,8 +1,8 @@ import { readdir } from "node:fs/promises"; import { join } from "node:path"; -import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; +import { createCanvas, GlobalFonts, type SKRSContext2D } from "@napi-rs/canvas"; import { file } from "bun"; -import type { Row, Section } from "./factory"; +import type { Item, Row, Section } from "./factory"; import { measureText, transformSvg, wrapText } from "./utils"; async function bulkRegister(family: string, path: string) { @@ -11,6 +11,37 @@ async function bulkRegister(family: string, path: string) { await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans")); await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono")); +const layout = { + background: { padding: 24, gap: 18 }, + section: { gap: 8 }, + card: { radius: 24, padding: 12, gapX: 16, gapY: 8 }, + blob: { radius: 12 }, + color: { radius: 24 }, +}; + +const textSizes = { header: 24, title: 18, blob: 24 }; + +const colors = { + background: "#1c1d23", + section: "#26272f", + blob: "#6c6f7c1f", + textNormal: "#c7c8ce", + textMuted: "#818491", + watermark: "#2f3035", +}; + +const itemSize = 128; +const blobSize = itemSize + layout.card.gapY + textSizes.title; + +const watermark = await transformSvg(await file(join(import.meta.dir, "placeholders/watermark.svg")).text(), { + color: colors.watermark, + height: layout.background.padding, +}); + +const mctx = createCanvas(1, 1).getContext("2d"); +const headerFont = `600 ${textSizes.header}px GG Sans`; +const titleFont = `400 ${textSizes.title}px GG Mono`; + function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) { return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth))); } @@ -27,81 +58,37 @@ function calcSectionHeight(columns: Row[][]) { return Math.max(0, ...columns.map((column) => calcColumnHeight(column))); } -const layout = { - background: { - padding: 24, - gap: 18, - }, - section: { - gap: 8, - }, - card: { - radius: 24, - padding: 12, - gapX: 16, - gapY: 8, - }, - blob: { - radius: 24 - 12, - }, - color: { - radius: 24, - }, -}; +function measureItemWidth(item: Item) { + if (item.type === "blob") return blobSize; + if (item.type === "image") return (item.image.width / item.image.height) * itemSize; + return itemSize; +} -const textSizes = { - header: 24, - title: 18, - blob: 24, -}; - -const colors = { - background: "#1c1d23", // BACKGROUND_BASE_LOWEST - section: "#26272f", // CARD_BACKGROUND_DEFAULT - blob: "#6c6f7c1f", // BACKGROUND_MOD_MUTED - textNormal: "#c7c8ce", // TEXT_NORMAL - textMuted: "#818491", // TEXT_MUTED - watermark: "#2f3035", // TEXT_OVERLAY_DARK -}; - -const itemSize = 128; -const blobSize = itemSize + layout.card.gapY + textSizes.title; - -const watermark = await transformSvg(await file(join(import.meta.dir, "placeholders/watermark.svg")).text(), { - color: colors.watermark, - height: layout.background.padding, -}); - -const mctx = createCanvas(1, 1).getContext("2d"); -export function drawSections(sections: Section[]) { - const headerFont = `600 ${textSizes.header}px GG Sans`; - const titleFont = `400 ${textSizes.title}px GG Mono`; +function measureRow(row: Row) { + return { + title: row.title, + subtitle: row.subtitle, + textWidth: Math.max( + 0, + row.title ? measureText(mctx, titleFont, row.title).width : 0, + row.subtitle ? measureText(mctx, titleFont, row.subtitle).width : 0, + ), + item: row.item, + itemWidth: measureItemWidth(row.item), + }; +} +function measureSections(sections: Section[]) { const sects = sections.map(({ header, columns }) => ({ headerWidth: measureText(mctx, headerFont, header).width, header, - columns: columns.map((column) => - column.map(({ title, subtitle, item }) => ({ - title, - subtitle, - textWidth: Math.max( - 0, - title ? measureText(mctx, titleFont, title).width : 0, - subtitle ? measureText(mctx, titleFont, subtitle).width : 0, - ), - item, - itemWidth: - item.type === "blob" - ? blobSize - : item.type === "image" - ? (item.image.width / item.image.height) * itemSize - : itemSize, - })), - ), + columns: columns.map((column) => column.map(measureRow)), })); + const columnWidths = Array.from({ length: Math.max(0, ...sects.map(({ columns }) => columns.length)) }, (_, i) => Math.max(0, ...sects.map(({ columns }) => (columns[i] ? calcColumnWidth(columns[i]) : 0))), ); + const sectionWidth = columnWidths.reduce((a, b) => a + b, 0) + layout.card.gapX * (columnWidths.length - 1) + layout.card.padding * 2; @@ -118,6 +105,56 @@ export function drawSections(sections: Section[]) { ) .reduce((a, b) => a + b, 0) - layout.background.gap; + return { sects, columnWidths, sectionWidth, width, height }; +} + +function renderBlob(ctx: SKRSContext2D, blob: string, x: number, y: number, width: number, height: number) { + ctx.fillStyle = colors.blob; + ctx.beginPath(); + ctx.roundRect(x, y, width, height, layout.blob.radius); + ctx.fill(); + + ctx.font = `600 ${textSizes.blob}px GG Sans`; + ctx.fillStyle = colors.textNormal; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const wrapped = wrapText(ctx, blob, width); + const lineHeight = textSizes.blob / 2; + for (let i = 0; i < wrapped.length; i++) { + ctx.fillText(wrapped[i], x + width / 2, y + height / 2 - lineHeight * (wrapped.length - 1) + textSizes.blob * i); + } + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; +} + +function renderColor(ctx: SKRSContext2D, colorGrid: string[][], x: number, y: number) { + for (let i = 0; i < colorGrid.length; i++) { + const row = colorGrid[i]; + for (let j = 0; j < row.length; j++) { + ctx.save(); + ctx.beginPath(); + ctx.rect( + Math.floor(x + (itemSize / row.length) * j), + Math.floor(y + (itemSize / colorGrid.length) * i), + Math.ceil(itemSize / row.length), + Math.ceil(itemSize / colorGrid.length), + ); + ctx.clip(); + + ctx.beginPath(); + ctx.fillStyle = row[j]; + ctx.roundRect(x, y, itemSize, itemSize, layout.color.radius); + ctx.fill(); + ctx.restore(); + } + } +} + +export function drawSections(sections: Section[]) { + const { sects, columnWidths, sectionWidth, width, height } = measureSections(sections); + const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height); const ctx = canvas.getContext("2d"); ctx.textBaseline = "top"; @@ -125,8 +162,8 @@ export function drawSections(sections: Section[]) { ctx.fillStyle = colors.background; ctx.fillRect(0, 0, canvas.width, canvas.height); - let x = layout.background.padding, - y = layout.background.padding; + let x = layout.background.padding; + let y = layout.background.padding; for (const sect of sects) { ctx.fillStyle = colors.textNormal; ctx.font = headerFont; @@ -165,54 +202,12 @@ export function drawSections(sections: Section[]) { const midX = centerX - row.itemWidth / 2; if (row.item.type === "blob") { - const blobHeight = sectionHeight - wy + y; - - ctx.fillStyle = colors.blob; - ctx.beginPath(); - ctx.roundRect(wx, wy, columnWidth, blobHeight, layout.blob.radius); - ctx.fill(); - - ctx.font = `600 ${textSizes.blob}px GG Sans`; - ctx.fillStyle = colors.textNormal; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - - const wrapped = wrapText(ctx, row.item.blob, columnWidth); - const height = textSizes.blob / 2; - for (let i = 0; i < wrapped.length; i++) - ctx.fillText( - wrapped[i], - wx + columnWidth / 2, - wy + blobHeight / 2 - height * (wrapped.length - 1) + textSizes.blob * i, - ); - - ctx.textAlign = "left"; - ctx.textBaseline = "top"; + renderBlob(ctx, row.item.blob, wx, wy, columnWidth, sectionHeight - wy + y); } else if (row.item.type === "image") { ctx.drawImage(row.item.image, midX, wy, row.itemWidth, itemSize); wy += itemSize + layout.card.gapY; } else if (row.item.type === "color") { - for (let i = 0; i < row.item.colors.length; i++) { - const colors = row.item.colors[i]; - for (let j = 0; j < colors.length; j++) { - const color = colors[j]; - ctx.save(); - ctx.beginPath(); - ctx.rect( - Math.floor(midX + (itemSize / colors.length) * j), - Math.floor(wy + (itemSize / row.item.colors.length) * i), - Math.ceil(itemSize / colors.length), - Math.ceil(itemSize / row.item.colors.length), - ); - ctx.clip(); - - ctx.beginPath(); - ctx.fillStyle = color; - ctx.roundRect(midX, wy, itemSize, itemSize, layout.color.radius); - ctx.fill(); - ctx.restore(); - } - } + renderColor(ctx, row.item.colors, midX, wy); wy += itemSize + layout.card.gapY; } } @@ -225,7 +220,6 @@ export function drawSections(sections: Section[]) { y += layout.background.gap; } - // watermark ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1); return canvas; diff --git a/src/mock.ts b/src/mock.ts index c25b1d25c1b..c43c4b5dc32 100644 --- a/src/mock.ts +++ b/src/mock.ts @@ -1,13 +1,16 @@ import { type CodeDiff, DiffType } from "./types"; export default { - code: new Map([ + raw: new Map(), + semantic: new Map(), + icons: new Map(), + code: new Map([ [ "app/foomodule.tsx", { type: DiffType.Added, size: 24782, - } as CodeDiff, + }, ], [ "app/ui/barmodule.ts", @@ -15,14 +18,14 @@ export default { type: DiffType.Renamed, oldName: "app/legacy/ui/bar_module.ts", size: 5212, - } as CodeDiff, + }, ], [ "app/intl/ReallyBigCoolComponent.tsx", { type: DiffType.Removed, size: 46532, - } as CodeDiff, + }, ], ]), }; diff --git a/src/types.ts b/src/types.ts index 2994c6feaf3..be0141ab21d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,8 +35,11 @@ export type CodeDiff = size: number; }; -export type Differs = Record<"semantic" | "raw" | "icons", Map | undefined> & { - code: Map | undefined; +export type Differs = { + semantic: Map; + raw: Map; + icons: Map; + code: Map; }; export type RawColors = Record; diff --git a/src/update/git.ts b/src/update/git.ts index b784c564ded..88591bf7c1d 100644 --- a/src/update/git.ts +++ b/src/update/git.ts @@ -6,7 +6,11 @@ export const gitChanged = new Set(); export async function fetchGitChanged() { for (const cwd of ["../data", "../canvas"] as const) { try { - const out = await Bun.$`git status -z`.cwd(cwd).quiet().nothrow().then((r) => r.text()); + const out = await Bun.$`git status -z` + .cwd(cwd) + .quiet() + .nothrow() + .then((r) => r.text()); for (const changed of out .split("\x00") .filter((x) => x !== "") @@ -16,35 +20,26 @@ export async function fetchGitChanged() { } } -const gitQueue: Promise[] = []; +let lastCommit: Promise = Promise.resolve(); export async function commit(files: string[], message: string, cwd = "../data") { - while (gitQueue[0]) { - await gitQueue[0]; - } - - const { promise, resolve } = Promise.withResolvers(); - gitQueue.push(promise); - - if (process.env.NODE_ENV === "test" && !commitAnyway) { - await fetchGitChanged(); - } else { - await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr); - if (cwd === "../data") await fetchGitChanged(); - await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}` - .cwd(cwd) - .nothrow() - .quiet() - .then(handleShellErr); - await Bun.$`git commit -m ${message}` - .cwd(cwd) - .nothrow() - .quiet() - .then((e) => void e); - } - - resolve(); - - const promInd = gitQueue.indexOf(promise); - if (promInd !== -1) gitQueue.splice(promInd, 1); + lastCommit = lastCommit.then(async () => { + if (process.env.NODE_ENV === "test" && !commitAnyway) { + await fetchGitChanged(); + } else { + await Bun.$`git restore --staged .`.cwd(cwd).nothrow().quiet().then(handleShellErr); + if (cwd === "../data") await fetchGitChanged(); + await Bun.$`git add ${{ raw: files.map((x) => Bun.$.escape(x)).join(" ") }}` + .cwd(cwd) + .nothrow() + .quiet() + .then(handleShellErr); + await Bun.$`git commit -m ${message}` + .cwd(cwd) + .nothrow() + .quiet() + .then((e) => void e); + } + }); + return lastCommit; } diff --git a/src/update/runner.ts b/src/update/runner.ts index 67f2a757540..8e02bae9c31 100644 --- a/src/update/runner.ts +++ b/src/update/runner.ts @@ -1,4 +1,3 @@ -import { exists, rm } from "node:fs/promises"; import mock from "../mock"; import type { Differs } from "../types"; import { commit } from "./git"; @@ -8,7 +7,6 @@ import codeTask from "./tasks/code"; import colorsTask from "./tasks/colors"; import decompile from "./tasks/decompile"; import diffs from "./tasks/diffs"; -import iconsTask from "./tasks/icons"; import { formatError, handleShellErr, join } from "./utils"; export async function runTasks() { @@ -40,7 +38,9 @@ export async function runTasks() { }, true, ); - let differs: Differs | undefined; + let differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() }; + + let gzipDone: Promise | undefined; if (!isMock) { try { @@ -50,11 +50,19 @@ export async function runTasks() { await Bun.$`git reset --hard`.cwd("../data").nothrow().quiet().then(handleShellErr); await Bun.write("../data/version.txt", version); // canvas branch may not exist in test - await Bun.$`git reset --hard`.cwd("../canvas").nothrow().quiet().then(() => {}); + await Bun.$`git reset --hard` + .cwd("../canvas") + .nothrow() + .quiet() + .then(() => {}); await Bun.write("../canvas/version.txt", version).catch(() => {}); await Bun.$`git restore --staged .`.cwd("../data").nothrow().quiet().then(handleShellErr); - await Bun.$`git restore --staged .`.cwd("../canvas").nothrow().quiet().then(() => {}); + await Bun.$`git restore --staged .` + .cwd("../canvas") + .nothrow() + .quiet() + .then(() => {}); progress.update("preinit_discard", true); @@ -76,11 +84,11 @@ export async function runTasks() { } try { - await wrapPromise( + gzipDone = (await wrapPromise( decompile(progress, join(apksFolder, "base", "assets", "index.android.bundle")), progress, "decompile", - ); + )) as Promise | undefined; } catch (e) { throw new Error(`Failed to decompile!\n${e}`); } @@ -88,8 +96,7 @@ export async function runTasks() { const code = (await Bun.file(codePath).text()).replace(/\r/g, "").split("\n"); await wrapPromise(codeTask(progress, code), progress, "code"); - if (progress.someFailed("code")) - throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`); + if (progress.someFailed("code")) throw new Error(`Failed at parser tasks!\n${progress.prettyErrors("code")}`); // colors is non-critical, run in background wrapPromise(colorsTask(code), progress, "colors").catch((e) => { progress.update("colors", false, String(e)); @@ -103,22 +110,25 @@ export async function runTasks() { progress.update("icons", null); progress.update("diff_icons", null); - let waits = 0; - while (!progress.isFinished("decompile_gzip") && waits < 120) { - await Bun.sleep(1000); - waits++; - } - if (!progress.isFinished("decompile_gzip")) { - console.warn("decompile_gzip still not finished after 120s, continuing anyway"); - progress.update("decompile_gzip", true); + if (gzipDone) { + try { + await gzipDone; + } catch (e) { + if (progress.someFailed("decompile_gzip")) + throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`); + throw e; + } } if (progress.someFailed("decompile_gzip")) throw new Error(`Failed at the decompile gzip task!\n${progress.prettyErrors("decompile_gzip")}`); try { - differs = await diffs(progress); - progress.update("diff", true); + const result = await diffs(progress); + if (result) { + differs = result; + progress.update("diff", true); + } } catch (e: any) { progress.update("diff", false, formatError(e)); throw new Error(`Failed to generate diffs!\n${formatError(e)}`); @@ -127,7 +137,7 @@ export async function runTasks() { differs = mock; } - if (differs && !isQuiet) { + if (!isQuiet) { try { const { webhook } = await import("./tasks/webhook"); await wrapPromise(webhook(differs), progress, "webhook"); diff --git a/src/update/tasks/code.ts b/src/update/tasks/code.ts index 891ea0b9a4d..8780dcfb0d2 100644 --- a/src/update/tasks/code.ts +++ b/src/update/tasks/code.ts @@ -1,4 +1,4 @@ -import { mkdir, rm, readdir, stat } from "node:fs/promises"; +import { mkdir, readdir, rm, stat } from "node:fs/promises"; import { join as pathJoin } from "node:path"; import { commit } from "../git"; import type { Progress } from "../progress"; @@ -13,7 +13,10 @@ function extractModuleId(filename: string): number | undefined { } function sanitizePath(p: string): string { - return p.replace(/\\/g, "/").replace(/^\.\.\//, "").replace(/[<>:"|?*]/g, "_"); + return p + .replace(/\\/g, "/") + .replace(/^\.\.\//, "") + .replace(/[<>:"|?*]/g, "_"); } export default async function code(progress: Progress, _code: string[]) { diff --git a/src/update/tasks/colors.ts b/src/update/tasks/colors.ts index d23428cbb9c..6d5aaa11635 100644 --- a/src/update/tasks/colors.ts +++ b/src/update/tasks/colors.ts @@ -28,19 +28,34 @@ function evalRawModule(snippet: string): any { code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting"); code = code.replace(/export\s+const\s+_private/, "const _private"); code = code + "\n; _private;"; - return runInNewContext(code, {}); + const result = runInNewContext(code, {}); + if (!result || typeof result !== "object" || !result.RawColors || typeof result.RawColors !== "object") { + throw new Error( + "VM evaluation failed: RawColors not found or invalid shape. The regex transforms may not match the current decompiler output.", + ); + } + return result; } function evalSemanticModule(snippet: string): any { let code = snippet; code = code.replace(/^import set from "set".*$/m, "const __importSet = { fileFinishedImporting: () => {} };"); - code = code.replace(/^import items from "items".*$/m, "const items = { _private: { Themes: new Proxy({}, { get(_, k){ return k.toString().toLowerCase(); } }) } };"); + code = code.replace( + /^import items from "items".*$/m, + "const items = { _private: { Themes: new Proxy({}, { get(_, k){ return k.toString().toLowerCase(); } }) } };", + ); code = code.replace(/^import.*$/gm, ""); code = code.replace(/set\.fileFinishedImporting/g, "__importSet.fileFinishedImporting"); code = code.replace(/export\s+const\s+_private/, "const _private"); code = code + "\n; _private;"; code = fixHermesDecIndex(code); - return runInNewContext(code, {}); + const result = runInNewContext(code, {}); + if (!result || typeof result !== "object" || !result.SemanticColors || typeof result.SemanticColors !== "object") { + throw new Error( + "VM evaluation failed: SemanticColors not found or invalid shape. The regex transforms may not match the current decompiler output.", + ); + } + return result; } export function getInternalRawColors(code: string[]) { @@ -59,7 +74,9 @@ export function getInternalSemanticColors(code: string[], raw: Record>)) { + for (const [key, colors] of Object.entries( + mod.SemanticColors as Record>, + )) { const color: SemanticColors[string] = {}; for (const [theme, val] of Object.entries(colors)) { if (typeof val !== "object" || !("raw" in val)) continue; @@ -88,5 +105,9 @@ export default async function colors(code: string[]) { await Bun.write("../canvas/semantic.json", JSON.stringify(sortObj(semantic), null, 4)); await Bun.write("../canvas/semantic_simple.json", JSON.stringify(sortObj(convertSimpleSemantic(semantic)), null, 4)); - await commit(["raw.json", "semantic.json", "semantic_simple.json"], `chore: update colors for ${cuteVersion}`, "../canvas"); + await commit( + ["raw.json", "semantic.json", "semantic_simple.json"], + `chore: update colors for ${cuteVersion}`, + "../canvas", + ); } diff --git a/src/update/tasks/decompile.ts b/src/update/tasks/decompile.ts index 2963a4c304a..501eeb218a5 100644 --- a/src/update/tasks/decompile.ts +++ b/src/update/tasks/decompile.ts @@ -26,18 +26,11 @@ export default async function decompile(progress: Progress, pathToBundle: string progress.start("decompile_decompiling"); const decompilerBin = join(pathToDecompiler, "target/release/hermes-decomp"); if (!(await Bun.file(decompilerBin).exists())) { - await Bun.$`cargo build --release -p hbc-decomp-cli` - .cwd(pathToDecompiler) - .quiet() - .nothrow() - .then(handleShellErr); + await Bun.$`cargo build --release -p hbc-decomp-cli`.cwd(pathToDecompiler).quiet().nothrow().then(handleShellErr); } if (!(await Bun.file(codePath).exists())) { - await Bun.$`${decompilerBin} decompile ${pathToBundle} --output ${codePath}` - .quiet() - .nothrow() - .then(handleShellErr); + await Bun.$`${decompilerBin} decompile ${pathToBundle} --output ${codePath}`.quiet().nothrow().then(handleShellErr); } if (!(await exists(modulesPath))) { @@ -70,15 +63,20 @@ export default async function decompile(progress: Progress, pathToBundle: string const gzipper = new Worker(gzipWorkerURL); progress.start("decompile_gzip"); + const { promise: gzipDone, resolve: resolveGzip } = Promise.withResolvers(); gzipper.addEventListener("message", async ({ data }) => { if (data === true) { - await commit([gzFile, "module-paths.json", "index.android.bundle"], `chore: update decompiled code for ${cuteVersion}`); + await commit( + [gzFile, "module-paths.json", "index.android.bundle"], + `chore: update decompiled code for ${cuteVersion}`, + ); progress.update("decompile_gzip", true); } gzipper.terminate(); + resolveGzip(); }); gzipper.postMessage({ path: codePath, target: join("../data", gzFile) }); - } else { - progress.update("decompile_gzip", null); + return gzipDone; } + progress.update("decompile_gzip", null); } diff --git a/src/update/tasks/diffs.ts b/src/update/tasks/diffs.ts index 9827287ac8d..0d72203719e 100644 --- a/src/update/tasks/diffs.ts +++ b/src/update/tasks/diffs.ts @@ -12,185 +12,183 @@ import { type Progress, wrapPromise } from "../progress"; import { diffAnyway, prevFiles } from "../shared"; import { join, parseJsonl } from "../utils"; -async function diffRaw() { - if (!prevFiles.has("raw.json")) { - throw new Error("Missing prevFile: raw.json"); - } +interface DiffOpts { + getSource: (key: string, val: TNew) => string; + getLabel?: (key: string, val: TNew) => string; + getOldSource?: (key: string, val: TOld) => string; + getOldLabel?: (key: string, val: TOld) => string; + isRename: (newKey: string, oldVal: TOld, newVal: TNew) => boolean; + isChanged?: (oldVal: TOld, newVal: TNew) => boolean; +} - const oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors; - const newRaw: RawColors = await Bun.file(join("../canvas", "raw.json")).json(); - - const renamed = new Set(); +function diffEntries( + oldEntries: Record, + newEntries: Record, + opts: DiffOpts, +): Map { const changes = new Map(); - for (const [name, source] of Object.entries(newRaw)) - if (!oldRaw[name]) { - const oldName = Object.entries(oldRaw).find(([key, val]) => !newRaw[key] && val === source)?.[0]; + const renamed = new Set(); - if (!oldName) + for (const [name, newVal] of Object.entries(newEntries)) { + const oldVal = oldEntries[name]; + if (oldVal === undefined) { + const oldName = Object.entries(oldEntries).find( + ([key, val]) => !(key in newEntries) && opts.isRename(name, val, newVal), + )?.[0]; + + if (!oldName) { changes.set(name, { type: DiffType.Added, - source, + source: opts.getSource(name, newVal), + label: opts.getLabel?.(name, newVal), }); - else { + } else { renamed.add(oldName); - changes.set(name, { - type: DiffType.Renamed, - oldName, - source, - }); + changes.set(name, { type: DiffType.Renamed, oldName, source: opts.getSource(name, newVal) }); } - } else if (oldRaw[name].toLowerCase() !== source.toLowerCase()) + } else if (opts.isChanged?.(oldVal, newVal)) { changes.set(name, { type: DiffType.Changed, - source, - oldSource: oldRaw[name], - }); - for (const [name, source] of Object.entries(oldRaw)) - if (!(newRaw[name] || renamed.has(name))) - changes.set(name, { - type: DiffType.Removed, - source, + source: opts.getSource(name, newVal), + label: opts.getLabel?.(name, newVal), + oldSource: opts.getOldSource?.(name, oldVal) ?? "", + oldLabel: opts.getOldLabel?.(name, oldVal), }); + } + } + + for (const [name, oldVal] of Object.entries(oldEntries)) { + if (name in newEntries || renamed.has(name)) continue; + changes.set(name, { + type: DiffType.Removed, + source: opts.getOldSource?.(name, oldVal) ?? "", + label: opts.getOldLabel?.(name, oldVal), + }); + } return changes; } -function getSemanticLabel(semantic: any) { - const light = semantic.light, - dark = semantic.darker ?? semantic.dark; - if (dark && light) return `☀️ ${light[0]}, 🌙 ${dark[0]}`; +function diffEntriesCode( + oldEntries: Record, + newEntries: Record, +): Map { + const changes = new Map(); + const renamed = new Set(); + + for (const [name, newVal] of Object.entries(newEntries)) { + if (!(name in oldEntries)) { + const oldName = Object.entries(oldEntries).find( + ([key, val]) => !(key in newEntries) && val.compSize === newVal.compSize, + )?.[0]; + + if (!oldName) { + changes.set(name, { type: DiffType.Added, size: newVal.size }); + } else { + renamed.add(oldName); + changes.set(name, { type: DiffType.Renamed, oldName, size: newVal.size }); + } + } + } + + for (const [name, oldVal] of Object.entries(oldEntries)) { + if (!(name in newEntries || renamed.has(name))) { + changes.set(name, { type: DiffType.Removed, size: oldVal.size }); + } + } + + return changes; +} + +async function diffRaw() { + if (!prevFiles.has("raw.json")) throw new Error("Missing prevFile: raw.json"); + + const oldRaw = JSON.parse(new TextDecoder().decode(prevFiles.get("raw.json"))) as RawColors; + const newRaw: RawColors = await Bun.file(join("../canvas", "raw.json")).json(); + + return diffEntries(oldRaw, newRaw, { + getSource: (_, val) => val, + isRename: (newKey, oldVal, newVal) => !(newKey in newRaw) && oldVal === newVal, + isChanged: (oldVal, newVal) => oldVal.toLowerCase() !== newVal.toLowerCase(), + }); +} + +function getSemanticLabel(semantic: Record) { + const light = semantic.light; + const dark = semantic.darker ?? semantic.dark; + if (dark && light) return `${light[0]}, ${dark[0]}`; return Object.entries(semantic) .map(([name, [color]]) => `${name} ${color}`) .join(", "); } async function diffSemantic() { - if (!prevFiles.has("semantic.json")) { - throw new Error("Missing prevFile: semantic.json"); - } + if (!prevFiles.has("semantic.json")) throw new Error("Missing prevFile: semantic.json"); const oldSemantic = JSON.parse(new TextDecoder().decode(prevFiles.get("semantic.json"))) as SemanticColors; const newSemantic: SemanticColors = await Bun.file(join("../canvas", "semantic.json")).json(); - const renamed = new Set(); - const changes = new Map(); - for (const [name, semantic] of Object.entries(newSemantic)) { - const source = Object.values(semantic) - .map(([color]) => color) - .join(","); - if (!oldSemantic[name]) { - const themes = Object.keys(semantic).length; - const oldName = Object.entries(oldSemantic).find( - ([key, val]) => - !newSemantic[key] && - Object.keys(val).length === themes && - Object.entries(val).every(([skey, sval]) => sval[0] === semantic[skey][0]), - )?.[0]; + const changes = diffEntries(oldSemantic, newSemantic, { + getSource: (_, val) => + Object.values(val) + .map(([color]) => color) + .join(","), + getLabel: (_, val) => getSemanticLabel(val), + isRename: (newKey, oldVal, newVal) => { + const themes = Object.keys(newVal).length; + return ( + !(newKey in newSemantic) && + Object.keys(oldVal).length === themes && + Object.entries(oldVal).every(([skey, sval]) => sval[0] === newVal[skey]?.[0]) + ); + }, + }); - if (!oldName) - changes.set(name, { - type: DiffType.Added, - source, - label: getSemanticLabel(semantic), - }); - else { - renamed.add(oldName); - changes.set(name, { - type: DiffType.Renamed, - oldName, - source, - }); - } - } else - for (const [theme, [color]] of Object.entries(semantic)) - if (!oldSemantic[name][theme]) - changes.set(`${name}.${theme}`, { - type: DiffType.Added, - source: color, - }); - else if (oldSemantic[name][theme][0].toLowerCase() !== color.toLowerCase()) + for (const [name, semantic] of Object.entries(newSemantic)) { + if (oldSemantic[name]) { + for (const [theme, [color]] of Object.entries(semantic)) { + if (!oldSemantic[name][theme]) { + changes.set(`${name}.${theme}`, { type: DiffType.Added, source: color }); + } else if (oldSemantic[name][theme][0].toLowerCase() !== color.toLowerCase()) { changes.set(`${name}.${theme}`, { type: DiffType.Changed, source: color, oldSource: oldSemantic[name][theme][0], }); + } + } + } } - for (const [name, semantic] of Object.entries(oldSemantic)) { - if (renamed.has(name)) continue; - const source = Object.values(semantic) - .map(([color]) => color) - .join(","); - if (!newSemantic[name]) - changes.set(name, { - type: DiffType.Removed, - source, - label: getSemanticLabel(semantic), - }); - else - for (const [theme, [source]] of Object.entries(semantic)) - if (!newSemantic[name][theme]) - changes.set(`${name}.${theme}`, { - type: DiffType.Removed, - source, - }); + for (const [name, semantic] of Object.entries(oldSemantic)) { + if (name in newSemantic) { + for (const [theme, [source]] of Object.entries(semantic)) { + if (!newSemantic[name][theme]) { + changes.set(`${name}.${theme}`, { type: DiffType.Removed, source }); + } + } + } } return changes; } async function diffIcons() { - if (!prevFiles.has("icons.json")) { - throw new Error("Missing prevFile: icons.json"); - } + if (!prevFiles.has("icons.json")) throw new Error("Missing prevFile: icons.json"); const oldIcons = JSON.parse(new TextDecoder().decode(prevFiles.get("icons.json"))) as Icons; const newIcons: Icons = await Bun.file(join("../canvas", "icons.json")).json(); - const iconDir = { - old: join("../canvas", "oldicons"), - new: join("../canvas", "icons"), - }; + const iconDir = { old: join("../canvas", "oldicons"), new: join("../canvas", "icons") }; - const renamed = new Set(); - const changes = new Map(); - for (const [name, icon] of Object.entries(newIcons)) { - const label = icon.hash.slice(0, 8); - if (!oldIcons[name]) { - const oldName = Object.entries(oldIcons).find(([key, val]) => !newIcons[key] && val.hash === icon.hash)?.[0]; - - if (!oldName) - changes.set(name, { - type: DiffType.Added, - source: join(iconDir.new, icon.file), - label, - }); - else { - renamed.add(oldName); - changes.set(name, { - type: DiffType.Renamed, - oldName, - source: join(iconDir.new, icon.file), - }); - } - } else if (oldIcons[name].hash !== icon.hash) - changes.set(name, { - type: DiffType.Changed, - source: join(iconDir.new, icon.file), - label, - oldSource: join(iconDir.old, oldIcons[name].file), - oldLabel: oldIcons[name].hash.slice(0, 8), - }); - } - for (const [name, icon] of Object.entries(oldIcons)) - if (!(newIcons[name] || renamed.has(name))) - changes.set(name, { - type: DiffType.Removed, - source: join(iconDir.old, icon.file), - label: icon.hash.slice(0, 8), - }); - - return changes; + return diffEntries(oldIcons, newIcons, { + getSource: (_, val) => join(iconDir.new, val.file), + getLabel: (_, val) => val.hash.slice(0, 8), + getOldSource: (_, val) => join(iconDir.old, val.file), + getOldLabel: (_, val) => val.hash.slice(0, 8), + isRename: (newKey, oldVal, newVal) => !(newKey in newIcons) && oldVal.hash === newVal.hash, + isChanged: (oldVal, newVal) => oldVal.hash !== newVal.hash, + }); } function parseSource(text: string) { @@ -201,42 +199,12 @@ function parseSource(text: string) { } async function diffCode() { - if (!prevFiles.has("source.jsonl")) { - throw new Error("Missing prevFile: source.jsonl"); - } + if (!prevFiles.has("source.jsonl")) throw new Error("Missing prevFile: source.jsonl"); const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl"))); const newCode = parseSource(await Bun.file(join("../data", "source.jsonl")).text()); - const renamed = new Set(); - const changes = new Map(); - for (const [name, { size, compSize }] of Object.entries(newCode)) { - if (!oldCode[name]) { - const oldName = Object.entries(oldCode).find(([key, val]) => !newCode[key] && val.compSize === compSize)?.[0]; - - if (!oldName) - changes.set(name, { - type: DiffType.Added, - size, - }); - else { - renamed.add(oldName); - changes.set(name, { - type: DiffType.Renamed, - oldName, - size, - }); - } - } - } - for (const [name, { size }] of Object.entries(oldCode)) - if (!(newCode[name] || renamed.has(name))) - changes.set(name, { - type: DiffType.Removed, - size, - }); - - return changes; + return diffEntriesCode(oldCode, newCode); } export default async function diffs(progress: Progress) { @@ -251,7 +219,7 @@ export default async function diffs(progress: Progress) { return; } - const differs = {} as Differs; + const differs: Differs = { raw: new Map(), semantic: new Map(), icons: new Map(), code: new Map() }; await Promise.all([ gitChanged.has("raw.json") diff --git a/src/update/tasks/webhook.ts b/src/update/tasks/webhook.ts index 9bcf18001db..80f77519a36 100644 --- a/src/update/tasks/webhook.ts +++ b/src/update/tasks/webhook.ts @@ -12,7 +12,7 @@ import { drawSections } from "../../canvas"; import { makeSections } from "../../canvas/factory"; import { type CodeDiff, type Diff, type Differs, DiffType } from "../../types"; import { cuteVersion, maxDiffChanges, version } from "../shared"; -import { assert, formatBytes, sortEntries } from "../utils"; +import { formatBytes, sortEntries } from "../utils"; function fileBase(path: string, other?: string) { if (!other) return `... ${basename(path)}`; @@ -49,43 +49,39 @@ interface FormattedDiff { } function formatDiff(diffs: Map): FormattedDiff { - const entries = [...diffs.entries()]; + const entries = sortEntries([...diffs.entries()]); const sections = { - Added: sortEntries(entries) - .map( - ([name, diff]) => - diff.type === DiffType.Added && - ("size" in diff - ? `+ ${fileBase(name)} (${formatBytes(diff.size)})` - : `+ ${name}: ${diff.label || diff.source}`), - ) - .filter((x) => typeof x === "string"), - Changed: sortEntries(entries) - .map( - ([name, diff]) => - diff.type === DiffType.Changed && - `- ${name}: ${diff.oldLabel || diff.oldSource}\n+ ${name}: ${diff.label || diff.source}`, - ) - .filter((x) => typeof x === "string"), - Renamed: sortEntries(entries) - .map( - ([name, diff]) => - diff.type === DiffType.Renamed && - ("size" in diff - ? `- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}` - : `- ${diff.oldName}\n+ ${name}`), - ) - .filter((x) => typeof x === "string"), - Removed: sortEntries(entries) - .map( - ([name, diff]) => - diff.type === DiffType.Removed && - ("size" in diff - ? `- ${fileBase(name)} (${formatBytes(diff.size)})` - : `- ${name}: ${diff.label || diff.source}`), - ) - .filter((x) => typeof x === "string"), + Added: entries + .filter(([, diff]) => diff.type === DiffType.Added) + .map(([name, diff]) => { + if ("size" in diff) return `+ ${fileBase(name)} (${formatBytes(diff.size)})`; + const d = diff as Extract; + return `+ ${name}: ${d.label || d.source}`; + }), + Changed: entries + .filter(([, diff]) => diff.type === DiffType.Changed) + .map(([name, diff]) => { + const d = diff as Extract; + return `- ${name}: ${d.oldLabel || d.oldSource}\n+ ${name}: ${d.label || d.source}`; + }), + Renamed: entries + .filter(([, diff]) => diff.type === DiffType.Renamed) + .map(([name, diff]) => { + if ("size" in diff) { + const cd = diff as Extract; + return `- ${fileBase(cd.oldName, name)}\n+ ${fileBase(name, cd.oldName)}`; + } + const d = diff as Extract; + return `- ${d.oldName}\n+ ${name}`; + }), + Removed: entries + .filter(([, diff]) => diff.type === DiffType.Removed) + .map(([name, diff]) => { + if ("size" in diff) return `- ${fileBase(name)} (${formatBytes(diff.size)})`; + const d = diff as Extract; + return `- ${name}: ${d.label || d.source}`; + }), }; const text = Object.entries(sections).filter(([, lines]) => lines.length); @@ -214,7 +210,7 @@ async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[] } export async function webhook(diffs: Differs) { - if (diffs.raw?.size || diffs.semantic?.size) { + if (diffs.raw.size || diffs.semantic.size) { const wh = process.env.color_webhook; if (!wh) { console.warn("Missing color_webhook, skipping color webhook"); @@ -223,14 +219,14 @@ export async function webhook(diffs: Differs) { wh, "1227327297795657850", [ - diffs.raw?.size && { + diffs.raw.size && { title: "Raw colors", body: formatDiff(diffs.raw), image: drawSections(await makeSections(diffs.raw)), footer: makeFooter(diffs.raw.size, "raw color"), key: "raw-colors", }, - diffs.semantic?.size && { + diffs.semantic.size && { title: "Semantic colors", body: formatDiff(diffs.semantic), image: drawSections(await makeSections(diffs.semantic)), @@ -243,7 +239,7 @@ export async function webhook(diffs: Differs) { } } - if (diffs.icons?.size) { + if (diffs.icons.size) { const wh = process.env.icons_webhook; if (!wh) { console.warn("Missing icons_webhook, skipping"); @@ -265,7 +261,7 @@ export async function webhook(diffs: Differs) { } } - if (diffs.code?.size) { + if (diffs.code.size) { const wh = process.env.code_webhook; if (!wh) { console.warn("Missing code_webhook, skipping");