BA Torment Translate

Translate BA Torment to English

// ==UserScript==
// @name        BA Torment Translate
// @namespace   bluearchive-torment
// @match       https://bluearchive-torment.netlify.app/*
// @grant       none
// @version     1.1
// @author      u/aisjsjdjdjskwkw
// @license     MIT
// @description Translate BA Torment to English
// ==/UserScript==

// Full strings to replace
const strings = {
	"총력전 리포트 (ARONA.AI)": "Raid Report (Arona.AI)",

	"파티 찾기": "Search",
	"파티 Filter": "Team Filters",
	"필터 Reset": "Reset Filters",
	"난이도": "Difficulty",
	"파티": " Team",
	"포함할 ": "Include",
	"내 캐릭터": " Students",
	"캐릭터를 선택하세요": "Select a student",
	"※ 성급 관계없이 보고 싶다면 왼쪽 체크박스를 사용하세요.": "※ To include a student regardless of star level, use the checkbox on the left.",
	"제외할 ": "Exclude",
	"제외할 캐릭터를 선택하세요": "Select a student to exclude",
	"조력자에서도 제외": "Exclude from assistants",
	"조력자": "Assistant",
	"조력자를 선택하세요": "Select an assistant",
	"조력자 포함 중복 허용": "Allow duplicates",
	"Youtube 링크 (beta)": "Youtube link (beta)",
	"위: ": (e) => {
		// Can't replace parentElement.textContent because the rank and score
		// text nodes need to exist as they're updated when changing page
		const rank = e.previousSibling
		const score = e.nextSibling
		const divider = score.nextSibling

		e.after(rank)
		divider.after(score)

		divider.textContent = " | "
		return "Rank "
	},
	"채널": "Channel",
	"영상": "Video",
	"서비스 안내": "Service Information",
	"이제 영상 정보 업데이트를 지원하지 않아요.": "Submitting video links is no longer supported.",
	"기존 데이터는 계속 조회할 수 있어요.": "Existing links can still be accessed.",
	"5파티 이후 보기": "Show more teams",

	"요약": "Summary (Torment)",
	"요약 (루나틱)": "Summary (Lunatic)",
	"검색어 (클릭하면 복사됩니다)": "Search Term (click to copy)",
	"Platinum 클리어 비율: ": "Platinum Clears: ",
	"Top 5 파티": "Top 5 Most Common Clears",
	"※ 전용무기와 배치는 고려하지 않았습니다.": "※ Without considering student star level or positioning.",
	"파티 비율": "Team Count Usage",
	"1파티": "1 team",
	"2파티": "2 teams",
	"3파티": "3 teams",
	"4파티 이상": "4 or more teams",
	"캐릭터 사용률": "Student Usage",
	"※ 1% 이상만 표시됩니다.": "※ Only students with at least 1% usage are displayed.",
	"이름": "Name",
	"사용률 (%)": "Usage Rate (%)",
	"캐릭터 성장 통계": "Student Investment Statistics",
	"공략 영상": "Clear Videos",
	"※ 최대 10개까지 표시됩니다.": "※ Up to 10 are displayed.",

	"오류 제보 (이메일)": "Bug Report (Email)",
}

// Words to replace within strings
const words = [
	["총력전", "TA"],
	["대결전", "GA"],

	["비나", "Binah"],
	["헤세드", "Chesed"],
	["시로쿠로", "ShiroKuro"],
	["예로니무스", "Hieronymus"],
	["카이텐", "Kaiten"],
	["페로로지라", "Perorodzilla"],
	["호드", "Hod"],
	["고즈", "Goz"],
	["그레고리오", "Gregorius"],
	["호버크래프트", "Hovercraft"],
	["쿠로카게", "Kurokage"],
	["게부라", "Geburah"],

	["실내", "Indoors"],
	["야외", "Outdoors"],
	["시가지", "Urban"],

	["경장갑", "Red"],
	["중장갑", "Yellow"],
	["특수장갑", "Blue"],
	["탄력장갑", "Purple"],

	["인세인", "Insane"],
	["토먼트", "Torment"],
	["루나틱", "Lunatic"],
	["총합", "Total"],

	[/\d{8}/, (e) => Number(e.textContent).toLocaleString()],
	[/최종 (\d+)위/, "Overall Rank $1"],
	[/(\d+)위: (\d+)명 사용/, "#$1 | $2 uses"],
	[/^in (\d+)$/, "Top $1"],

	["1성", "1★"], ["2성", "2★"], ["3성", "3★"], ["4성", "4★"], ["5성", "5★"],
	["전1", "UE1"], ["전2", "UE2"], ["전3", "UE3"], ["전4", "UE4"], ["전5", "UE5"],
	["아","a"], ["바","ba"], ["비","bi"], ["부","bu"], ["체","che"], ["치","chi"], ["데","de"], ["도","do"], ["에","e"], ["후","fu"], ["게","ge"], ["기","gi"], ["구","gu"], ["하","ha"], ["히","hi"], ["호","ho"], ["이","i"], ["지","ji"], ["죠","jo"], ["주","ju"], ["준","jun"], ["카","ka"], ["칸","kan"], ["키","ki"], ["코","ko"], ["쿠","ku"], ["쿄","kyou"], ["마","ma"], ["메","me"], ["미","mi"], ["모","mo"], ["무","mu"], ["나","na"], ["네","ne"], ["니","ni"], ["노","no"], ["오","o"], ["피","pi"], ["라","ra"], ["레","re"], ["렌","ren"], ["리","ri"], ["린","rin"], ["로","ro"], ["루","ru"], ["사","sa"], ["세","se"], ["시","shi"], ["쇼","shou"], ["스","su"], ["타","ta"], ["테","te"], ["텐","ten"], ["토","to"], ["츠","tsu"], ["우","u"], ["와","wa"], ["야","ya"], ["요","yo"], ["유","yu"], ["조","zo"], ["즈","zu"],

	["아루", "Aru"],
	["에이미", "Eimi"],
	["하루나", "Haruna"],
	["히후미", "Hifumi"],
	["히나", "Hina"],
	["호시노", "Hoshino"],
	["이오리", "Iori"],
	["마키", "Maki"],
	["네루", "Neru"],
	["이즈미", "Izumi"],
	["시로코", "Shiroko"],
	["슌", "Shun"],
	["스미레", "Sumire"],
	["츠루기", "Tsurugi"],
	["아카네", "Akane"],
	["치세", "Chise"],
	["아카리", "Akari"],
	["하스미", "Hasumi"],
	["노노미", "Nonomi"],
	["카요코", "Kayoko"],
	["무츠키", "Mutsuki"],
	["준코", "Junko"],
	["세리카", "Serika"],
	["츠바키", "Tsubaki"],
	["유우카", "Yuuka"],
	["하루카", "Haruka"],
	["아스나", "Asuna"],
	["코토리", "Kotori"],
	["스즈미", "Suzumi"],
	["피나", "Pina"],
	["히비키", "Hibiki"],
	["카린", "Karin"],
	["사야", "Saya"],
	["아이리", "Airi"],
	["후우카", "Fuuka"],
	["하나에", "Hanae"],
	["하레", "Hare"],
	["우타하", "Utaha"],
	["아야네", "Ayane"],
	["치나츠", "Chinatsu"],
	["코타마", "Kotama"],
	["주리", "Juri"],
	["세리나", "Serina"],
	["시미코", "Shimiko"],
	["요시미", "Yoshimi"],
	["마시로", "Mashiro"],
	["이즈나", "Izuna"],
	["시즈코", "Shizuko"],
	["아리스", "Arisu"],
	["미도리", "Midori"],
	["모모이", "Momoi"],
	["체리노", "Cherino"],
	["노도카", "Nodoka"],
	["유즈", "Yuzu"],
	["아즈사", "Azusa"],
	["하나코", "Hanako"],
	["코하루", "Koharu"],
	["아즈사(수영복)", "S.Azusa"],
	["마시로(수영복)", "S.Mashiro"],
	["츠루기(수영복)", "S.Tsurugi"],
	["히후미(수영복)", "S.Hifumi"],
	["히나(수영복)", "S.Hina"],
	["이오리(수영복)", "S.Iori"],
	["이즈미(수영복)", "S.Izumi"],
	["시로코(라이딩)", "C.Shiroko"],
	["슌(어린이)", "Shun (Small)"],
	["키리노", "Kirino"],
	["사야(사복)", "C.Saya"],
	["네루(바니걸)", "B.Neru"],
	["카린(바니걸)", "B.Karin"],
	["아스나(바니걸)", "B.Asuna"],
	["나츠", "Natsu"],
	["마리", "Mari"],
	["하츠네 미쿠", "Hatsune Miku"],
	["아코", "Ako"],
	["체리노(온천)", "O.Cherino"],
	["치나츠(온천)", "O.Chinatsu"],
	["토모에", "Tomoe"],
	["노도카(온천)", "O.Nodoka"],
	["아루(새해)", "NY.Aru"],
	["무츠키(새해)", "NY.Mutsuki"],
	["세리카(새해)", "NY.Serika"],
	["와카모", "Wakamo"],
	["후부키", "Fubuki"],
	["세나", "Sena"],
	["치히로", "Chihiro"],
	["미모리", "Mimori"],
	["우이", "Ui"],
	["히나타", "Hinata"],
	["마리나", "Marina"],
	["미야코", "Miyako"],
	["사키", "Saki"],
	["미유", "Miyu"],
	["카에데", "Kaede"],
	["이로하", "Iroha"],
	["미치루", "Michiru"],
	["츠쿠요", "Tsukuyo"],
	["미사키", "Misaki"],
	["히요리", "Hiyori"],
	["아츠코", "Atsuko"],
	["와카모(수영복)", "S.Wakamo"],
	["노노미(수영복)", "S.Nonomi"],
	["아야네(수영복)", "S.Ayane"],
	["호시노(수영복)", "S.Hoshino"],
	["시즈코(수영복)", "S.Shizuko"],
	["이즈나(수영복)", "S.Izuna"],
	["치세(수영복)", "S.Chise"],
	["사오리", "Saori"],
	["모에", "Moe"],
	["카즈사", "Kazusa"],
	["코코나", "Kokona"],
	["우타하(응원단)", "C.Utaha"],
	["노아", "Noa"],
	["히비키(응원단)", "C.Hibiki"],
	["아카네(바니걸)", "B.Akane"],
	["유우카(체육복)", "T.Yuuka"],
	["마리(체육복)", "T.Mari"],
	["하스미(체육복)", "T.Hasumi"],
	["히마리", "Himari"],
	["시구레", "Shigure"],
	["세리나(크리스마스)", "C.Serina"],
	["하나에(크리스마스)", "C.Hanae"],
	["하루나(새해)", "NY.Haruna"],
	["후우카(새해)", "NY.Fuuka"],
	["준코(새해)", "NY.Junko"],
	["미네", "Mine"],
	["미카", "Mika"],
	["메구", "Megu"],
	["칸나", "Kanna"],
	["사쿠라코", "Sakurako"],
	["토키", "Toki"],
	["나기사", "Nagisa"],
	["코유키", "Koyuki"],
	["카요코(새해)", "NY.Kayoko"],
	["하루카(새해)", "NY.Haruka"],
	["카호", "Kaho"],
	["아리스(메이드)", "M.Arisu"],
	["토키(바니걸)", "B.Toki"],
	["유즈(메이드)", "M.Yuzu"],
	["레이사", "Reisa"],
	["루미", "Rumi"],
	["미나", "Mina"],
	["미노리", "Minori"],
	["미야코(수영복)", "S.Miyako"],
	["사키(수영복)", "S.Saki"],
	["미유(수영복)", "S.Miyu"],
	["시로코(수영복)", "S.Shiroko"],
	["우이(수영복)", "S.Ui"],
	["히나타(수영복)", "S.Hinata"],
	["코하루(수영복)", "S.Koharu"],
	["하나코(수영복)", "S.Hanako"],
	["미모리(수영복)", "S.Mimori"],
	["메루", "Meru"],
	["모미지", "Momiji"],
	["코토리(응원단)", "C.Kotori"],
	["하루나(체육복)", "T.Haruna"],
	["이치카", "Ichika"],
	["카스미", "Kasumi"],
	["시구레(온천)", "O.Shigure"],
	["미사카 미코토", "Misaka Mikoto"],
	["쇼쿠호 미사키", "Shokuhou Misaki"],
	["사텐 루이코", "Saten Ruiko"],
	["유카리", "Yukari"],
	["렌게", "Renge"],
	["키쿄", "Kikyou"],
	["에이미(수영복)", "S.Eimi"],
	["코타마(캠핑)", "C.Kotama"],
	["하레(캠핑)", "C.Hare"],
	["아코(드레스)", "D.Ako"],
	["이부키", "Ibuki"],
	["마코토", "Makoto"],
	["히나(드레스)", "D.Hina"],
	["카요코(드레스)", "D.Kayoko"],
	["아루(드레스)", "D.Aru"],
	["아카리(새해)", "NY.Akari"],
	["우미카", "Umika"],
	["츠바키(가이드)", "G.Tsubaki"],
	["카즈사(밴드)", "B.Kazusa"],
	["요시미(밴드)", "B.Yoshimi"],
	["아이리(밴드)", "B.Airi"],
	["키라라", "Kirara"],
	["모모이(메이드)", "M.Momoi"],
	["미도리(메이드)", "M.Midori"],
	["세리카(수영복)", "S.Serika"],
	["칸나(수영복)", "S.Kanna"],
	["후부키(수영복)", "S.Fubuki"],
	["키리노(수영복)", "S.Kirino"],
	["모에(수영복)", "S.Moe"],
	["호시노(무장)", "B.Hoshino"],
	["시로코*테러", "Kuroko"],
	["아츠코(수영복)", "S.Atsuko"],
	["사오리(수영복)", "S.Saori"],
	["히요리(수영복)", "S.Hiyori"],
	["마리나(치파오)", "Q.Marina"],
	["토모에(치파오)", "Q.Tomoe"],
	["레이죠", "Reijo"],
	["키사키", "Kisaki"],
	["마리(아이돌)", "I.Mari"],
	["사쿠라코(아이돌)", "I.Sakurako"],
	["미네(아이돌)", "I.Mine"],
	["치아키", "Chiaki"],
	["사츠키", "Satsuki"],
	["유우카(파자마)", "PJ.Yuuka"],
	["노아(파자마)", "PJ.Noa"],
	["세이아", "Seia"],
	["아스나(교복)", "U.Asuna"],
	["카린(교복)", "U.Karin"],
	["네루(교복)", "U.Neru"],
	["리오", "Rio"],
	["마키(캠핑)", "C.Maki"],
	["세나(사복)", "C.Sena"],
	["주리(아르바이트)", "PT.Juri"],
	["이즈미(새해)", "NY.Izumi"],
	["레이", "Rei"],
	["스미레(아르바이트)", "PT.Sumire"],
	["사오리(드레스)", "D.Saori"],
	["히카리", "Hikari"],
	["노조미", "Nozomi"],
	["아오바", "Aoba"],
	["피나(가이드)", "G.Pina"],
	["나구사", "Nagusa"],
	["니야", "Niya"],
	["나츠(밴드)", "B.Natsu"],
	["유카리(수영복)", "S.Yukari"],
	["렌게(수영복)", "S.Renge"],
	["키쿄(수영복)", "S.Kikyou"],
	["세이아(수영복)", "S.Seia"],
	["하스미(수영복)", "S.Hasumi"],
	["이치카(수영복)", "S.Ichika"],

	["무장", "Armed"],
	["밴드", "Band"],
	["바니걸", "Bunny"],
	["캠핑", "Camp"],
	["사복", "Casual"],
	["응원단", "Cheer Squad"],
	["크리스마스", "Christmas"],
	["라이딩", "Cycling"],
	["드레스", "Dress"],
	["가이드", "Guide"],
	["온천", "Hot Spring"],
	["메이드", "Maid"],
	["새해", "New Year"],
	["아르바이트", "Part-time Job"],
	["파자마", "Pajamas"],
	["아이돌", "Pop Idol"],
	["치파오", "Qipao"],
	["교복", "School"],
	["어린이", "Small"],
	["수영복", "Swimsuit"],
	["러", "Terror"],
	["체육복", "Track"],
].sort((a, b) => a[0].length < b[0].length)  // Longest words first to avoid erroneously replacing substrings

// DOM transformations
// [css selector]: (e: Element) => {}
const transforms = {
	// Student search boxes
	".ant-select-selection-search > input": (e) => transliteratify(e),
}

// Romanised sounds to Hangul
const transliterations = [
	// Kana (this was generated by ChatGPT so I don't know how accurate it is)
	["a",  "아"], ["i",  "이"], ["u",  "우"], ["e",  "에"], ["o",  "오"],
	["ka", "카"], ["ki", "키"], ["ku", "쿠"], ["ke", "케"], ["ko", "코"],
	["ga", "가"], ["gi", "기"], ["gu", "구"], ["ge", "게"], ["go", "고"],
	["sa", "사"], ["shi", "시"], ["su", "스"], ["se", "세"], ["so", "소"],
	["ja", "자"], ["ji", "지"], ["ju", "주"],               ["zo", "조"],
	["ta", "타"], ["chi", "치"], ["tsu", "츠"], ["te", "테"], ["to", "토"],
	["da", "다"], ["di", "디"], ["zu", "즈"], ["de", "데"], ["do", "도"],
	["na", "나"], ["ni", "니"], ["nu", "누"], ["ne", "네"], ["no", "노"],
	["ha", "하"], ["hi", "히"], ["fu", "후"], ["he", "헤"], ["ho", "호"],
	["ba", "바"], ["bi", "비"], ["bu", "부"], ["be", "베"], ["bo", "보"],
	["pa", "파"], ["pi", "피"], ["pu", "푸"], ["pe", "페"], ["po", "포"],
	["ma", "마"], ["mi", "미"], ["mu", "무"], ["me", "메"], ["mo", "모"],
	["ya", "야"], ["yu", "유"], ["yo", "요"],
	["ra", "라"], ["ri", "리"], ["ru", "루"], ["re", "레"], ["ro", "로"],
	["wa", "와"], ["wo", "오"], ["nn", "ㄴ"],
	["kyo", "쿄"], ["sho", "쇼"],

	// Students
	["che", "체"],     // [che]rino
	["주n", "준"],     // [jun]ko
	["카ㄴ아", "칸나"], // kanna
	["테n", "텐"],      // sa[ten]
	["레nge", "렌게"],  // renge
	["쿠로코", "시로코*테러"], // kuroko
	["shun", "슌"],

	// Alts
	["(아rmed|바ttle)", "무장"], // armed/battle
	["바nd", "밴드"], // band
	["(부ㄴy|바니)", "바니걸"], // bunny
	["camp", "캠핑"],
	["casual", "사복"],
	["체에r", "응원단"], // cheer
	["(christmas|x)", "크리스마스"],
	["(cy|(리|라이)디ng)", "라이딩"], // cycling/riding
	["dress", "드레스"],
	["구이데", "가이드"], // guide
	["(hs|호t ?spring)", "온천"], // hot spring
	["이도l", "아이돌"], // idol
	["(마|메)이d", "메이드"], // maid
	["(ny|네w ?year)", "새해"], // new year
	["pj", "파자마"], // pajama
	["(pt|파rt ?time)", "아르바이트"], // part time
	["q", "치파오"], // qipao
	["(school|jk|우니form)", "교복"], // school
	["small", "어린이"],
	["swim", "수영복"], // swimsuit
	["테rror", "러"], // terror
	["track", "체육복"],
].map(h => [
	new RegExp("(^|[^a-z])" + h[0], "gi"),
	"$1" + h[1]
])

const observer = new MutationObserver(ms => ms.forEach(m => m.addedNodes.forEach(translateTree)))
observer.observe(document.body, { childList: true, subtree: true })
translateTree(document.body)

function translateTree(root) {
	switch (root.nodeType) {
	case Node.ELEMENT_NODE:
		transformNode(root)
		break
	case Node.TEXT_NODE:
		translate(root)
		break
	}

	const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT)
	while (walker.nextNode()) {
		const node = walker.currentNode
		if (node.nodeType === Node.TEXT_NODE) {
			new MutationObserver(() => translate(node)).observe(node, { characterData: true })
			// console.log(`"${node.textContent}" --> "${translate(node)}"`, node)
			translate(node)
			continue
		}

		transformNode(node)
	}
}

function translate(text) {
	// Ignore announcement banner
	if (text.parentElement.closest(".ant-alert")) return

	let content = text.textContent
	const translation = strings[content]
	if (translation !== undefined) {
		return text.textContent = typeof translation === "function" ? translation(text) : translation
	}

	for (const [pattern, translation] of words) {
		if (typeof pattern === "string" ? content.includes(pattern) : content.match(pattern)) {
			content = typeof translation === "function"
				? translation(text)
				: content.replace(pattern, translation)
		}
	}

	if (text.textContent !== content) {
		return text.textContent = content
	}

	return null
}

function transformNode(node) {
	for (const [css, transform] of Object.entries(transforms)) {
		if (node.matches(css)) {
			transform(node)
			return
		}
	}
}

function transliteratify(input) {
	const { onChange } = input[Object.keys(input).find(k => k.startsWith("__reactProps$"))]

	input.oninput = (e) => {
		const { value } = input
		if (value === "" || e.data === null) return

		let transliterated = value
		for (const [r, h] of transliterations) {
			transliterated = transliterated.replaceAll(r, h)
		}

		input.value = transliterated
		onChange({ target: { value: transliterated } })
	}
}