diff --git a/bun.lock b/bun.lock index 54a6ca9c533..70420470729 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,14 @@ "": { "name": "themelings", "dependencies": { - "color": "^5.0.2", + "@napi-rs/canvas": "^0.1.97", + "color": "^5.0.3", "discord-api-types": "^0.38.46", }, "devDependencies": { "@biomejs/biome": "^2.4.11", "@types/bun": "^1.3.12", + "@types/color": "^4.2.1", }, "peerDependencies": { "typescript": "^6.0.2", @@ -39,8 +41,38 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.100", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.100", "@napi-rs/canvas-darwin-arm64": "0.1.100", "@napi-rs/canvas-darwin-x64": "0.1.100", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", "@napi-rs/canvas-linux-arm64-musl": "0.1.100", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-gnu": "0.1.100", "@napi-rs/canvas-linux-x64-musl": "0.1.100", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.100", "", { "os": "android", "cpu": "arm64" }, "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.100", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.100", "", { "os": "darwin", "cpu": "x64" }, "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.100", "", { "os": "linux", "cpu": "arm" }, "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.100", "", { "os": "linux", "cpu": "arm64" }, "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.100", "", { "os": "linux", "cpu": "none" }, "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.100", "", { "os": "linux", "cpu": "x64" }, "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.100", "", { "os": "win32", "cpu": "arm64" }, "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/color": ["@types/color@4.2.1", "", { "dependencies": { "@types/color-convert": "2" } }, "sha512-ResWeDLy1vozIMbD6JLRKuNBbIcIlBkjTIxVHHd5Cqtm77T+ahH3BWE/PWv1OhFd1HAwcn8no4ig2uTaRXpYQQ=="], + + "@types/color-convert": ["@types/color-convert@2.0.4", "", { "dependencies": { "@types/color-name": "^1.1.0" } }, "sha512-Ub1MmDdyZ7mX//g25uBAoH/mWGd9swVbt8BseymnaE18SU4po/PjmCrHxqIIRjBo3hV/vh1KGr0eMxUhp+t+dQ=="], + + "@types/color-name": ["@types/color-name@1.1.5", "", {}, "sha512-j2K5UJqGTxeesj6oQuGpMgifpT5k9HprgQd8D1Y0lOFqKHl3PJu5GMeS4Y5EgjS55AE6OQxf8mPED9uaGbf4Cg=="], + "@types/node": ["@types/node@20.12.14", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], diff --git a/package.json b/package.json index ccfe4fcc6b7..c6d1e29935a 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,14 @@ "format": "biome check --fix ." }, "dependencies": { - "color": "^5.0.2", + "@napi-rs/canvas": "^0.1.97", + "color": "^5.0.3", "discord-api-types": "^0.38.46" }, "devDependencies": { "@biomejs/biome": "^2.4.11", - "@types/bun": "^1.3.12" + "@types/bun": "^1.3.12", + "@types/color": "^4.2.1" }, "peerDependencies": { "typescript": "^6.0.2" diff --git a/src/canvas/factory.ts b/src/canvas/factory.ts new file mode 100644 index 00000000000..112b56b3677 --- /dev/null +++ b/src/canvas/factory.ts @@ -0,0 +1,97 @@ +import { join } from "node:path"; +import { type Image, loadImage } from "@napi-rs/canvas"; +import { type Diff, DiffType } from "../types"; + +export type Item = + | { + type: "color"; + colors: string[][]; + } + | { + type: "image"; + image: Image; + } + | { + type: "blob"; + blob: string; + }; + +export interface Row { + title?: string; + subtitle?: string; + item: Item; +} + +export interface Section { + header: string; + columns: Row[][]; +} + +function placeholderFile(file: string) { + return file.endsWith(".lottie") ? join(import.meta.dir, "placeholders/lottie.png") : file; +} + +const numColumns = 5; +const maxRows = 3; + +async function makeSection(diffs: Map, isFile: boolean, type: DiffType, header: string) { + const filtered = [...diffs.entries()].filter(([, diff]) => diff.type === type); + const section: Section = { + header, + columns: Array.from({ length: numColumns }, () => []), + }; + + let i = 0; + for (const [name, diff] of filtered) { + const columnInd = i++ % numColumns; + + const column = section.columns[columnInd], + nextColumn = section.columns[i % numColumns]; + if (!column) throw new Error(`Couldn't find column of index ${columnInd}`); + + if (!nextColumn || nextColumn.length >= maxRows) { + column.push({ + item: { + type: "blob", + blob: `+${(filtered.length - i + 1).toLocaleString("en-US")} ${header.toLowerCase()}`, + }, + }); + break; + } + + const changed = diff.type === DiffType.Changed; + const item: Item = isFile + ? { type: "image", image: await loadImage(placeholderFile(diff.source)) } + : { type: "color", colors: [changed && diff.oldSource.split(","), diff.source.split(",")].filter((x) => !!x) }; + + column.push( + ...[ + changed && + isFile && { + subtitle: name, + item: { + type: "image", + image: await loadImage(placeholderFile(diff.oldSource)), + } as Item, + }, + { + title: name, + subtitle: diff.type === DiffType.Renamed ? diff.oldName : undefined, + item, + }, + ].filter((x) => !!x), + ); + } + + section.columns = section.columns.filter((x) => x.length); + return section; +} + +export async function makeSections(diffs: Map, isFile = false) { + return [ + await makeSection(diffs, isFile, DiffType.Added, "Added"), + await makeSection(diffs, isFile, DiffType.Changed, "Changed"), + await makeSection(diffs, isFile, DiffType.Renamed, "Renamed"), + await makeSection(diffs, isFile, DiffType.Removed, "Removed"), + ].filter((x) => x.columns[0]); +} diff --git a/src/canvas/fonts/ggmono/normal-400.ttf b/src/canvas/fonts/ggmono/normal-400.ttf new file mode 100644 index 00000000000..3a2aa0cb035 Binary files /dev/null and b/src/canvas/fonts/ggmono/normal-400.ttf differ diff --git a/src/canvas/fonts/ggsans/normal-400.ttf b/src/canvas/fonts/ggsans/normal-400.ttf new file mode 100644 index 00000000000..45eefcd3c69 Binary files /dev/null and b/src/canvas/fonts/ggsans/normal-400.ttf differ diff --git a/src/canvas/fonts/ggsans/semibold-600.ttf b/src/canvas/fonts/ggsans/semibold-600.ttf new file mode 100644 index 00000000000..28e88c38c8e Binary files /dev/null and b/src/canvas/fonts/ggsans/semibold-600.ttf differ diff --git a/src/canvas/index.test.ts b/src/canvas/index.test.ts new file mode 100644 index 00000000000..5e531d247e4 --- /dev/null +++ b/src/canvas/index.test.ts @@ -0,0 +1,12 @@ +import mock from "../mock"; +import { drawSections } from "."; +import { makeSections } from "./factory"; + +await Bun.write( + "temp/files.png", + new Uint8Array(drawSections(await makeSections(mock.icons, true)).toBuffer("image/png")), +); +await Bun.write( + "temp/colors.png", + new Uint8Array(drawSections(await makeSections(mock.semantic)).toBuffer("image/png")), +); diff --git a/src/canvas/index.ts b/src/canvas/index.ts new file mode 100644 index 00000000000..16fa988499d --- /dev/null +++ b/src/canvas/index.ts @@ -0,0 +1,232 @@ +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; +import { file } from "bun"; +import type { Row, Section } from "./factory"; +import { measureText, transformSvg, wrapText } from "./utils"; + +async function bulkRegister(family: string, path: string) { + for (const item of await readdir(path)) GlobalFonts.registerFromPath(join(path, item), family); +} +await bulkRegister("GG Sans", join(import.meta.dir, "fonts/ggsans")); +await bulkRegister("GG Mono", join(import.meta.dir, "fonts/ggmono")); + +function calcColumnWidth(column: { textWidth: number; itemWidth: number }[]) { + return Math.max(0, ...column.map(({ textWidth, itemWidth }) => Math.max(0, textWidth, itemWidth))); +} + +function calcColumnHeight(column: Row[]) { + const texts = column + .map((row) => Number(!!row.title) + Number(!!row.subtitle) + Number(row.item.type === "blob")) + .reduce((a, b) => a + b, 0); + const items = texts + column.length - 1; + return texts * textSizes.title + column.length * itemSize + items * layout.card.gapY; +} + +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, + }, +}; + +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`; + + 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, + })), + ), + })); + 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; + + const width = Math.max(0, ...sects.map(({ headerWidth }) => headerWidth), sectionWidth); + const height = + sects + .map( + ({ columns }) => + textSizes.header + + layout.section.gap + + layout.card.padding * 2 + + calcSectionHeight(columns) + + layout.background.gap, + ) + .reduce((a, b) => a + b, 0) - layout.background.gap; + + const canvas = createCanvas(layout.background.padding * 2 + width, layout.background.padding * 2 + height); + const ctx = canvas.getContext("2d"); + ctx.textBaseline = "top"; + + ctx.fillStyle = colors.background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + let x = layout.background.padding, + y = layout.background.padding; + for (const sect of sects) { + ctx.fillStyle = colors.textNormal; + ctx.font = headerFont; + ctx.fillText(sect.header, x, y); + y += textSizes.header + layout.section.gap; + + const sectionHeight = calcSectionHeight(sect.columns); + ctx.fillStyle = colors.section; + ctx.beginPath(); + ctx.roundRect(x, y, sectionWidth, sectionHeight + layout.card.padding * 2, layout.card.radius); + ctx.fill(); + y += layout.card.padding; + x += layout.card.padding; + + let wx = x; + for (let i = 0; i < sect.columns.length; i++) { + let wy = y; + + const column = sect.columns[i]; + const columnWidth = columnWidths[i]; + if (!columnWidth) throw new Error(`Failed to get columnWidth for ${i}, ${columnWidth}`); + + const centerX = wx + columnWidth / 2; + for (const row of column) { + ctx.font = titleFont; + if (row.subtitle) { + ctx.fillStyle = colors.textMuted; + ctx.fillText(row.subtitle, centerX - row.textWidth / 2, wy); + wy += textSizes.title + layout.card.gapY; + } + if (row.title) { + ctx.fillStyle = colors.textNormal; + ctx.fillText(row.title, centerX - row.textWidth / 2, wy); + wy += textSizes.title + layout.card.gapY; + } + + 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"; + } 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(); + } + } + wy += itemSize + layout.card.gapY; + } + } + wx += columnWidth + layout.card.gapX; + } + y += sectionHeight; + + x -= layout.card.padding; + y += layout.card.padding; + y += layout.background.gap; + } + + // watermark + ctx.drawImage(watermark, canvas.width - watermark.width - 1, 1); + + return canvas; +} diff --git a/src/canvas/mock/FirstMockIcon.png b/src/canvas/mock/FirstMockIcon.png new file mode 100644 index 00000000000..580037001a0 Binary files /dev/null and b/src/canvas/mock/FirstMockIcon.png differ diff --git a/src/canvas/mock/FourthMockIcon.png b/src/canvas/mock/FourthMockIcon.png new file mode 100644 index 00000000000..4bb486b21c8 Binary files /dev/null and b/src/canvas/mock/FourthMockIcon.png differ diff --git a/src/canvas/mock/LottieMockIcon.lottie b/src/canvas/mock/LottieMockIcon.lottie new file mode 100644 index 00000000000..a2a4fc1216a Binary files /dev/null and b/src/canvas/mock/LottieMockIcon.lottie differ diff --git a/src/canvas/mock/SecondMockIcon.png b/src/canvas/mock/SecondMockIcon.png new file mode 100644 index 00000000000..0d7eab30520 Binary files /dev/null and b/src/canvas/mock/SecondMockIcon.png differ diff --git a/src/canvas/mock/SecondMockIconNew.png b/src/canvas/mock/SecondMockIconNew.png new file mode 100644 index 00000000000..f83d0103c6b Binary files /dev/null and b/src/canvas/mock/SecondMockIconNew.png differ diff --git a/src/canvas/mock/SvgMockIcon.svg b/src/canvas/mock/SvgMockIcon.svg new file mode 100644 index 00000000000..b3a9647b356 --- /dev/null +++ b/src/canvas/mock/SvgMockIcon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/canvas/mock/ThirdMockIcon.png b/src/canvas/mock/ThirdMockIcon.png new file mode 100644 index 00000000000..65718d5454b Binary files /dev/null and b/src/canvas/mock/ThirdMockIcon.png differ diff --git a/src/canvas/placeholders/lottie.png b/src/canvas/placeholders/lottie.png new file mode 100644 index 00000000000..1a69318a9b1 Binary files /dev/null and b/src/canvas/placeholders/lottie.png differ diff --git a/src/canvas/placeholders/watermark.svg b/src/canvas/placeholders/watermark.svg new file mode 100644 index 00000000000..7d336c52399 --- /dev/null +++ b/src/canvas/placeholders/watermark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/canvas/utils.ts b/src/canvas/utils.ts new file mode 100644 index 00000000000..73d93bbdde0 --- /dev/null +++ b/src/canvas/utils.ts @@ -0,0 +1,45 @@ +import { type CanvasRenderingContext2D, loadImage } from "@napi-rs/canvas"; + +export function measureText(ctx: CanvasRenderingContext2D, font: string, text: string) { + ctx.font = font; + return ctx.measureText(text); +} + +export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) { + // https://stackoverflow.com/a/16599668 + const words = text.split(" "); + let lines: string[] = [], + line = words[0]; + + for (let i = 1; i < words.length; i++) { + const word = words[i]; + const newText = `${line} ${word}`; + + const width = ctx.measureText(newText).width; + if (width < maxWidth) line = newText; + else { + lines.push(line); + line = word; + } + } + + lines.push(line); + return lines; +} + +export async function transformSvg(svg: string, config: { color?: string; height?: number; width?: number }) { + const text = config.color ? svg.replace(/fill="#[a-z0-9]{3,6}"/gi, `fill="${config.color}"`) : svg; + + const image = await loadImage(Buffer.from(text)); + if (config.width && config.height) { + image.width = config.width; + image.height = config.height; + } else if (config.width) { + image.height = (image.height / image.width) * config.width; + image.width = config.width; + } else if (config.height) { + image.width = (image.width / image.height) * config.height; + image.height = config.height; + } + return image; +} diff --git a/src/types.ts b/src/types.ts index b0bac6187a5..2994c6feaf3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,25 @@ export enum DiffType { Removed, } +export type Diff = + | { + type: DiffType.Added | DiffType.Removed; + source: string; + label?: string; + } + | { + type: DiffType.Changed; + source: string; + label?: string; + oldSource: string; + oldLabel?: string; + } + | { + type: DiffType.Renamed; + oldName: string; + source: string; + }; + export type CodeDiff = | { type: DiffType.Added | DiffType.Removed; @@ -16,6 +35,10 @@ export type CodeDiff = size: number; }; +export type Differs = Record<"semantic" | "raw" | "icons", Map | undefined> & { + code: Map | undefined; +}; + export type RawColors = Record; export type Icons = Record< @@ -31,7 +54,3 @@ export type Icons = Record< export type Semantic = Record; export type SemanticColors = Record; - -export type Differs = { - code: Map | undefined; -}; diff --git a/src/update/git.ts b/src/update/git.ts index 304afd0b8dd..b784c564ded 100644 --- a/src/update/git.ts +++ b/src/update/git.ts @@ -4,14 +4,16 @@ import { handleShellErr } from "./utils"; export const gitChanged = new Set(); export async function fetchGitChanged() { - for (const changed of ( - await Bun.$`git status -z -- ':!source'`.cwd("../data").quiet().then(handleShellErr) - ) - .text() - .split("\x00") - .filter((x) => x !== "") - .map((x) => x.slice(3))) - gitChanged.add(changed); + for (const cwd of ["../data", "../canvas"] as const) { + try { + 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 !== "") + .map((x) => x.slice(3))) + if (changed) gitChanged.add(changed); + } catch {} + } } const gitQueue: Promise[] = []; diff --git a/src/update/runner.ts b/src/update/runner.ts index 037b32b9697..7a1af8885d1 100644 --- a/src/update/runner.ts +++ b/src/update/runner.ts @@ -30,6 +30,8 @@ export async function runTasks() { colors_pushing: "Committing colors", diff: "Diffs", diff_code: "Diffing code", + diff_raw: "Diffing raw colors", + diff_semantic: "Diffing semantic colors", }, true, ); @@ -53,8 +55,13 @@ export async function runTasks() { progress.start("preinit_save"); for (const oprev of oprevFiles) { - const file = Bun.file(join("../data", oprev)); - if (await file.exists()) prevFiles.set(oprev, await file.arrayBuffer()); + for (const base of ["../data", "../canvas"] as const) { + const file = Bun.file(join(base, oprev)); + if (await file.exists()) { + prevFiles.set(oprev, await file.arrayBuffer()); + break; + } + } } progress.update("preinit_save", true); progress.update("preinit", true); diff --git a/src/update/shared.ts b/src/update/shared.ts index 931fdf8b5e7..e974faae91b 100644 --- a/src/update/shared.ts +++ b/src/update/shared.ts @@ -10,7 +10,7 @@ export const modulesPath = join(workFolder, "modules"); export const modulePathsDest = join("..", "data", "module-paths.json"); export const apkSplits = ["base", "config.hdpi", "config.xxhdpi"] as const; -export const oprevFiles = ["source.jsonl"] as const; +export const oprevFiles = ["source.jsonl", "raw.json", "semantic.json", "semantic_simple.json"] as const; export const prevFiles = new Map<(typeof oprevFiles)[number], ArrayBuffer>(); export const version = await Bun.file("../data/version.txt").text(); diff --git a/src/update/tasks/diffs.ts b/src/update/tasks/diffs.ts index f939dc7dde5..a04a8716aea 100644 --- a/src/update/tasks/diffs.ts +++ b/src/update/tasks/diffs.ts @@ -1,9 +1,143 @@ -import { type CodeDiff, type Differs, DiffType } from "../../types"; +import { + type CodeDiff, + type Diff, + type Differs, + DiffType, + type RawColors, + type SemanticColors, +} from "../../types"; import { fetchGitChanged, gitChanged } from "../git"; 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"); + } + + 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(); + 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]; + + if (!oldName) + changes.set(name, { + type: DiffType.Added, + source, + }); + else { + renamed.add(oldName); + changes.set(name, { + type: DiffType.Renamed, + oldName, + source, + }); + } + } else if (oldRaw[name].toLowerCase() !== source.toLowerCase()) + 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, + }); + + return changes; +} + +function getSemanticLabel(semantic: any) { + const light = semantic.light, + 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"); + } + + 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]; + + 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()) + 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, + }); + } + + return changes; +} + function parseSource(text: string) { const source = parseJsonl(text) as { file: string; size: number }[]; return Object.fromEntries( @@ -13,7 +147,7 @@ function parseSource(text: string) { async function diffCode() { if (!prevFiles.has("source.jsonl")) { - return; + throw new Error("Missing prevFile: source.jsonl"); } const oldCode = parseSource(new TextDecoder().decode(prevFiles.get("source.jsonl"))); @@ -55,6 +189,8 @@ export default async function diffs(progress: Progress) { if (!(gitChanged.has("version.txt") || diffAnyway)) { progress.update("diff", null); + progress.update("diff_raw", null); + progress.update("diff_semantic", null); progress.update("diff_code", null); return; } @@ -62,6 +198,20 @@ export default async function diffs(progress: Progress) { const differs = {} as Differs; await Promise.all([ + gitChanged.has("raw.json") + ? wrapPromise( + diffRaw().then((x) => (differs.raw = x)), + progress, + "diff_raw", + ) + : progress.update("diff_raw", null), + gitChanged.has("semantic.json") + ? wrapPromise( + diffSemantic().then((x) => (differs.semantic = x)), + progress, + "diff_semantic", + ) + : progress.update("diff_semantic", null), gitChanged.has("source.jsonl") ? wrapPromise( diffCode().then((x) => (differs.code = x)), diff --git a/src/update/tasks/webhook.ts b/src/update/tasks/webhook.ts index 60dad69efcf..9bcf18001db 100644 --- a/src/update/tasks/webhook.ts +++ b/src/update/tasks/webhook.ts @@ -1,7 +1,18 @@ import { basename } from "node:path"; -import { type CodeDiff, type Differs, DiffType } from "../../types"; +import type { Canvas } from "@napi-rs/canvas"; +import { + type APIMessageTopLevelComponent, + ComponentType, + MessageFlags, + type RESTPostAPIWebhookWithTokenJSONBody, + RouteBases, + Routes, +} from "discord-api-types/v10"; +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 { formatBytes, sortEntries } from "../utils"; +import { assert, formatBytes, sortEntries } from "../utils"; function fileBase(path: string, other?: string) { if (!other) return `... ${basename(path)}`; @@ -37,27 +48,42 @@ interface FormattedDiff { capped: boolean; } -function formatDiff(diffs: Map): FormattedDiff { +function formatDiff(diffs: Map): FormattedDiff { const entries = [...diffs.entries()]; const sections = { Added: sortEntries(entries) .map( ([name, diff]) => - diff.type === DiffType.Added && `+ ${fileBase(name)} (${formatBytes(diff.size)})`, + 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 && - `- ${fileBase(diff.oldName, name)}\n+ ${fileBase(name, diff.oldName)}`, + ("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 && `- ${fileBase(name)} (${formatBytes(diff.size)})`, + diff.type === DiffType.Removed && + ("size" in diff + ? `- ${fileBase(name)} (${formatBytes(diff.size)})` + : `- ${name}: ${diff.label || diff.source}`), ) .filter((x) => typeof x === "string"), }; @@ -72,26 +98,191 @@ function formatDiff(diffs: Map): FormattedDiff { }; } -export async function webhook(diffs: Differs) { - if (diffs.code?.size) { - const body = JSON.stringify({ - content: `Code changes for **${version} (${cuteVersion})**`, - embeds: [ - { - title: "Code", - description: formatDiff(diffs.code).embed.join("\n"), - footer: { text: makeFooter(diffs.code.size, "code") }, - }, - ], - }); +interface WebhookEmbed { + title: string; + body: FormattedDiff; + image?: Canvas; + footer: string; + key: string; +} - const webhookUrl = process.env.code_webhook; - if (webhookUrl) { - await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - }); +async function sendWebhook(webhook: string, role: string, embeds: WebhookEmbed[], color?: number) { + const body = new FormData(); + const images = new Map(), + diffs = new Map(); + + let fi = 0; + for (let i = 0; i < embeds.length; i++) { + const embed = embeds[i]; + if (embed.image) { + const key = fi++; + const filename = `${embed.key}.png`; + images.set(i, filename); + + body.append(`files[${key}]`, new Blob([embed.image.toBuffer("image/png")], { type: "image/png" }), filename); + } + if (embed.body.capped) { + const key = fi++; + const filename = `${embed.key}-changes.diff`; + diffs.set(i, filename); + + body.append(`files[${key}]`, new Blob([embed.body.file], { type: "text/plain" }), filename); } } + + body.append( + "payload_json", + JSON.stringify({ + flags: MessageFlags.IsComponentsV2, + components: [ + { + type: ComponentType.TextDisplay, + content: `<@&${role}>`, + }, + ...embeds.map( + ({ title, body, image, footer }, i) => + ({ + type: ComponentType.Container, + accent_color: color, + components: [ + { + type: ComponentType.TextDisplay, + content: `## ${title}\n-# ${version} (${cuteVersion})`, + }, + ...body.embed.map((content) => ({ + type: ComponentType.TextDisplay, + content, + })), + body.capped && { + type: ComponentType.File, + file: { + url: `attachment://${diffs.get(i)}`, + }, + }, + body.capped && { + type: ComponentType.Separator, + }, + image && { + type: ComponentType.MediaGallery, + items: [ + { + media: { + url: `attachment://${images.get(i)}`, + }, + }, + ], + }, + { + type: ComponentType.TextDisplay, + content: footer, + }, + ].filter((x) => !!x), + }) as APIMessageTopLevelComponent, + ), + ], + allowed_mentions: process.env.NODE_ENV === "test" ? { parse: [] } : { roles: [role] }, + } as RESTPostAPIWebhookWithTokenJSONBody), + ); + + const url = new URL(webhook); + url.searchParams.set("wait", "true"); + url.searchParams.set("with_components", "true"); + + const res = await fetch(url, { + method: "POST", + body, + }); + + const data = await res.text(); + if (!res.ok) + throw new Error(`Failed to send webhook message with embeds ${embeds.map((x) => x.title).join(", ")}: ${data}`); + + const discordToken = process.env.bot_token; + if (!discordToken) return; + + try { + const message = JSON.parse(data); + + const route = `${RouteBases.api}/${Routes.channelMessageCrosspost(message.channel_id, message.id)}`; + await fetch(route, { + method: "POST", + headers: { + Authorization: discordToken, + }, + }); + } catch {} +} + +export async function webhook(diffs: Differs) { + if (diffs.raw?.size || diffs.semantic?.size) { + const wh = process.env.color_webhook; + if (!wh) { + console.warn("Missing color_webhook, skipping color webhook"); + } else { + await sendWebhook( + wh, + "1227327297795657850", + [ + 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 && { + title: "Semantic colors", + body: formatDiff(diffs.semantic), + image: drawSections(await makeSections(diffs.semantic)), + footer: makeFooter(diffs.semantic.size, "semantic color"), + key: "semantic-colors", + }, + ].filter((x) => !!x) as WebhookEmbed[], + 0xd8ef9e, + ); + } + } + + if (diffs.icons?.size) { + const wh = process.env.icons_webhook; + if (!wh) { + console.warn("Missing icons_webhook, skipping"); + } else { + await sendWebhook( + wh, + "1227327765079003217", + [ + { + title: "Icons", + body: formatDiff(diffs.icons), + image: drawSections(await makeSections(diffs.icons, true)), + footer: makeFooter(diffs.icons.size, "icon"), + key: "icons", + }, + ], + 0xf4b8f7, + ); + } + } + + if (diffs.code?.size) { + const wh = process.env.code_webhook; + if (!wh) { + console.warn("Missing code_webhook, skipping"); + return; + } + await sendWebhook( + wh, + "1233861867059941387", + [ + { + title: "Code", + body: formatDiff(diffs.code), + footer: makeFooter(diffs.code.size, "code"), + key: "code", + }, + ], + 0x86faf3, + ); + } }