impr(dev): add endpoint to create test user/data (fehmer) (#5396)

!nuf
This commit is contained in:
Christian Fehmer 2024-06-17 15:21:55 +02:00 committed by GitHub
parent 57a6fd9bd5
commit b4ea7f119f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 996 additions and 136 deletions

View file

@ -0,0 +1,388 @@
import { MonkeyResponse } from "../../utils/monkey-response";
import * as UserDal from "../../dal/user";
import FirebaseAdmin from "../../init/firebase-admin";
import Logger from "../../utils/logger";
import * as DateUtils from "date-fns";
import { UTCDate } from "@date-fns/utc";
import * as ResultDal from "../../dal/result";
import { roundTo2 } from "../../utils/misc";
import { ObjectId } from "mongodb";
import * as LeaderboardDal from "../../dal/leaderboards";
import { isNumber } from "lodash";
import MonkeyError from "../../utils/error";
type GenerateDataOptions = {
firstTestTimestamp: Date;
lastTestTimestamp: Date;
minTestsPerDay: number;
maxTestsPerDay: number;
};
const CREATE_RESULT_DEFAULT_OPTIONS: GenerateDataOptions = {
firstTestTimestamp: DateUtils.startOfDay(new UTCDate(Date.now())),
lastTestTimestamp: DateUtils.endOfDay(new UTCDate(Date.now())),
minTestsPerDay: 0,
maxTestsPerDay: 50,
};
export async function createTestData(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { username, createUser } = req.body;
const user = await getOrCreateUser(username, "password", createUser);
const { uid, email } = user;
await createTestResults(user, req.body);
await updateUser(uid);
await updateLeaderboard();
return new MonkeyResponse("test data created", { uid, email }, 200);
}
async function getOrCreateUser(
username: string,
password: string,
createUser = false
): Promise<MonkeyTypes.DBUser> {
const existingUser = await UserDal.findByName(username);
if (existingUser !== undefined && existingUser !== null) {
return existingUser;
} else if (createUser === false) {
throw new MonkeyError(404, `User ${username} does not exist.`);
}
const email = username + "@example.com";
Logger.success("create user " + username);
const { uid } = await FirebaseAdmin().auth().createUser({
displayName: username,
password: password,
email,
emailVerified: true,
});
await UserDal.addUser(username, email, uid);
return UserDal.getUser(uid, "getOrCreateUser");
}
async function createTestResults(
user: MonkeyTypes.DBUser,
configOptions: Partial<GenerateDataOptions>
): Promise<void> {
const config = {
...CREATE_RESULT_DEFAULT_OPTIONS,
...configOptions,
};
if (isNumber(config.firstTestTimestamp))
config.firstTestTimestamp = toDate(config.firstTestTimestamp);
if (isNumber(config.lastTestTimestamp))
config.lastTestTimestamp = toDate(config.lastTestTimestamp);
const days = DateUtils.eachDayOfInterval({
start: config.firstTestTimestamp,
end: config.lastTestTimestamp,
}).map((day) => ({
timestamp: DateUtils.startOfDay(day),
amount: Math.round(random(config.minTestsPerDay, config.maxTestsPerDay)),
}));
for (const day of days) {
Logger.success(
`User ${user.name} insert ${day.amount} results on ${new Date(
day.timestamp
)}`
);
const results = createArray(day.amount, () =>
createResult(user, day.timestamp)
);
if (results.length > 0)
await ResultDal.getResultCollection().insertMany(results);
}
}
function toDate(value: number): Date {
return new UTCDate(value);
}
function random(min: number, max: number): number {
return roundTo2(Math.random() * (max - min) + min);
}
function createResult(
user: MonkeyTypes.DBUser,
timestamp: Date //evil, we modify this value
): MonkeyTypes.DBResult {
const mode: SharedTypes.Config.Mode = randomValue(["time", "words"]);
const mode2: number =
mode === "time"
? randomValue([15, 30, 60, 120])
: randomValue([10, 25, 50, 100]);
const testDuration = mode2;
timestamp = DateUtils.addSeconds(timestamp, testDuration);
return {
_id: new ObjectId(),
uid: user.uid,
wpm: random(80, 120),
rawWpm: random(80, 120),
charStats: [131, 0, 0, 0],
acc: random(80, 100),
language: "english",
mode: mode as SharedTypes.Config.Mode,
mode2: mode2 as unknown as never,
timestamp: timestamp.valueOf(),
testDuration: testDuration,
consistency: random(80, 100),
keyConsistency: 33.18,
chartData: {
wpm: createArray(testDuration, () => random(80, 120)),
raw: createArray(testDuration, () => random(80, 120)),
err: createArray(testDuration, () => (Math.random() < 0.1 ? 1 : 0)),
},
keySpacingStats: {
average: 113.88,
sd: 77.3,
},
keyDurationStats: {
average: 107.13,
sd: 39.86,
},
isPb: Math.random() < 0.1,
name: user.name,
};
}
async function updateUser(uid: string): Promise<void> {
//update timetyping and completedTests
const stats = await ResultDal.getResultCollection()
.aggregate([
{
$match: {
uid,
},
},
{
$group: {
_id: {
language: "$language",
mode: "$mode",
mode2: "$mode2",
},
timeTyping: {
$sum: "$testDuration",
},
completedTests: {
$count: {},
},
},
},
])
.toArray();
const timeTyping = stats.reduce((a, c) => a + c["timeTyping"], 0);
const completedTests = stats.reduce((a, c) => a + c["completedTests"], 0);
//update PBs
const lbPersonalBests: MonkeyTypes.LbPersonalBests = {
time: {
15: {},
60: {},
},
};
const personalBests: SharedTypes.PersonalBests = {
time: {},
custom: {},
words: {},
zen: {},
quote: {},
};
const modes = stats.map((it) => it["_id"]);
for (const mode of modes) {
const best = (
await ResultDal.getResultCollection()
.find({
uid,
language: mode.language,
mode: mode.mode,
mode2: mode.mode2,
})
.sort({ wpm: -1, timestamp: 1 })
.limit(1)
.toArray()
)[0] as MonkeyTypes.DBResult;
if (personalBests[mode.mode] === undefined) personalBests[mode.mode] = {};
if (personalBests[mode.mode][mode.mode2] === undefined)
personalBests[mode.mode][mode.mode2] = [];
const entry = {
acc: best.acc,
consistency: best.consistency,
difficulty: best.difficulty ?? "normal",
lazyMode: best.lazyMode,
language: mode.language,
punctuation: best.punctuation,
raw: best.rawWpm,
wpm: best.wpm,
numbers: best.numbers,
timestamp: best.timestamp,
} as SharedTypes.PersonalBest;
personalBests[mode.mode][mode.mode2].push(entry);
if (mode.mode === "time") {
if (lbPersonalBests[mode.mode][mode.mode2] === undefined)
lbPersonalBests[mode.mode][mode.mode2] = {};
lbPersonalBests[mode.mode][mode.mode2][mode.language] = entry;
}
//update testActivity
await updateTestActicity(uid);
}
//update the user
await UserDal.getUsersCollection().updateOne(
{ uid },
{
$set: {
timeTyping: timeTyping,
completedTests: completedTests,
startedTests: Math.round(completedTests * 1.25),
personalBests: personalBests as SharedTypes.PersonalBests,
lbPersonalBests: lbPersonalBests,
},
}
);
}
async function updateLeaderboard(): Promise<void> {
await LeaderboardDal.update("time", "15", "english");
await LeaderboardDal.update("time", "60", "english");
}
function randomValue<T>(values: T[]): T {
const rnd = Math.round(Math.random() * (values.length - 1));
return values[rnd] as T;
}
function createArray<T>(size: number, builder: () => T): T[] {
return new Array(size).fill(0).map((it) => builder());
}
async function updateTestActicity(uid: string): Promise<void> {
await ResultDal.getResultCollection()
.aggregate(
[
{
$match: {
uid,
},
},
{
$project: {
_id: 0,
timestamp: -1,
uid: 1,
},
},
{
$addFields: {
date: {
$toDate: "$timestamp",
},
},
},
{
$replaceWith: {
uid: "$uid",
year: {
$year: "$date",
},
day: {
$dayOfYear: "$date",
},
},
},
{
$group: {
_id: {
uid: "$uid",
year: "$year",
day: "$day",
},
count: {
$sum: 1,
},
},
},
{
$group: {
_id: {
uid: "$_id.uid",
year: "$_id.year",
},
days: {
$addToSet: {
day: "$_id.day",
tests: "$count",
},
},
},
},
{
$replaceWith: {
uid: "$_id.uid",
days: {
$function: {
lang: "js",
args: ["$days", "$_id.year"],
body: `function (days, year) {
var max = Math.max(
...days.map((it) => it.day)
)-1;
var arr = new Array(max).fill(null);
for (day of days) {
arr[day.day-1] = day.tests;
}
let result = {};
result[year] = arr;
return result;
}`,
},
},
},
},
{
$group: {
_id: "$uid",
testActivity: {
$mergeObjects: "$days",
},
},
},
{
$addFields: {
uid: "$_id",
},
},
{
$project: {
_id: 0,
},
},
{
$merge: {
into: "users",
on: "uid",
whenMatched: "merge",
whenNotMatched: "discard",
},
},
],
{ allowDiskUse: true }
)
.toArray();
}

View file

@ -0,0 +1,38 @@
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import {
asyncHandler,
validateConfiguration,
validateRequest,
} from "../../middlewares/api-utils";
import joi from "joi";
import { createTestData } from "../controllers/dev";
import { isDevEnvironment } from "../../utils/misc";
const router = Router();
router.use(
validateConfiguration({
criteria: () => {
return isDevEnvironment();
},
invalidMessage: "Development endpoints are only available in DEV mode.",
})
);
router.post(
"/generateData",
validateRequest({
body: {
username: joi.string().required(),
createUser: joi.boolean().optional(),
firstTestTimestamp: joi.number().optional(),
lastTestTimestamp: joi.number().optional(),
minTestsPerDay: joi.number().optional(),
maxTestsPerDay: joi.number().optional(),
},
}),
asyncHandler(createTestData)
);
export default router;

View file

@ -10,6 +10,7 @@ import presets from "./presets";
import apeKeys from "./ape-keys";
import admin from "./admin";
import webhooks from "./webhooks";
import dev from "./dev";
import configuration from "./configuration";
import { version } from "../../version";
import leaderboards from "./leaderboards";
@ -67,6 +68,9 @@ function addApiRoutes(app: Application): void {
}
next();
});
//enable dev edpoints
app.use("/dev", dev);
}
// Cannot be added to the route map because it needs to be added before the maintenance handler

View file

@ -1,10 +1,17 @@
import _ from "lodash";
import { DeleteResult, ObjectId, UpdateResult } from "mongodb";
import { Collection, DeleteResult, ObjectId, UpdateResult } from "mongodb";
import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { getUser, getTags } from "./user";
type DBResult = MonkeyTypes.WithObjectId<
SharedTypes.DBResult<SharedTypes.Config.Mode>
>;
export const getResultCollection = (): Collection<DBResult> =>
db.collection<DBResult>("results");
export async function addResult(
uid: string,
result: MonkeyTypes.DBResult
@ -18,18 +25,14 @@ export async function addResult(
if (!user) throw new MonkeyError(404, "User not found", "add result");
if (result.uid === undefined) result.uid = uid;
// result.ir = true;
const res = await db
.collection<MonkeyTypes.DBResult>("results")
.insertOne(result);
const res = await getResultCollection().insertOne(result);
return {
insertedId: res.insertedId,
};
}
export async function deleteAll(uid: string): Promise<DeleteResult> {
return await db
.collection<MonkeyTypes.DBResult>("results")
.deleteMany({ uid });
return await getResultCollection().deleteMany({ uid });
}
export async function updateTags(
@ -37,9 +40,10 @@ export async function updateTags(
resultId: string,
tags: string[]
): Promise<UpdateResult> {
const result = await db
.collection<MonkeyTypes.DBResult>("results")
.findOne({ _id: new ObjectId(resultId), uid });
const result = await getResultCollection().findOne({
_id: new ObjectId(resultId),
uid,
});
if (!result) throw new MonkeyError(404, "Result not found");
const userTags = await getTags(uid);
const userTagIds = userTags.map((tag) => tag._id.toString());
@ -50,18 +54,20 @@ export async function updateTags(
if (!validTags) {
throw new MonkeyError(422, "One of the tag id's is not valid");
}
return await db
.collection<MonkeyTypes.DBResult>("results")
.updateOne({ _id: new ObjectId(resultId), uid }, { $set: { tags } });
return await getResultCollection().updateOne(
{ _id: new ObjectId(resultId), uid },
{ $set: { tags } }
);
}
export async function getResult(
uid: string,
id: string
): Promise<MonkeyTypes.DBResult> {
const result = await db
.collection<MonkeyTypes.DBResult>("results")
.findOne({ _id: new ObjectId(id), uid });
const result = await getResultCollection().findOne({
_id: new ObjectId(id),
uid,
});
if (!result) throw new MonkeyError(404, "Result not found");
return result;
}
@ -69,8 +75,7 @@ export async function getResult(
export async function getLastResult(
uid: string
): Promise<Omit<MonkeyTypes.DBResult, "uid">> {
const [lastResult] = await db
.collection<MonkeyTypes.DBResult>("results")
const [lastResult] = await getResultCollection()
.find({ uid })
.sort({ timestamp: -1 })
.limit(1)
@ -83,9 +88,7 @@ export async function getResultByTimestamp(
uid: string,
timestamp
): Promise<MonkeyTypes.DBResult | null> {
return await db
.collection<MonkeyTypes.DBResult>("results")
.findOne({ uid, timestamp });
return await getResultCollection().findOne({ uid, timestamp });
}
type GetResultsOpts = {
@ -99,8 +102,7 @@ export async function getResults(
opts?: GetResultsOpts
): Promise<MonkeyTypes.DBResult[]> {
const { onOrAfterTimestamp, offset, limit } = opts ?? {};
let query = db
.collection<MonkeyTypes.DBResult>("results")
let query = getResultCollection()
.find({
uid,
...(!_.isNil(onOrAfterTimestamp) &&

View file

@ -202,7 +202,7 @@ export async function getUser(
return user;
}
async function findByName(
export async function findByName(
name: string
): Promise<MonkeyTypes.DBUser | undefined> {
return (

View file

@ -0,0 +1,51 @@
import { buildTag } from "../../src/ts/utils/tag-builder";
describe("simple-modals", () => {
describe("buildTag", () => {
it("builds with mandatory", () => {
expect(buildTag({ tagname: "input" })).toBe("<input />");
});
it("builds with classes", () => {
expect(buildTag({ tagname: "input", classes: ["hidden", "bold"] })).toBe(
'<input class="hidden bold" />'
);
});
it("builds with attributes", () => {
expect(
buildTag({
tagname: "input",
attributes: {
id: "4711",
oninput: "console.log()",
required: true,
checked: true,
missing: undefined,
},
})
).toBe('<input checked id="4711" oninput="console.log()" required />');
});
it("builds with innerHtml", () => {
expect(
buildTag({ tagname: "textarea", innerHTML: "<h1>Hello</h1>" })
).toBe("<textarea><h1>Hello</h1></textarea>");
});
it("builds with everything", () => {
expect(
buildTag({
tagname: "textarea",
classes: ["hidden", "bold"],
attributes: {
id: "4711",
oninput: "console.log()",
readonly: true,
required: true,
},
innerHTML: "<h1>Hello</h1>",
})
).toBe(
'<textarea class="hidden bold" id="4711" oninput="console.log()" readonly required><h1>Hello</h1></textarea>'
);
});
});
});

View file

@ -4,6 +4,13 @@
</div>
</dialog>
<dialog id="devOptionsModal" class="modalWrapper hidden">
<div class="modal">
<div class="title">Dev options</div>
<button class="generateData">generate data</button>
</div>
</dialog>
<dialog id="alertsPopup" class="modalWrapper hidden">
<div class="modal">
<button class="mobileClose">

View file

@ -334,17 +334,22 @@ key {
}
}
.configureAPI.button {
#devButtons {
position: fixed;
left: 0;
top: 10rem;
display: grid;
grid-auto-flow: column;
grid-auto-flow: row;
gap: 0.5rem;
text-decoration: none;
z-index: 999999999;
border-radius: 0 1rem 1rem 0;
padding: 1rem;
.button {
padding: 1rem;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
.avatar {

View file

@ -273,4 +273,8 @@
aspect-ratio: 1;
}
}
.popupWrapper .modal .inputs.withLabel,
.modalWrapper .modal .inputs.withLabel {
grid-template-columns: 1fr;
}
}

View file

@ -41,6 +41,37 @@
font-size: 1.5rem;
color: var(--sub-color);
}
.inputs.withLabel {
display: grid;
grid-template-columns: max-content auto;
grid-auto-flow: row;
}
.inputs {
div:has(> input[type="range"]) {
display: grid;
grid-auto-columns: auto 3rem;
grid-auto-flow: column;
gap: 0.5rem;
span {
text-align: right;
}
}
}
}
}
body.darkMode {
.popupWrapper,
.modalWrapper {
.modal .inputs {
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
filter: invert(1);
}
}
}
}
@ -486,6 +517,9 @@
opacity: 1;
}
}
& [data-popup-id="devGenerateData"] {
max-width: 700px;
}
}
#mobileTestConfigModal {
@ -594,6 +628,12 @@
}
}
#devOptionsModal {
.modal {
max-width: 400px;
}
}
#shareTestSettingsModal {
.modal {
max-width: 600px;

View file

@ -0,0 +1,15 @@
const BASE_PATH = "/dev";
export default class Dev {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async generateData(
params: Ape.Dev.GenerateData
): Ape.EndpointResponse<Ape.Dev.GenerateDataResponse> {
return await this.httpClient.post(BASE_PATH + "/generateData", {
payload: params,
});
}
}

View file

@ -8,6 +8,7 @@ import Users from "./users";
import ApeKeys from "./ape-keys";
import Public from "./public";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Configs,
@ -20,4 +21,5 @@ export default {
Users,
ApeKeys,
Configuration,
Dev,
};

View file

@ -20,6 +20,7 @@ const Ape = {
publicStats: new endpoints.Public(httpClient),
apeKeys: new endpoints.ApeKeys(httpClient),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
};
export default Ape;

14
frontend/src/ts/ape/types/dev.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
declare namespace Ape.Dev {
type GenerateData = {
username: string;
createUser?: boolean;
firstTestTimestamp?: number;
lastTestTimestamp?: number;
minTestsPerDay?: number;
maxTestsPerDay?: number;
};
type GenerateDataResponse = {
uid: string;
email: string;
};
}

View file

@ -181,6 +181,12 @@ async function apply(
$("#metaThemeColor").attr("content", colors.bg);
// }
updateFooterThemeName(isPreview ? themeName : undefined);
if (isColorDark(await ThemeColors.get("bg"))) {
$("body").addClass("darkMode");
} else {
$("body").removeClass("darkMode");
}
}
function updateFooterThemeName(nameOverride?: string): void {

View file

@ -42,6 +42,7 @@ import "./controllers/profile-search-controller";
import { isDevEnvironment } from "./utils/misc";
import * as VersionButton from "./elements/version-button";
import * as Focus from "./test/focus";
import { getDevOptionsModal } from "./utils/async-modules";
function addToGlobal(items: Record<string, unknown>): void {
for (const [name, item] of Object.entries(items)) {
@ -72,4 +73,7 @@ if (isDevEnvironment()) {
void import("jquery").then((jq) => {
addToGlobal({ $: jq.default });
});
void getDevOptionsModal().then((module) => {
module.appendButton();
});
}

View file

@ -0,0 +1,34 @@
import { envConfig } from "../constants/env-config";
import AnimatedModal from "../utils/animated-modal";
import { showPopup } from "./simple-modals";
export function show(): void {
void modal.show();
}
async function setup(modalEl: HTMLElement): Promise<void> {
modalEl.querySelector(".generateData")?.addEventListener("click", () => {
showPopup("devGenerateData");
});
}
const modal = new AnimatedModal({
dialogId: "devOptionsModal",
setup,
});
export function appendButton(): void {
$("body").prepend(
`
<div id="devButtons">
<a class='button configureAPI' href='${envConfig.backendUrl}/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a>
<button class='button showDevOptionsModal' aria-label="Dev options" data-balloon-pos="right"><i class="fas fa-fw fa-flask"></i></button>
<div>
`
);
document
.querySelector("#devButtons .button.showDevOptionsModal")
?.addEventListener("click", () => {
show();
});
}

View file

@ -31,16 +31,62 @@ import AnimatedModal, {
HideOptions,
ShowOptions,
} from "../utils/animated-modal";
import { format as dateFormat } from "date-fns/format";
import { Attributes, buildTag } from "../utils/tag-builder";
type Input = {
type CommonInput<TType, TValue> = {
type: TType;
initVal?: TValue;
placeholder?: string;
type?: string;
initVal: string;
hidden?: boolean;
disabled?: boolean;
optional?: boolean;
label?: string;
oninput?: (event: Event) => void;
};
type TextInput = CommonInput<"text", string>;
type TextArea = CommonInput<"textarea", string>;
type PasswordInput = CommonInput<"password", string>;
type EmailInput = CommonInput<"email", string>;
type RangeInput = {
min: number;
max: number;
step?: number;
} & CommonInput<"range", number>;
type DateTimeInput = {
min?: Date;
max?: Date;
} & CommonInput<"datetime-local", Date>;
type DateInput = {
min?: Date;
max?: Date;
} & CommonInput<"date", Date>;
type CheckboxInput = {
label: string;
placeholder?: never;
description?: string;
} & CommonInput<"checkbox", boolean>;
type NumberInput = {
min?: number;
max?: number;
} & CommonInput<"number", number>;
type CommonInputType =
| TextInput
| TextArea
| PasswordInput
| EmailInput
| RangeInput
| DateTimeInput
| DateInput
| CheckboxInput
| NumberInput;
let activePopup: SimpleModal | null = null;
type ExecReturn = {
@ -78,7 +124,8 @@ type PopupKey =
| "resetProgressCustomTextLong"
| "updateCustomTheme"
| "deleteCustomTheme"
| "forgotPassword";
| "forgotPassword"
| "devGenerateData";
const list: Record<PopupKey, SimpleModal | undefined> = {
updateEmail: undefined,
@ -106,13 +153,13 @@ const list: Record<PopupKey, SimpleModal | undefined> = {
updateCustomTheme: undefined,
deleteCustomTheme: undefined,
forgotPassword: undefined,
devGenerateData: undefined,
};
type SimpleModalOptions = {
id: string;
type: string;
title: string;
inputs?: Input[];
inputs?: CommonInputType[];
text?: string;
buttonText: string;
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
@ -121,6 +168,7 @@ type SimpleModalOptions = {
canClose?: boolean;
onlineOnly?: boolean;
hideCallsExec?: boolean;
showLabels?: boolean;
};
const modal = new AnimatedModal({
@ -144,9 +192,8 @@ class SimpleModal {
wrapper: HTMLElement;
element: HTMLElement;
id: string;
type: string;
title: string;
inputs: Input[];
inputs: CommonInputType[];
text?: string;
buttonText: string;
execFn: (thisPopup: SimpleModal, ...params: string[]) => Promise<ExecReturn>;
@ -155,10 +202,10 @@ class SimpleModal {
canClose: boolean;
onlineOnly: boolean;
hideCallsExec: boolean;
showLabels: boolean;
constructor(options: SimpleModalOptions) {
this.parameters = [];
this.id = options.id;
this.type = options.type;
this.execFn = options.execFn;
this.title = options.title;
this.inputs = options.inputs ?? [];
@ -171,6 +218,7 @@ class SimpleModal {
this.canClose = options.canClose ?? true;
this.onlineOnly = options.onlineOnly ?? false;
this.hideCallsExec = options.hideCallsExec ?? false;
this.showLabels = options.showLabels ?? false;
}
reset(): void {
this.element.innerHTML = `
@ -214,68 +262,138 @@ class SimpleModal {
return;
}
if (this.type === "number") {
this.inputs.forEach((input) => {
el.find(".inputs").append(`
<input
type="number"
min="1"
value="${input.initVal}"
placeholder="${input.placeholder}"
class="${input.hidden ? "hidden" : ""}"
${input.hidden ? "" : "required"}
autocomplete="off"
>
`);
});
} else if (this.type === "text") {
this.inputs.forEach((input) => {
if (input.type !== undefined && input.type !== "") {
if (input.type === "textarea") {
el.find(".inputs").append(`
<textarea
placeholder="${input.placeholder}"
class="${input.hidden ? "hidden" : ""}"
${input.hidden ? "" : "required"}
${input.disabled ? "disabled" : ""}
autocomplete="off"
>${input.initVal}</textarea>
`);
} else if (input.type === "checkbox") {
el.find(".inputs").append(`
<label class="checkbox">
<input type="checkbox" checked="">
<div>${input.label}</div>
</label>
`);
} else {
el.find(".inputs").append(`
<input
type="${input.type}"
value="${input.initVal}"
placeholder="${input.placeholder}"
class="${input.hidden ? "hidden" : ""}"
${input.hidden ? "" : "required"}
${input.disabled ? "disabled" : ""}
autocomplete="off"
>
`);
}
} else {
el.find(".inputs").append(`
<input
type="text"
value="${input.initVal}"
placeholder="${input.placeholder}"
class="${input.hidden ? "hidden" : ""}"
${input.hidden ? "" : "required"}
${input.disabled ? "disabled" : ""}
autocomplete="off"
>
`);
const inputs = el.find(".inputs");
if (this.showLabels) inputs.addClass("withLabel");
this.inputs.forEach((input, index) => {
const id = `${this.id}_${index}`;
if (this.showLabels && !input.hidden) {
inputs.append(`<label for="${id}">${input.label ?? ""}</label>`);
}
const tagname = input.type === "textarea" ? "textarea" : "input";
const classes = input.hidden ? ["hidden"] : undefined;
const attributes: Attributes = {
id: id,
placeholder: input.placeholder ?? "",
autocomplete: "off",
};
if (input.type !== "textarea") {
attributes["value"] = input.initVal?.toString() ?? "";
attributes["type"] = input.type;
}
if (!input.hidden && !input.optional === true) {
attributes["required"] = true;
}
if (input.disabled) {
attributes["disabled"] = true;
}
if (input.type === "textarea") {
inputs.append(
buildTag({
tagname,
classes,
attributes,
innerHTML: input.initVal,
})
);
} else if (input.type === "checkbox") {
let html = `
<input
id="${id}"
type="checkbox"
class="${input.hidden ? "hidden" : ""}"
${input.initVal ? 'checked="checked"' : ""}>
`;
if (input.description !== undefined) {
html += `<span>${input.description}</span>`;
}
});
}
if (!this.showLabels) {
html = `
<label class="checkbox">
${html}
<div>${input.label}</div>
</label>
`;
} else {
html = `<div>${html}</div>`;
}
inputs.append(html);
} else if (input.type === "range") {
inputs.append(`
<div>
${buildTag({
tagname,
classes,
attributes: {
...attributes,
min: input.min.toString(),
max: input.max.toString(),
step: input.step?.toString(),
oninput: "this.nextElementSibling.innerHTML = this.value",
},
})}
<span>${input.initVal ?? ""}</span>
</div>
`);
} else {
switch (input.type) {
case "text":
case "password":
case "email":
break;
case "datetime-local": {
if (input.min !== undefined) {
attributes["min"] = dateFormat(
input.min,
"yyyy-MM-dd'T'HH:mm:ss"
);
}
if (input.max !== undefined) {
attributes["max"] = dateFormat(
input.max,
"yyyy-MM-dd'T'HH:mm:ss"
);
}
if (input.initVal !== undefined) {
attributes["value"] = dateFormat(
input.initVal,
"yyyy-MM-dd'T'HH:mm:ss"
);
}
break;
}
case "date": {
if (input.min !== undefined) {
attributes["min"] = dateFormat(input.min, "yyyy-MM-dd");
}
if (input.max !== undefined) {
attributes["max"] = dateFormat(input.max, "yyyy-MM-dd");
}
if (input.initVal !== undefined) {
attributes["value"] = dateFormat(input.initVal, "yyyy-MM-dd");
}
break;
}
case "number": {
attributes["min"] = input.min?.toString();
attributes["max"] = input.max?.toString();
break;
}
}
inputs.append(buildTag({ tagname, classes, attributes }));
}
if (input.oninput !== undefined) {
(
document.querySelector("#" + attributes["id"]) as HTMLElement
).oninput = input.oninput;
}
});
el.find(".inputs").removeClass("hidden");
}
@ -290,14 +408,21 @@ class SimpleModal {
}
}
const inputsWithCurrentValue = [];
type CommonInputWithCurrentValue = CommonInputType & {
currentValue: string | undefined;
};
const inputsWithCurrentValue: CommonInputWithCurrentValue[] = [];
for (let i = 0; i < this.inputs.length; i++) {
inputsWithCurrentValue.push({ ...this.inputs[i], currentValue: vals[i] });
inputsWithCurrentValue.push({
...(this.inputs[i] as CommonInputType),
currentValue: vals[i],
});
}
if (
inputsWithCurrentValue
.filter((i) => !i.hidden)
.filter((i) => i.hidden !== true && i.optional !== true)
.some((v) => v.currentValue === undefined || v.currentValue === "")
) {
Notifications.add("Please fill in all fields", 0);
@ -494,7 +619,6 @@ async function reauthenticate(
list.updateEmail = new SimpleModal({
id: "updateEmail",
type: "text",
title: "Update email",
inputs: [
{
@ -503,10 +627,12 @@ list.updateEmail = new SimpleModal({
initVal: "",
},
{
type: "text",
placeholder: "New email",
initVal: "",
},
{
type: "text",
placeholder: "Confirm new email",
initVal: "",
},
@ -565,7 +691,6 @@ list.updateEmail = new SimpleModal({
list.removeGoogleAuth = new SimpleModal({
id: "removeGoogleAuth",
type: "text",
title: "Remove Google authentication",
inputs: [
{
@ -620,7 +745,6 @@ list.removeGoogleAuth = new SimpleModal({
list.removeGithubAuth = new SimpleModal({
id: "removeGithubAuth",
type: "text",
title: "Remove GitHub authentication",
inputs: [
{
@ -675,7 +799,6 @@ list.removeGithubAuth = new SimpleModal({
list.updateName = new SimpleModal({
id: "updateName",
type: "text",
title: "Update name",
inputs: [
{
@ -741,7 +864,7 @@ list.updateName = new SimpleModal({
const snapshot = DB.getSnapshot();
if (!snapshot) return;
if (!isUsingPasswordAuthentication()) {
(thisPopup.inputs[0] as Input).hidden = true;
(thisPopup.inputs[0] as PasswordInput).hidden = true;
thisPopup.buttonText = "reauthenticate to update";
}
if (snapshot.needsToChangeName === true) {
@ -753,7 +876,6 @@ list.updateName = new SimpleModal({
list.updatePassword = new SimpleModal({
id: "updatePassword",
type: "text",
title: "Update password",
inputs: [
{
@ -838,7 +960,6 @@ list.updatePassword = new SimpleModal({
list.addPasswordAuth = new SimpleModal({
id: "addPasswordAuth",
type: "text",
title: "Add password authentication",
inputs: [
{
@ -930,7 +1051,6 @@ list.addPasswordAuth = new SimpleModal({
list.deleteAccount = new SimpleModal({
id: "deleteAccount",
type: "text",
title: "Delete account",
inputs: [
{
@ -979,7 +1099,6 @@ list.deleteAccount = new SimpleModal({
list.resetAccount = new SimpleModal({
id: "resetAccount",
type: "text",
title: "Reset account",
inputs: [
{
@ -1030,7 +1149,6 @@ list.resetAccount = new SimpleModal({
list.optOutOfLeaderboards = new SimpleModal({
id: "optOutOfLeaderboards",
type: "text",
title: "Opt out of leaderboards",
inputs: [
{
@ -1077,7 +1195,6 @@ list.optOutOfLeaderboards = new SimpleModal({
list.clearTagPb = new SimpleModal({
id: "clearTagPb",
type: "text",
title: "Clear tag PB",
text: "Are you sure you want to clear this tags PB?",
buttonText: "clear",
@ -1121,9 +1238,8 @@ list.clearTagPb = new SimpleModal({
list.applyCustomFont = new SimpleModal({
id: "applyCustomFont",
type: "text",
title: "Custom font",
inputs: [{ placeholder: "Font name", initVal: "" }],
inputs: [{ type: "text", placeholder: "Font name", initVal: "" }],
text: "Make sure you have the font installed on your computer before applying",
buttonText: "apply",
execFn: async (_thisPopup, fontName): Promise<ExecReturn> => {
@ -1138,7 +1254,6 @@ list.applyCustomFont = new SimpleModal({
list.resetPersonalBests = new SimpleModal({
id: "resetPersonalBests",
type: "text",
title: "Reset personal bests",
inputs: [
{
@ -1198,7 +1313,6 @@ list.resetPersonalBests = new SimpleModal({
list.resetSettings = new SimpleModal({
id: "resetSettings",
type: "text",
title: "Reset settings",
text: "Are you sure you want to reset all your settings?",
buttonText: "reset",
@ -1214,7 +1328,6 @@ list.resetSettings = new SimpleModal({
list.revokeAllTokens = new SimpleModal({
id: "revokeAllTokens",
type: "text",
title: "Revoke all tokens",
inputs: [
{
@ -1255,7 +1368,7 @@ list.revokeAllTokens = new SimpleModal({
const snapshot = DB.getSnapshot();
if (!snapshot) return;
if (!isUsingPasswordAuthentication()) {
(thisPopup.inputs[0] as Input).hidden = true;
(thisPopup.inputs[0] as PasswordInput).hidden = true;
thisPopup.buttonText = "reauthenticate to revoke all tokens";
}
},
@ -1263,7 +1376,6 @@ list.revokeAllTokens = new SimpleModal({
list.unlinkDiscord = new SimpleModal({
id: "unlinkDiscord",
type: "text",
title: "Unlink Discord",
text: "Are you sure you want to unlink your Discord account?",
buttonText: "unlink",
@ -1300,10 +1412,10 @@ list.unlinkDiscord = new SimpleModal({
list.generateApeKey = new SimpleModal({
id: "generateApeKey",
type: "text",
title: "Generate new Ape key",
inputs: [
{
type: "text",
placeholder: "Name",
initVal: "",
},
@ -1342,7 +1454,6 @@ list.generateApeKey = new SimpleModal({
list.viewApeKey = new SimpleModal({
id: "viewApeKey",
type: "text",
title: "Ape key",
inputs: [
{
@ -1366,7 +1477,7 @@ list.viewApeKey = new SimpleModal({
};
},
beforeInitFn: (_thisPopup): void => {
(_thisPopup.inputs[0] as Input).initVal = _thisPopup
(_thisPopup.inputs[0] as TextArea).initVal = _thisPopup
.parameters[0] as string;
},
beforeShowFn: (_thisPopup): void => {
@ -1382,7 +1493,6 @@ list.viewApeKey = new SimpleModal({
list.deleteApeKey = new SimpleModal({
id: "deleteApeKey",
type: "text",
title: "Delete Ape key",
text: "Are you sure?",
buttonText: "delete",
@ -1408,10 +1518,10 @@ list.deleteApeKey = new SimpleModal({
list.editApeKey = new SimpleModal({
id: "editApeKey",
type: "text",
title: "Edit Ape key",
inputs: [
{
type: "text",
placeholder: "name",
initVal: "",
},
@ -1440,7 +1550,6 @@ list.editApeKey = new SimpleModal({
list.deleteCustomText = new SimpleModal({
id: "deleteCustomText",
type: "text",
title: "Delete custom text",
text: "Are you sure?",
buttonText: "delete",
@ -1460,7 +1569,6 @@ list.deleteCustomText = new SimpleModal({
list.deleteCustomTextLong = new SimpleModal({
id: "deleteCustomTextLong",
type: "text",
title: "Delete custom text",
text: "Are you sure?",
buttonText: "delete",
@ -1480,7 +1588,6 @@ list.deleteCustomTextLong = new SimpleModal({
list.resetProgressCustomTextLong = new SimpleModal({
id: "resetProgressCustomTextLong",
type: "text",
title: "Reset progress for custom text",
text: "Are you sure?",
buttonText: "reset",
@ -1503,7 +1610,6 @@ list.resetProgressCustomTextLong = new SimpleModal({
list.updateCustomTheme = new SimpleModal({
id: "updateCustomTheme",
type: "text",
title: "Update custom theme",
inputs: [
{
@ -1513,7 +1619,7 @@ list.updateCustomTheme = new SimpleModal({
},
{
type: "checkbox",
initVal: "false",
initVal: false,
label: "Update custom theme to current colors",
},
],
@ -1578,13 +1684,12 @@ list.updateCustomTheme = new SimpleModal({
(t) => t._id === _thisPopup.parameters[0]
);
if (!customTheme) return;
(_thisPopup.inputs[0] as Input).initVal = customTheme.name;
(_thisPopup.inputs[0] as TextInput).initVal = customTheme.name;
},
});
list.deleteCustomTheme = new SimpleModal({
id: "deleteCustomTheme",
type: "text",
title: "Delete custom theme",
text: "Are you sure?",
buttonText: "delete",
@ -1602,7 +1707,6 @@ list.deleteCustomTheme = new SimpleModal({
list.forgotPassword = new SimpleModal({
id: "forgotPassword",
type: "text",
title: "Forgot password",
inputs: [
{
@ -1634,11 +1738,97 @@ list.forgotPassword = new SimpleModal({
`.pageLogin .login input[name="current-email"]`
).val() as string;
if (inputValue) {
(thisPopup.inputs[0] as Input).initVal = inputValue;
(thisPopup.inputs[0] as TextInput).initVal = inputValue;
}
},
});
list.devGenerateData = new SimpleModal({
id: "devGenerateData",
title: "Generate data",
showLabels: true,
inputs: [
{
type: "text",
label: "username",
placeholder: "username",
oninput: (event): void => {
const target = event.target as HTMLInputElement;
const span = document.querySelector(
"#devGenerateData_1 + span"
) as HTMLInputElement;
span.innerHTML = `if checked, user will be created with ${target.value}@example.com and password: password`;
return;
},
},
{
type: "checkbox",
label: "create user",
description:
"if checked, user will be created with {username}@example.com and password: password",
},
{
type: "date",
label: "first test",
optional: true,
},
{
type: "date",
label: "last test",
max: new Date(),
optional: true,
},
{
type: "range",
label: "min tests per day",
initVal: 0,
min: 0,
max: 200,
step: 10,
},
{
type: "range",
label: "max tests per day",
initVal: 50,
min: 0,
max: 200,
step: 10,
},
],
buttonText: "generate (might take a while)",
execFn: async (
_thisPopup,
username,
createUser,
firstTestTimestamp,
lastTestTimestamp,
minTestsPerDay,
maxTestsPerDay
): Promise<ExecReturn> => {
const request: Ape.Dev.GenerateData = {
username,
createUser: createUser === "true",
};
if (firstTestTimestamp !== undefined && firstTestTimestamp.length > 0)
request.firstTestTimestamp = Date.parse(firstTestTimestamp);
if (lastTestTimestamp !== undefined && lastTestTimestamp.length > 0)
request.lastTestTimestamp = Date.parse(lastTestTimestamp);
if (minTestsPerDay !== undefined && minTestsPerDay.length > 0)
request.minTestsPerDay = Number.parseInt(minTestsPerDay);
if (maxTestsPerDay !== undefined && maxTestsPerDay.length > 0)
request.maxTestsPerDay = Number.parseInt(maxTestsPerDay);
const result = await Ape.dev.generateData(request);
return {
status: result.status === 200 ? 1 : -1,
message: result.message,
hideOptions: {
clearModalChain: true,
},
};
},
});
export function showPopup(
key: PopupKey,
showParams = [] as string[],

View file

@ -8,7 +8,6 @@ import * as ConnectionState from "./states/connection";
import * as FunboxList from "./test/funbox/funbox-list";
//@ts-expect-error
import Konami from "konami";
import { envConfig } from "./constants/env-config";
import * as ServerConfiguration from "./ape/server-configuration";
$((): void => {
@ -69,8 +68,5 @@ $((): void => {
void registration.unregister();
}
});
$("body").prepend(
`<a class='button configureAPI' href='${envConfig.backendUrl}/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a>`
);
}
});

View file

@ -30,3 +30,31 @@ export async function getCommandline(): Promise<
throw e;
}
}
Skeleton.save("devOptionsModal");
export async function getDevOptionsModal(): Promise<
typeof import("../modals/dev-options.js")
> {
try {
Loader.show();
const module = await import("../modals/dev-options.js");
Loader.hide();
return module;
} catch (e) {
Loader.hide();
if (
e instanceof Error &&
e.message.includes("Failed to fetch dynamically imported module")
) {
Notifications.add(
"Failed to load dev options module: could not fetch",
-1
);
} else {
const msg = createErrorMessage(e, "Failed to load dev options module");
Notifications.add(msg, -1);
}
throw e;
}
}

View file

@ -0,0 +1,31 @@
export type Attributes = Record<string, string | true | undefined>;
type TagOptions = {
tagname: string;
classes?: string[];
attributes?: Attributes;
innerHTML?: string;
};
export function buildTag({
tagname,
classes,
attributes,
innerHTML,
}: TagOptions): string {
let html = `<${tagname}`;
if (classes !== undefined) html += ` class="${classes.join(" ")}"`;
if (attributes !== undefined) {
html +=
" " +
Object.entries(attributes)
.filter((it) => it[1] !== undefined)
.sort((a, b) => a[0].localeCompare(b[0]))
.map((it) => (it[1] === true ? `${it[0]}` : `${it[0]}="${it[1]}"`))
.join(" ");
}
if (innerHTML !== undefined) html += `>${innerHTML}</${tagname}>`;
else html += " />";
return html;
}