@kyvrixon/utils 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/package.json +6 -7
- package/src/index.ts +1 -1
- package/src/lib/discord-utils/Command.ts +4 -0
- package/src/lib/discord-utils/Event.ts +34 -34
- package/src/lib/discord-utils/createPagination.ts +326 -240
- package/src/lib/formatSeconds.ts +29 -15
- package/src/lib/modules/LoggerModule.ts +26 -6
- package/src/lib/toOrdinal.ts +6 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kyvrixon/utils",
|
|
3
3
|
"main": "./src/index.ts",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.12",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"license": "MIT",
|
|
@@ -9,21 +9,20 @@
|
|
|
9
9
|
"src"
|
|
10
10
|
],
|
|
11
11
|
"engines": {
|
|
12
|
-
"bun": "1.3.
|
|
12
|
+
"bun": "1.3.11"
|
|
13
13
|
},
|
|
14
14
|
"repository": {
|
|
15
15
|
"url": "https://github.com/kyvrixon/utils"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"pub": "bun
|
|
18
|
+
"pub": "bun publish --access public",
|
|
19
19
|
"pretty": "bun biome format --write ."
|
|
20
20
|
},
|
|
21
|
-
"packageManager": "bun@1.3.10",
|
|
22
21
|
"types": "./src/index.ts",
|
|
23
22
|
"devDependencies": {
|
|
24
|
-
"@biomejs/biome": "2.4.
|
|
25
|
-
"@types/bun": "1.3.
|
|
26
|
-
"typescript": "
|
|
23
|
+
"@biomejs/biome": "2.4.9",
|
|
24
|
+
"@types/bun": "1.3.11",
|
|
25
|
+
"typescript": "6.0.2"
|
|
27
26
|
},
|
|
28
27
|
"dependencies": {
|
|
29
28
|
"chalk": "5.6.2",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,10 @@ import type {
|
|
|
7
7
|
SlashCommandSubcommandsOnlyBuilder,
|
|
8
8
|
} from "discord.js";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Wraps a discord.js slash command with typed `execute` and optional `autocomplete` handlers.
|
|
12
|
+
* @typeParam C - The bot's `Client` type.
|
|
13
|
+
*/
|
|
10
14
|
export class DiscordCommand<C extends Client<boolean>> {
|
|
11
15
|
public readonly data:
|
|
12
16
|
| SlashCommandBuilder
|
|
@@ -1,61 +1,61 @@
|
|
|
1
1
|
/** biome-ignore-all lint/suspicious/noExplicitAny: Its fine */
|
|
2
2
|
import type { Client, ClientEvents, RestEvents } from "discord.js";
|
|
3
3
|
|
|
4
|
-
// biome-ignore lint/suspicious/noEmptyInterface: Its fine
|
|
5
|
-
export interface DiscordEventCustomType {}
|
|
6
4
|
/**
|
|
7
|
-
*
|
|
5
|
+
* Augment this interface via module declaration to register custom event types.
|
|
8
6
|
* @example
|
|
9
7
|
* declare module "@kyvrixon/utils" {
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* }
|
|
14
|
-
* // You can also add these to `ClientEvents` if you wish for better typing like so:
|
|
15
|
-
* declare module "discord.js" {
|
|
16
|
-
* interface ClientEvents extends DiscordEventCustomType {}
|
|
8
|
+
* interface DiscordEventCustomType {
|
|
9
|
+
* myEvent: [data: string];
|
|
10
|
+
* }
|
|
17
11
|
* }
|
|
18
12
|
*/
|
|
13
|
+
// biome-ignore lint/suspicious/noEmptyInterface: Its fine
|
|
14
|
+
export interface DiscordEventCustomType {}
|
|
15
|
+
|
|
16
|
+
/** Maps an event type discriminant to its corresponding event map. */
|
|
17
|
+
type EventMap<T extends "client" | "rest" | "custom"> = T extends "client"
|
|
18
|
+
? ClientEvents
|
|
19
|
+
: T extends "rest"
|
|
20
|
+
? RestEvents
|
|
21
|
+
: keyof DiscordEventCustomType extends never
|
|
22
|
+
? { "sooo.. there's no custom events..": [] }
|
|
23
|
+
: DiscordEventCustomType;
|
|
24
|
+
|
|
25
|
+
/** Resolves the argument tuple for a given event type + key pair. */
|
|
26
|
+
type EventArgs<
|
|
27
|
+
T extends "client" | "rest" | "custom",
|
|
28
|
+
K extends keyof EventMap<T>,
|
|
29
|
+
> = Extract<EventMap<T>[K], any[]>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Wraps a discord.js event handler. Supports `"client"`, `"rest"`, and `"custom"` event types.
|
|
33
|
+
* @typeParam V - The bot's `Client` type.
|
|
34
|
+
* @typeParam T - The event source discriminant.
|
|
35
|
+
* @typeParam K - The event name within the chosen source.
|
|
36
|
+
*/
|
|
19
37
|
export class DiscordEvent<
|
|
20
38
|
V extends Client,
|
|
21
|
-
T extends "client" | "
|
|
22
|
-
K extends T
|
|
23
|
-
? keyof DiscordEventCustomType
|
|
24
|
-
: T extends "client"
|
|
25
|
-
? keyof ClientEvents
|
|
26
|
-
: keyof RestEvents = T extends "custom"
|
|
27
|
-
? keyof DiscordEventCustomType
|
|
28
|
-
: T extends "client"
|
|
29
|
-
? keyof ClientEvents
|
|
30
|
-
: keyof RestEvents,
|
|
39
|
+
T extends "client" | "rest" | "custom",
|
|
40
|
+
K extends keyof EventMap<T> = keyof EventMap<T>,
|
|
31
41
|
> {
|
|
32
42
|
public readonly type: T;
|
|
33
43
|
public readonly name: K;
|
|
34
44
|
public readonly once: boolean;
|
|
35
45
|
public readonly method: (
|
|
36
46
|
client: V,
|
|
37
|
-
...args: T
|
|
38
|
-
|
|
39
|
-
? ClientEvents[K]
|
|
40
|
-
: any[]
|
|
41
|
-
: T extends "rest"
|
|
42
|
-
? K extends keyof RestEvents
|
|
43
|
-
? RestEvents[K]
|
|
44
|
-
: any[]
|
|
45
|
-
: K extends keyof DiscordEventCustomType
|
|
46
|
-
? DiscordEventCustomType[K]
|
|
47
|
-
: any[]
|
|
48
|
-
) => Promise<void>;
|
|
47
|
+
...args: EventArgs<T, K>
|
|
48
|
+
) => void | Promise<void>;
|
|
49
49
|
|
|
50
50
|
constructor(opts: {
|
|
51
51
|
type: T;
|
|
52
52
|
name: K;
|
|
53
|
-
once
|
|
53
|
+
once: boolean;
|
|
54
54
|
method: DiscordEvent<V, T, K>["method"];
|
|
55
55
|
}) {
|
|
56
56
|
this.type = opts.type;
|
|
57
57
|
this.name = opts.name;
|
|
58
|
-
this.once = opts.once
|
|
58
|
+
this.once = opts.once;
|
|
59
59
|
this.method = opts.method;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
@@ -8,14 +8,14 @@ import {
|
|
|
8
8
|
type ChatInputCommandInteraction,
|
|
9
9
|
ComponentType,
|
|
10
10
|
ContainerBuilder,
|
|
11
|
-
|
|
11
|
+
FileBuilder,
|
|
12
12
|
LabelBuilder,
|
|
13
|
-
|
|
13
|
+
MediaGalleryBuilder,
|
|
14
14
|
type MentionableSelectMenuBuilder,
|
|
15
15
|
ModalBuilder,
|
|
16
16
|
type RoleSelectMenuBuilder,
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
SectionBuilder,
|
|
18
|
+
SeparatorBuilder,
|
|
19
19
|
type StringSelectMenuBuilder,
|
|
20
20
|
TextDisplayBuilder,
|
|
21
21
|
TextInputBuilder,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type UserSelectMenuBuilder,
|
|
24
24
|
} from "discord.js";
|
|
25
25
|
|
|
26
|
+
/** An action row containing any interactive message component. */
|
|
26
27
|
export type MessageActionRow = ActionRowBuilder<
|
|
27
28
|
| ButtonBuilder
|
|
28
29
|
| StringSelectMenuBuilder
|
|
@@ -32,8 +33,24 @@ export type MessageActionRow = ActionRowBuilder<
|
|
|
32
33
|
| MentionableSelectMenuBuilder
|
|
33
34
|
>;
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
const BUTTONS_SYMBOL: unique symbol = Symbol("pagination-buttons");
|
|
37
|
+
const DATA_SYMBOL: unique symbol = Symbol("pagination-data");
|
|
38
|
+
|
|
39
|
+
/** Valid component types that can appear in a pagination page layout. */
|
|
40
|
+
export type PaginationInput =
|
|
41
|
+
| string
|
|
42
|
+
| TextDisplayBuilder
|
|
43
|
+
| SectionBuilder
|
|
44
|
+
| SeparatorBuilder
|
|
45
|
+
| FileBuilder
|
|
46
|
+
| MediaGalleryBuilder
|
|
47
|
+
| MessageActionRow
|
|
48
|
+
| typeof BUTTONS_SYMBOL
|
|
49
|
+
| typeof DATA_SYMBOL;
|
|
50
|
+
|
|
51
|
+
type InternalComponent =
|
|
36
52
|
| { type: "buttons" }
|
|
53
|
+
| { type: "data" }
|
|
37
54
|
| { type: "display"; component: TextDisplayBuilder }
|
|
38
55
|
| { type: "section"; component: SectionBuilder }
|
|
39
56
|
| { type: "separator"; component: SeparatorBuilder }
|
|
@@ -41,287 +58,356 @@ export type LeaderboardComponentType =
|
|
|
41
58
|
| { type: "gallery"; component: MediaGalleryBuilder }
|
|
42
59
|
| { type: "actionrow"; component: MessageActionRow };
|
|
43
60
|
|
|
44
|
-
export interface
|
|
45
|
-
|
|
61
|
+
export interface PaginationOptions {
|
|
62
|
+
/** Number of list entries shown per page (default: 5). */
|
|
46
63
|
entriesPerPage?: number;
|
|
64
|
+
/** Key-value pairs replaced in rendered content. */
|
|
47
65
|
replacements?: Record<string, string>;
|
|
66
|
+
/** Per-page container styling overrides. */
|
|
48
67
|
styling?: Array<{ accent_color?: number; spoiler?: boolean }>;
|
|
68
|
+
/** Whether the pagination message is ephemeral. */
|
|
49
69
|
ephemeral?: boolean;
|
|
50
70
|
}
|
|
51
71
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Discord Components V2 paginator. Renders a `ContainerBuilder`-based layout
|
|
74
|
+
* with Prev/Next/page-jump buttons. Collector expires after 60 seconds.
|
|
75
|
+
*
|
|
76
|
+
* Use `DiscordPagination.BUTTONS` and `DiscordPagination.DATA` as sentinel
|
|
77
|
+
* values in the structure array to position the navigation row and list data.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const pagination = new DiscordPagination(
|
|
81
|
+
* entries,
|
|
82
|
+
* [
|
|
83
|
+
* [
|
|
84
|
+
* "# Leaderboard",
|
|
85
|
+
* new SeparatorBuilder(),
|
|
86
|
+
* DiscordPagination.DATA,
|
|
87
|
+
* new SeparatorBuilder(),
|
|
88
|
+
* DiscordPagination.BUTTONS,
|
|
89
|
+
* ],
|
|
90
|
+
* ],
|
|
91
|
+
* {
|
|
92
|
+
* entriesPerPage: 5,
|
|
93
|
+
* ephemeral: false,
|
|
94
|
+
* styling: [{ accent_color: 0x5865f2 }],
|
|
95
|
+
* },
|
|
96
|
+
* );
|
|
97
|
+
*/
|
|
98
|
+
export class DiscordPagination {
|
|
99
|
+
/** Sentinel — marks where the pagination buttons should render. */
|
|
100
|
+
static readonly BUTTONS: typeof BUTTONS_SYMBOL = BUTTONS_SYMBOL;
|
|
101
|
+
/** Sentinel — marks where the paginated list entries should render. */
|
|
102
|
+
static readonly DATA: typeof DATA_SYMBOL = DATA_SYMBOL;
|
|
103
|
+
|
|
104
|
+
private readonly list: string[];
|
|
105
|
+
private readonly structure: Array<Array<InternalComponent>>;
|
|
106
|
+
private readonly entriesPerPage: number;
|
|
107
|
+
private readonly replacements?: Record<string, string>;
|
|
108
|
+
private readonly styling?: PaginationOptions["styling"];
|
|
109
|
+
private readonly ephemeral: boolean;
|
|
110
|
+
private readonly prefix: string;
|
|
111
|
+
private readonly totalPages: number;
|
|
112
|
+
|
|
113
|
+
private currentIndex = 0;
|
|
114
|
+
private ended = false;
|
|
115
|
+
private interaction!: ButtonInteraction | ChatInputCommandInteraction;
|
|
116
|
+
|
|
117
|
+
constructor(
|
|
118
|
+
list: string[],
|
|
119
|
+
structure: Array<Array<PaginationInput>>,
|
|
120
|
+
options: PaginationOptions = {},
|
|
121
|
+
) {
|
|
122
|
+
const {
|
|
123
|
+
entriesPerPage = 5,
|
|
124
|
+
replacements,
|
|
125
|
+
styling,
|
|
126
|
+
ephemeral = false,
|
|
127
|
+
} = options;
|
|
128
|
+
|
|
129
|
+
if (entriesPerPage <= 0)
|
|
130
|
+
throw new Error("entriesPerPage must be greater than 0");
|
|
131
|
+
|
|
132
|
+
this.list = list;
|
|
133
|
+
this.entriesPerPage = entriesPerPage;
|
|
134
|
+
this.replacements = replacements;
|
|
135
|
+
this.styling = styling;
|
|
136
|
+
this.ephemeral = ephemeral;
|
|
137
|
+
this.prefix = `~PAGINATION_${randomUUIDv7()}_`;
|
|
138
|
+
this.totalPages = Math.ceil(list.length / entriesPerPage);
|
|
139
|
+
this.structure = this.expandStructure(
|
|
140
|
+
structure.map((page) => page.map((input) => this.normalize(input))),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
62
143
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.setDisabled(ended || currentIndex + entriesPerPage >= listLength),
|
|
89
|
-
);
|
|
90
|
-
}
|
|
144
|
+
/**
|
|
145
|
+
* Sends the paginated message and starts the button collector.
|
|
146
|
+
* @param interaction - The interaction to reply to.
|
|
147
|
+
*/
|
|
148
|
+
public async send(
|
|
149
|
+
interaction: ButtonInteraction | ChatInputCommandInteraction,
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
this.interaction = interaction;
|
|
152
|
+
|
|
153
|
+
if (!this.list.length) {
|
|
154
|
+
await interaction
|
|
155
|
+
.reply({
|
|
156
|
+
allowedMentions: { parse: [], repliedUser: false },
|
|
157
|
+
components: [
|
|
158
|
+
new ContainerBuilder().addTextDisplayComponents(
|
|
159
|
+
new TextDisplayBuilder().setContent("No data to show"),
|
|
160
|
+
),
|
|
161
|
+
],
|
|
162
|
+
flags: this.ephemeral
|
|
163
|
+
? ["Ephemeral", "IsComponentsV2"]
|
|
164
|
+
: ["IsComponentsV2"],
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
91
169
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
170
|
+
if (!interaction.replied && !interaction.deferred) {
|
|
171
|
+
await interaction
|
|
172
|
+
.deferReply({
|
|
173
|
+
withResponse: true,
|
|
174
|
+
flags: this.ephemeral ? ["Ephemeral"] : [],
|
|
175
|
+
})
|
|
176
|
+
.catch(() => null);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const channel = interaction.channel;
|
|
180
|
+
if (!channel || !("createMessageComponentCollector" in channel)) {
|
|
181
|
+
throw new Error("Invalid channel type");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await this.render();
|
|
185
|
+
|
|
186
|
+
const collector = channel.createMessageComponentCollector({
|
|
187
|
+
componentType: ComponentType.Button,
|
|
188
|
+
time: 60000,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
collector.on("collect", async (btn) => {
|
|
192
|
+
if (btn.user.id !== interaction.user.id) {
|
|
193
|
+
return void btn.deferUpdate();
|
|
194
|
+
}
|
|
195
|
+
if (!btn.customId.startsWith(this.prefix)) return;
|
|
196
|
+
|
|
197
|
+
this.ended = false;
|
|
198
|
+
collector.resetTimer();
|
|
199
|
+
|
|
200
|
+
if (btn.customId === `${this.prefix}info`) {
|
|
201
|
+
await this.handlePageJump(btn);
|
|
202
|
+
} else {
|
|
203
|
+
this.currentIndex +=
|
|
204
|
+
btn.customId === `${this.prefix}back`
|
|
205
|
+
? -this.entriesPerPage
|
|
206
|
+
: this.entriesPerPage;
|
|
207
|
+
this.currentIndex = Math.max(
|
|
208
|
+
0,
|
|
209
|
+
Math.min(
|
|
210
|
+
this.currentIndex,
|
|
211
|
+
(this.totalPages - 1) * this.entriesPerPage,
|
|
124
212
|
),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
213
|
+
);
|
|
214
|
+
await btn.deferUpdate().catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.render();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
collector.on("end", async () => {
|
|
221
|
+
this.ended = true;
|
|
222
|
+
await this.render();
|
|
223
|
+
});
|
|
130
224
|
}
|
|
131
225
|
|
|
132
|
-
|
|
133
|
-
|
|
226
|
+
private normalize(input: PaginationInput): InternalComponent {
|
|
227
|
+
if (input === BUTTONS_SYMBOL) return { type: "buttons" };
|
|
228
|
+
if (input === DATA_SYMBOL) return { type: "data" };
|
|
229
|
+
if (typeof input === "string")
|
|
230
|
+
return {
|
|
231
|
+
type: "display",
|
|
232
|
+
component: new TextDisplayBuilder().setContent(input),
|
|
233
|
+
};
|
|
234
|
+
if (input instanceof TextDisplayBuilder)
|
|
235
|
+
return { type: "display", component: input };
|
|
236
|
+
if (input instanceof SeparatorBuilder)
|
|
237
|
+
return { type: "separator", component: input };
|
|
238
|
+
if (input instanceof SectionBuilder)
|
|
239
|
+
return { type: "section", component: input };
|
|
240
|
+
if (input instanceof FileBuilder) return { type: "file", component: input };
|
|
241
|
+
if (input instanceof MediaGalleryBuilder)
|
|
242
|
+
return { type: "gallery", component: input };
|
|
243
|
+
return { type: "actionrow", component: input };
|
|
244
|
+
}
|
|
134
245
|
|
|
135
|
-
|
|
246
|
+
private expandStructure(
|
|
247
|
+
structure: Array<Array<InternalComponent>>,
|
|
248
|
+
): Array<Array<InternalComponent>> {
|
|
249
|
+
const lastPage = structure[structure.length - 1];
|
|
250
|
+
if (!lastPage) throw new Error("Structure must have at least one page");
|
|
251
|
+
|
|
252
|
+
while (structure.length < this.totalPages) {
|
|
253
|
+
structure.push(this.clonePage(lastPage));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return structure;
|
|
257
|
+
}
|
|
136
258
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
type: "display",
|
|
259
|
+
private clonePage(page: Array<InternalComponent>): Array<InternalComponent> {
|
|
260
|
+
return page.map((comp) =>
|
|
261
|
+
comp.type === "display"
|
|
262
|
+
? {
|
|
263
|
+
type: "display" as const,
|
|
143
264
|
component: new TextDisplayBuilder(comp.component.toJSON()),
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
265
|
+
}
|
|
266
|
+
: comp,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private async handlePageJump(btn: ButtonInteraction): Promise<void> {
|
|
271
|
+
const modal = new ModalBuilder()
|
|
272
|
+
.setCustomId(`${this.prefix}modal`)
|
|
273
|
+
.setTitle("Page Indexer")
|
|
274
|
+
.addLabelComponents(
|
|
275
|
+
new LabelBuilder()
|
|
276
|
+
.setLabel("Input a page number")
|
|
277
|
+
.setTextInputComponent(
|
|
278
|
+
new TextInputBuilder()
|
|
279
|
+
.setCustomId(`${this.prefix}number`)
|
|
280
|
+
.setRequired(true)
|
|
281
|
+
.setMinLength(1)
|
|
282
|
+
.setStyle(TextInputStyle.Short),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
await btn.showModal(modal).catch((e) => console.error(e));
|
|
287
|
+
const modalSubmit = await btn
|
|
288
|
+
.awaitModalSubmit({ time: 60_000 })
|
|
289
|
+
.catch(() => null);
|
|
290
|
+
|
|
291
|
+
if (!modalSubmit) {
|
|
292
|
+
await btn
|
|
293
|
+
.followUp({
|
|
294
|
+
content: "Modal timed out.",
|
|
295
|
+
flags: ["Ephemeral"],
|
|
296
|
+
})
|
|
297
|
+
.catch(() => null);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const pageNumber = Number(
|
|
302
|
+
modalSubmit.fields.getTextInputValue(`${this.prefix}number`),
|
|
148
303
|
);
|
|
304
|
+
|
|
305
|
+
if (
|
|
306
|
+
!Number.isInteger(pageNumber) ||
|
|
307
|
+
pageNumber < 1 ||
|
|
308
|
+
pageNumber > this.totalPages
|
|
309
|
+
) {
|
|
310
|
+
await modalSubmit
|
|
311
|
+
.reply({
|
|
312
|
+
content: `Invalid page! Choose a number between **1** and **${this.totalPages}**.`,
|
|
313
|
+
flags: ["Ephemeral"],
|
|
314
|
+
allowedMentions: { parse: [], repliedUser: false },
|
|
315
|
+
})
|
|
316
|
+
.catch(() => null);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await modalSubmit.deferUpdate().catch(() => null);
|
|
321
|
+
this.currentIndex = (pageNumber - 1) * this.entriesPerPage;
|
|
149
322
|
}
|
|
150
323
|
|
|
151
|
-
|
|
152
|
-
const pageStructure = structure[page];
|
|
324
|
+
private generateContainer(page: number): ContainerBuilder {
|
|
325
|
+
const pageStructure = this.structure[page];
|
|
153
326
|
if (!pageStructure) throw new Error(`Page ${page} structure not found`);
|
|
154
327
|
|
|
155
328
|
return new ContainerBuilder({
|
|
156
329
|
components: pageStructure.map((comp) => {
|
|
157
330
|
switch (comp.type) {
|
|
158
331
|
case "buttons":
|
|
159
|
-
return getPaginationRow(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
totalPages,
|
|
164
|
-
list.length,
|
|
165
|
-
ended,
|
|
166
|
-
).toJSON();
|
|
167
|
-
|
|
168
|
-
case "display": {
|
|
169
|
-
if (comp.component.data.content !== contentMarker) {
|
|
170
|
-
return comp.component.toJSON();
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const content = list
|
|
332
|
+
return this.getPaginationRow().toJSON();
|
|
333
|
+
|
|
334
|
+
case "data": {
|
|
335
|
+
const content = this.list
|
|
174
336
|
.slice(
|
|
175
|
-
page * entriesPerPage,
|
|
176
|
-
page
|
|
337
|
+
page * this.entriesPerPage,
|
|
338
|
+
(page + 1) * this.entriesPerPage,
|
|
177
339
|
)
|
|
178
340
|
.join("\n");
|
|
179
|
-
|
|
180
|
-
content
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
return comp.component.toJSON();
|
|
341
|
+
return new TextDisplayBuilder()
|
|
342
|
+
.setContent(this.applyReplacements(content))
|
|
343
|
+
.toJSON();
|
|
184
344
|
}
|
|
185
345
|
|
|
186
|
-
|
|
187
|
-
case "
|
|
188
|
-
case "
|
|
189
|
-
case "
|
|
190
|
-
case "
|
|
346
|
+
// !! Default clause applies to the below as well
|
|
347
|
+
// case "display":
|
|
348
|
+
// case "section":
|
|
349
|
+
// case "separator":
|
|
350
|
+
// case "file":
|
|
351
|
+
// case "gallery":
|
|
352
|
+
// case "actionrow":
|
|
353
|
+
default:
|
|
191
354
|
return comp.component.toJSON();
|
|
192
|
-
|
|
193
|
-
default: {
|
|
194
|
-
return comp;
|
|
195
|
-
}
|
|
196
355
|
}
|
|
197
356
|
}),
|
|
198
|
-
accent_color: styling?.[page]?.accent_color,
|
|
199
|
-
spoiler: styling?.[page]?.spoiler,
|
|
357
|
+
accent_color: this.styling?.[page]?.accent_color,
|
|
358
|
+
spoiler: this.styling?.[page]?.spoiler,
|
|
200
359
|
});
|
|
201
360
|
}
|
|
202
361
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
362
|
+
private getPaginationRow(): ActionRowBuilder<ButtonBuilder> {
|
|
363
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
364
|
+
new ButtonBuilder()
|
|
365
|
+
.setCustomId(`${this.prefix}back`)
|
|
366
|
+
.setLabel("Prev")
|
|
367
|
+
.setStyle(ButtonStyle.Secondary)
|
|
368
|
+
.setDisabled(this.ended || this.currentIndex === 0),
|
|
369
|
+
new ButtonBuilder()
|
|
370
|
+
.setCustomId(`${this.prefix}info`)
|
|
371
|
+
.setLabel(
|
|
372
|
+
`${Math.floor(this.currentIndex / this.entriesPerPage) + 1}/${this.totalPages}`,
|
|
373
|
+
)
|
|
374
|
+
.setStyle(ButtonStyle.Secondary)
|
|
375
|
+
.setDisabled(this.ended || this.totalPages === 1),
|
|
376
|
+
new ButtonBuilder()
|
|
377
|
+
.setCustomId(`${this.prefix}forward`)
|
|
378
|
+
.setLabel("Next")
|
|
379
|
+
.setStyle(ButtonStyle.Secondary)
|
|
380
|
+
.setDisabled(
|
|
381
|
+
this.ended ||
|
|
382
|
+
this.currentIndex + this.entriesPerPage >= this.list.length,
|
|
383
|
+
),
|
|
384
|
+
);
|
|
210
385
|
}
|
|
211
386
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
387
|
+
private applyReplacements(content: string): string {
|
|
388
|
+
if (!this.replacements) return content;
|
|
389
|
+
return Object.entries(this.replacements).reduce(
|
|
390
|
+
(acc, [key, value]) => acc.replaceAll(key, value),
|
|
391
|
+
content,
|
|
392
|
+
);
|
|
215
393
|
}
|
|
216
394
|
|
|
217
|
-
async
|
|
395
|
+
private async render(): Promise<void> {
|
|
218
396
|
try {
|
|
219
|
-
await interaction.editReply({
|
|
397
|
+
await this.interaction.editReply({
|
|
220
398
|
components: [
|
|
221
|
-
generateContainer(
|
|
399
|
+
this.generateContainer(
|
|
400
|
+
Math.floor(this.currentIndex / this.entriesPerPage),
|
|
401
|
+
),
|
|
222
402
|
],
|
|
223
403
|
flags: ["IsComponentsV2"],
|
|
224
|
-
allowedMentions: {
|
|
225
|
-
parse: [],
|
|
226
|
-
repliedUser: false,
|
|
227
|
-
},
|
|
404
|
+
allowedMentions: { parse: [], repliedUser: false },
|
|
228
405
|
});
|
|
229
406
|
} catch (error) {
|
|
230
407
|
const e = error as Error;
|
|
231
408
|
if (!e.message.includes("Unknown Message")) {
|
|
232
|
-
console.error("Failed to render
|
|
409
|
+
console.error("Failed to render pagination:", error);
|
|
233
410
|
}
|
|
234
411
|
}
|
|
235
412
|
}
|
|
236
|
-
|
|
237
|
-
await render();
|
|
238
|
-
|
|
239
|
-
const collector = channel.createMessageComponentCollector({
|
|
240
|
-
componentType: ComponentType.Button,
|
|
241
|
-
time: 60000,
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
collector.on("collect", async (btn) => {
|
|
245
|
-
if (btn.user.id !== interaction.user.id) {
|
|
246
|
-
return void btn.deferUpdate();
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (!btn.customId.startsWith(prefix)) return;
|
|
250
|
-
|
|
251
|
-
ended = false;
|
|
252
|
-
collector.resetTimer();
|
|
253
|
-
|
|
254
|
-
if (btn.customId === `${prefix}info`) {
|
|
255
|
-
const modal = new ModalBuilder()
|
|
256
|
-
.setCustomId(`${prefix}modal`)
|
|
257
|
-
.setTitle("Page Indexer")
|
|
258
|
-
.addLabelComponents(
|
|
259
|
-
new LabelBuilder()
|
|
260
|
-
// .setDescription("Input a page number")
|
|
261
|
-
.setLabel("Input a page number")
|
|
262
|
-
.setTextInputComponent(
|
|
263
|
-
new TextInputBuilder()
|
|
264
|
-
.setCustomId(`${prefix}number`)
|
|
265
|
-
.setRequired(true)
|
|
266
|
-
.setMinLength(1)
|
|
267
|
-
.setStyle(TextInputStyle.Short),
|
|
268
|
-
),
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
await btn.showModal(modal).catch((e) => console.error(e));
|
|
272
|
-
const modalSubmit = await btn
|
|
273
|
-
.awaitModalSubmit({ time: 60_000 })
|
|
274
|
-
.catch((e) => console.error(e));
|
|
275
|
-
|
|
276
|
-
if (!modalSubmit) {
|
|
277
|
-
await btn
|
|
278
|
-
.followUp({
|
|
279
|
-
content: "Modal timed out.",
|
|
280
|
-
flags: ["Ephemeral"],
|
|
281
|
-
})
|
|
282
|
-
.catch(() => null);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
await modalSubmit.deferUpdate().catch(() => null);
|
|
287
|
-
const pageNumber = Number(
|
|
288
|
-
modalSubmit.fields.getTextInputValue(`${prefix}number`),
|
|
289
|
-
);
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
Number.isNaN(pageNumber) ||
|
|
293
|
-
pageNumber < 1 ||
|
|
294
|
-
pageNumber > totalPages
|
|
295
|
-
) {
|
|
296
|
-
await modalSubmit
|
|
297
|
-
.reply({
|
|
298
|
-
content: `Invalid page! Choose a number between **1** and **${totalPages}**.`,
|
|
299
|
-
flags: ["Ephemeral"],
|
|
300
|
-
allowedMentions: {
|
|
301
|
-
parse: [],
|
|
302
|
-
repliedUser: false,
|
|
303
|
-
},
|
|
304
|
-
})
|
|
305
|
-
.catch(() => null);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
currentIndex = (pageNumber - 1) * entriesPerPage;
|
|
310
|
-
} else {
|
|
311
|
-
currentIndex +=
|
|
312
|
-
btn.customId === `${prefix}back` ? -entriesPerPage : entriesPerPage;
|
|
313
|
-
currentIndex = Math.max(
|
|
314
|
-
0,
|
|
315
|
-
Math.min(currentIndex, (totalPages - 1) * entriesPerPage),
|
|
316
|
-
);
|
|
317
|
-
await btn.deferUpdate().catch(() => {});
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
await render();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
collector.on("end", async () => {
|
|
324
|
-
ended = true;
|
|
325
|
-
await render();
|
|
326
|
-
});
|
|
327
|
-
}
|
|
413
|
+
}
|
package/src/lib/formatSeconds.ts
CHANGED
|
@@ -25,6 +25,16 @@ const ALL_UNITS_ORDER: Array<TimeUnitTypes> = [
|
|
|
25
25
|
"ms",
|
|
26
26
|
];
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Calendar-aware duration formatter. Converts raw seconds into a human-readable string.
|
|
30
|
+
* @param seconds - The duration in seconds to format.
|
|
31
|
+
* @param options - Formatting options.
|
|
32
|
+
* @param options.format - `"long"` (default) for full words, `"short"` for abbreviated units.
|
|
33
|
+
* @param options.onlyUnits - Restrict output to specific time units.
|
|
34
|
+
* @param options.includeZeroUnits - Include units with a value of zero.
|
|
35
|
+
* @param options.customFormatter - Override per-unit rendering.
|
|
36
|
+
* @returns A formatted duration string (e.g. `"2 hours and 30 minutes"` or `"2h 30m"`).
|
|
37
|
+
*/
|
|
28
38
|
export function formatSeconds(
|
|
29
39
|
seconds: number,
|
|
30
40
|
options: {
|
|
@@ -44,10 +54,13 @@ export function formatSeconds(
|
|
|
44
54
|
format = "long",
|
|
45
55
|
customFormatter,
|
|
46
56
|
} = options;
|
|
57
|
+
|
|
58
|
+
if (!Number.isFinite(seconds)) return format === "short" ? "0s" : "0 seconds";
|
|
59
|
+
|
|
47
60
|
let totalMs = Math.max(0, Math.round(seconds * 1000));
|
|
48
|
-
const unitsToDisplay =
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
const unitsToDisplay = onlyUnits.length
|
|
62
|
+
? ALL_UNITS_ORDER.filter((u) => onlyUnits.includes(u))
|
|
63
|
+
: ALL_UNITS_ORDER;
|
|
51
64
|
|
|
52
65
|
const diff: Partial<Record<TimeUnitTypes, number>> = {};
|
|
53
66
|
const now = new Date();
|
|
@@ -55,28 +68,29 @@ export function formatSeconds(
|
|
|
55
68
|
|
|
56
69
|
if (unitsToDisplay.includes("y")) {
|
|
57
70
|
let y = end.getFullYear() - now.getFullYear();
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
diff.y = y;
|
|
62
|
-
totalMs -=
|
|
71
|
+
const afterYears = new Date(now);
|
|
72
|
+
afterYears.setFullYear(now.getFullYear() + y);
|
|
73
|
+
if (afterYears > end) y--;
|
|
74
|
+
diff.y = Math.max(0, y);
|
|
75
|
+
totalMs -=
|
|
76
|
+
new Date(now).setFullYear(now.getFullYear() + diff.y) - now.getTime();
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
if (unitsToDisplay.includes("mo")) {
|
|
66
80
|
const startTotalMonths = now.getFullYear() * 12 + now.getMonth();
|
|
67
81
|
const endTotalMonths = end.getFullYear() * 12 + end.getMonth();
|
|
68
82
|
let mo = endTotalMonths - startTotalMonths;
|
|
69
|
-
if (diff.y) mo -= diff.y * 12;
|
|
83
|
+
if (diff.y !== undefined) mo -= diff.y * 12;
|
|
70
84
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
85
|
+
const afterYearsAndMonths = new Date(now);
|
|
86
|
+
afterYearsAndMonths.setFullYear(now.getFullYear() + (diff.y ?? 0));
|
|
87
|
+
afterYearsAndMonths.setMonth(now.getMonth() + mo);
|
|
88
|
+
if (afterYearsAndMonths > end) mo--;
|
|
75
89
|
|
|
76
90
|
diff.mo = Math.max(0, mo);
|
|
77
91
|
const jumpDate = new Date(now);
|
|
78
|
-
jumpDate.setFullYear(now.getFullYear() + (diff.y
|
|
79
|
-
jumpDate.setMonth(now.getMonth() +
|
|
92
|
+
jumpDate.setFullYear(now.getFullYear() + (diff.y ?? 0));
|
|
93
|
+
jumpDate.setMonth(now.getMonth() + diff.mo);
|
|
80
94
|
totalMs = end.getTime() - jumpDate.getTime();
|
|
81
95
|
}
|
|
82
96
|
|
|
@@ -10,6 +10,10 @@ const formatter = new Intl.DateTimeFormat("en-AU", {
|
|
|
10
10
|
hour12: false,
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Chalk-based structured logger with timestamped, color-coded output.
|
|
15
|
+
* Supports levels: `notif`, `alert`, `error`, `debug`.
|
|
16
|
+
*/
|
|
13
17
|
export class LoggerModule {
|
|
14
18
|
private readonly colors: Record<LogLevel, typeof chalk> = {
|
|
15
19
|
notif: cyan,
|
|
@@ -37,10 +41,15 @@ export class LoggerModule {
|
|
|
37
41
|
const timestamp = gray(`[${this.getTimestamp()}]`);
|
|
38
42
|
const levelLabel = bold(this.colors[level](level.toUpperCase().padEnd(5)));
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
let content: string;
|
|
45
|
+
if (message instanceof Error) {
|
|
46
|
+
const stack = this.sanitizeStack(message.stack || "");
|
|
47
|
+
content = stack
|
|
48
|
+
? `${red(message.message)}\n${stack}`
|
|
49
|
+
: red(message.message);
|
|
50
|
+
} else {
|
|
51
|
+
content = String(message);
|
|
52
|
+
}
|
|
44
53
|
|
|
45
54
|
return `${timestamp} ${levelLabel} ${dim("»")} ${content}`;
|
|
46
55
|
}
|
|
@@ -76,21 +85,32 @@ export class LoggerModule {
|
|
|
76
85
|
console[this.logMethods[level]](msg);
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/** Logs at the `notif` (info) level. If `raw` is true, returns the string instead of printing. */
|
|
79
89
|
public notif(m: unknown, raw = false) {
|
|
80
90
|
return this.log("notif", m, raw);
|
|
81
91
|
}
|
|
92
|
+
/** Logs at the `alert` (warn) level. If `raw` is true, returns the string instead of printing. */
|
|
82
93
|
public alert(m: unknown, raw = false) {
|
|
83
94
|
return this.log("alert", m, raw);
|
|
84
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Logs at the `error` level. Pass an optional `Error` to include its sanitized stack trace.
|
|
98
|
+
* If `raw` is true, returns the string instead of printing.
|
|
99
|
+
*/
|
|
85
100
|
public error(m: unknown, e?: Error, raw = false) {
|
|
86
101
|
return this.log("error", e ?? m, raw);
|
|
87
102
|
}
|
|
103
|
+
/** Logs at the `debug` level. If `raw` is true, returns the string instead of printing. */
|
|
88
104
|
public debug(m: unknown, raw = false) {
|
|
89
105
|
return this.log("debug", m, raw);
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
/** Prints a centered `─` divider line with the given text. */
|
|
92
109
|
public divider(text: string): void {
|
|
93
|
-
const
|
|
94
|
-
|
|
110
|
+
const trimmed = text.trim();
|
|
111
|
+
const remaining = Math.max(0, 50 - trimmed.length - 2);
|
|
112
|
+
const left = dim("─".repeat(Math.ceil(remaining / 2)));
|
|
113
|
+
const right = dim("─".repeat(Math.floor(remaining / 2)));
|
|
114
|
+
console.log(`\n${left} ${bold(trimmed)} ${right}`);
|
|
95
115
|
}
|
|
96
116
|
}
|
package/src/lib/toOrdinal.ts
CHANGED
|
@@ -9,6 +9,12 @@ const suffixes: Record<Intl.LDMLPluralRule, string> = {
|
|
|
9
9
|
many: "th",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Converts a number to its English ordinal string (e.g. 1 → "1st", 12 → "12th").
|
|
14
|
+
* Handles teens, zero, negatives, and infinities.
|
|
15
|
+
* @param n - The number to convert.
|
|
16
|
+
* @returns The number with its ordinal suffix appended.
|
|
17
|
+
*/
|
|
12
18
|
export function toOrdinal(n: number): string {
|
|
13
19
|
return `${n}${suffixes[pr.select(n)]}`;
|
|
14
20
|
}
|