1121 lines
19 KiB
JavaScript
1121 lines
19 KiB
JavaScript
const {
|
|
EmbedBuilder,
|
|
AttachmentBuilder,
|
|
GuildScheduledEventEntityType,
|
|
GuildScheduledEventPrivacyLevel,
|
|
PermissionFlagsBits,
|
|
MessageFlags
|
|
} = require("discord.js");
|
|
|
|
const { ObjectId } = require("mongodb");
|
|
const path = require("node:path");
|
|
const { mClient } = require("..");
|
|
|
|
|
|
|
|
|
|
/*
|
|
* CONFIG
|
|
*/
|
|
|
|
const DATABASE = "neo-db";
|
|
const DUTY_COLLECTION = "items_duties";
|
|
|
|
const RAID_PLANNER_CHANNEL_ID =
|
|
process.env.RAID_PLANNER_CHANNEL_ID;
|
|
|
|
const RAID_TIMEZONE =
|
|
process.env.RAID_TIMEZONE || "Europe/Berlin";
|
|
|
|
|
|
/*
|
|
* LOCAL DUTY ASSETS
|
|
*/
|
|
|
|
const dutyIconDirectory = path.resolve(
|
|
__dirname,
|
|
"../assets/duties"
|
|
);
|
|
|
|
|
|
const DUTY_ICONS = {
|
|
Guildhest: "Guildhest.png",
|
|
Dungeon: "Dungeon.png",
|
|
Trial: "Trial.png",
|
|
Raid: "Raid.png",
|
|
AllianceRaid: "Raid.png",
|
|
|
|
VariantDungeon:
|
|
"Variant_and_criterion_dungeons_icon1.png",
|
|
|
|
Criterion:
|
|
"Variant_and_criterion_dungeons_icon1.png"
|
|
};
|
|
|
|
|
|
const DUTY_DIFFICULTY_ICONS = {
|
|
Ultimate: "Ultimate_Raid.png",
|
|
Chaotic: "Chaotic_Alliance_Raid.png"
|
|
};
|
|
|
|
|
|
/*
|
|
* RAID SIGNUP EMOJIS
|
|
*/
|
|
|
|
const RAID_EMOJIS = {
|
|
Tank: {
|
|
id: "1537931883369726042",
|
|
display:
|
|
"<:TankRole:1537931883369726042>"
|
|
},
|
|
|
|
Healer: {
|
|
id: "1537931880756805704",
|
|
display:
|
|
"<:HealerRole:1537931880756805704>"
|
|
},
|
|
|
|
DPS: {
|
|
id: "1537931878668050442",
|
|
display:
|
|
"<:DPSRole:1537931878668050442>"
|
|
}
|
|
};
|
|
|
|
|
|
/*
|
|
* MODAL EXPORT
|
|
*
|
|
* Your modal handler should use the base customId:
|
|
* raidPlan:<dutyObjectId>
|
|
*
|
|
* and resolve "raidPlan".
|
|
*/
|
|
|
|
module.exports = {
|
|
customId: "raidPlan",
|
|
|
|
async execute(interaction) {
|
|
return handleRaidPlan(interaction);
|
|
}
|
|
};
|
|
|
|
|
|
/*
|
|
* MAIN HANDLER
|
|
*/
|
|
|
|
async function handleRaidPlan(interaction) {
|
|
await interaction.deferReply({
|
|
flags: MessageFlags.Ephemeral
|
|
});
|
|
|
|
|
|
if (!interaction.guild) {
|
|
return interaction.editReply({
|
|
content:
|
|
"Raid planning can only be used inside a server."
|
|
});
|
|
}
|
|
|
|
|
|
if (!RAID_PLANNER_CHANNEL_ID) {
|
|
return interaction.editReply({
|
|
content:
|
|
"RAID_PLANNER_CHANNEL_ID is not configured."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* customId:
|
|
*
|
|
* raidPlan:<ObjectId>
|
|
*/
|
|
const [
|
|
prefix,
|
|
dutyId
|
|
] = interaction.customId.split(":");
|
|
|
|
|
|
if (
|
|
prefix !== "raidPlan" ||
|
|
!ObjectId.isValid(dutyId)
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"Invalid raid planner data."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Fetch selected duty.
|
|
*/
|
|
|
|
const collection = mClient
|
|
.db(DATABASE)
|
|
.collection(DUTY_COLLECTION);
|
|
|
|
|
|
const duty =
|
|
await collection.findOne({
|
|
_id: new ObjectId(dutyId)
|
|
});
|
|
|
|
|
|
if (!duty) {
|
|
return interaction.editReply({
|
|
content:
|
|
"The selected duty could not be found."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Read modal fields.
|
|
*/
|
|
|
|
const name =
|
|
interaction.fields
|
|
.getTextInputValue(
|
|
"raid-name"
|
|
)
|
|
.trim();
|
|
|
|
|
|
const description =
|
|
interaction.fields
|
|
.getTextInputValue(
|
|
"raid-description"
|
|
)
|
|
.trim();
|
|
|
|
|
|
const enteredDuty =
|
|
interaction.fields
|
|
.getTextInputValue(
|
|
"raid-duty"
|
|
)
|
|
.trim();
|
|
|
|
|
|
const date =
|
|
interaction.fields
|
|
.getTextInputValue(
|
|
"raid-date"
|
|
)
|
|
.trim();
|
|
|
|
|
|
const timeRange =
|
|
interaction.fields
|
|
.getTextInputValue(
|
|
"raid-time"
|
|
)
|
|
.trim();
|
|
|
|
|
|
/*
|
|
* Duty field is display-only in practice.
|
|
* Verify user didn't modify it.
|
|
*/
|
|
|
|
if (
|
|
enteredDuty !==
|
|
duty.Name
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"The Duty field was changed. Please create the raid again."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Validate date.
|
|
*/
|
|
|
|
if (
|
|
!isValidDateString(
|
|
date
|
|
)
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"Invalid date. Use `DD-MM-YYYY`, for example `22-08-2026`."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Validate time range.
|
|
*/
|
|
|
|
const parsedTimeRange =
|
|
parseTimeRange(
|
|
timeRange
|
|
);
|
|
|
|
|
|
if (!parsedTimeRange) {
|
|
return interaction.editReply({
|
|
content:
|
|
"Invalid time. Use the 24-hour format `HH:mm - HH:mm`, for example `20:00 - 23:00`."
|
|
});
|
|
}
|
|
|
|
|
|
const {
|
|
startTime,
|
|
endTime
|
|
} = parsedTimeRange;
|
|
|
|
|
|
/*
|
|
* Convert configured timezone to JS Date.
|
|
*/
|
|
|
|
const startDate =
|
|
zonedDateTimeToDate(
|
|
date,
|
|
startTime,
|
|
RAID_TIMEZONE
|
|
);
|
|
|
|
|
|
let endDate =
|
|
zonedDateTimeToDate(
|
|
date,
|
|
endTime,
|
|
RAID_TIMEZONE
|
|
);
|
|
|
|
|
|
if (
|
|
!startDate ||
|
|
!endDate
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"I couldn't understand that date and time."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* If the end time is before/equal to the start,
|
|
* assume the raid crosses midnight.
|
|
*
|
|
* Example:
|
|
* 23:00 - 02:00
|
|
*/
|
|
|
|
if (
|
|
endDate <=
|
|
startDate
|
|
) {
|
|
endDate =
|
|
new Date(
|
|
endDate.getTime() +
|
|
24 * 60 * 60 * 1000
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Start must be in the future.
|
|
*/
|
|
|
|
if (
|
|
startDate.getTime() <=
|
|
Date.now()
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"The raid must be scheduled for a future date and time."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Fetch planner channel.
|
|
*/
|
|
|
|
const channel =
|
|
await interaction.guild
|
|
.channels
|
|
.fetch(
|
|
RAID_PLANNER_CHANNEL_ID
|
|
);
|
|
|
|
|
|
if (
|
|
!channel ||
|
|
!channel.isTextBased() ||
|
|
typeof channel.send !==
|
|
"function"
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"The configured raid planner channel could not be found or is not a text channel."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Bot member.
|
|
*/
|
|
|
|
const me =
|
|
interaction.guild.members.me;
|
|
|
|
|
|
if (!me) {
|
|
return interaction.editReply({
|
|
content:
|
|
"I couldn't determine my server permissions."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Event permission.
|
|
*/
|
|
|
|
if (
|
|
!me.permissions.has(
|
|
PermissionFlagsBits.CreateEvents
|
|
)
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"I need the **Create Events** permission."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Destination channel permissions.
|
|
*/
|
|
|
|
const channelPermissions =
|
|
channel.permissionsFor(me);
|
|
|
|
|
|
if (
|
|
!channelPermissions ||
|
|
!channelPermissions.has([
|
|
PermissionFlagsBits.ViewChannel,
|
|
PermissionFlagsBits.SendMessages,
|
|
PermissionFlagsBits.EmbedLinks,
|
|
PermissionFlagsBits.AddReactions
|
|
])
|
|
) {
|
|
return interaction.editReply({
|
|
content:
|
|
"I need **View Channel**, **Send Messages**, **Embed Links**, and **Add Reactions** in the raid planner channel."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Create scheduled event.
|
|
*/
|
|
|
|
let event;
|
|
|
|
|
|
try {
|
|
event =
|
|
await createRaidEvent(
|
|
interaction,
|
|
{
|
|
name,
|
|
description,
|
|
duty,
|
|
startDate,
|
|
endDate
|
|
}
|
|
);
|
|
|
|
} catch (error) {
|
|
console.error(
|
|
"Raid event creation failed:",
|
|
error
|
|
);
|
|
|
|
|
|
return interaction.editReply({
|
|
content:
|
|
"I couldn't create the scheduled event."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Create planner embed/message.
|
|
*/
|
|
|
|
let message;
|
|
|
|
|
|
try {
|
|
message =
|
|
await createRaidMessage(
|
|
interaction,
|
|
channel,
|
|
{
|
|
name,
|
|
description,
|
|
duty,
|
|
startDate,
|
|
endDate
|
|
},
|
|
event
|
|
);
|
|
|
|
} catch (error) {
|
|
console.error(
|
|
"Raid planner message failed:",
|
|
error
|
|
);
|
|
|
|
|
|
/*
|
|
* Remove event if planner post fails.
|
|
*/
|
|
try {
|
|
await event.delete(
|
|
"Raid planner message failed"
|
|
);
|
|
|
|
} catch (cleanupError) {
|
|
console.error(
|
|
"Raid event cleanup failed:",
|
|
cleanupError
|
|
);
|
|
}
|
|
|
|
|
|
return interaction.editReply({
|
|
content:
|
|
"I couldn't publish the raid planner."
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* Confirmation to raid creator.
|
|
*/
|
|
|
|
return interaction.editReply({
|
|
content:
|
|
`Raid created successfully!\n${message.url}`
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* CREATE DISCORD SCHEDULED EVENT
|
|
*/
|
|
|
|
async function createRaidEvent(
|
|
interaction,
|
|
data
|
|
) {
|
|
const eventDescription = [
|
|
data.description,
|
|
"",
|
|
`Duty: ${data.duty.Name}`,
|
|
`Difficulty: ${data.duty.Difficulty}`,
|
|
`Organized by: ${interaction.user.displayName}`
|
|
]
|
|
.join("\n")
|
|
.slice(
|
|
0,
|
|
1000
|
|
);
|
|
|
|
|
|
return interaction.guild
|
|
.scheduledEvents
|
|
.create({
|
|
|
|
name:
|
|
data.name.slice(
|
|
0,
|
|
100
|
|
),
|
|
|
|
description:
|
|
eventDescription,
|
|
|
|
scheduledStartTime:
|
|
data.startDate,
|
|
|
|
scheduledEndTime:
|
|
data.endDate,
|
|
|
|
privacyLevel:
|
|
GuildScheduledEventPrivacyLevel
|
|
.GuildOnly,
|
|
|
|
entityType:
|
|
GuildScheduledEventEntityType
|
|
.External,
|
|
|
|
entityMetadata: {
|
|
location:
|
|
`Final Fantasy XIV — ${data.duty.Name}`
|
|
.slice(
|
|
0,
|
|
100
|
|
)
|
|
},
|
|
|
|
reason:
|
|
`Raid planned by ${interaction.user.tag}`
|
|
});
|
|
}
|
|
|
|
|
|
/*
|
|
* CREATE RAID PLANNER MESSAGE
|
|
*/
|
|
|
|
async function createRaidMessage(
|
|
interaction,
|
|
channel,
|
|
data,
|
|
event
|
|
) {
|
|
const startUnix =
|
|
Math.floor(
|
|
data.startDate
|
|
.getTime() /
|
|
1000
|
|
);
|
|
|
|
|
|
const endUnix =
|
|
Math.floor(
|
|
data.endDate
|
|
.getTime() /
|
|
1000
|
|
);
|
|
|
|
|
|
const eventUrl =
|
|
`https://discord.com/events/${interaction.guildId}/${event.id}`;
|
|
|
|
|
|
const icon =
|
|
getDutyIconFile(
|
|
data.duty
|
|
);
|
|
|
|
|
|
const embed =
|
|
new EmbedBuilder()
|
|
|
|
.setTitle(
|
|
"Shaiwase Raid Planner"
|
|
)
|
|
|
|
.setDescription(
|
|
[
|
|
`## ${data.name}`,
|
|
data.description
|
|
].join(
|
|
"\n\n"
|
|
)
|
|
)
|
|
|
|
.addFields(
|
|
|
|
{
|
|
name:
|
|
"Duty",
|
|
|
|
value:
|
|
`**${data.duty.Name}**\n` +
|
|
`${formatDutyType(data.duty.Type)} • ${data.duty.Difficulty}`,
|
|
|
|
inline:
|
|
false
|
|
},
|
|
|
|
{
|
|
name:
|
|
"Date",
|
|
|
|
value:
|
|
`<t:${startUnix}:D>`,
|
|
|
|
inline:
|
|
true
|
|
},
|
|
|
|
{
|
|
name:
|
|
"Time",
|
|
|
|
value:
|
|
`<t:${startUnix}:t> - <t:${endUnix}:t>`,
|
|
|
|
inline:
|
|
true
|
|
},
|
|
|
|
{
|
|
name:
|
|
"Starts",
|
|
|
|
value:
|
|
`<t:${startUnix}:R>`,
|
|
|
|
inline:
|
|
true
|
|
},
|
|
|
|
{
|
|
name:
|
|
"Discord Event",
|
|
|
|
value:
|
|
`[View Event](${eventUrl})`,
|
|
|
|
inline:
|
|
false
|
|
},
|
|
|
|
{
|
|
name:
|
|
"Sign Up",
|
|
|
|
value:
|
|
`${RAID_EMOJIS.Tank.display} Tank\n` +
|
|
`${RAID_EMOJIS.Healer.display} Healer\n` +
|
|
`${RAID_EMOJIS.DPS.display} DPS`,
|
|
|
|
inline:
|
|
false
|
|
}
|
|
)
|
|
|
|
.setFooter({
|
|
text:
|
|
`Created by ${interaction.user.displayName}`
|
|
})
|
|
|
|
.setTimestamp();
|
|
|
|
|
|
/*
|
|
* Local duty icon attachment.
|
|
*/
|
|
|
|
const files = [];
|
|
|
|
|
|
if (icon) {
|
|
const attachmentName =
|
|
"duty-icon.png";
|
|
|
|
|
|
files.push(
|
|
new AttachmentBuilder(
|
|
icon.path,
|
|
{
|
|
name:
|
|
attachmentName
|
|
}
|
|
)
|
|
);
|
|
|
|
|
|
embed.setThumbnail(
|
|
`attachment://${attachmentName}`
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* Send only into raid planner channel.
|
|
*/
|
|
|
|
const message =
|
|
await channel.send({
|
|
embeds: [
|
|
embed
|
|
],
|
|
|
|
files
|
|
});
|
|
|
|
|
|
/*
|
|
* Signup reactions.
|
|
*/
|
|
|
|
await message.react(
|
|
RAID_EMOJIS.Tank.id
|
|
);
|
|
|
|
|
|
await message.react(
|
|
RAID_EMOJIS.Healer.id
|
|
);
|
|
|
|
|
|
await message.react(
|
|
RAID_EMOJIS.DPS.id
|
|
);
|
|
|
|
|
|
return message;
|
|
}
|
|
|
|
|
|
/*
|
|
* GET DUTY ICON
|
|
*/
|
|
|
|
function getDutyIconFile(duty) {
|
|
const fileName =
|
|
DUTY_DIFFICULTY_ICONS[
|
|
duty.Difficulty
|
|
]
|
|
??
|
|
DUTY_ICONS[
|
|
duty.Type
|
|
];
|
|
|
|
|
|
if (!fileName)
|
|
return null;
|
|
|
|
|
|
return {
|
|
fileName,
|
|
|
|
path:
|
|
path.join(
|
|
dutyIconDirectory,
|
|
fileName
|
|
)
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* FORMAT DUTY TYPE
|
|
*/
|
|
|
|
function formatDutyType(type) {
|
|
switch (type) {
|
|
case "AllianceRaid":
|
|
return "Alliance Raid";
|
|
|
|
case "VariantDungeon":
|
|
return "Variant Dungeon";
|
|
|
|
default:
|
|
return type;
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* VALIDATE DD-MM-YYYY
|
|
*/
|
|
|
|
function isValidDateString(value) {
|
|
const match =
|
|
/^(\d{2})-(\d{2})-(\d{4})$/
|
|
.exec(value);
|
|
|
|
|
|
if (!match)
|
|
return false;
|
|
|
|
|
|
const day =
|
|
Number(
|
|
match[1]
|
|
);
|
|
|
|
|
|
const month =
|
|
Number(
|
|
match[2]
|
|
);
|
|
|
|
|
|
const year =
|
|
Number(
|
|
match[3]
|
|
);
|
|
|
|
|
|
const date =
|
|
new Date(
|
|
Date.UTC(
|
|
year,
|
|
month - 1,
|
|
day
|
|
)
|
|
);
|
|
|
|
|
|
return (
|
|
date.getUTCFullYear() ===
|
|
year
|
|
&&
|
|
date.getUTCMonth() ===
|
|
month - 1
|
|
&&
|
|
date.getUTCDate() ===
|
|
day
|
|
);
|
|
}
|
|
|
|
|
|
/*
|
|
* PARSE:
|
|
*
|
|
* 20:00 - 23:00
|
|
*
|
|
* 24-hour clock
|
|
*/
|
|
|
|
function parseTimeRange(value) {
|
|
const match =
|
|
/^(\d{2}):(\d{2})\s*-\s*(\d{2}):(\d{2})$/
|
|
.exec(value);
|
|
|
|
|
|
if (!match)
|
|
return null;
|
|
|
|
|
|
const startHour =
|
|
Number(
|
|
match[1]
|
|
);
|
|
|
|
|
|
const startMinute =
|
|
Number(
|
|
match[2]
|
|
);
|
|
|
|
|
|
const endHour =
|
|
Number(
|
|
match[3]
|
|
);
|
|
|
|
|
|
const endMinute =
|
|
Number(
|
|
match[4]
|
|
);
|
|
|
|
|
|
if (
|
|
startHour < 0 ||
|
|
startHour > 23 ||
|
|
startMinute < 0 ||
|
|
startMinute > 59 ||
|
|
endHour < 0 ||
|
|
endHour > 23 ||
|
|
endMinute < 0 ||
|
|
endMinute > 59
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
|
|
return {
|
|
startTime:
|
|
`${String(startHour).padStart(2, "0")}:${String(startMinute).padStart(2, "0")}`,
|
|
|
|
endTime:
|
|
`${String(endHour).padStart(2, "0")}:${String(endMinute).padStart(2, "0")}`
|
|
};
|
|
}
|
|
|
|
|
|
/*
|
|
* CONVERT DD-MM-YYYY + HH:mm FROM
|
|
* CONFIGURED TIMEZONE INTO JS DATE.
|
|
*/
|
|
|
|
function zonedDateTimeToDate(
|
|
dateString,
|
|
timeString,
|
|
timeZone
|
|
) {
|
|
const [
|
|
day,
|
|
month,
|
|
year
|
|
] =
|
|
dateString
|
|
.split("-")
|
|
.map(Number);
|
|
|
|
|
|
const [
|
|
hour,
|
|
minute
|
|
] =
|
|
timeString
|
|
.split(":")
|
|
.map(Number);
|
|
|
|
|
|
let timestamp =
|
|
Date.UTC(
|
|
year,
|
|
month - 1,
|
|
day,
|
|
hour,
|
|
minute,
|
|
0
|
|
);
|
|
|
|
|
|
/*
|
|
* Two passes helps account for DST.
|
|
*/
|
|
|
|
for (
|
|
let i = 0;
|
|
i < 2;
|
|
i++
|
|
) {
|
|
const offset =
|
|
getTimeZoneOffset(
|
|
new Date(
|
|
timestamp
|
|
),
|
|
timeZone
|
|
);
|
|
|
|
|
|
timestamp =
|
|
Date.UTC(
|
|
year,
|
|
month - 1,
|
|
day,
|
|
hour,
|
|
minute,
|
|
0
|
|
)
|
|
-
|
|
offset;
|
|
}
|
|
|
|
|
|
const result =
|
|
new Date(
|
|
timestamp
|
|
);
|
|
|
|
|
|
return Number.isNaN(
|
|
result.getTime()
|
|
)
|
|
? null
|
|
: result;
|
|
}
|
|
|
|
|
|
/*
|
|
* GET UTC OFFSET OF IANA TIMEZONE
|
|
*/
|
|
|
|
function getTimeZoneOffset(
|
|
date,
|
|
timeZone
|
|
) {
|
|
const formatter =
|
|
new Intl.DateTimeFormat(
|
|
"en-US",
|
|
{
|
|
timeZone,
|
|
|
|
year:
|
|
"numeric",
|
|
|
|
month:
|
|
"2-digit",
|
|
|
|
day:
|
|
"2-digit",
|
|
|
|
hour:
|
|
"2-digit",
|
|
|
|
minute:
|
|
"2-digit",
|
|
|
|
second:
|
|
"2-digit",
|
|
|
|
hourCycle:
|
|
"h23"
|
|
}
|
|
);
|
|
|
|
|
|
const parts =
|
|
formatter
|
|
.formatToParts(
|
|
date
|
|
)
|
|
.reduce(
|
|
(
|
|
result,
|
|
part
|
|
) => {
|
|
|
|
if (
|
|
part.type !==
|
|
"literal"
|
|
) {
|
|
result[
|
|
part.type
|
|
] =
|
|
Number(
|
|
part.value
|
|
);
|
|
}
|
|
|
|
|
|
return result;
|
|
},
|
|
{}
|
|
);
|
|
|
|
|
|
const asUTC =
|
|
Date.UTC(
|
|
parts.year,
|
|
parts.month - 1,
|
|
parts.day,
|
|
parts.hour,
|
|
parts.minute,
|
|
parts.second
|
|
);
|
|
|
|
|
|
return (
|
|
asUTC -
|
|
date.getTime()
|
|
);
|
|
} |