Merge branch 'master' into newnav

This commit is contained in:
Miodec 2022-08-30 15:31:04 +02:00
commit 24f1c7c609
28 changed files with 672 additions and 95 deletions

View file

@ -489,4 +489,32 @@ describe("UserDal", () => {
expect(resetUser.bananas).toStrictEqual(0);
expect(resetUser.xp).toStrictEqual(0);
});
it("getInbox should return the user's inbox", async () => {
await UserDAL.addUser("test name", "test email", "TestID");
const emptyInbox = await UserDAL.getInbox("TestID");
expect(emptyInbox).toStrictEqual([]);
await UserDAL.addToInbox(
"TestID",
[
{
getTemplate: (user) => ({
subject: `Hello ${user.name}!`,
}),
} as any,
],
0
);
const inbox = await UserDAL.getInbox("TestID");
expect(inbox).toStrictEqual([
{
subject: "Hello test name!",
},
]);
});
});

View file

@ -21,6 +21,7 @@ const dailyLeaderboardsConfig = {
],
dailyLeaderboardCacheSize: 3,
topResultsToAnnounce: 3,
xpReward: 0,
};
describe("Daily Leaderboards", () => {

View file

@ -0,0 +1,22 @@
import { buildMonkeyMail } from "../../src/utils/monkey-mail";
describe("Monkey Mail", () => {
it("should properly create a mail object", () => {
const mailConfig = {
subject: "",
body: "",
timestamp: Date.now(),
getTemplate: (): any => ({}),
};
const mail = buildMonkeyMail(mailConfig) as any;
expect(mail.id).toBeDefined();
expect(mail.subject).toBe("");
expect(mail.body).toBe("");
expect(mail.timestamp).toBeDefined();
expect(mail.read).toBe(false);
expect(mail.rewards).toEqual([]);
expect(mail.getTemplate).toBeDefined();
});
});

View file

@ -117,6 +117,19 @@ export async function updateEmail(
return new MonkeyResponse("Email updated");
}
function getRelevantUserInfo(
user: MonkeyTypes.User
): Partial<MonkeyTypes.User> {
return _.omit(user, [
"bananas",
"lbPersonalBests",
"quoteMod",
"inbox",
"nameHistory",
"lastNameChange",
]);
}
export async function getUser(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
@ -142,7 +155,12 @@ export async function getUser(
const agentLog = buildAgentLog(req);
Logger.logToDb("user_data_requested", agentLog, uid);
return new MonkeyResponse("User data retrieved", userInfo);
const userData = {
...getRelevantUserInfo(userInfo),
inboxUnreadSize: _.filter(userInfo.inbox, { read: false }).length,
};
return new MonkeyResponse("User data retrieved", userData);
}
export async function linkDiscord(
@ -487,3 +505,24 @@ export async function updateProfile(
return new MonkeyResponse("Profile updated");
}
export async function getInbox(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const inbox = await UserDAL.getInbox(uid);
return new MonkeyResponse("Inbox retrieved", inbox);
}
export async function updateInbox(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { mailIdsToMarkRead, mailIdsToDelete } = req.body;
await UserDAL.updateInbox(uid, mailIdsToMarkRead, mailIdsToDelete);
return new MonkeyResponse("Inbox updated");
}

View file

@ -470,4 +470,35 @@ router.patch(
asyncHandler(UserController.updateProfile)
);
const mailIdSchema = joi.array().items(joi.string().guid()).min(1).default([]);
const requireInboxEnabled = validateConfiguration({
criteria: (configuration) => {
return configuration.users.inbox.enabled;
},
invalidMessage: "Your inbox is not available at this time.",
});
router.get(
"/inbox",
requireInboxEnabled,
authenticateRequest(),
RateLimit.userMailGet,
asyncHandler(UserController.getInbox)
);
router.patch(
"/inbox",
requireInboxEnabled,
authenticateRequest(),
RateLimit.userMailUpdate,
validateRequest({
body: {
mailIdsToDelete: mailIdSchema,
mailIdsToMarkRead: mailIdSchema,
},
}),
asyncHandler(UserController.updateInbox)
);
export default router;

View file

@ -47,6 +47,10 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
maxDailyBonus: 0,
minDailyBonus: 0,
},
inbox: {
enabled: false,
maxMail: 0,
},
},
rateLimiting: {
badAuthentication: {
@ -63,6 +67,7 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
// GOTCHA! MUST ATLEAST BE 1, LRUCache module will make process crash and die
dailyLeaderboardCacheSize: 1,
topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results.
xpReward: 0,
},
};
@ -255,6 +260,21 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = {
},
},
},
inbox: {
type: "object",
label: "Inbox",
fields: {
enabled: {
type: "boolean",
label: "Enabled",
},
maxMail: {
type: "number",
label: "Max Messages",
min: 0,
},
},
},
profiles: {
type: "object",
label: "User Profiles",
@ -347,6 +367,11 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = {
label: "Top Results To Announce",
min: 1,
},
xpReward: {
type: "number",
label: "XP Reward",
min: 0,
},
},
},
},

View file

@ -4,9 +4,10 @@ import { updateUserEmail } from "../utils/auth";
import { checkAndUpdatePb } from "../utils/pb";
import * as db from "../init/db";
import MonkeyError from "../utils/error";
import { Collection, ObjectId, WithId, Long } from "mongodb";
import { Collection, ObjectId, WithId, Long, UpdateFilter } from "mongodb";
import Logger from "../utils/logger";
import { flattenObjectDeep } from "../utils/misc";
import { MonkeyMailWithTemplate } from "../utils/monkey-mail";
const SECONDS_PER_HOUR = 3600;
@ -732,3 +733,110 @@ export async function updateProfile(
}
);
}
export async function getInbox(
uid: string
): Promise<MonkeyTypes.User["inbox"]> {
const user = await getUser(uid, "get inventory");
return user.inbox ?? [];
}
export async function addToInbox(
uid: string,
mail: MonkeyMailWithTemplate[],
maxInboxSize: number
): Promise<void> {
const user = await getUser(uid, "add to inbox");
const inbox = user.inbox ?? [];
for (let i = 0; i < inbox.length + mail.length - maxInboxSize; i++) {
inbox.pop();
}
const evaluatedMail: MonkeyTypes.MonkeyMail[] = mail.map((mail) => {
return _.omit(
{
...mail,
...(mail.getTemplate && mail.getTemplate(user)),
},
"getTemplate"
);
});
inbox.unshift(...evaluatedMail);
const newInbox = inbox.sort((a, b) => b.timestamp - a.timestamp);
await getUsersCollection().updateOne(
{ uid },
{
$set: {
inbox: newInbox,
},
}
);
}
function buildRewardUpdates(
rewards: MonkeyTypes.AllRewards[]
): UpdateFilter<WithId<MonkeyTypes.User>> {
let totalXp = 0;
const newBadges: MonkeyTypes.Badge[] = [];
rewards.forEach((reward) => {
if (reward.type === "xp") {
totalXp += reward.item;
} else if (reward.type === "badge") {
newBadges.push(reward.item);
}
});
return {
$inc: {
xp: totalXp,
},
$push: {
"inventory.badges": { $each: newBadges },
},
};
}
export async function updateInbox(
uid: string,
mailToRead: string[],
mailToDelete: string[]
): Promise<void> {
const user = await getUser(uid, "update inbox");
const inbox = user.inbox ?? [];
const mailToReadSet = new Set(mailToRead);
const mailToDeleteSet = new Set(mailToDelete);
const allRewards: MonkeyTypes.AllRewards[] = [];
const newInbox = inbox
.filter((mail) => {
const { id, rewards } = mail;
if (mailToReadSet.has(id) && !mail.read) {
mail.read = true;
if (rewards.length > 0) {
allRewards.push(...rewards);
}
}
return !mailToDeleteSet.has(id);
})
.sort((a, b) => b.timestamp - a.timestamp);
const baseUpdate = {
$set: {
inbox: newInbox,
},
};
const rewardUpdates = buildRewardUpdates(allRewards);
const mergedUpdates = _.merge(baseUpdate, rewardUpdates);
await getUsersCollection().updateOne({ uid }, mergedUpdates);
}

View file

@ -3,6 +3,8 @@ import { getCurrentDayTimestamp } from "../utils/misc";
import { getCachedConfiguration } from "../init/configuration";
import { DailyLeaderboard } from "../utils/daily-leaderboards";
import { announceDailyLeaderboardTopResults } from "../tasks/george";
import { addToInbox } from "../dal/user";
import { buildMonkeyMail } from "../utils/monkey-mail";
const CRON_SCHEDULE = "1 0 * * *"; // At 00:01.
const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
@ -24,7 +26,8 @@ async function announceDailyLeaderboard(
language: string,
mode: string,
mode2: string,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"],
inboxConfig: MonkeyTypes.Configuration["users"]["inbox"]
): Promise<void> {
const yesterday = getCurrentDayTimestamp() - ONE_DAY_IN_MILLISECONDS;
const dailyLeaderboard = new DailyLeaderboard(
@ -43,6 +46,29 @@ async function announceDailyLeaderboard(
return;
}
if (inboxConfig.enabled) {
const { xpReward } = dailyLeaderboardsConfig;
const inboxPromises = topResults.map(async (entry) => {
const mail = buildMonkeyMail({
rewards: [
{
type: "xp",
item: xpReward,
},
],
getTemplate: (user) => ({
subject: `${xpReward} XP for top placement in the daily leaderboard!`,
body: `Congratulations ${user.name} on placing top ${entry.rank} in the ${language} ${mode} ${mode2} daily leaderboard! Claim your ${xpReward} xp!`,
}),
});
return await addToInbox(entry.uid, [mail], inboxConfig.maxMail);
});
await Promise.allSettled(inboxPromises);
}
const leaderboardId = `${mode} ${mode2} ${language}`;
await announceDailyLeaderboardTopResults(
leaderboardId,
@ -52,14 +78,24 @@ async function announceDailyLeaderboard(
}
async function announceDailyLeaderboards(): Promise<void> {
const { dailyLeaderboards, maintenance } = await getCachedConfiguration();
const {
dailyLeaderboards,
users: { inbox },
maintenance,
} = await getCachedConfiguration();
if (!dailyLeaderboards.enabled || maintenance) {
return;
}
await Promise.allSettled(
leaderboardsToAnnounce.map(({ language, mode, mode2 }) => {
return announceDailyLeaderboard(language, mode, mode2, dailyLeaderboards);
return announceDailyLeaderboard(
language,
mode,
mode2,
dailyLeaderboards,
inbox
);
})
);
}

View file

@ -439,6 +439,20 @@ export const userProfileUpdate = rateLimit({
handler: customHandler,
});
export const userMailGet = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userMailUpdate = rateLimit({
windowMs: ONE_HOUR_MS,
max: 60 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
// ApeKeys Routing
export const apeKeysGet = rateLimit({
windowMs: ONE_HOUR_MS,

View file

@ -46,6 +46,10 @@ declare namespace MonkeyTypes {
maxDailyBonus: number;
minDailyBonus: number;
};
inbox: {
enabled: boolean;
maxMail: number;
};
};
apeKeys: {
endpointsEnabled: boolean;
@ -68,6 +72,7 @@ declare namespace MonkeyTypes {
validModeRules: ValidModeRule[];
dailyLeaderboardCacheSize: number;
topResultsToAnnounce: number;
xpReward: number;
};
}
@ -98,6 +103,32 @@ declare namespace MonkeyTypes {
};
}
interface Reward<T> {
type: string;
item: T;
}
interface XpReward extends Reward<number> {
type: "xp";
item: number;
}
interface BadgeReward extends Reward<Badge> {
type: "badge";
item: Badge;
}
type AllRewards = XpReward | BadgeReward;
interface MonkeyMail {
id: string;
subject: string;
body: string;
timestamp: number;
read: boolean;
rewards: AllRewards[];
}
interface User {
autoBanTimestamps?: number[];
addedAt: number;
@ -128,6 +159,7 @@ declare namespace MonkeyTypes {
profileDetails?: UserProfileDetails;
inventory?: UserInventory;
xp?: number;
inbox?: MonkeyMail[];
}
interface UserInventory {

View file

@ -0,0 +1,20 @@
import { v4 } from "uuid";
type MonkeyMailOptions = Partial<Omit<MonkeyMailWithTemplate, "id" | "read">>;
export interface MonkeyMailWithTemplate extends MonkeyTypes.MonkeyMail {
getTemplate?: (user: MonkeyTypes.User) => MonkeyMailOptions;
}
export function buildMonkeyMail(
options: MonkeyMailOptions
): MonkeyMailWithTemplate {
return {
id: v4(),
subject: options.subject || "",
body: options.body || "",
timestamp: options.timestamp || Date.now(),
read: false,
rewards: options.rewards || [],
getTemplate: options.getTemplate,
};
}

View file

@ -7188,6 +7188,11 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/immutable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
@ -20123,6 +20128,11 @@
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"immutable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",

View file

@ -84,7 +84,7 @@ a {
}
}
a[data-link] * {
a[router-link] * {
pointer-events: none;
}
@ -471,13 +471,15 @@ key {
.configureAPI.button {
position: fixed;
left: 2rem;
bottom: 2rem;
left: 0;
top: 10rem;
display: grid;
grid-auto-flow: column;
gap: 0.5rem;
text-decoration: none;
z-index: 999999999;
border-radius: 0 1rem 1rem 0;
padding: 1rem;
}
.avatar {

View file

@ -38,7 +38,7 @@
.pbsWords,
.pbsTime,
.details {
user-select: none;
// user-select: none;
background: var(--sub-alt-color);
padding: 1rem;
border-radius: var(--roundness);

View file

@ -156,7 +156,7 @@ window.addEventListener("popstate", () => {
document.addEventListener("DOMContentLoaded", () => {
document.body.addEventListener("click", (e) => {
const target = e?.target as HTMLLinkElement;
if (target.matches("[data-link]") && target?.href) {
if (target.matches("[router-link]") && target?.href) {
e.preventDefault();
navigate(target.href);
}
@ -167,10 +167,6 @@ $("#top .logo").on("click", () => {
navigate("/");
});
$(document).on("click", "#leaderboards .entryName", (e) => {
const uid = $(e.target).attr("uid");
if (uid) {
navigate(`/profile/${uid}`);
Leaderboards.hide();
}
$(document).on("click", "#leaderboards a.entryName", () => {
Leaderboards.hide();
});

View file

@ -392,6 +392,9 @@ $(document).ready(() => {
// opens command line if escape or ctrl/cmd + shift + p
if (
((event.key === "Escape" && Config.quickRestart !== "esc") ||
(event.key?.toLowerCase() === "p" &&
(event.metaKey || event.ctrlKey) &&
event.shiftKey) ||
(event.key === "Tab" && Config.quickRestart === "esc")) &&
!$("#commandLineWrapper").hasClass("hidden")
) {

View file

@ -322,7 +322,9 @@ async function fillTable(lb: LbKey, prepend?: number): Promise<void> {
}</td>
<td>
<div class="avatarNameBadge">${avatar}
<span class="entryName" uid=${entry.uid}>${entry.name}</span>
<a href="${location.origin}/profile/${entry.uid}" class="entryName" uid=${
entry.uid
} router-link>${entry.name}</a>
${entry.badgeId ? getBadgeHTMLbyId(entry.badgeId) : ""}
</div>
</td>

View file

@ -16,7 +16,7 @@ if (window.location.hostname === "localhost") {
$("#bottom .version .text").text("localhost");
$("#bottom .version").css("opacity", 1);
$("body").prepend(
`<a class='button configureAPI' href='http://localhost:5005/configure/' target='_blank'><i class="fas fa-fw fa-server"></i>Configure Server</a>`
`<a class='button configureAPI' href='http://localhost:5005/configure/' target='_blank' aria-label="Configure API" data-balloon-pos="right"><i class="fas fa-fw fa-server"></i></a>`
);
} else {
Misc.getReleasesFromGitHub().then((v) => {

View file

@ -45,12 +45,11 @@ interface PoemObject {
author: string;
}
export async function getPoem(): Promise<Poem | undefined> {
export async function getPoem(): Promise<Poem | false> {
console.log("Getting poem");
const response = await axios.get(apiURL);
try {
const response = await axios.get(apiURL);
const poemObj: PoemObject = response.data[0];
const words: string[] = [];
@ -64,7 +63,6 @@ export async function getPoem(): Promise<Poem | undefined> {
return new Poem(poemObj.title, poemObj.author, words);
} catch (e) {
console.log(e);
return false;
}
return;
}

View file

@ -763,7 +763,9 @@ async function getNextWord(
let regenarationCount = 0; //infinite loop emergency stop button
while (
regenarationCount < 100 &&
((/[A-Z]/.test(randomWord) && !Config.punctuation) ||
((Config.mode !== "custom" &&
/[A-Z]/.test(randomWord) &&
!Config.punctuation) ||
previousWord == randomWord ||
previousWord2 == randomWord ||
(Config.mode !== "custom" &&
@ -947,7 +949,18 @@ export async function init(): Promise<void> {
? await Wikipedia.getSection(Config.language)
: await Poetry.getPoem();
if (Config.funbox == "poetry" && section === false) {
Notifications.add(
"Error while getting poetry. Please try again later",
-1
);
UpdateConfig.setFunbox("none");
restart();
return;
}
if (section === undefined) continue;
if (section === false) continue;
for (const word of section.words) {
if (wordCount >= Config.words && Config.mode == "words") {
@ -1156,7 +1169,19 @@ export async function addWord(): Promise<void> {
? await Wikipedia.getSection(Config.language)
: await Poetry.getPoem();
if (Config.funbox == "poetry" && section === false) {
Notifications.add(
"Error while getting poetry. Please try again later",
-1
);
UpdateConfig.setFunbox("none");
restart();
return;
}
if (section === undefined) return;
if (section === false) return;
let wordCount = 0;
for (const word of section.words) {
if (wordCount >= Config.words && Config.mode == "words") {
@ -1243,63 +1268,7 @@ export async function retrySavingResult(): Promise<void> {
Notifications.add("Retrying to save...");
const response = await Ape.results.save(completedEvent);
AccountButton.loading(false);
Result.hideCrown();
if (response.status !== 200) {
retrySaving.canRetry = true;
$("#retrySavingResultButton").removeClass("hidden");
return Notifications.add("Result not saved. " + response.message, -1);
}
if (response.data.xp) {
const snapxp = DB.getSnapshot().xp;
AccountButton.updateXpBar(
snapxp,
response.data.xp,
response.data.dailyXpBonus,
response.data.xpBreakdown
);
DB.addXp(response.data.xp);
}
completedEvent._id = response.data.insertedId;
if (response.data.isPb) {
completedEvent.isPb = true;
}
DB.saveLocalResult(completedEvent);
DB.updateLocalStats(
TestStats.restartCount + 1,
completedEvent.testDuration +
completedEvent.incompleteTestSeconds -
completedEvent.afkDuration
);
AnalyticsController.log("testCompleted");
if (response.data.isPb) {
//new pb
Result.showCrown();
Result.updateCrown();
DB.saveLocalPB(
Config.mode,
completedEvent.mode2,
Config.punctuation,
Config.language,
Config.difficulty,
Config.lazyMode,
completedEvent.wpm,
completedEvent.acc,
completedEvent.rawWpm,
completedEvent.consistency
);
}
$("#retrySavingResultButton").addClass("hidden");
Notifications.add("Result saved", 1);
saveResult(completedEvent, true);
}
function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent {
@ -1650,18 +1619,25 @@ export async function finish(difficultyFailed = false): Promise<void> {
completedEvent.hash = objectHash(completedEvent);
saveResult(completedEvent, false);
}
async function saveResult(
completedEvent: CompletedEvent,
isRetrying: boolean
): Promise<void> {
const response = await Ape.results.save(completedEvent);
AccountButton.loading(false);
Result.hideCrown();
if (response.status !== 200) {
console.log("Error saving result", completedEvent);
$("#retrySavingResultButton").removeClass("hidden");
if (response.message === "Incorrect result hash") {
console.log(completedEvent);
}
retrySaving.completedEvent = completedEvent;
retrySaving.canRetry = true;
$("#retrySavingResultButton").removeClass("hidden");
if (!isRetrying) {
retrySaving.completedEvent = completedEvent;
}
return Notifications.add("Failed to save result: " + response.message, -1);
}
@ -1676,8 +1652,6 @@ export async function finish(difficultyFailed = false): Promise<void> {
DB.addXp(response.data.xp);
}
Result.hideCrown();
completedEvent._id = response.data.insertedId;
if (response.data.isPb) {
completedEvent.isPb = true;
@ -1723,6 +1697,9 @@ export async function finish(difficultyFailed = false): Promise<void> {
}
$("#retrySavingResultButton").addClass("hidden");
if (isRetrying) {
Notifications.add("Result saved", 1);
}
}
export function fail(reason: string): void {

View file

@ -2,7 +2,7 @@
<div class="content">
<div class="image"></div>
<div>Ooops! Looks like you found a page that doesn't exist.</div>
<a href="/" class="button" data-link>
<a href="/" class="button" router-link>
<i class="fas fa-home"></i>
Go Home
</a>

View file

@ -288,7 +288,7 @@
<canvas id="wpmChart"></canvas>
</div>
<div class="loginTip">
<a href="/login" tabindex="2" data-link>Sign in</a>
<a href="/login" tabindex="2" router-link>Sign in</a>
to save your result
</div>
<div class="bottom" style="grid-column: 1/3">

View file

@ -50,7 +50,7 @@
tabindex="2"
href="/"
onclick="this.blur();"
data-link
router-link
>
<div class="icon">
<i class="fas fa-fw fa-keyboard"></i>
@ -70,7 +70,7 @@
tabindex="2"
href="/about"
onclick="this.blur();"
data-link
router-link
>
<div class="icon">
<i class="fas fa-fw fa-info"></i>
@ -93,7 +93,7 @@
tabindex="2"
href="/settings"
onclick="this.blur();"
data-link
router-link
>
<div class="icon">
<i class="fas fa-fw fa-cog"></i>
@ -105,7 +105,7 @@
tabindex="2"
href="/account"
onclick="this.blur();"
data-link
router-link
>
<div class="icon">
<i class="fas fa-fw fa-user"></i>
@ -126,7 +126,7 @@
tabindex="2"
href="/login"
onclick="this.blur();"
data-link
router-link
>
<div class="icon">
<i class="far fa-fw fa-user"></i>

View file

@ -292,6 +292,10 @@
"name": "armenian",
"languages": ["armenian_western", "armenian_western_1k"]
},
{
"name": "myanmar",
"languages": ["myanmar_burmese"]
},
{
"name": "japanese",
"languages": ["japanese_hiragana", "japanese_katakana"]

View file

@ -170,6 +170,7 @@
,"shona_1k"
,"armenian_western"
,"armenian_western_1k"
,"myanmar_burmese"
,"japanese_hiragana"
,"japanese_katakana"
,"sinhala"

View file

@ -82,6 +82,7 @@
"demikian",
"dengan",
"depan",
"desa",
"detik",
"di",
"dia",
@ -89,6 +90,7 @@
"dua",
"dulu",
"ganti",
"gila",
"habis",
"hanya",
"hari",
@ -107,6 +109,7 @@
"jarak",
"jika",
"jumlah",
"jari",
"kalah",
"kalau",
"kalimat",
@ -123,6 +126,7 @@
"kemarin",
"kembali",
"kemudian",
"kemana",
"keras",
"kerja",
"kertas",
@ -154,6 +158,7 @@
"membuat",
"memerlukan",
"memiliki",
"mencuri",
"menang",
"mengapa",
"mengatakan",
@ -191,6 +196,7 @@
"radius",
"ragam",
"ras",
"rasa",
"rasio",
"rasional",
"rayu",
@ -261,6 +267,7 @@
"tinggi",
"tua",
"tumbuh",
"tunas",
"tunggu",
"turun",
"tutup",
@ -268,6 +275,7 @@
"untuk",
"wahai",
"wajah",
"wajar",
"waktu",
"wanita",
"yaitu",

View file

@ -0,0 +1,208 @@
{
"name": "myanmar_burmese",
"leftToRight": true,
"noLazyMode": true,
"bcp47": "my-MY",
"words": [
"အဆိုပါ",
"ဖြစ်သည်",
"ဝေဖန်",
"နှင့်",
"တစ်ခု",
"အပြင်သို့",
"အထဲသို့",
"သူ",
"တည်ရှိ",
"ထိုအရာ",
"ဝတ်ဆင်",
"အတွက်",
"သူတို့",
"ကျွန်တော်",
"နှင့်အတူ",
"အဖက်လုပ်",
"မဟုတ်ဘူး",
"အရွဲ့တိုက်",
"သတင်းစကား",
"ထာဝရ",
"ငလျင်လှုပ်",
"အသေးစိတ်",
"ကျွန်မ",
"ခင်ဗျား",
"ပုရွက်ဆိတ်",
"ဒါပေမယ့်",
"ပြည်သူ",
"သံသရာ",
"ချစ်ကျွမ်းဝင်",
"တကယ်တမ်း",
"ဖက်လဲတကင်း",
"အားလုံး",
"သေတမ်းစာ",
"ဟိုနေရာမှာ",
"ပြောဆို",
"မုန့်ဟင်းခါး",
"မုန်းတီး",
"သောအခါ",
"ဒယိမ်းဒယိုင်",
"ဒလန်",
"ဓားစာခံ",
"ညီအစ်မ",
"အမျိုးသား",
"ပယောဂ",
"အခြား",
"ထို့နောက်",
"ပစ်ခတ်",
"အချိန်",
"မှတစ်ပါး",
"သွားလာ",
"အကြောင်း",
"နှောင်ကြိုး",
"ဖက်ဆစ်",
"တော်လှန်ရေး",
"ပြည်နယ်",
"ငြိမ်းချမ်းရေး",
"အသစ်",
"ခုနှစ်",
"ဖောက်ပြန်",
"ဘိလပ်မြေ",
"ဆောင်ရွက်",
"ရေတံခွန်",
"လူရည်လည်",
"အမြင်",
"အသုံးပြု",
"ရယူခြင်း",
"ကြိုက်နှစ်သက်",
"ထိုအခါ",
"ပထမ",
"တစ်ခုခု",
"အလုပ်",
"လောလောဆယ်",
"မိခင်",
"ဖခင်",
"ပေးဝေ",
"ပြီးဆုံး",
"တွေးတော",
"အများဆုံး",
"စုစည်း",
"ရှာဖွေ",
"နေ့ရက်",
"နှစ်ကာလ",
"ပြီးနောက်",
"နည်းလမ်း",
"များစွာ",
"မဖြစ်မနေ",
"အသွင်အပြင်",
"ဟင်းလျာ",
"ကြီးမား",
"လက်လုပ်လက်စား",
"မှတဆင့်",
"ရှည်လျား",
"ဘယ်မှာ",
"အများကြီး",
"လုပ်သင့်လုပ်ထိုက်",
"ရေတွင်း",
"လူထု",
"ဆင်းလျက်",
"ကိုယ်ပိုင်",
"အချစ်",
"အဘယ်ကြောင့်",
"ကောင်းတယ်",
"ခါးသက်",
"ကြည်လင်",
"ခံစားမှု",
"လည်းကောင်း",
"ဘယ်လို",
"မြင့်မားသော",
"မြန်မာ",
"နေရာ",
"နည်းနည်း",
"ကမ္ဘာ",
"အလွန်",
"ငြိမ်သက်",
"နိုင်ငံ",
"လက်",
"အဟောင်း",
"ဘဝ",
"ဝန်ကြီးချုပ်",
"စာအရေးအသား",
"ဖြစ်လာသည်",
"ဒီမှာ",
"ပြဇာတ်",
"စာစီစာကုံး",
"လုံချည်",
"အကြား",
"လိုအပ်",
"စိတ်လိုက်မာန်ပါ",
"အလည်အပတ်",
"ဖွံ့ဖြိုးတိုးတက်",
"အောက်မေ့",
"နောက်ဆုံး",
"မှန်ကန်",
"လှုပ်ရှား",
"အရာရှိ",
"အထွေထွေ",
"ကျောင်း",
"ဘယ်တော့မှ",
"တူရိယာ",
"အကြွင်းမဲ့",
"အစပြု",
"အချုပ်အခြာ",
"နံပါတ်",
"အစိတ်အပိုင်း",
"အလှည့်",
"အစစ်အမှန်",
"ထွက်သွား",
"တန်ခိုး",
"အလိုရှိ",
"အမှတ်",
"ပုံစံ",
"အချင်းများ",
"ကလေး",
"အနည်းငယ်",
"သေးငယ်",
"ကတည်းက",
"ဆန့်ကျင်ဘက်",
"မေးမြန်း",
"နောက်ကျ",
"အိမ်",
"အကျိုးစီးပွား",
"ကြီးကျယ်",
"လူ",
"ပိတ်သည်",
"ဖွင့်သည်",
"အများသူငှာ",
"လိုက်နာ",
"ကာလအတွင်း",
"လက်ရှိ",
"ကောင်းမွန်",
"ထပ်မံ၍",
"ကိုင်ဆုပ်",
"အုပ်ချုပ်",
"ပတ်ပတ်လည်",
"ဖြစ်နိုင်သော",
"ဦးခေါင်း",
"စဉ်းစားဆင်ခြင်",
"စကားလုံး",
"ဆွဲဆောင်",
"ပြဿနာ",
"သို့သော်",
"ခေါင်းဆောင်",
"စနစ်",
"သတ်မှတ်",
"အမိန့်",
"မျက်လုံး",
"အစီအစဉ်",
"ပြေးသည်",
"စောင့်ရှောက်",
"မျက်နှာ",
"အချက်အလက်",
"အစုအဖွဲ့",
"ကစား",
"ရပ်နေ",
"တိုးမြှင့်",
"စောစော",
"သင်တန်း",
"ပြောင်းလဲမှု",
"အကူအညီ",
"မျဉ်းကြောင်း"
]
}

View file

@ -1260,6 +1260,18 @@
"source": "Wa Brontok, Hansip Sukrawetan Indramayu",
"length": 209,
"id": 210
},
{
"text": "Saya manusia biasa, makan nasi.",
"source": "Joko Widodo",
"length": 31,
"id": 211
},
{
"text": "Kok kamu tanya begitu, siapa yang suruh?",
"source": "Soeharto",
"length": 40,
"id": 212
}
]
}