Release Time Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
import { parseInstant } from "./epoch";
|
||||
|
||||
const NS = {
|
||||
week: 604_800_000_000_000n,
|
||||
day: 86_400_000_000_000n,
|
||||
hour: 3_600_000_000_000n,
|
||||
minute: 60_000_000_000n,
|
||||
second: 1_000_000_000n,
|
||||
millisecond: 1_000_000n,
|
||||
microsecond: 1_000n,
|
||||
};
|
||||
|
||||
export interface ArithmeticComparison {
|
||||
start: string;
|
||||
duration: string;
|
||||
timeZone: string;
|
||||
wallClockResult: string;
|
||||
wallClockInstant: string;
|
||||
elapsedResult?: string;
|
||||
elapsedInstant?: string;
|
||||
elapsedUnavailable?: string;
|
||||
differenceSeconds?: string;
|
||||
}
|
||||
|
||||
function elapsedNanoseconds(duration: Temporal.Duration): bigint {
|
||||
if (duration.years || duration.months)
|
||||
throw new RangeError(
|
||||
"Elapsed arithmetic cannot assign a fixed length to years or months.",
|
||||
);
|
||||
const integral = [
|
||||
duration.weeks,
|
||||
duration.days,
|
||||
duration.hours,
|
||||
duration.minutes,
|
||||
duration.seconds,
|
||||
duration.milliseconds,
|
||||
duration.microseconds,
|
||||
duration.nanoseconds,
|
||||
].every(Number.isSafeInteger);
|
||||
if (!integral)
|
||||
throw new RangeError(
|
||||
"Elapsed arithmetic requires integral duration fields.",
|
||||
);
|
||||
return (
|
||||
BigInt(duration.weeks) * NS.week +
|
||||
BigInt(duration.days) * NS.day +
|
||||
BigInt(duration.hours) * NS.hour +
|
||||
BigInt(duration.minutes) * NS.minute +
|
||||
BigInt(duration.seconds) * NS.second +
|
||||
BigInt(duration.milliseconds) * NS.millisecond +
|
||||
BigInt(duration.microseconds) * NS.microsecond +
|
||||
BigInt(duration.nanoseconds)
|
||||
);
|
||||
}
|
||||
|
||||
function exactSeconds(nanoseconds: bigint): string {
|
||||
const negative = nanoseconds < 0n;
|
||||
const absolute = negative ? -nanoseconds : nanoseconds;
|
||||
const whole = absolute / NS.second;
|
||||
const remainder = absolute % NS.second;
|
||||
return `${negative ? "-" : ""}${whole}${remainder ? `.${remainder.toString().padStart(9, "0").replace(/0+$/u, "")}` : ""}`;
|
||||
}
|
||||
|
||||
export function compareArithmetic(
|
||||
startInput: string,
|
||||
timeZoneInput: string,
|
||||
durationInput: string,
|
||||
): ArithmeticComparison {
|
||||
const start = parseInstant(startInput);
|
||||
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
|
||||
const duration = Temporal.Duration.from(
|
||||
assertBoundedText(durationInput, 256, "Duration").trim(),
|
||||
);
|
||||
const zonedStart = start.toZonedDateTimeISO(timeZone);
|
||||
const wall = zonedStart.add(duration);
|
||||
const result: ArithmeticComparison = {
|
||||
start: zonedStart.toString(),
|
||||
duration: duration.toString(),
|
||||
timeZone,
|
||||
wallClockResult: wall.toString(),
|
||||
wallClockInstant: wall.toInstant().toString(),
|
||||
};
|
||||
try {
|
||||
const elapsedNs = elapsedNanoseconds(duration);
|
||||
const elapsed = Temporal.Instant.fromEpochNanoseconds(
|
||||
start.epochNanoseconds + elapsedNs,
|
||||
);
|
||||
result.elapsedResult = elapsed.toZonedDateTimeISO(timeZone).toString();
|
||||
result.elapsedInstant = elapsed.toString();
|
||||
result.differenceSeconds = exactSeconds(
|
||||
wall.epochNanoseconds - elapsed.epochNanoseconds,
|
||||
);
|
||||
} catch (reason) {
|
||||
result.elapsedUnavailable =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Elapsed result is unavailable.";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function addBusinessDays(
|
||||
startInput: string,
|
||||
countInput: number,
|
||||
holidayInputs: string[] = [],
|
||||
): {
|
||||
start: string;
|
||||
result: string;
|
||||
traversedCalendarDays: number;
|
||||
holidays: string[];
|
||||
} {
|
||||
if (!Number.isSafeInteger(countInput) || Math.abs(countInput) > 100_000)
|
||||
throw new RangeError("Business-day count must be within ±100,000.");
|
||||
const count = countInput;
|
||||
const start = Temporal.PlainDate.from(
|
||||
assertBoundedText(startInput, 64, "Start date").trim(),
|
||||
);
|
||||
let current = start;
|
||||
if (holidayInputs.length > 10_000)
|
||||
throw new RangeError("Holiday list is limited to 10,000 dates.");
|
||||
const holidays = new Set(
|
||||
holidayInputs
|
||||
.filter(Boolean)
|
||||
.map((value) =>
|
||||
Temporal.PlainDate.from(
|
||||
assertBoundedText(value, 64, "Holiday date").trim(),
|
||||
).toString(),
|
||||
),
|
||||
);
|
||||
const direction = count < 0 ? -1 : 1;
|
||||
let remaining = Math.abs(count);
|
||||
let traversedCalendarDays = 0;
|
||||
while (remaining > 0) {
|
||||
current = current.add({ days: direction });
|
||||
traversedCalendarDays += 1;
|
||||
if (current.dayOfWeek < 6 && !holidays.has(current.toString()))
|
||||
remaining -= 1;
|
||||
}
|
||||
return {
|
||||
start: start.toString(),
|
||||
result: current.toString(),
|
||||
traversedCalendarDays,
|
||||
holidays: [...holidays].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
|
||||
export type EpochUnit =
|
||||
"seconds" | "milliseconds" | "microseconds" | "nanoseconds";
|
||||
|
||||
const FACTORS: Record<EpochUnit, bigint> = {
|
||||
seconds: 1_000_000_000n,
|
||||
milliseconds: 1_000_000n,
|
||||
microseconds: 1_000n,
|
||||
nanoseconds: 1n,
|
||||
};
|
||||
|
||||
export interface EpochInspection {
|
||||
input: string;
|
||||
interpretedAs: EpochUnit;
|
||||
epochNanoseconds: bigint;
|
||||
seconds: string;
|
||||
milliseconds: string;
|
||||
microseconds: string;
|
||||
nanoseconds: string;
|
||||
iso: string;
|
||||
}
|
||||
|
||||
function decimalToNanoseconds(input: string, unit: EpochUnit): bigint {
|
||||
const value = assertBoundedText(input, 128, "Timestamp").trim();
|
||||
const match = /^([+-]?)(\d+)(?:\.(\d+))?$/u.exec(value);
|
||||
if (!match)
|
||||
throw new SyntaxError(
|
||||
"Use an integer or decimal epoch value without exponent notation.",
|
||||
);
|
||||
const fraction = match[3] ?? "";
|
||||
const denominator = 10n ** BigInt(fraction.length);
|
||||
const factor = FACTORS[unit];
|
||||
const fractionalNumerator = BigInt(fraction || "0") * factor;
|
||||
if (fractionalNumerator % denominator !== 0n) {
|
||||
throw new RangeError(
|
||||
"The value contains precision smaller than one nanosecond.",
|
||||
);
|
||||
}
|
||||
const magnitude =
|
||||
BigInt(match[2] ?? "0") * factor + fractionalNumerator / denominator;
|
||||
return match[1] === "-" ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
function scaledInteger(value: bigint, factor: bigint): string {
|
||||
const negative = value < 0n;
|
||||
const absolute = negative ? -value : value;
|
||||
const whole = absolute / factor;
|
||||
const remainder = absolute % factor;
|
||||
if (remainder === 0n) return `${negative ? "-" : ""}${whole}`;
|
||||
const width = factor.toString().length - 1;
|
||||
const fraction = remainder
|
||||
.toString()
|
||||
.padStart(width, "0")
|
||||
.replace(/0+$/u, "");
|
||||
return `${negative ? "-" : ""}${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
export function inspectEpoch(input: string, unit: EpochUnit): EpochInspection {
|
||||
const epochNanoseconds = decimalToNanoseconds(input, unit);
|
||||
const instant = Temporal.Instant.fromEpochNanoseconds(epochNanoseconds);
|
||||
return {
|
||||
input: input.trim(),
|
||||
interpretedAs: unit,
|
||||
epochNanoseconds,
|
||||
seconds: scaledInteger(epochNanoseconds, FACTORS.seconds),
|
||||
milliseconds: scaledInteger(epochNanoseconds, FACTORS.milliseconds),
|
||||
microseconds: scaledInteger(epochNanoseconds, FACTORS.microseconds),
|
||||
nanoseconds: epochNanoseconds.toString(),
|
||||
iso: instant.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseInstant(
|
||||
input: string,
|
||||
numericUnit: EpochUnit = "seconds",
|
||||
): Temporal.Instant {
|
||||
const value = assertBoundedText(input, 256, "Instant").trim();
|
||||
if (/^[+-]?\d+(?:\.\d+)?$/u.test(value)) {
|
||||
return Temporal.Instant.fromEpochNanoseconds(
|
||||
decimalToNanoseconds(value, numericUnit),
|
||||
);
|
||||
}
|
||||
return Temporal.Instant.from(value);
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
|
||||
export interface EventInput {
|
||||
startLocal: string;
|
||||
timeZone: string;
|
||||
duration: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
function escapeText(value: string): string {
|
||||
return value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll(";", "\\;")
|
||||
.replaceAll(",", "\\,")
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("\r", "\n")
|
||||
.replaceAll("\n", "\\n");
|
||||
}
|
||||
|
||||
function utcBasic(instant: Temporal.Instant): string {
|
||||
return instant
|
||||
.toString({ smallestUnit: "second" })
|
||||
.replaceAll("-", "")
|
||||
.replaceAll(":", "");
|
||||
}
|
||||
|
||||
export function foldIcsLine(line: string): string {
|
||||
const encoder = new TextEncoder();
|
||||
const codePoints = [...line];
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
let bytes = 0;
|
||||
for (const value of codePoints) {
|
||||
const length = encoder.encode(value).length;
|
||||
const limit = lines.length === 0 ? 75 : 74;
|
||||
if (bytes + length > limit && current) {
|
||||
lines.push(current);
|
||||
current = value;
|
||||
bytes = length;
|
||||
} else {
|
||||
current += value;
|
||||
bytes += length;
|
||||
}
|
||||
}
|
||||
lines.push(current);
|
||||
return lines.join("\r\n ");
|
||||
}
|
||||
|
||||
export function createUtcEvent(input: EventInput): {
|
||||
ics: string;
|
||||
startInstant: string;
|
||||
endInstant: string;
|
||||
note: string;
|
||||
} {
|
||||
const timeZone = assertBoundedText(input.timeZone, 128, "Time zone").trim();
|
||||
const startLocal = assertBoundedText(
|
||||
input.startLocal,
|
||||
128,
|
||||
"Start local date-time",
|
||||
).trim();
|
||||
const durationInput = assertBoundedText(
|
||||
input.duration,
|
||||
256,
|
||||
"Duration",
|
||||
).trim();
|
||||
const start = Temporal.PlainDateTime.from(startLocal).toZonedDateTime(
|
||||
timeZone,
|
||||
{ disambiguation: "reject" },
|
||||
);
|
||||
const duration = Temporal.Duration.from(durationInput);
|
||||
const end = start.add(duration);
|
||||
if (end.epochNanoseconds <= start.epochNanoseconds)
|
||||
throw new RangeError(
|
||||
"Event duration must result in an end after the start.",
|
||||
);
|
||||
const summary = assertBoundedText(input.summary, 1_024, "Summary").trim();
|
||||
if (!summary) throw new SyntaxError("Summary is required.");
|
||||
const uid = assertBoundedText(
|
||||
input.uid?.trim() || `${start.epochNanoseconds}@time-tools.local`,
|
||||
512,
|
||||
"UID",
|
||||
);
|
||||
const lines = [
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//add ideas//Time Tools 0.1.0//EN",
|
||||
"CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT",
|
||||
`UID:${escapeText(uid)}`,
|
||||
`DTSTAMP:${utcBasic(Temporal.Now.instant())}`,
|
||||
`DTSTART:${utcBasic(start.toInstant())}`,
|
||||
`DTEND:${utcBasic(end.toInstant())}`,
|
||||
`SUMMARY:${escapeText(summary)}`,
|
||||
];
|
||||
if (input.description?.trim())
|
||||
lines.push(
|
||||
`DESCRIPTION:${escapeText(assertBoundedText(input.description, 32_768, "Description"))}`,
|
||||
);
|
||||
if (input.location?.trim())
|
||||
lines.push(
|
||||
`LOCATION:${escapeText(assertBoundedText(input.location, 4_096, "Location"))}`,
|
||||
);
|
||||
lines.push("END:VEVENT", "END:VCALENDAR");
|
||||
return {
|
||||
ics: `${lines.map(foldIcsLine).join("\r\n")}\r\n`,
|
||||
startInstant: start.toInstant().toString(),
|
||||
endInstant: end.toInstant().toString(),
|
||||
note: `The event was converted from ${timeZone} to UTC. This avoids an incomplete VTIMEZONE definition but does not preserve a named-zone wall-clock recurrence.`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
|
||||
import { Cron, type CronMode } from "croner";
|
||||
import { RRuleTemporal } from "rrule-temporal";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
|
||||
export interface RecurrenceOccurrence {
|
||||
index: number;
|
||||
instant: string;
|
||||
zoned: string;
|
||||
offset: string;
|
||||
}
|
||||
|
||||
function boundedCount(value: number): number {
|
||||
const count = Math.trunc(value);
|
||||
if (!Number.isSafeInteger(count) || count < 1 || count > 1_000)
|
||||
throw new RangeError("Occurrence count must be between 1 and 1,000.");
|
||||
return count;
|
||||
}
|
||||
|
||||
export function previewCron(
|
||||
patternInput: string,
|
||||
startInput: string,
|
||||
timeZoneInput: string,
|
||||
countInput: number,
|
||||
mode: CronMode = "auto",
|
||||
): RecurrenceOccurrence[] {
|
||||
const pattern = assertBoundedText(
|
||||
patternInput,
|
||||
512,
|
||||
"Cron expression",
|
||||
).trim();
|
||||
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
|
||||
const count = boundedCount(countInput);
|
||||
const start = Temporal.Instant.from(startInput);
|
||||
const cron = new Cron(pattern, {
|
||||
paused: true,
|
||||
timezone: timeZone,
|
||||
mode,
|
||||
legacyMode: true,
|
||||
});
|
||||
return cron
|
||||
.nextRuns(count, new Date(start.epochMilliseconds))
|
||||
.map((date, index) => {
|
||||
const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
|
||||
const zoned = instant.toZonedDateTimeISO(timeZone);
|
||||
return {
|
||||
index: index + 1,
|
||||
instant: instant.toString(),
|
||||
zoned: zoned.toString(),
|
||||
offset: zoned.offset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function previewRRule(
|
||||
ruleInput: string,
|
||||
startLocalInput: string,
|
||||
timeZoneInput: string,
|
||||
countInput: number,
|
||||
): RecurrenceOccurrence[] {
|
||||
let rruleString = assertBoundedText(ruleInput, 4_096, "RRULE")
|
||||
.trim()
|
||||
.replaceAll("\r\n", "\n");
|
||||
if (!rruleString) throw new SyntaxError("RRULE is required.");
|
||||
if (!rruleString.toUpperCase().includes("RRULE:"))
|
||||
rruleString = `RRULE:${rruleString}`;
|
||||
if (
|
||||
/\n(?:DTSTART|RRULE|RDATE|EXDATE)[^\n]*\n(?:DTSTART|RRULE|RDATE|EXDATE)/iu.test(
|
||||
`\n${rruleString}`,
|
||||
)
|
||||
) {
|
||||
throw new RangeError(
|
||||
"Provide one RRULE. DTSTART is configured separately in this workspace.",
|
||||
);
|
||||
}
|
||||
const count = boundedCount(countInput);
|
||||
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
|
||||
const start = Temporal.PlainDateTime.from(
|
||||
startLocalInput.trim(),
|
||||
).toZonedDateTime(timeZone, { disambiguation: "compatible" });
|
||||
const rule = new RRuleTemporal({
|
||||
rruleString,
|
||||
dtstart: start,
|
||||
temporal: Temporal,
|
||||
maxIterations: 20_000,
|
||||
maxCandidateEvaluations: 250_000,
|
||||
cache: false,
|
||||
strict: true,
|
||||
});
|
||||
return rule
|
||||
.all((_date, index) => index < count)
|
||||
.map((value, index) => {
|
||||
const zoned = Temporal.ZonedDateTime.from(value.toString());
|
||||
return {
|
||||
index: index + 1,
|
||||
instant: zoned.toInstant().toString(),
|
||||
zoned: zoned.toString(),
|
||||
offset: zoned.offset,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
|
||||
import { Temporal } from "temporal-polyfill";
|
||||
import { parseInstant, type EpochUnit } from "./epoch";
|
||||
|
||||
export interface LocalResolution {
|
||||
status: "exact" | "ambiguous" | "skipped";
|
||||
local: string;
|
||||
timeZone: string;
|
||||
choices: Array<{
|
||||
label: "only" | "earlier" | "later";
|
||||
instant: string;
|
||||
zoned: string;
|
||||
offset: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ZoneSnapshot {
|
||||
timeZone: string;
|
||||
local: string;
|
||||
offset: string;
|
||||
epochNanoseconds: string;
|
||||
}
|
||||
|
||||
export interface ZoneTransition {
|
||||
instant: string;
|
||||
localAfter: string;
|
||||
offsetBefore: string;
|
||||
offsetAfter: string;
|
||||
changeMinutes: number;
|
||||
}
|
||||
|
||||
function samePlain(
|
||||
left: Temporal.PlainDateTime,
|
||||
right: Temporal.PlainDateTime,
|
||||
): boolean {
|
||||
return left.equals(right);
|
||||
}
|
||||
|
||||
export function resolveLocalDateTime(
|
||||
localInput: string,
|
||||
timeZoneInput: string,
|
||||
): LocalResolution {
|
||||
const local = Temporal.PlainDateTime.from(
|
||||
assertBoundedText(localInput, 128, "Local date-time").trim(),
|
||||
);
|
||||
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
|
||||
if (!timeZone) throw new SyntaxError("Time zone is required.");
|
||||
const earlier = local.toZonedDateTime(timeZone, {
|
||||
disambiguation: "earlier",
|
||||
});
|
||||
const later = local.toZonedDateTime(timeZone, { disambiguation: "later" });
|
||||
if (earlier.epochNanoseconds === later.epochNanoseconds) {
|
||||
return {
|
||||
status: "exact",
|
||||
local: local.toString(),
|
||||
timeZone,
|
||||
choices: [
|
||||
{
|
||||
label: "only",
|
||||
instant: earlier.toInstant().toString(),
|
||||
zoned: earlier.toString(),
|
||||
offset: earlier.offset,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const bothMatch =
|
||||
samePlain(earlier.toPlainDateTime(), local) &&
|
||||
samePlain(later.toPlainDateTime(), local);
|
||||
return {
|
||||
status: bothMatch ? "ambiguous" : "skipped",
|
||||
local: local.toString(),
|
||||
timeZone,
|
||||
choices: [
|
||||
{
|
||||
label: "earlier",
|
||||
instant: earlier.toInstant().toString(),
|
||||
zoned: earlier.toString(),
|
||||
offset: earlier.offset,
|
||||
},
|
||||
{
|
||||
label: "later",
|
||||
instant: later.toInstant().toString(),
|
||||
zoned: later.toString(),
|
||||
offset: later.offset,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function compareTimeZones(
|
||||
instantInput: string,
|
||||
zonesInput: string[],
|
||||
numericUnit: EpochUnit = "seconds",
|
||||
): ZoneSnapshot[] {
|
||||
if (zonesInput.length === 0 || zonesInput.length > 24)
|
||||
throw new RangeError("Choose between 1 and 24 time zones.");
|
||||
const instant = parseInstant(instantInput, numericUnit);
|
||||
return zonesInput.map((zoneInput) => {
|
||||
const timeZone = assertBoundedText(zoneInput, 128, "Time zone").trim();
|
||||
const zoned = instant.toZonedDateTimeISO(timeZone);
|
||||
return {
|
||||
timeZone: zoned.timeZoneId,
|
||||
local: zoned.toPlainDateTime().toString(),
|
||||
offset: zoned.offset,
|
||||
epochNanoseconds: zoned.epochNanoseconds.toString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function nextTransitions(
|
||||
instantInput: string,
|
||||
timeZoneInput: string,
|
||||
countInput = 6,
|
||||
): ZoneTransition[] {
|
||||
if (!Number.isSafeInteger(countInput) || countInput < 1 || countInput > 32)
|
||||
throw new RangeError("Transition count must be between 1 and 32.");
|
||||
const count = countInput;
|
||||
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
|
||||
let cursor = parseInstant(instantInput).toZonedDateTimeISO(timeZone);
|
||||
const result: ZoneTransition[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const transition = cursor.getTimeZoneTransition("next");
|
||||
if (!transition) break;
|
||||
const before = transition.subtract({ nanoseconds: 1 });
|
||||
result.push({
|
||||
instant: transition.toInstant().toString(),
|
||||
localAfter: transition.toPlainDateTime().toString(),
|
||||
offsetBefore: before.offset,
|
||||
offsetAfter: transition.offset,
|
||||
changeMinutes:
|
||||
(transition.offsetNanoseconds - before.offsetNanoseconds) /
|
||||
60_000_000_000,
|
||||
});
|
||||
cursor = transition.add({ nanoseconds: 1 });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user