first commit
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/additional/
|
||||||
|
/fix*/
|
||||||
|
/locales*/
|
||||||
|
/node_modules/
|
||||||
|
/logs/
|
||||||
|
*.po
|
||||||
|
*.json
|
||||||
|
*.csv
|
||||||
86
src/embedding.js
Normal file
86
src/embedding.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import * as fs from "fs"
|
||||||
|
import { getEmbedding } from "./openai.js"
|
||||||
|
import PO from "pofile";
|
||||||
|
|
||||||
|
async function generateEmbeddingsFromPo(poPath, embeddingPath) {
|
||||||
|
const embeddingMap = fs.existsSync(embeddingPath)
|
||||||
|
? JSON.parse(fs.readFileSync(embeddingPath, 'utf8'))
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
PO.load(poPath, async (err, po) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
const items = po.items;
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const msgid = item.msgid.trim();
|
||||||
|
if (embeddingMap[msgid] && !embeddingMap[msgid].vec) {
|
||||||
|
const dummy = {};
|
||||||
|
dummy.vec = embeddingMap[msgid];
|
||||||
|
embeddingMap[msgid] = dummy;
|
||||||
|
updated = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msgid || embeddingMap[msgid]) continue;
|
||||||
|
|
||||||
|
|
||||||
|
embeddingMap[msgid] = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔍 Embedding: ${msgid}`);
|
||||||
|
const embedding = await getEmbedding(msgid);
|
||||||
|
embeddingMap[msgid].vec = embedding;
|
||||||
|
updated = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Failed to embed: "${msgid}"`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
// embeddingMap 에 simiality 리스트를 작성한다
|
||||||
|
console.log('✅ 높은 유사도를 가진 텍스트를 연결합니다');
|
||||||
|
for (const item of items) {
|
||||||
|
const msgid = item.msgid.trim();
|
||||||
|
const similars = searchSimilarItems(embeddingMap, msgid)
|
||||||
|
console.log(`🔍 Add Similars: ${msgid} => ${similars}`);
|
||||||
|
embeddingMap[msgid].similars = similars;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(embeddingPath, JSON.stringify(embeddingMap, null, 2), 'utf8');
|
||||||
|
console.log(`✅ embedding.json 저장 완료 (${Object.keys(embeddingMap).length}개)`);
|
||||||
|
} else {
|
||||||
|
console.log('✅ 모든 임베딩이 이미 존재합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(embeddingMap);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cosineSimilarity(vecA, vecB) {
|
||||||
|
const dot = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0);
|
||||||
|
const normA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0));
|
||||||
|
const normB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0));
|
||||||
|
return dot / (normA * normB);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchSimilarItems(embeddingMap, queryText, topN = 5) {
|
||||||
|
const queryVec = embeddingMap[queryText].vec;
|
||||||
|
const scored = Object.entries(embeddingMap)
|
||||||
|
.map(([msgid, emb]) => ({
|
||||||
|
msgid,
|
||||||
|
score: cosineSimilarity(queryVec, emb.vec)
|
||||||
|
}))
|
||||||
|
.filter(x => x.score > 0.5) // 자기 자신은 제외하고 일정 이상의 스코어만을 추가
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, topN);
|
||||||
|
|
||||||
|
return scored.map(x => x.msgid);// msgid 만 반환
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateEmbeddingsFromPo
|
||||||
|
}
|
||||||
12
src/log.js
Normal file
12
src/log.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import * as fs from "fs"
|
||||||
|
|
||||||
|
function logToFile(prompt, result, logFile = 'conversion.log') {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const log = `[${timestamp}]\nInput: ${prompt}\nOutput: ${result}\n\n`;
|
||||||
|
fs.appendFileSync(logFile, log, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
logToFile
|
||||||
|
}
|
||||||
152
src/main.js
Normal file
152
src/main.js
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import fs from 'fs-extra'
|
||||||
|
import * as path from 'path'
|
||||||
|
import PO from "pofile";
|
||||||
|
import { loadVocaDict, extractWordsList } from './voca.js'
|
||||||
|
import { translateText } from "./openai.js"
|
||||||
|
import { generateEmbeddingsFromPo } from "./embedding.js";
|
||||||
|
import { logToFile } from './log.js';
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ 입력 PO 파일 경로
|
||||||
|
const ROOT_NAME = "fix001"
|
||||||
|
const INPUT_FILE = `./${ROOT_NAME}.po`;
|
||||||
|
const HINT_FILE = `./${ROOT_NAME}/en-US/TextExport.po`;
|
||||||
|
const EMBEDDING_FILE = './embedding.json';
|
||||||
|
|
||||||
|
// ✅ 번역 대상 언어
|
||||||
|
const languages = {
|
||||||
|
// 'en-US': 'English',
|
||||||
|
// 'ja-JP': 'Japanese',
|
||||||
|
// 'zh-Hans': 'Simplified Chinese',
|
||||||
|
// 'zh-Hant': 'Traditional Chinese',
|
||||||
|
'es-ES': 'Spanish (Spain)',
|
||||||
|
'es-419': 'Spanish (Latin America)',
|
||||||
|
'fr-FR': 'French',
|
||||||
|
'de-DE': 'German',
|
||||||
|
'ru-RU': 'Russian',
|
||||||
|
'pt-BR': 'Portuguese (Brazil)',
|
||||||
|
'pt-PT': 'Portuguese (Portugal)',
|
||||||
|
'it-IT': 'Italian',
|
||||||
|
'pl-PL': 'Polish',
|
||||||
|
'tr-TR': 'Turkish',
|
||||||
|
'uk-UA': 'Ukrainian',
|
||||||
|
'vi-VN': 'Vietnamese',
|
||||||
|
//-----------------------------
|
||||||
|
// 'th': 'Thai',
|
||||||
|
// 'id-ID': 'Indonesian',
|
||||||
|
// 'ar': 'Arabic',
|
||||||
|
// 'sv-SE': 'Swedish',
|
||||||
|
// 'fi-FI': 'Finnish',
|
||||||
|
// 'da-DK': 'Danish',
|
||||||
|
// 'nl-NL': 'Dutch',
|
||||||
|
// 'hu': 'Hungarian',
|
||||||
|
// 'cs': 'Czech',
|
||||||
|
// 'el-GR': 'Greek',
|
||||||
|
// 'no': 'Norwegian',
|
||||||
|
// 'ro-RO': 'Romanian',
|
||||||
|
// 'bg-GB': 'Bulgarian',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function loadPoAsync(filePath) {
|
||||||
|
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 파일이 존재하는지 체크
|
||||||
|
if(!fs.existsSync(filePath))
|
||||||
|
{
|
||||||
|
return resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
PO.load(filePath, (err, po) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(po);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePoAsync(po, filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
po.save(filePath, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ PO 파일 번역
|
||||||
|
async function run() {
|
||||||
|
const embeddings = await generateEmbeddingsFromPo(INPUT_FILE, EMBEDDING_FILE);
|
||||||
|
const vocaDict = loadVocaDict(path.join(".", 'voca.csv'));
|
||||||
|
|
||||||
|
for (const [langCode, langName] of Object.entries(languages)) {
|
||||||
|
const targetDir = path.join(`./${ROOT_NAME}`, langCode);
|
||||||
|
const outputFile = path.join(targetDir, 'TextExport.po');
|
||||||
|
const logfile = path.join('./logs', langCode+".log");
|
||||||
|
|
||||||
|
await fs.ensureDir(targetDir);
|
||||||
|
const po = (await loadPoAsync(outputFile)) || (await loadPoAsync(INPUT_FILE));
|
||||||
|
const referencess = {};
|
||||||
|
|
||||||
|
|
||||||
|
const hint_po = await loadPoAsync(HINT_FILE)
|
||||||
|
|
||||||
|
for (const item of po.items) {
|
||||||
|
if (!item.msgid || !/[가-힣]/.test(item.msgid)) continue;
|
||||||
|
|
||||||
|
// 힌트에서 동일한 key (msgctxt) 를 가지는 언어를 찾는다
|
||||||
|
const words = extractWordsList(vocaDict, langName, langCode, item.msgid);
|
||||||
|
const hintItem = hint_po.items.find(x => x.msgctxt === item.msgctxt);
|
||||||
|
const hintText = hintItem && hintItem.msgstr && hintItem.msgstr[0] && hintItem.msgid != hintItem.msgstr[0] ? hintItem.msgstr[0] : undefined;
|
||||||
|
const comment = item.references[0];
|
||||||
|
const similars = embeddings[item.msgid]?.similars || [];
|
||||||
|
const history = [];
|
||||||
|
for (const sim of similars) {
|
||||||
|
if (referencess[sim]) {
|
||||||
|
history.push(sim + " => " + referencess[sim]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동일한 단어를 포함한 경우에도 추가한다
|
||||||
|
const hWords = item.msgid.split(/\s+/);
|
||||||
|
for(const refId in referencess) {
|
||||||
|
if (referencess[refId]) {
|
||||||
|
if (hWords.some(word => refId.includes(word))) {
|
||||||
|
history.push(refId + " => " + referencess[refId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [prompt, translatedText] = await translateText(item.msgid, hintText, comment, langName, words, history);
|
||||||
|
item.msgstr = translatedText;
|
||||||
|
|
||||||
|
console.log(prompt, translatedText);
|
||||||
|
|
||||||
|
if (!translatedText.includes("\n")) {
|
||||||
|
referencess[item.msgid] = translatedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${langCode}] ${item.msgid} → ${translatedText}`);
|
||||||
|
|
||||||
|
// 로그를 기록한다
|
||||||
|
logToFile(prompt, translatedText, logfile);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ [${langCode}] 번역 실패 (${item.msgid}):`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await savePoAsync(po, outputFile);
|
||||||
|
console.log(`✅ [${langCode}] 저장 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
37
src/openai.js
Normal file
37
src/openai.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import OpenAI from 'openai'
|
||||||
|
import {getSystemPrompt, getUserPrompt } from "./prompt.js"
|
||||||
|
|
||||||
|
process.env.OPENAI_API_KEY = "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"
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ GPT 번역 함수
|
||||||
|
async function translateText(text, hint_text, comment, targetLangName, words, history) {
|
||||||
|
const systemPrompt = getSystemPrompt();
|
||||||
|
const userPrompt = getUserPrompt(text, hint_text, targetLangName, comment, words, history);
|
||||||
|
const res = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
messages: [{
|
||||||
|
role: "system", content: systemPrompt,
|
||||||
|
role: 'user', content: userPrompt
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
return [userPrompt, res.choices[0].message.content.trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmbedding(text) {
|
||||||
|
const response = await openai.embeddings.create({
|
||||||
|
model: 'text-embedding-3-small', // 또는 'text-embedding-ada-002'
|
||||||
|
input: text
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data[0].embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
translateText,
|
||||||
|
getEmbedding,
|
||||||
|
}
|
||||||
105
src/prompt.js
Normal file
105
src/prompt.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
function getSystemPrompt() {
|
||||||
|
return "You are an assistant that helps to translate";
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRule = `다음 규칙에 따라 번역해 주세요.
|
||||||
|
────────────────────────────
|
||||||
|
1. 변수 보호:
|
||||||
|
- {VariableName} 형태의 변수는 절대 변경하거나 번역하지 마세요.
|
||||||
|
- 예: {Value}, {PlayerName} → 원문 그대로 유지
|
||||||
|
2. 리치 텍스트 태그 보존:
|
||||||
|
- <TagName>...</> 또는 <TagName> 형식의 태그는 번역하지 마세요.
|
||||||
|
- 태그 내부 텍스트는 번역하지만, 태그 구조와 위치는 그대로 유지하세요.
|
||||||
|
3. 줄바꿈 및 특수 문자 보존:
|
||||||
|
- 줄바꿈과 특수문자는 원문 그대로 유지하세요.
|
||||||
|
4. 번역된 문장 이외에 어떤 것도 포함하지 않는다.
|
||||||
|
- "### 수정 결과" 라는 문장은 응답에 포함하지 않는다.
|
||||||
|
- 원문에 존재하지 않는 태그나 특수기호를 결과에 추가하지 않는다.
|
||||||
|
`
|
||||||
|
|
||||||
|
function getCategoryGuide(category) {
|
||||||
|
category = category.toLowerCase()
|
||||||
|
if (category.includes("character")) {
|
||||||
|
return "캐릭터와 스탯의 이름은 원문에 가깝게 하되 용어집을 적극 참조해야 됩니다."
|
||||||
|
} else if (category.includes("skill") || category.includes("combat")) {
|
||||||
|
return "스킬 이름은 **캐릭터의 직업 및 특성과 일관성**을 가져야 합니다. 스킬 설명에 사용되는 '피해량', '지속시간', '범위' 등의 용어는 용어집을 적극 참조한다."
|
||||||
|
} else if (category.includes("item") || category.includes("equip")) {
|
||||||
|
return "아이템의 판타지 세계관과 특성을 잘 나타내도록 번역해야 하며, 아이템 등급(일반, 고급, 희귀, 전설)에 따라 이름의 무게감을 조절해야 합니다. 아이템의 고유 이름은 **원본의 느낌을 살리는 창의적인 번역**이 매우 중요합니다."
|
||||||
|
} else if (category.includes("world") || category.includes("interaction")) {
|
||||||
|
return "원본의 독특한 어투('~할지어다')는 마녀의 정체성이 나타나므로 각 언어별 문화에 맞게 톤을 통일해줘야 합니다."
|
||||||
|
} else if (category.includes("ui") || category.includes("system")) {
|
||||||
|
return "사용자에게 직접 노출되는 UI와 설정이 대부분이므로 **간결하고 명확한 번역**이 필수입니다. 특히 '확인', '취소'와 같은 표준 UI 용어와 'Anti-Aliasing' 같은 기술 용어는 **완벽한 일관성**을 유지해야 합니다."
|
||||||
|
} else if (category.includes("store") || category.includes("event")) {
|
||||||
|
return "상품의 정보를 **명확하고 오해의 소지가 없도록** 번역해야 합니다."
|
||||||
|
} else {
|
||||||
|
return "- **문맥 파악이 가장 중요**합니다. 텍스트의 정확한 용도를 이해하도록 합니다"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeDuplicates = (array) => [...new Set(array)];
|
||||||
|
|
||||||
|
function getUserPrompt(koText, enText, targetLangName, category, wordDic, history) {
|
||||||
|
let prompt = baseRule;
|
||||||
|
// 카테고리 가이드를 적용
|
||||||
|
prompt += `5. ${getCategoryGuide(category)}
|
||||||
|
`
|
||||||
|
|
||||||
|
// 용어집 있는 경우 용어집을 추가
|
||||||
|
if (wordDic.length > 0) {
|
||||||
|
prompt += `6. 용어집 사용:
|
||||||
|
- 지정된 번역 용어가 있는 경우 반드시 해당 언어로 대체하세요.
|
||||||
|
- 용어집과 비교시 대소문자에 의한 차이를 두지 않는다.
|
||||||
|
- 용어집에는 {"full":풀네임, "short":약어} 형식으로 병기되었을때, 둘중에 반드시 하나만 표기한다
|
||||||
|
- 예: {"full":"Armor Points", "short":"AP"} => "Armor Points" 혹은 "AP" 중에 하나를 선택한다. **이 규칙은 반드시 지킨다**
|
||||||
|
${enText ? "- 풀네임과 약어는 #영어 문장의 형태를 우선하여 선택한다. " : "- 문맥에 가장 자연스러운 표현을 선택한다"}
|
||||||
|
────────────────────────────
|
||||||
|
### 용어집
|
||||||
|
${wordDic.join("\r\n")}
|
||||||
|
────────────────────────────
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 과거 번역 기록이 있는 경우 번역기록읅 추가
|
||||||
|
if (history.length > 0) {
|
||||||
|
prompt += `7. 과거 번역 기록을 이용하여 일관성 유지한다
|
||||||
|
────────────────────────────
|
||||||
|
### 번역기록
|
||||||
|
${removeDuplicates(history).join("\r\n")}
|
||||||
|
────────────────────────────
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enText) {
|
||||||
|
// 영어 원문이 있는 경우는 두가짖 언어를 모두 보고 결과를 도출
|
||||||
|
prompt += `────────────────────────────
|
||||||
|
위의 규칙을 지키며 다음의 문장을 [${targetLangName}]로 번역한다.
|
||||||
|
- 한국어와 영어는 동일한 의미를 가진다. 이를 참고래서 번역 대상 언어로 번역을 한다.
|
||||||
|
- 하나의 번역 결과만을 생성하여야 한다
|
||||||
|
- 응답에는 번역된 문장 이외에 어떤 것도 포함하지 않는다
|
||||||
|
|
||||||
|
### 한국어
|
||||||
|
${koText}
|
||||||
|
### 영어
|
||||||
|
${enText}
|
||||||
|
────────────────────────────
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
prompt += `────────────────────────────
|
||||||
|
위의 규칙을 지키며 다음의 문장을 [${targetLangName}]로 번역한다.
|
||||||
|
- 응답에는 번역된 문장 이외에 어떤 것도 포함하지 않는다
|
||||||
|
### 한국어
|
||||||
|
${koText}
|
||||||
|
────────────────────────────
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += `### 번역 결과
|
||||||
|
`
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getSystemPrompt,
|
||||||
|
getUserPrompt,
|
||||||
|
}
|
||||||
54
src/voca.js
Normal file
54
src/voca.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import { parse } from 'csv-parse/sync';
|
||||||
|
|
||||||
|
// 용어집 로드
|
||||||
|
function loadVocaDict(csvPath) {
|
||||||
|
const csv = fs.readFileSync(csvPath, 'utf8');
|
||||||
|
const records = parse(csv, {
|
||||||
|
columns: true,
|
||||||
|
skip_empty_lines: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map(row => {
|
||||||
|
const keys = Object.keys(row).map(x => x.trim());
|
||||||
|
const values = Object.values(row);
|
||||||
|
const obj = {}
|
||||||
|
|
||||||
|
for(let i = 0; i < keys.length; i++)
|
||||||
|
{
|
||||||
|
obj[keys[i]] = values[i];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractWordsList(voca, contry, langCode, inputKorean) {
|
||||||
|
const words = inputKorean.split(/\s+/); // 공백 기준
|
||||||
|
const targetLang = `${contry} (${langCode})`;
|
||||||
|
const rows = voca
|
||||||
|
.filter(row =>
|
||||||
|
row['원본 용어 (ko)'] &&
|
||||||
|
row[targetLang] &&
|
||||||
|
row['대분류'] &&
|
||||||
|
row['중분류']
|
||||||
|
)
|
||||||
|
.filter(row => words.every(word => row['원본 용어 (ko)'].includes(word) || words.some(word => row['원본 용어 (ko)'] == word)))
|
||||||
|
.map(row => `{'${row['원본 용어 (ko)']}' => '${replaceFullNameShortName(row[targetLang])}'}`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFullNameShortName(text) {
|
||||||
|
const pattern = /^\s*([\p{L}\p{M}\p{Zs}'\-]+?)\s*\(\s*([\p{L}\p{N}]+)\s*\)\s*$/u;
|
||||||
|
const match = text.match(pattern);
|
||||||
|
|
||||||
|
if (!match) return text;
|
||||||
|
|
||||||
|
const [, fullName, shortName] = match;
|
||||||
|
return `{"full":"${fullName}", "short":"${shortName}"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export {
|
||||||
|
loadVocaDict,
|
||||||
|
extractWordsList,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user