diff --git a/bun.lockb b/bun.lockb index eda9a2f4..fca0ffb2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli.ts b/cli.ts index ba0b7a72..be12b5ee 100644 --- a/cli.ts +++ b/cli.ts @@ -5,6 +5,10 @@ import { createNewLocalUser } from "~database/entities/User"; import Table from "cli-table"; import { rebuildSearchIndexes, MeiliIndexType } from "@meilisearch"; import { getConfig } from "@config"; +import { uploadFile } from "~classes/media"; +import { getUrl } from "~database/entities/Attachment"; +import { mkdir, exists } from "fs/promises"; +import extract from "extract-zip"; const args = process.argv; @@ -101,7 +105,39 @@ ${chalk.bold("Commands:")} )} Only rebuild the users index (optional) ${chalk.bold("Example:")} ${chalk.bgGray( `bun cli index rebuild --users 200` - )} + )} + ${alignDots(chalk.blue("emoji"), 24)} Manage custom emojis + ${alignDots(chalk.blue("add"))} Add a custom emoji + ${alignDotsSmall(chalk.green("name"))} Name of the emoji + ${alignDotsSmall(chalk.green("url"))} URL of the emoji + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli emoji add bun https://bun.com/bun.png` + )} + ${alignDots(chalk.blue("delete"))} Delete a custom emoji + ${alignDotsSmall(chalk.green("name"))} Name of the emoji + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli emoji delete bun` + )} + ${alignDots(chalk.blue("list"))} List all custom emojis + ${chalk.bold("Example:")} ${chalk.bgGray(`bun cli emoji list`)} + ${alignDots(chalk.blue("search"))} Search for a custom emoji + ${alignDotsSmall(chalk.green("query"))} Query to search for + ${alignDotsSmall( + chalk.yellow("--local") + )} Search in local emojis (optional, default) + ${alignDotsSmall( + chalk.yellow("--remote") + )} Search in remote emojis (optional) + ${alignDotsSmall(chalk.yellow("--json"))} Output as JSON (optional) + ${alignDotsSmall(chalk.yellow("--csv"))} Output as CSV (optional) + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli emoji search bun` + )} + ${alignDots(chalk.blue("import"))} Import a Pleroma emoji pack + ${alignDotsSmall(chalk.green("url"))} URL of the emoji pack + ${chalk.bold("Example:")} ${chalk.bgGray( + `bun cli emoji import https://site.com/neofox/manifest.json` + )} `; if (args.length < 3) { @@ -597,6 +633,360 @@ switch (command) { } break; } + case "emoji": { + switch (args[3]) { + case "add": { + const name = args[4]; + const url = args[5]; + + if (!name || !url) { + console.log(`${chalk.red(`✗`)} Missing name or URL`); + process.exit(1); + } + + const content_type = `image/${url + .split(".") + .pop() + ?.replace("jpg", "jpeg")}}`; + + const emoji = await client.emoji.create({ + data: { + shortcode: name, + url: url, + visible_in_picker: true, + content_type: content_type, + }, + }); + + console.log( + `${chalk.green(`✓`)} Created emoji ${chalk.blue( + emoji.shortcode + )}` + ); + + break; + } + case "delete": { + const name = args[4]; + + if (!name) { + console.log(`${chalk.red(`✗`)} Missing name`); + process.exit(1); + } + + const emoji = await client.emoji.findFirst({ + where: { + shortcode: name, + }, + }); + + if (!emoji) { + console.log(`${chalk.red(`✗`)} Emoji not found`); + process.exit(1); + } + + await client.emoji.delete({ + where: { + id: emoji.id, + }, + }); + + console.log( + `${chalk.green(`✓`)} Deleted emoji ${chalk.blue( + emoji.shortcode + )}` + ); + + break; + } + case "list": { + const emojis = await client.emoji.findMany(); + + console.log( + `${chalk.green(`✓`)} Found ${chalk.blue( + emojis.length + )} emojis` + ); + + for (const emoji of emojis) { + console.log( + `\t${chalk.blue(emoji.shortcode)} ${chalk.gray( + emoji.url + )}` + ); + } + break; + } + case "search": { + const argsWithoutFlags = args.filter( + arg => !arg.startsWith("--") + ); + const query = argsWithoutFlags[4]; + + if (!query) { + console.log(`${chalk.red(`✗`)} Missing query`); + process.exit(1); + } + + const local = args.includes("--local"); + const remote = args.includes("--remote"); + const json = args.includes("--json"); + const csv = args.includes("--csv"); + + const queries: Prisma.EmojiWhereInput[] = []; + + if (local) { + queries.push({ + instanceId: null, + }); + } + + if (remote) { + queries.push({ + instanceId: { + not: null, + }, + }); + } + + const emojis = await client.emoji.findMany({ + where: { + AND: queries, + shortcode: { + contains: query, + mode: "insensitive", + }, + }, + take: 40, + include: { + instance: true, + }, + }); + + if (json || csv) { + if (json) { + console.log(JSON.stringify(emojis, null, 4)); + } + if (csv) { + // Convert the outputted JSON to CSV + + // Remove all object children from each object + const items = emojis.map(emoji => { + const item = { + ...emoji, + instance: undefined, + }; + return item; + }); + const replacer = (key: string, value: any): any => + value === null ? "" : value; // Null values are returned as empty strings + const header = Object.keys(items[0]); + const csv = [ + header.join(","), // header row first + ...items.map(row => + header + .map(fieldName => + // @ts-expect-error This is fine + JSON.stringify(row[fieldName], replacer) + ) + .join(",") + ), + ].join("\r\n"); + + console.log(csv); + } + } else { + console.log( + `${chalk.green(`✓`)} Found ${chalk.blue( + emojis.length + )} emojis` + ); + + const table = new Table({ + head: [ + chalk.white(chalk.bold("Shortcode")), + chalk.white(chalk.bold("Instance URL")), + chalk.white(chalk.bold("URL")), + ], + }); + + for (const emoji of emojis) { + table.push([ + chalk.yellow(`:${emoji.shortcode}:`), + chalk.blue( + emoji.instanceId + ? emoji.instance?.base_url + : "Local" + ), + chalk.gray(emoji.url), + ]); + } + + console.log(table.toString()); + } + + break; + } + case "import": { + const url = args[4]; + + if (!url) { + console.log(`${chalk.red(`✗`)} Missing URL`); + process.exit(1); + } + + const response = await fetch(url); + + if (!response.ok) { + console.log(`${chalk.red(`✗`)} Failed to fetch emoji pack`); + process.exit(1); + } + + const res = (await response.json()) as Record< + string, + { + description: string; + files: string; + homepage: string; + src: string; + src_sha256?: string; + license?: string; + } + >; + + const pack = Object.values(res)[0]; + + // Fetch emoji list from `files`, can be a relative URL + + if (!pack.files) { + console.log(`${chalk.red(`✗`)} Missing files`); + process.exit(1); + } + + let pack_url = pack.files; + + if (!pack.files.includes("http")) { + // Is relative URL to pack manifest URL + pack_url = + url.split("/").slice(0, -1).join("/") + + "/" + + pack.files; + } + + const zip = new File( + [await (await fetch(pack.src)).arrayBuffer()], + "emoji.zip", + { + type: "application/zip", + } + ); + + // Check if the SHA256 hash matches + const hasher = new Bun.SHA256(); + + hasher.update(await zip.arrayBuffer()); + + const hash = hasher.digest("hex"); + + if (pack.src_sha256 && pack.src_sha256 !== hash) { + console.log(`${chalk.red(`✗`)} SHA256 hash does not match`); + console.log( + `${chalk.red(`✗`)} Expected ${chalk.blue( + pack.src_sha256 + )}, got ${chalk.blue(hash)}` + ); + process.exit(1); + } + + // Store file in /tmp + const tempDirectory = `/tmp/lysand-${hash}`; + + if (!(await exists(tempDirectory))) { + await mkdir(tempDirectory); + } + + await Bun.write(`${tempDirectory}/emojis.zip`, zip); + + // Extract zip + await extract(`${tempDirectory}/emojis.zip`, { + dir: tempDirectory, + }); + + // In the format + // emoji_name: emoji_url + const pack_response = (await ( + await fetch(pack_url) + ).json()) as Record; + + let emojisCreated = 0; + + for (const [name, path] of Object.entries(pack_response)) { + // Get emoji URL, as it can be relative + + const emoji = Bun.file(`${tempDirectory}/${path}`); + + const content_type = emoji.type; + + const hash = await uploadFile(emoji as File, config); + + if (!hash) { + console.log( + `${chalk.red(`✗`)} Failed to upload emoji ${name}` + ); + process.exit(1); + } + + const finalUrl = getUrl(hash, config); + + // Check if emoji already exists + const existingEmoji = await client.emoji.findFirst({ + where: { + shortcode: name, + instanceId: null, + }, + }); + + if (existingEmoji) { + console.log( + `${chalk.red(`✗`)} Emoji ${chalk.blue( + name + )} already exists` + ); + continue; + } + + // Create emoji + await client.emoji.create({ + data: { + shortcode: name, + url: finalUrl, + visible_in_picker: true, + content_type: content_type, + }, + }); + + emojisCreated++; + + console.log( + `${chalk.green(`✓`)} Created emoji ${chalk.blue(name)}` + ); + } + + console.log( + `${chalk.green(`✓`)} Imported ${chalk.blue( + emojisCreated + )} emojis` + ); + + break; + } + default: + console.log(`Unknown command ${chalk.blue(command)}`); + break; + } + + break; + } default: console.log(`Unknown command ${chalk.blue(command)}`); break; diff --git a/database/entities/Status.ts b/database/entities/Status.ts index d025f4ea..25b36b7e 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -325,7 +325,10 @@ export const createNewStatus = async (data: { let mentions = data.mentions || []; - // TODO: Parse emojis + // Parse emojis + const emojis = await parseEmojis(data.content); + + data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; // Get list of mentioned users if (mentions.length === 0) { @@ -371,7 +374,7 @@ export const createNewStatus = async (data: { sensitive: data.sensitive, spoilerText: data.spoiler_text, emojis: { - connect: data.emojis?.map(emoji => { + connect: data.emojis.map(emoji => { return { id: emoji.id, }; @@ -453,7 +456,10 @@ export const editStatus = async ( let mentions = data.mentions || []; - // TODO: Parse emojis + // Parse emojis + const emojis = await parseEmojis(data.content); + + data.emojis = data.emojis ? [...data.emojis, ...emojis] : emojis; // Get list of mentioned users if (mentions.length === 0) { @@ -500,7 +506,7 @@ export const editStatus = async ( sensitive: data.sensitive, spoilerText: data.spoiler_text, emojis: { - connect: data.emojis?.map(emoji => { + connect: data.emojis.map(emoji => { return { id: emoji.id, }; diff --git a/package.json b/package.json index d8ce8262..94687490 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "chalk": "^5.3.0", "cli-table": "^0.3.11", "eventemitter3": "^5.0.1", + "extract-zip": "^2.0.1", "html-to-text": "^9.0.5", "ioredis": "^5.3.2", "ip-matching": "^2.1.2",