diff --git a/.gitignore b/.gitignore index 410fea08..38212caa 100755 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +# Font file +*.ttf diff --git a/config.json.example b/config.json.example index 52beef05..ebadf3cc 100755 --- a/config.json.example +++ b/config.json.example @@ -26,5 +26,7 @@ "rconpass1": "yourpassword1234", "syslogChannel": "1151139585791901746", "notificationChannel": "1151139585791901746", - "language": "default" + "language": "default", + "fontFile": "font.ttf", + "fontFamily": "sans-serif" } \ No newline at end of file diff --git a/language/default.json b/language/default.json index 39fdeccc..6fd6cf26 100644 --- a/language/default.json +++ b/language/default.json @@ -117,6 +117,49 @@ } }, "commands": { + "cal": { + "name": "cal", + "description": "カレンダーを表示します", + "options": { + "month": { + "name": "month", + "description": "月" + }, + "year": { + "name": "year", + "description": "年" + }, + "weekStart": { + "name": "week_start", + "description": "週の始まりの曜日" + } + }, + "dayNames": [ + "日曜日", + "月曜日", + "火曜日", + "水曜日", + "木曜日", + "金曜日", + "土曜日" + ], + "dayLabels": ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + "monthNames": [ + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月" + ], + "monthYear": "${year}年${month}" + }, "check": { "name": "check", "description": "Ping Checker", diff --git a/language/en.json b/language/en.json index 12165c33..e7a534b6 100644 --- a/language/en.json +++ b/language/en.json @@ -117,6 +117,49 @@ } }, "commands": { + "cal": { + "name": "cal", + "description": "Shows the calendar", + "options": { + "month": { + "name": "month", + "description": "Month" + }, + "year": { + "name": "year", + "description": "Year" + }, + "weekStart": { + "name": "week_start", + "description": "The days to start weeks" + } + }, + "dayNames": [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" + ], + "dayLabels": ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"], + "monthNames": [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December" + ], + "monthYear": "${month} ${year}" + }, "check": { "name": "check", "description": "Ping Checker", diff --git a/language/ja.json b/language/ja.json index 15739be1..99a2e3c4 100644 --- a/language/ja.json +++ b/language/ja.json @@ -117,6 +117,49 @@ } }, "commands": { + "cal": { + "name": "cal", + "description": "カレンダーを表示します", + "options": { + "month": { + "name": "month", + "description": "月" + }, + "year": { + "name": "year", + "description": "年" + }, + "weekStart": { + "name": "week_start", + "description": "週の始まりの曜日" + } + }, + "dayNames": [ + "日曜日", + "月曜日", + "火曜日", + "水曜日", + "木曜日", + "金曜日", + "土曜日" + ], + "dayLabels": ["日", "月", "火", "水", "木", "金", "土"], + "monthNames": [ + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月" + ], + "monthYear": "${year}年${month}" + }, "check": { "name": "check", "description": "Ping を確認", diff --git a/packages/misc/commands/cal.ts b/packages/misc/commands/cal.ts new file mode 100644 index 00000000..bbd2af32 --- /dev/null +++ b/packages/misc/commands/cal.ts @@ -0,0 +1,120 @@ +import { createCanvas } from 'canvas'; +import { SimpleSlashCommandBuilder } from '../../../common/SimpleCommand'; +import { LANG, strFormat } from '../../../util/languages'; +import { DayOfWeek, MonthCalendar } from '../util/calendar'; +import { + BoundingBox, + CanvasTable, + CanvasTextBox, + FONT_FAMILY, + InlineText, +} from '../util/canvasUtils'; + +const WORKDAY_COLOR = 'black'; +const HOLIDAY_COLOR = 'red'; +const SUNDAY_COLOR = 'red'; +const SATURDAY_COLOR = 'blue'; +const EXCLUDED_COLOR = 'gray'; + +function dayColor(day: DayOfWeek) { + switch (day) { + case DayOfWeek.Sunday: + return SUNDAY_COLOR; + case DayOfWeek.Saturday: + return SATURDAY_COLOR; + default: + return WORKDAY_COLOR; + } +} + +export default SimpleSlashCommandBuilder.create( + LANG.commands.cal.name, + LANG.commands.cal.description, +) + .addIntegerOption({ + name: LANG.commands.cal.options.month.name, + description: LANG.commands.cal.options.month.description, + choices: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((value) => ({ + name: LANG.commands.cal.monthNames[value], + value, + })), + required: false, + }) + .addIntegerOption({ + name: LANG.commands.cal.options.year.name, + description: LANG.commands.cal.options.year.description, + required: false, + }) + .addIntegerOption({ + name: LANG.commands.cal.options.weekStart.name, + description: LANG.commands.cal.options.weekStart.description, + choices: Object.values(DayOfWeek).map((value) => ({ + name: LANG.commands.cal.dayNames[value], + value, + })), + required: false, + }) + .build(async (interaction, month, year, weekStart = DayOfWeek.Sunday) => { + const today = new Date(); + const calendar = new MonthCalendar( + month ?? today.getMonth(), + year ?? today.getFullYear(), + ); + const days = []; + for (let i = 0; i < 7; i++) { + days.push((weekStart + i) % 7); + } + const table = [ + days.map((i) => { + const text = new InlineText(LANG.commands.cal.dayLabels[i]); + text.color = dayColor(i as DayOfWeek); + return text; + }), + ...Array.from(calendar.weeks(weekStart)).map((week) => + week.map((day) => { + const text = new InlineText(day.date.toString()); + text.color = dayColor(day.day); + if (day.isHoliday()) { + text.color = HOLIDAY_COLOR; + } + if (!calendar.includes(day)) { + text.color = EXCLUDED_COLOR; + } + if (day.is(today)) { + text.font = `bold 24px ${FONT_FAMILY}`; + } + return text; + }), + ), + ]; + const canvas = createCanvas(800, 400); + const ctx = canvas.getContext('2d'); + new BoundingBox(0, 0, 800, 400).fill(ctx, 'white'); + const title = strFormat(LANG.commands.cal.monthYear, { + month: LANG.commands.cal.monthNames[calendar.month], + year: calendar.year, + }); + const titleStyle = new InlineText(title); + titleStyle.color = 'black'; + titleStyle.font = `48px ${FONT_FAMILY}`; + new CanvasTextBox(titleStyle, new BoundingBox(50, 0, 700, 100)).renderTo( + ctx, + ); + new CanvasTable(table, new BoundingBox(50, 100, 700, 300)).renderTo(ctx); + await interaction.reply({ + files: [ + { + attachment: canvas.toBuffer(), + name: 'calendar.png', + }, + ], + embeds: [ + { + title: strFormat(title), + image: { + url: 'attachment://calendar.png', + }, + }, + ], + }); + }); diff --git a/packages/misc/index.js b/packages/misc/index.js index 991ce3b2..c1d1a0f0 100644 --- a/packages/misc/index.js +++ b/packages/misc/index.js @@ -2,15 +2,20 @@ const fs = require('fs'); const path = require('path'); const { CommandManager } = require('../../internal/commands'); +const { registerConfiguredFont } = require('./util/canvasUtils'); class MiscFeature { onLoad() { + registerConfiguredFont(); fs.readdirSync(path.join(__dirname, 'commands'), { withFileTypes: true, }).forEach((file) => { const ext = path.extname(file.name); if (!file.isFile() || (ext != '.js' && ext != '.ts')) return; - const cmds = require(path.join(__dirname, 'commands', file.name)); + let cmds = require(path.join(__dirname, 'commands', file.name)); + if ('default' in cmds) { + cmds = cmds.default; + } CommandManager.default.addCommands(cmds); }); } diff --git a/packages/misc/util/calendar.ts b/packages/misc/util/calendar.ts new file mode 100644 index 00000000..bd34187f --- /dev/null +++ b/packages/misc/util/calendar.ts @@ -0,0 +1,127 @@ +import axios from 'axios'; + +export const DayOfWeek = Object.freeze({ + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, +}); + +export type DayOfWeek = (typeof DayOfWeek)[keyof typeof DayOfWeek]; + +const HOLIDAYS_CSV = 'https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv'; + +let holidays = new Map(); + +async function getHolidays() { + const res = await axios.get(HOLIDAYS_CSV, { + responseType: 'arraybuffer', + }); + const text = new TextDecoder('shift_jis').decode(res.data); + const data = text + .split('\n') + .map((row) => row.trim()) // '\r' を削除 + .filter((row) => row != '') // 空行を削除 + .slice(1) // 見出し行を削除 + .map((row) => row.split(',') as [string, string]); + holidays = new Map(data); +} +getHolidays(); + +export class Day { + public readonly year: number; + + public readonly month: number; + + public readonly date: number; + + public readonly day: DayOfWeek; + + constructor(year: number, monthIndex: number, date: number) { + const dateObj = new Date(year, monthIndex, date); + this.year = dateObj.getFullYear(); + this.month = dateObj.getMonth(); + this.date = dateObj.getDate(); + this.day = dateObj.getDay() as DayOfWeek; + } + + add(days: number) { + return new Day(this.year, this.month, this.date + days); + } + + is(date: Date): boolean { + return ( + this.year == date.getFullYear() && + this.month == date.getMonth() && + this.date == date.getDate() + ); + } + + toString() { + return `${this.year}/${this.month + 1}/${this.date}`; + } + + isHoliday() { + return holidays.has(this.toString()); + } +} + +export type Week = { [K in DayOfWeek]: Day } & Array; + +export class MonthCalendar { + public readonly month: number; + + public readonly year: number; + + public readonly size: number; + + /** + * 月のカレンダーを作成する。 + * @param monthIndex 0始まりの月の番号 (0~11) + * @param year 西暦 + */ + constructor(monthIndex?: number, year?: number) { + if (year == null) { + const date = new Date(); + year = date.getFullYear(); + if (monthIndex == null) { + monthIndex = date.getMonth(); + } + } + this.month = monthIndex; + this.year = year; + this.size = new Date(year, monthIndex + 1, 0).getDate(); + } + + firstDay() { + return new Day(this.year, this.month, 1); + } + + includes(day: Day) { + if (day.year == this.year && day.month == this.month) { + return true; + } + } + + *weeks(weekStart: DayOfWeek = DayOfWeek.Sunday): Iterable { + const monthFirst = this.firstDay(); + let firstDate = weekStart - monthFirst.day + 7; // カレンダーの最初の日付 (1日以前、負になり得る) + while (firstDate > 1) { + firstDate -= 7; + } + let weekFirst = monthFirst.add(firstDate); + do { + const week = [weekFirst]; + let day = weekFirst; + for (let i = 1; i < 7; i++) { + day = day.add(1); + week[i] = day; + } + yield week as Week; + weekFirst = weekFirst.add(7); + } while (this.includes(weekFirst)); + } +} diff --git a/packages/misc/util/canvasUtils.ts b/packages/misc/util/canvasUtils.ts new file mode 100644 index 00000000..1f9f5297 --- /dev/null +++ b/packages/misc/util/canvasUtils.ts @@ -0,0 +1,154 @@ +import { CanvasRenderingContext2D, registerFont } from 'canvas'; +import config from '../../../config.json'; + +const FONT_FILE = config.fontFile ?? 'font.ttf'; +export const FONT_FAMILY = config.fontFamily ?? 'serif'; + +function requireNonnegative(x: number, name: string): number { + if (x < 0) { + throw new RangeError(`${name} must not be less than ${x}`); + } + return x; +} + +export function registerConfiguredFont() { + try { + registerFont(FONT_FILE, { family: FONT_FAMILY }); + } catch (e) { + console.error(e); + } +} + +export class BoundingBox { + readonly x: number; + + readonly y: number; + + readonly width: number; + + readonly height: number; + + constructor(x: number, y: number, width: number, height: number) { + this.x = requireNonnegative(x, 'x'); + this.y = requireNonnegative(y, 'y'); + this.width = requireNonnegative(width, 'width'); + this.height = requireNonnegative(height, 'height'); + } + + stroke(ctx: CanvasRenderingContext2D, color: string) { + ctx.save(); + ctx.strokeStyle = color; + ctx.strokeRect(this.x, this.y, this.width, this.height); + ctx.restore(); + } + + fill(ctx: CanvasRenderingContext2D, color: string) { + ctx.save(); + ctx.fillStyle = color; + ctx.fillRect(this.x, this.y, this.width, this.height); + ctx.restore(); + } +} + +export class InlineText { + public text: string; + + public color: string = 'black'; + + public font: string = `24px ${FONT_FAMILY}`; + + constructor(text: string) { + this.text = text; + } + + renderTo( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + maxWidth?: number, + ) { + ctx.save(); + ctx.fillStyle = this.color; + ctx.font = this.font; + ctx.fillText(this.text, x, y, maxWidth); + ctx.restore(); + } +} + +export class CanvasTextBox { + public text: InlineText; + + public boundingBox: BoundingBox; + + public align: 'center' = 'center'; + + public verticalAlign: 'middle' = 'middle'; + + constructor(text: InlineText, boundingBox: BoundingBox) { + this.text = text; + this.boundingBox = boundingBox; + } + + renderTo(ctx: CanvasRenderingContext2D) { + ctx.save(); + ctx.textBaseline = 'top'; + ctx.textAlign = this.align; + ctx.textBaseline = this.verticalAlign; + this.text.renderTo(ctx, this.getX(), this.getY()); + ctx.restore(); + } + + private getX() { + const boundingBox = this.boundingBox; + switch (this.align) { + case 'center': + return boundingBox.x + boundingBox.width / 2; + } + } + + private getY() { + const boundingBox = this.boundingBox; + switch (this.verticalAlign) { + case 'middle': + return boundingBox.y + boundingBox.height / 2; + } + } +} + +export class CanvasTable { + private cells: CanvasTextBox[][]; + + private boundingBox: BoundingBox; + + public color: string; + + constructor(cells: InlineText[][], boundingBox: BoundingBox) { + const rowCount = cells.length; + const columnCount = cells[0].length; + const cellWidth = boundingBox.width / columnCount; + const cellHeight = boundingBox.height / rowCount; + this.cells = cells.map((row, i) => + row.map( + (cell, j) => + new CanvasTextBox( + cell, + new BoundingBox( + boundingBox.x + cellWidth * j, + boundingBox.y + cellHeight * i, + cellWidth, + cellHeight, + ), + ), + ), + ); + this.boundingBox = boundingBox; + } + + renderTo(ctx: CanvasRenderingContext2D) { + for (const row of this.cells) { + for (const cell of row) { + cell.renderTo(ctx); + } + } + } +}