@kyvrixon/utils 1.0.11 → 1.4.0
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/package.json +8 -4
- package/src/lib/discord-utils/Command.ts +18 -1
- package/src/lib/discord-utils/Event.ts +13 -7
- package/src/lib/discord-utils/createPagination.ts +437 -241
- package/src/lib/discord-utils/index.ts +3 -0
- package/src/lib/formatSeconds.ts +29 -15
- package/src/lib/modules/LoggerModule.ts +26 -6
- package/src/lib/modules/index.ts +1 -0
- package/src/lib/toOrdinal.ts +6 -0
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.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"license": "MIT",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"engines": {
|
|
12
12
|
"bun": "1.3.11"
|
|
13
13
|
},
|
|
14
|
+
"engineStrict": true,
|
|
14
15
|
"repository": {
|
|
15
16
|
"url": "https://github.com/kyvrixon/utils"
|
|
16
17
|
},
|
|
@@ -18,12 +19,11 @@
|
|
|
18
19
|
"pub": "bun publish --access public",
|
|
19
20
|
"pretty": "bun biome format --write ."
|
|
20
21
|
},
|
|
21
|
-
"packageManager": "bun@1.3.10",
|
|
22
22
|
"types": "./src/index.ts",
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@biomejs/biome": "2.4.
|
|
24
|
+
"@biomejs/biome": "2.4.9",
|
|
25
25
|
"@types/bun": "1.3.11",
|
|
26
|
-
"typescript": "
|
|
26
|
+
"typescript": "6.0.2"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"chalk": "5.6.2",
|
|
@@ -33,6 +33,10 @@
|
|
|
33
33
|
".": {
|
|
34
34
|
"types": "./src/index.ts",
|
|
35
35
|
"import": "./src/index.ts"
|
|
36
|
+
},
|
|
37
|
+
"./*": {
|
|
38
|
+
"types": "./src/lib/*/index.ts",
|
|
39
|
+
"import": "./src/lib/*/index.ts"
|
|
36
40
|
}
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -7,7 +7,21 @@ import type {
|
|
|
7
7
|
SlashCommandSubcommandsOnlyBuilder,
|
|
8
8
|
} from "discord.js";
|
|
9
9
|
|
|
10
|
-
export
|
|
10
|
+
export interface DiscordCommandMetadata extends Record<string, unknown> {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wraps a discord.js slash command with typed `execute` and optional `autocomplete` handlers.
|
|
14
|
+
* @typeParam C - The bot's `Client` type. Inferred from args[0] in `method`.
|
|
15
|
+
*
|
|
16
|
+
* @example To extend the `metadata` types
|
|
17
|
+
* declare module "@kyvrixon/utils" {
|
|
18
|
+
* interface DiscordCommandMetadata {
|
|
19
|
+
* cooldown?: number;
|
|
20
|
+
* category?: string;
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
export class DiscordCommand<C extends Client> {
|
|
11
25
|
public readonly data:
|
|
12
26
|
| SlashCommandBuilder
|
|
13
27
|
| SlashCommandOptionsOnlyBuilder
|
|
@@ -20,13 +34,16 @@ export class DiscordCommand<C extends Client<boolean>> {
|
|
|
20
34
|
client: C,
|
|
21
35
|
interaction: AutocompleteInteraction,
|
|
22
36
|
) => Promise<void>;
|
|
37
|
+
public metadata: DiscordCommandMetadata;
|
|
23
38
|
|
|
24
39
|
constructor(ops: {
|
|
25
40
|
data: DiscordCommand<C>["data"];
|
|
41
|
+
metadata: DiscordCommand<C>["metadata"];
|
|
26
42
|
execute: DiscordCommand<C>["execute"];
|
|
27
43
|
autocomplete?: DiscordCommand<C>["autocomplete"];
|
|
28
44
|
}) {
|
|
29
45
|
this.data = ops.data;
|
|
46
|
+
this.metadata = ops.metadata;
|
|
30
47
|
this.execute = ops.execute;
|
|
31
48
|
this.autocomplete = ops.autocomplete;
|
|
32
49
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/** biome-ignore-all lint/suspicious/noExplicitAny: Its fine */
|
|
2
2
|
import type { Client, ClientEvents, RestEvents } from "discord.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Augment this interface via module declaration to register custom event types.
|
|
6
|
+
* @example
|
|
7
|
+
* declare module "@kyvrixon/utils" {
|
|
8
|
+
* interface DiscordEventCustomType {
|
|
9
|
+
* myEvent: [data: string];
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
*/
|
|
4
13
|
// biome-ignore lint/suspicious/noEmptyInterface: Its fine
|
|
5
14
|
export interface DiscordEventCustomType {}
|
|
6
15
|
|
|
@@ -20,13 +29,10 @@ type EventArgs<
|
|
|
20
29
|
> = Extract<EventMap<T>[K], any[]>;
|
|
21
30
|
|
|
22
31
|
/**
|
|
23
|
-
*
|
|
24
|
-
* @
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* myEventName: [data: string];
|
|
28
|
-
* }
|
|
29
|
-
* }
|
|
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.
|
|
30
36
|
*/
|
|
31
37
|
export class DiscordEvent<
|
|
32
38
|
V extends Client,
|
|
@@ -8,14 +8,16 @@ import {
|
|
|
8
8
|
type ChatInputCommandInteraction,
|
|
9
9
|
ComponentType,
|
|
10
10
|
ContainerBuilder,
|
|
11
|
-
|
|
11
|
+
EmbedBuilder,
|
|
12
|
+
FileBuilder,
|
|
12
13
|
LabelBuilder,
|
|
13
|
-
|
|
14
|
+
MediaGalleryBuilder,
|
|
14
15
|
type MentionableSelectMenuBuilder,
|
|
16
|
+
type Message,
|
|
15
17
|
ModalBuilder,
|
|
16
18
|
type RoleSelectMenuBuilder,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
SectionBuilder,
|
|
20
|
+
SeparatorBuilder,
|
|
19
21
|
type StringSelectMenuBuilder,
|
|
20
22
|
TextDisplayBuilder,
|
|
21
23
|
TextInputBuilder,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
type UserSelectMenuBuilder,
|
|
24
26
|
} from "discord.js";
|
|
25
27
|
|
|
28
|
+
/** An action row containing any interactive message component. */
|
|
26
29
|
export type MessageActionRow = ActionRowBuilder<
|
|
27
30
|
| ButtonBuilder
|
|
28
31
|
| StringSelectMenuBuilder
|
|
@@ -32,8 +35,24 @@ export type MessageActionRow = ActionRowBuilder<
|
|
|
32
35
|
| MentionableSelectMenuBuilder
|
|
33
36
|
>;
|
|
34
37
|
|
|
35
|
-
export
|
|
38
|
+
export const BUTTONS_SYMBOL: unique symbol = Symbol("pagination-buttons");
|
|
39
|
+
export const DATA_SYMBOL: unique symbol = Symbol("pagination-data");
|
|
40
|
+
|
|
41
|
+
/** Valid component types that can appear in a pagination page layout. */
|
|
42
|
+
export type PaginationInput =
|
|
43
|
+
| string
|
|
44
|
+
| TextDisplayBuilder
|
|
45
|
+
| SectionBuilder
|
|
46
|
+
| SeparatorBuilder
|
|
47
|
+
| FileBuilder
|
|
48
|
+
| MediaGalleryBuilder
|
|
49
|
+
| MessageActionRow
|
|
50
|
+
| typeof BUTTONS_SYMBOL
|
|
51
|
+
| typeof DATA_SYMBOL;
|
|
52
|
+
|
|
53
|
+
type InternalComponent =
|
|
36
54
|
| { type: "buttons" }
|
|
55
|
+
| { type: "data" }
|
|
37
56
|
| { type: "display"; component: TextDisplayBuilder }
|
|
38
57
|
| { type: "section"; component: SectionBuilder }
|
|
39
58
|
| { type: "separator"; component: SeparatorBuilder }
|
|
@@ -41,287 +60,464 @@ export type LeaderboardComponentType =
|
|
|
41
60
|
| { type: "gallery"; component: MediaGalleryBuilder }
|
|
42
61
|
| { type: "actionrow"; component: MessageActionRow };
|
|
43
62
|
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
/** Shared options for all pagination modes. */
|
|
64
|
+
export interface PaginationBaseOptions {
|
|
65
|
+
/** Number of list entries shown per page (default: 5). */
|
|
46
66
|
entriesPerPage?: number;
|
|
67
|
+
/** Key-value pairs replaced in rendered content. */
|
|
47
68
|
replacements?: Record<string, string>;
|
|
48
|
-
|
|
69
|
+
/** Whether the pagination message is ephemeral. */
|
|
49
70
|
ephemeral?: boolean;
|
|
50
71
|
}
|
|
51
72
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Options for **container** mode (Components V2).
|
|
75
|
+
* Uses a `ContainerBuilder`-based layout with the `IsComponentsV2` message flag.
|
|
76
|
+
*/
|
|
77
|
+
export interface PaginationContainerOptions extends PaginationBaseOptions {
|
|
78
|
+
/** Selects container mode. */
|
|
79
|
+
type: "container";
|
|
80
|
+
/** Single layout template using sentinels `DiscordPagination.DATA` and `DiscordPagination.BUTTONS`. */
|
|
81
|
+
layout: PaginationInput[];
|
|
82
|
+
/** Container accent color. */
|
|
83
|
+
accentColor?: number;
|
|
84
|
+
/** Whether the container is a spoiler. */
|
|
85
|
+
spoiler?: boolean;
|
|
61
86
|
}
|
|
62
87
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.setCustomId(`${prefix}back`)
|
|
74
|
-
.setLabel("Prev")
|
|
75
|
-
.setStyle(ButtonStyle.Secondary)
|
|
76
|
-
.setDisabled(ended || currentIndex === 0),
|
|
77
|
-
new ButtonBuilder()
|
|
78
|
-
.setCustomId(`${prefix}info`)
|
|
79
|
-
.setLabel(
|
|
80
|
-
`${Math.floor(currentIndex / entriesPerPage) + 1}/${totalPages}`,
|
|
81
|
-
)
|
|
82
|
-
.setStyle(ButtonStyle.Secondary)
|
|
83
|
-
.setDisabled(ended || totalPages === 1),
|
|
84
|
-
new ButtonBuilder()
|
|
85
|
-
.setCustomId(`${prefix}forward`)
|
|
86
|
-
.setLabel("Next")
|
|
87
|
-
.setStyle(ButtonStyle.Secondary)
|
|
88
|
-
.setDisabled(ended || currentIndex + entriesPerPage >= listLength),
|
|
89
|
-
);
|
|
88
|
+
/**
|
|
89
|
+
* Options for **embed** mode.
|
|
90
|
+
* Uses a standard `EmbedBuilder` with an `ActionRow` for navigation buttons.
|
|
91
|
+
* The embed's `description` and `footer` are reserved for page data and the page counter.
|
|
92
|
+
*/
|
|
93
|
+
export interface PaginationEmbedOptions extends PaginationBaseOptions {
|
|
94
|
+
/** Selects embed mode. */
|
|
95
|
+
type: "embed";
|
|
96
|
+
/** EmbedBuilder template. Description and footer are overwritten per page. */
|
|
97
|
+
embed: EmbedBuilder;
|
|
90
98
|
}
|
|
91
99
|
|
|
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
|
-
|
|
100
|
+
/** Discriminated union of all pagination option types. Use the `type` field to select a mode. */
|
|
101
|
+
export type PaginationOptions =
|
|
102
|
+
| PaginationContainerOptions
|
|
103
|
+
| PaginationEmbedOptions;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Discord paginator supporting both **Components V2** (`ContainerBuilder`) and
|
|
107
|
+
* **Embed** (`EmbedBuilder`) modes.
|
|
108
|
+
*
|
|
109
|
+
* @example Container mode
|
|
110
|
+
* ```ts
|
|
111
|
+
* const pagination = new DiscordPagination(entries, {
|
|
112
|
+
* type: "container",
|
|
113
|
+
* layout: [
|
|
114
|
+
* "# Leaderboard",
|
|
115
|
+
* new SeparatorBuilder(),
|
|
116
|
+
* DiscordPagination.DATA,
|
|
117
|
+
* new SeparatorBuilder(),
|
|
118
|
+
* DiscordPagination.BUTTONS,
|
|
119
|
+
* ],
|
|
120
|
+
* entriesPerPage: 5,
|
|
121
|
+
* accentColor: 0x5865f2,
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @example Embed mode
|
|
126
|
+
* ```ts
|
|
127
|
+
* const pagination = new DiscordPagination(entries, {
|
|
128
|
+
* type: "embed",
|
|
129
|
+
* embed: new EmbedBuilder().setTitle("Leaderboard").setColor(0x5865f2),
|
|
130
|
+
* entriesPerPage: 5,
|
|
131
|
+
* });
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export class DiscordPagination {
|
|
135
|
+
/** Sentinel — marks where the pagination buttons should render. */
|
|
136
|
+
static readonly BUTTONS: typeof BUTTONS_SYMBOL = BUTTONS_SYMBOL;
|
|
137
|
+
/** Sentinel — marks where the paginated list entries should render. */
|
|
138
|
+
static readonly DATA: typeof DATA_SYMBOL = DATA_SYMBOL;
|
|
139
|
+
|
|
140
|
+
private readonly list: string[];
|
|
141
|
+
private readonly entriesPerPage: number;
|
|
142
|
+
private readonly replacements?: Record<string, string>;
|
|
143
|
+
private readonly ephemeral: boolean;
|
|
144
|
+
private readonly prefix: string;
|
|
145
|
+
private readonly totalPages: number;
|
|
146
|
+
private readonly mode: "container" | "embed";
|
|
147
|
+
|
|
148
|
+
// Container mode
|
|
149
|
+
private readonly layout?: InternalComponent[];
|
|
150
|
+
private readonly accentColor?: number;
|
|
151
|
+
private readonly spoiler?: boolean;
|
|
152
|
+
|
|
153
|
+
// Embed mode
|
|
154
|
+
private readonly embedTemplate?: EmbedBuilder;
|
|
155
|
+
|
|
156
|
+
// Runtime state
|
|
157
|
+
private currentIndex = 0;
|
|
158
|
+
private ended = false;
|
|
159
|
+
private isMessage = false;
|
|
160
|
+
private interaction?: ButtonInteraction | ChatInputCommandInteraction;
|
|
161
|
+
private replyMessage?: Message;
|
|
162
|
+
|
|
163
|
+
constructor(list: string[], options: PaginationOptions) {
|
|
164
|
+
const { entriesPerPage = 5, replacements, ephemeral = false } = options;
|
|
165
|
+
|
|
166
|
+
if (entriesPerPage <= 0)
|
|
167
|
+
throw new Error("entriesPerPage must be greater than 0");
|
|
168
|
+
|
|
169
|
+
this.list = list;
|
|
170
|
+
this.entriesPerPage = entriesPerPage;
|
|
171
|
+
this.replacements = replacements;
|
|
172
|
+
this.ephemeral = ephemeral;
|
|
173
|
+
this.prefix = `~PAGINATION_${randomUUIDv7()}_`;
|
|
174
|
+
this.totalPages = Math.ceil(list.length / entriesPerPage);
|
|
175
|
+
|
|
176
|
+
this.mode = options.type;
|
|
177
|
+
|
|
178
|
+
if (options.type === "container") {
|
|
179
|
+
this.layout = options.layout.map((input) => this.normalize(input));
|
|
180
|
+
this.accentColor = options.accentColor;
|
|
181
|
+
this.spoiler = options.spoiler;
|
|
182
|
+
} else {
|
|
183
|
+
this.embedTemplate = options.embed;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sends the paginated message and starts the button collector.
|
|
189
|
+
* @param target - The interaction or message to reply to.
|
|
190
|
+
*/
|
|
191
|
+
public async send(
|
|
192
|
+
target: ButtonInteraction | ChatInputCommandInteraction | Message,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
this.isMessage = !("deferReply" in target);
|
|
195
|
+
const userId = this.isMessage
|
|
196
|
+
? (target as Message).author.id
|
|
197
|
+
: (target as ButtonInteraction | ChatInputCommandInteraction).user.id;
|
|
198
|
+
|
|
199
|
+
if (!this.list.length) {
|
|
200
|
+
await this.sendEmpty(target);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (this.isMessage) {
|
|
205
|
+
this.replyMessage = await (target as Message).reply(this.buildPayload());
|
|
206
|
+
} else {
|
|
207
|
+
const interaction = target as
|
|
208
|
+
| ButtonInteraction
|
|
209
|
+
| ChatInputCommandInteraction;
|
|
210
|
+
this.interaction = interaction;
|
|
211
|
+
|
|
212
|
+
if (!interaction.replied && !interaction.deferred) {
|
|
213
|
+
const response = await interaction
|
|
214
|
+
.deferReply({
|
|
215
|
+
withResponse: true,
|
|
216
|
+
flags: this.ephemeral ? ["Ephemeral"] : [],
|
|
217
|
+
})
|
|
218
|
+
.catch(() => null);
|
|
219
|
+
this.replyMessage =
|
|
220
|
+
response?.resource?.message ??
|
|
221
|
+
(await interaction.fetchReply().catch(() => undefined));
|
|
222
|
+
} else {
|
|
223
|
+
this.replyMessage = await interaction
|
|
224
|
+
.fetchReply()
|
|
225
|
+
.catch(() => undefined);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
await this.render();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!this.replyMessage) return;
|
|
232
|
+
|
|
233
|
+
const collector = this.replyMessage.createMessageComponentCollector({
|
|
234
|
+
componentType: ComponentType.Button,
|
|
235
|
+
time: 60_000,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
collector.on("collect", async (btn) => {
|
|
239
|
+
if (btn.user.id !== userId) {
|
|
240
|
+
return void btn.deferUpdate();
|
|
241
|
+
}
|
|
242
|
+
if (!btn.customId.startsWith(this.prefix)) return;
|
|
243
|
+
|
|
244
|
+
this.ended = false;
|
|
245
|
+
collector.resetTimer();
|
|
246
|
+
|
|
247
|
+
if (btn.customId === `${this.prefix}info`) {
|
|
248
|
+
await this.handlePageJump(btn);
|
|
249
|
+
} else {
|
|
250
|
+
this.currentIndex +=
|
|
251
|
+
btn.customId === `${this.prefix}back`
|
|
252
|
+
? -this.entriesPerPage
|
|
253
|
+
: this.entriesPerPage;
|
|
254
|
+
this.currentIndex = Math.max(
|
|
255
|
+
0,
|
|
256
|
+
Math.min(
|
|
257
|
+
this.currentIndex,
|
|
258
|
+
(this.totalPages - 1) * this.entriesPerPage,
|
|
124
259
|
),
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
260
|
+
);
|
|
261
|
+
await btn.deferUpdate().catch(() => {});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await this.render();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
collector.on("end", async () => {
|
|
268
|
+
this.ended = true;
|
|
269
|
+
await this.render();
|
|
270
|
+
});
|
|
130
271
|
}
|
|
131
272
|
|
|
132
|
-
|
|
133
|
-
|
|
273
|
+
private async sendEmpty(
|
|
274
|
+
target: ButtonInteraction | ChatInputCommandInteraction | Message,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
if (this.mode === "embed" && !this.embedTemplate)
|
|
277
|
+
throw new Error(
|
|
278
|
+
"[@kyvrixon/utils]: Pagination: embedTemplate is in a corrupted state",
|
|
279
|
+
);
|
|
134
280
|
|
|
135
|
-
|
|
281
|
+
const allowedMentions = { parse: [] as const, repliedUser: false };
|
|
282
|
+
|
|
283
|
+
if (this.isMessage) {
|
|
284
|
+
const payload =
|
|
285
|
+
this.mode === "container"
|
|
286
|
+
? {
|
|
287
|
+
components: [
|
|
288
|
+
new ContainerBuilder().addTextDisplayComponents(
|
|
289
|
+
new TextDisplayBuilder().setContent("No data to show"),
|
|
290
|
+
),
|
|
291
|
+
],
|
|
292
|
+
flags: ["IsComponentsV2"] as const,
|
|
293
|
+
allowedMentions,
|
|
294
|
+
}
|
|
295
|
+
: {
|
|
296
|
+
embeds: [
|
|
297
|
+
new EmbedBuilder(this.embedTemplate?.toJSON()).setDescription(
|
|
298
|
+
"No data to show",
|
|
299
|
+
),
|
|
300
|
+
],
|
|
301
|
+
allowedMentions,
|
|
302
|
+
};
|
|
303
|
+
await (target as Message).reply(payload).catch(() => {});
|
|
304
|
+
} else {
|
|
305
|
+
const payload =
|
|
306
|
+
this.mode === "container"
|
|
307
|
+
? {
|
|
308
|
+
components: [
|
|
309
|
+
new ContainerBuilder().addTextDisplayComponents(
|
|
310
|
+
new TextDisplayBuilder().setContent("No data to show"),
|
|
311
|
+
),
|
|
312
|
+
],
|
|
313
|
+
flags: this.ephemeral
|
|
314
|
+
? (["Ephemeral", "IsComponentsV2"] as const)
|
|
315
|
+
: (["IsComponentsV2"] as const),
|
|
316
|
+
allowedMentions,
|
|
317
|
+
}
|
|
318
|
+
: {
|
|
319
|
+
embeds: [
|
|
320
|
+
new EmbedBuilder(this.embedTemplate?.toJSON()).setDescription(
|
|
321
|
+
"No data to show",
|
|
322
|
+
),
|
|
323
|
+
],
|
|
324
|
+
flags: this.ephemeral ? (["Ephemeral"] as const) : ([] as const),
|
|
325
|
+
allowedMentions,
|
|
326
|
+
};
|
|
327
|
+
await (target as ButtonInteraction | ChatInputCommandInteraction)
|
|
328
|
+
.reply(payload)
|
|
329
|
+
.catch(() => {});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
136
332
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
333
|
+
private normalize(input: PaginationInput): InternalComponent {
|
|
334
|
+
if (input === BUTTONS_SYMBOL) return { type: "buttons" };
|
|
335
|
+
if (input === DATA_SYMBOL) return { type: "data" };
|
|
336
|
+
if (typeof input === "string")
|
|
337
|
+
return {
|
|
338
|
+
type: "display",
|
|
339
|
+
component: new TextDisplayBuilder().setContent(input),
|
|
340
|
+
};
|
|
341
|
+
if (input instanceof TextDisplayBuilder)
|
|
342
|
+
return { type: "display", component: input };
|
|
343
|
+
if (input instanceof SeparatorBuilder)
|
|
344
|
+
return { type: "separator", component: input };
|
|
345
|
+
if (input instanceof SectionBuilder)
|
|
346
|
+
return { type: "section", component: input };
|
|
347
|
+
if (input instanceof FileBuilder) return { type: "file", component: input };
|
|
348
|
+
if (input instanceof MediaGalleryBuilder)
|
|
349
|
+
return { type: "gallery", component: input };
|
|
350
|
+
return { type: "actionrow", component: input };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private buildPayload() {
|
|
354
|
+
if (this.mode === "embed") {
|
|
355
|
+
return {
|
|
356
|
+
embeds: [this.generateEmbed()],
|
|
357
|
+
components: [this.getPaginationRow()],
|
|
358
|
+
allowedMentions: { parse: [] as const, repliedUser: false },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
components: [this.generateContainer()],
|
|
364
|
+
flags: ["IsComponentsV2"] as const,
|
|
365
|
+
allowedMentions: { parse: [] as const, repliedUser: false },
|
|
366
|
+
};
|
|
149
367
|
}
|
|
150
368
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
369
|
+
private generateContainer(): ContainerBuilder {
|
|
370
|
+
if (!this.layout)
|
|
371
|
+
throw new Error(
|
|
372
|
+
"[@kyvrixon/utils]: Pagination: layout is in a corrupted state",
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const page = Math.floor(this.currentIndex / this.entriesPerPage);
|
|
154
376
|
|
|
155
377
|
return new ContainerBuilder({
|
|
156
|
-
components:
|
|
378
|
+
components: this.layout.map((comp) => {
|
|
157
379
|
switch (comp.type) {
|
|
158
380
|
case "buttons":
|
|
159
|
-
return getPaginationRow(
|
|
160
|
-
prefix,
|
|
161
|
-
currentIndex,
|
|
162
|
-
entriesPerPage,
|
|
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
|
-
}
|
|
381
|
+
return this.getPaginationRow().toJSON();
|
|
172
382
|
|
|
173
|
-
|
|
383
|
+
case "data": {
|
|
384
|
+
const content = this.list
|
|
174
385
|
.slice(
|
|
175
|
-
page * entriesPerPage,
|
|
176
|
-
page
|
|
386
|
+
page * this.entriesPerPage,
|
|
387
|
+
(page + 1) * this.entriesPerPage,
|
|
177
388
|
)
|
|
178
389
|
.join("\n");
|
|
179
|
-
|
|
180
|
-
content
|
|
181
|
-
|
|
182
|
-
);
|
|
183
|
-
return comp.component.toJSON();
|
|
390
|
+
return new TextDisplayBuilder()
|
|
391
|
+
.setContent(this.applyReplacements(content))
|
|
392
|
+
.toJSON();
|
|
184
393
|
}
|
|
185
394
|
|
|
186
|
-
|
|
187
|
-
case "separator":
|
|
188
|
-
case "file":
|
|
189
|
-
case "gallery":
|
|
190
|
-
case "actionrow":
|
|
395
|
+
default:
|
|
191
396
|
return comp.component.toJSON();
|
|
192
|
-
|
|
193
|
-
default: {
|
|
194
|
-
return comp;
|
|
195
|
-
}
|
|
196
397
|
}
|
|
197
398
|
}),
|
|
198
|
-
accent_color:
|
|
199
|
-
spoiler:
|
|
399
|
+
accent_color: this.accentColor,
|
|
400
|
+
spoiler: this.spoiler,
|
|
200
401
|
});
|
|
201
402
|
}
|
|
202
403
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
})
|
|
209
|
-
.catch(() => null);
|
|
210
|
-
}
|
|
404
|
+
private generateEmbed(): EmbedBuilder {
|
|
405
|
+
if (!this.embedTemplate)
|
|
406
|
+
throw new Error(
|
|
407
|
+
"[@kyvrixon/utils]: Pagination: embedTemplate is in a corrupted state",
|
|
408
|
+
);
|
|
211
409
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
410
|
+
const page = Math.floor(this.currentIndex / this.entriesPerPage);
|
|
411
|
+
const content = this.list
|
|
412
|
+
.slice(page * this.entriesPerPage, (page + 1) * this.entriesPerPage)
|
|
413
|
+
.join("\n");
|
|
414
|
+
|
|
415
|
+
return new EmbedBuilder(this.embedTemplate.toJSON())
|
|
416
|
+
.setDescription(this.applyReplacements(content))
|
|
417
|
+
.setFooter({ text: `Page ${page + 1}/${this.totalPages}` });
|
|
215
418
|
}
|
|
216
419
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
420
|
+
private getPaginationRow(): ActionRowBuilder<ButtonBuilder> {
|
|
421
|
+
return new ActionRowBuilder<ButtonBuilder>().addComponents(
|
|
422
|
+
new ButtonBuilder()
|
|
423
|
+
.setCustomId(`${this.prefix}back`)
|
|
424
|
+
.setLabel("Prev")
|
|
425
|
+
.setStyle(ButtonStyle.Secondary)
|
|
426
|
+
.setDisabled(this.ended || this.currentIndex === 0),
|
|
427
|
+
new ButtonBuilder()
|
|
428
|
+
.setCustomId(`${this.prefix}info`)
|
|
429
|
+
.setLabel(
|
|
430
|
+
`${Math.floor(this.currentIndex / this.entriesPerPage) + 1}/${this.totalPages}`,
|
|
431
|
+
)
|
|
432
|
+
.setStyle(ButtonStyle.Secondary)
|
|
433
|
+
.setDisabled(this.ended || this.totalPages === 1 || this.isMessage),
|
|
434
|
+
new ButtonBuilder()
|
|
435
|
+
.setCustomId(`${this.prefix}forward`)
|
|
436
|
+
.setLabel("Next")
|
|
437
|
+
.setStyle(ButtonStyle.Secondary)
|
|
438
|
+
.setDisabled(
|
|
439
|
+
this.ended ||
|
|
440
|
+
this.currentIndex + this.entriesPerPage >= this.list.length,
|
|
441
|
+
),
|
|
442
|
+
);
|
|
235
443
|
}
|
|
236
444
|
|
|
237
|
-
|
|
445
|
+
private async handlePageJump(btn: ButtonInteraction): Promise<void> {
|
|
446
|
+
const modal = new ModalBuilder()
|
|
447
|
+
.setCustomId(`${this.prefix}modal`)
|
|
448
|
+
.setTitle("Page Indexer")
|
|
449
|
+
.addLabelComponents(
|
|
450
|
+
new LabelBuilder()
|
|
451
|
+
.setLabel("Input a page number")
|
|
452
|
+
.setTextInputComponent(
|
|
453
|
+
new TextInputBuilder()
|
|
454
|
+
.setCustomId(`${this.prefix}number`)
|
|
455
|
+
.setRequired(true)
|
|
456
|
+
.setMinLength(1)
|
|
457
|
+
.setStyle(TextInputStyle.Short),
|
|
458
|
+
),
|
|
459
|
+
);
|
|
238
460
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
461
|
+
await btn.showModal(modal).catch((e) => console.error(e));
|
|
462
|
+
const modalSubmit = await btn
|
|
463
|
+
.awaitModalSubmit({ time: 60_000 })
|
|
464
|
+
.catch(() => null);
|
|
243
465
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
466
|
+
if (!modalSubmit) {
|
|
467
|
+
await btn
|
|
468
|
+
.followUp({
|
|
469
|
+
content: "Modal timed out.",
|
|
470
|
+
flags: ["Ephemeral"],
|
|
471
|
+
})
|
|
472
|
+
.catch(() => null);
|
|
473
|
+
return;
|
|
247
474
|
}
|
|
248
475
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
);
|
|
476
|
+
const pageNumber = Number(
|
|
477
|
+
modalSubmit.fields.getTextInputValue(`${this.prefix}number`),
|
|
478
|
+
);
|
|
270
479
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
480
|
+
if (
|
|
481
|
+
!Number.isInteger(pageNumber) ||
|
|
482
|
+
pageNumber < 1 ||
|
|
483
|
+
pageNumber > this.totalPages
|
|
484
|
+
) {
|
|
485
|
+
await modalSubmit
|
|
486
|
+
.reply({
|
|
487
|
+
content: `Invalid page! Choose a number between **1** and **${this.totalPages}**.`,
|
|
488
|
+
flags: ["Ephemeral"],
|
|
489
|
+
allowedMentions: { parse: [], repliedUser: false },
|
|
490
|
+
})
|
|
491
|
+
.catch(() => null);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
275
494
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
content: "Modal timed out.",
|
|
280
|
-
flags: ["Ephemeral"],
|
|
281
|
-
})
|
|
282
|
-
.catch(() => null);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
495
|
+
await modalSubmit.deferUpdate().catch(() => null);
|
|
496
|
+
this.currentIndex = (pageNumber - 1) * this.entriesPerPage;
|
|
497
|
+
}
|
|
285
498
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
)
|
|
499
|
+
private applyReplacements(content: string): string {
|
|
500
|
+
if (!this.replacements) return content;
|
|
501
|
+
return Object.entries(this.replacements).reduce(
|
|
502
|
+
(acc, [key, value]) => acc.replaceAll(key, value),
|
|
503
|
+
content,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
290
506
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
}
|
|
507
|
+
private async render(): Promise<void> {
|
|
508
|
+
try {
|
|
509
|
+
const payload = this.buildPayload();
|
|
308
510
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
)
|
|
317
|
-
|
|
511
|
+
if (this.isMessage && this.replyMessage) {
|
|
512
|
+
await this.replyMessage.edit(payload);
|
|
513
|
+
} else if (this.interaction) {
|
|
514
|
+
await this.interaction.editReply(payload);
|
|
515
|
+
}
|
|
516
|
+
} catch (error) {
|
|
517
|
+
const e = error as Error;
|
|
518
|
+
if (!e.message.includes("Unknown Message")) {
|
|
519
|
+
console.error("Failed to render pagination:", error);
|
|
520
|
+
}
|
|
318
521
|
}
|
|
319
|
-
|
|
320
|
-
await render();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
collector.on("end", async () => {
|
|
324
|
-
ended = true;
|
|
325
|
-
await render();
|
|
326
|
-
});
|
|
522
|
+
}
|
|
327
523
|
}
|
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
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./LoggerModule";
|
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
|
}
|