Skip to main content
Glama

GonMCPtool

localizationTool.ts17.8 kB
import fs from 'fs/promises'; import { parse } from 'csv-parse/sync'; import { stringify } from 'csv-stringify/sync'; /** * CSV 檔案結構 */ export interface LocalizationEntry { Key: string; 'zh-TW': string; 'zh-CN': string; en: string; [key: string]: string; // 其他可能的語言欄位 } /** * 搜尋結果 */ export interface SearchResult { totalResults: number; entries: LocalizationEntry[]; } /** * 超長文字結果 */ export interface LongValueResult { totalResults: number; entries: Array<{ key: string; language: string; value: string; length: number; }>; } /** * Unity多國語系CSV檔案處理工具 - 進階實現 * 支持部分CRUD操作,使用緩存系統避免重複讀取檔案 */ export class LocalizationTool { // 用於檔案緩存 private static cache = new Map<string, { data: LocalizationEntry[], timestamp: number }>(); // 緩存過期時間(毫秒) private static CACHE_EXPIRY = 30000; // 30秒 /** * 讀取CSV檔案的原始內容 * @param filePath CSV檔案路徑 * @returns 檔案原始內容 */ private static async readCSVFileRaw(filePath: string): Promise<string> { try { return await fs.readFile(filePath, 'utf-8'); } catch (error) { console.error(`讀取CSV檔案失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw new Error(`無法讀取檔案 ${filePath}: ${error}`); } } /** * 直接解析CSV內容為本地化項目 * 使用更穩健的解析策略處理非標準CSV * @param content CSV檔案內容 * @returns 解析後的本地化項目陣列 */ private static parseCSVContent(content: string): LocalizationEntry[] { try { // 首先嘗試正常解析,大多數情況下這應該能工作 return parse(content, { columns: true, skip_empty_lines: true, relaxColumnCount: true, relaxQuotes: true, escape: '\\', trim: true }) as LocalizationEntry[]; } catch (error) { console.warn(`標準CSV解析失敗,嘗試手動解析: ${error}`); // 手動解析CSV return this.manualParseCSV(content); } } /** * 手動解析CSV內容,當標準解析失敗時使用 * @param content CSV內容 * @returns 解析後的本地化項目陣列 */ private static manualParseCSV(content: string): LocalizationEntry[] { // 標準化換行符 const normalizedContent = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = normalizedContent.split('\n').filter(line => line.trim()); if (lines.length < 2) { // 至少需要標題行和一行數據 return []; } // 解析標題行 const headers = this.parseCSVRow(lines[0]); if (!headers.includes('Key')) { throw new Error('CSV檔案缺少必要的Key欄位'); } const entries: LocalizationEntry[] = []; // 解析數據行 for (let i = 1; i < lines.length; i++) { try { const row = this.parseCSVRow(lines[i]); const entry: Record<string, string> = {}; // 將每個欄位值與標題對應 for (let j = 0; j < headers.length; j++) { entry[headers[j]] = j < row.length ? row[j] : ''; } // 確保至少有Key if (entry.Key) { entries.push(entry as LocalizationEntry); } } catch (e) { console.warn(`解析第 ${i+1} 行時出錯,已跳過: ${e}`); } } return entries; } /** * 解析單行CSV數據 * @param line CSV行 * @returns 欄位值陣列 */ private static parseCSVRow(line: string): string[] { const row: string[] = []; let inQuotes = false; let currentValue = ''; for (let i = 0; i < line.length; i++) { const char = line.charAt(i); const nextChar = i < line.length - 1 ? line.charAt(i + 1) : ''; if (char === '"') { if (inQuotes && nextChar === '"') { // 處理轉義的引號 (兩個連續的引號) currentValue += '"'; i++; // 跳過下一個引號 } else { // 切換引號狀態 inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { // 欄位分隔符,但不在引號內 row.push(currentValue); currentValue = ''; } else { // 普通字符 currentValue += char; } } // 添加最後一個欄位 row.push(currentValue); return row; } /** * 從檔案緩存或直接讀取CSV資料 * @param filePath CSV檔案路徑 * @param force 是否強制重新讀取 */ private static async getCSVData(filePath: string, force = false): Promise<LocalizationEntry[]> { const now = Date.now(); const cached = this.cache.get(filePath); // 如果緩存有效且不需要強制重新讀取 if (!force && cached && (now - cached.timestamp < this.CACHE_EXPIRY)) { return cached.data; } try { // 讀取並解析CSV檔案 const content = await this.readCSVFileRaw(filePath); const records = this.parseCSVContent(content); // 更新緩存 this.cache.set(filePath, { data: records, timestamp: now }); return records; } catch (error) { console.error(`解析CSV檔案失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 寫入CSV資料到檔案並更新緩存 * @param filePath CSV檔案路徑 * @param data 要寫入的資料 */ private static async writeCSVData(filePath: string, data: LocalizationEntry[]): Promise<void> { try { // 檢查是否有數據 if (data.length === 0) { throw new Error('沒有要寫入的數據'); } // 獲取所有列名 const columnSet = new Set<string>(); columnSet.add('Key'); // 確保Key始終是第一列 // 收集所有可能的列 data.forEach(entry => { Object.keys(entry).forEach(key => columnSet.add(key)); }); // 轉換為陣列並將Key移到第一位 const columns = Array.from(columnSet); if (columns[0] !== 'Key') { const keyIndex = columns.indexOf('Key'); if (keyIndex > 0) { columns.splice(keyIndex, 1); columns.unshift('Key'); } } // 使用csv-stringify生成CSV內容 const output = stringify(data, { header: true, columns, quoted_string: true, quoted_empty: true }); // 寫入檔案 await fs.writeFile(filePath, output, 'utf-8'); // 更新緩存 this.cache.set(filePath, { data: [...data], // 深拷貝防止引用問題 timestamp: Date.now() }); } catch (error) { console.error(`寫入CSV檔案失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 清除檔案緩存 * @param filePath 特定檔案路徑,如果不提供則清除所有緩存 */ static clearCache(filePath?: string): void { if (filePath) { this.cache.delete(filePath); } else { this.cache.clear(); } } /** * 讀取特定Key的翻譯項目 * @param filePath CSV檔案路徑 * @param key 要查詢的Key * @returns 找到的翻譯項目,若不存在則返回null */ static async getEntryByKey(filePath: string, key: string): Promise<LocalizationEntry | null> { try { // 讀取所有資料 const records = await this.getCSVData(filePath); // 尋找匹配的Key const foundEntry = records.find(entry => entry.Key === key); return foundEntry || null; } catch (error) { console.error(`讀取Key失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 搜尋包含特定文字的翻譯項目 * @param filePath CSV檔案路徑 * @param searchText 要搜尋的文字 * @param language 限定搜尋的語言,例如 'zh-TW',若為空則搜尋所有語言 * @param limit 最大返回結果數量 * @returns 符合條件的翻譯項目陣列 */ static async searchEntries( filePath: string, searchText: string, language: string = '', limit: number = 10 ): Promise<SearchResult> { try { const records = await this.getCSVData(filePath); const searchTextLower = searchText.toLowerCase(); // 搜尋邏輯 let results: LocalizationEntry[] = []; if (language) { // 僅搜尋指定語言 results = records.filter(entry => entry[language] && entry[language].toLowerCase().includes(searchTextLower) ); } else { // 搜尋所有欄位 results = records.filter(entry => Object.entries(entry).some(([key, value]) => key !== 'Key' && value && value.toLowerCase().includes(searchTextLower) ) ); } // 限制返回數量 const limitedResults = results.slice(0, limit); return { totalResults: results.length, entries: limitedResults }; } catch (error) { console.error(`搜尋文字失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 新增翻譯項目 * @param filePath CSV檔案路徑 * @param entry 要新增的翻譯項目 * @returns 成功或失敗訊息 */ static async addEntry(filePath: string, entry: LocalizationEntry): Promise<string> { try { // 檢查輸入是否有效 if (!entry.Key) { return '錯誤: Key 不能為空'; } const records = await this.getCSVData(filePath); // 檢查Key是否已存在 if (records.some(e => e.Key === entry.Key)) { return `錯誤: Key "${entry.Key}" 已存在`; } // 新增項目 records.push({...entry}); // 寫回檔案並更新緩存 await this.writeCSVData(filePath, records); return `成功新增Key "${entry.Key}"`; } catch (error) { console.error(`新增翻譯項失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 更新翻譯項目 * @param filePath CSV檔案路徑 * @param key 要更新的Key * @param updateData 更新的數據,可以是部分欄位 * @returns 成功或失敗訊息 */ static async updateEntry( filePath: string, key: string, updateData: Partial<Omit<LocalizationEntry, 'Key'>> ): Promise<string> { try { const records = await this.getCSVData(filePath); // 找到要更新的項目索引 const index = records.findIndex(entry => entry.Key === key); if (index === -1) { return `錯誤: Key "${key}" 不存在`; } // 更新欄位 (確保過濾掉任何undefined值) const validUpdateData = Object.fromEntries( Object.entries(updateData).filter(([_, value]) => value !== undefined) ) as Record<string, string>; records[index] = { ...records[index], ...validUpdateData }; // 寫回檔案並更新緩存 await this.writeCSVData(filePath, records); return `成功更新Key "${key}"`; } catch (error) { console.error(`更新翻譯項失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 刪除翻譯項目 * @param filePath CSV檔案路徑 * @param key 要刪除的Key * @returns 成功或失敗訊息 */ static async deleteEntry(filePath: string, key: string): Promise<string> { try { const records = await this.getCSVData(filePath); // 找到要刪除的項目索引 const index = records.findIndex(entry => entry.Key === key); if (index === -1) { return `錯誤: Key "${key}" 不存在`; } // 刪除項目 records.splice(index, 1); // 寫回檔案並更新緩存 await this.writeCSVData(filePath, records); return `成功刪除Key "${key}"`; } catch (error) { console.error(`刪除翻譯項失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 匯出特定語言的翻譯資料為JSON格式 * @param filePath CSV檔案路徑 * @param language 要匯出的語言,例如 'zh-TW' * @returns JSON字串或失敗訊息 */ static async exportLanguageAsJson(filePath: string, language: string): Promise<string> { try { const records = await this.getCSVData(filePath); // 創建Key-Value的映射 const langData: Record<string, string> = {}; for (const entry of records) { if (entry.Key && entry[language]) { langData[entry.Key] = entry[language]; } } return JSON.stringify(langData, null, 2); } catch (error) { console.error(`匯出語言資料失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 從本地文件直接讀取目標Key的值 * 當標準解析方法失敗時使用,直接通過文本搜索查找Key * @param filePath CSV檔案路徑 * @param key 要查詢的Key * @returns 找到的翻譯項目,若不存在則返回null */ static async getEntryByKeyDirect(filePath: string, key: string): Promise<LocalizationEntry | null> { try { // 直接讀取檔案內容 const content = await this.readCSVFileRaw(filePath); const lines = content.split(/\r?\n/); if (lines.length < 2) { return null; } // 解析標題行 const headers = this.parseCSVRow(lines[0]); // 尋找包含該Key的行 for (let i = 1; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; // 嘗試解析該行 const values = this.parseCSVRow(line); // 檢查第一個值是否與Key匹配 if (values[0] === key) { // 構建結果對象 const result: Record<string, string> = {}; for (let j = 0; j < headers.length; j++) { result[headers[j]] = j < values.length ? values[j] : ''; } return result as LocalizationEntry; } } return null; } catch (error) { console.error(`直接讀取Key失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 搜尋有Key值但某語言翻譯為空的項目 * @param filePath CSV檔案路徑 * @param language 要檢查的語言,例如 'zh-TW'、'zh-CN'、'en' 等 * @param limit 最大返回結果數量 * @returns 缺少指定語言翻譯的項目列表 */ static async findMissingTranslations(filePath: string, language: string, limit: number = 50): Promise<SearchResult> { try { if (!language) { throw new Error('必須指定要檢查的語言'); } const records = await this.getCSVData(filePath); // 尋找指定語言翻譯為空的項目 const missingEntries = records.filter(entry => { // 確保有Key且該語言的翻譯為空 return entry.Key && (entry[language] === undefined || entry[language] === null || entry[language].trim() === ''); }); // 限制返回數量 const limitedResults = missingEntries.slice(0, limit); return { totalResults: missingEntries.length, entries: limitedResults }; } catch (error) { console.error(`搜尋缺少翻譯的項目失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } /** * 查找字數超過指定長度的翻譯項目 * @param filePath CSV檔案路徑 * @param threshold 字數閾值 * @param language 要檢查的語言,如不指定則檢查所有語言 * @param limit 最大返回結果數量 * @returns 超過字數閾值的項目列表 */ static async findLongValues( filePath: string, threshold: number, language: string = '', limit: number = 50 ): Promise<LongValueResult> { try { if (threshold <= 0) { throw new Error('字數閾值必須大於0'); } const records = await this.getCSVData(filePath); // 用於存儲結果的陣列 const longValues: Array<{ key: string; language: string; value: string; length: number; }> = []; // 遍歷所有記錄 for (const entry of records) { if (!entry.Key) continue; // 取得要檢查的語言欄位 const languagesToCheck = language ? [language] : Object.keys(entry).filter(key => key !== 'Key'); // 檢查每個語言欄位 for (const lang of languagesToCheck) { if (entry[lang] && entry[lang].length > threshold) { longValues.push({ key: entry.Key, language: lang, value: entry[lang], length: entry[lang].length }); } } } // 依長度降序排序 longValues.sort((a, b) => b.length - a.length); // 限制返回數量 const limitedResults = longValues.slice(0, limit); return { totalResults: longValues.length, entries: limitedResults }; } catch (error) { console.error(`搜尋長文字失敗: ${error instanceof Error ? error.message : '未知錯誤'}`); throw error; } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/GonTwVn/GonMCPtool'

If you have feedback or need assistance with the MCP directory API, please join our Discord server