import { readdir, writeFile, readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { join, resolve } from 'path'; import os from 'os'; import AdmZip from 'adm-zip'; // --- Constants --- const GERMAN_LOCALE_DIR = 'en'; const LOCALE_DIR = 'locale'; const BASE_LOCALE_FILENAME = 'base.cfg'; const OUTPUT_CSV_FILENAME = 'factorio_english_items.csv'; const LOCALE_TARGET_SECTIONS = new Set([ 'entity-name', 'item-name', 'fluid-name', 'equipment-name', 'virtual-signal-name', ]); const LOCALE_KEY_VALUE_DELIMITER = '='; const LOCALE_COMMENT_PREFIXES = ['#', ';']; const MOD_FILE_EXTENSION = '.zip'; const CSV_HEADER = 'section,item_key,localized_name\n'; const DEFAULT_ENCODING: BufferEncoding = 'utf8'; const LOCALE_FILE_REGEX = new RegExp(`[\\\\/]?${LOCALE_DIR}[\\\\/]${GERMAN_LOCALE_DIR}[\\\\/].*\\.cfg$`, 'i'); /** * A list of common Factorio installation directories checked in order. * Add non-standard installation paths here if needed. */ const FACTORIO_INSTALL_PATHS: string[] = [ 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio', 'C:\\Program Files\\Steam\\steamapps\\common\\Factorio', 'C:\\Program Files\\Factorio', ]; const FACTORIO_USERDATA_PATHS: Record = { win32: join(process.env.APPDATA ?? '', 'Factorio'), darwin: join(os.homedir(), 'Library', 'Application Support', 'factorio'), linux: join(os.homedir(), '.factorio'), }; /** * Resolves the Factorio installation and user data directories for the current platform. * @returns An object with `installPath` and `userdataPath`, each null if not found. */ function getFactorioPaths(): { installPath: string | null; userdataPath: string | null } { const installPath = FACTORIO_INSTALL_PATHS.find(existsSync) ?? null; const userdataPath = FACTORIO_USERDATA_PATHS[os.platform()] ?? null; return { installPath, userdataPath: userdataPath && existsSync(userdataPath) ? userdataPath : null, }; } /** * Parses INI/CFG file content to extract key-value pairs from all target sections. * @param content The raw text content of the .cfg file. * @returns A map of `"section/key"` to `{ section, key, value }`. */ function parseLocale(content: string): Map { const items = new Map(); const lines = content.split(/\r?\n/); let currentSection: string | null = null; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) { const sectionName = trimmedLine.slice(1, -1); currentSection = LOCALE_TARGET_SECTIONS.has(sectionName) ? sectionName : null; continue; } const isComment = LOCALE_COMMENT_PREFIXES.some(prefix => trimmedLine.startsWith(prefix)); if (currentSection && trimmedLine && !isComment) { const parts = trimmedLine.split(LOCALE_KEY_VALUE_DELIMITER, 2); if (parts.length === 2) { const [key, value] = parts; if (key && value) { const compositeKey = `${currentSection}/${key.trim()}`; if (!items.has(compositeKey)) { items.set(compositeKey, { section: currentSection, key: key.trim(), value: value.trim() }); } } } } } return items; } /** * Escapes a string for use in a CSV field, quoting where necessary. * @param value The value to escape. * @returns The CSV-safe value. */ function toCsvField(value: string): string { if (/[",\n]/.test(value)) { return `"${value.replace(/"/g, '""')}"`; } return value; } /** * Main function to orchestrate the scanning, parsing, and exporting process. */ async function run(): Promise { const allItems = new Map(); const { installPath, userdataPath } = getFactorioPaths(); const mergeItems = (source: Map) => { for (const [compositeKey, entry] of source) { if (!allItems.has(compositeKey)) allItems.set(compositeKey, entry); } }; // --- Process Base Game File First --- if (installPath) { const baseLocalePath = join(installPath, 'data', 'base', LOCALE_DIR, GERMAN_LOCALE_DIR, BASE_LOCALE_FILENAME); console.log(`Attempting to read base game locale from: ${baseLocalePath}`); if (existsSync(baseLocalePath)) { try { const content = await readFile(baseLocalePath, DEFAULT_ENCODING); console.log(` -> Reading ${baseLocalePath}`); mergeItems(parseLocale(content)); } catch (err) { console.warn(`[WARN] Could not read or parse base game locale file.`, err); } } else { console.warn(`[WARN] Base game locale file not found at the expected path. Skipping.`); } } else { console.warn(`[WARN] Factorio installation not found. Searched paths defined in FACTORIO_INSTALL_PATHS. Skipping base game file.`); } // --- Process Mods --- if (userdataPath) { const modsPath = join(userdataPath, 'mods'); console.log(`\nScanning for mods in: ${modsPath}`); if (!existsSync(modsPath)) { console.error(`[ERROR] Mods directory not found at the expected user data path.`); } else { const files = await readdir(modsPath); const zipFiles = files.filter(file => file.toLowerCase().endsWith(MOD_FILE_EXTENSION)); console.log(`Found ${zipFiles.length} zip archives to process.`); for (const zipFile of zipFiles) { const zipPath = join(modsPath, zipFile); try { const zip = new AdmZip(zipPath); for (const entry of zip.getEntries()) { if (LOCALE_FILE_REGEX.test(entry.entryName)) { console.log(` -> Reading ${entry.entryName} from ${zipFile}`); const content = entry.getData().toString(DEFAULT_ENCODING); mergeItems(parseLocale(content)); } } } catch (err) { console.warn(`[WARN] Could not process ${zipFile}. It may be corrupted or unreadable.`, err); } } } } else { console.warn(`[WARN] Factorio user data directory not found. Skipping mod processing.`); } if (allItems.size === 0) { console.log('\nFinished. No entries were found for the target sections.'); return; } console.log(`\nFound a total of ${allItems.size} unique entries across all files.`); const csvRows = Array.from(allItems.values()).map(({ section, key, value }) => `${toCsvField(section)},${toCsvField(key)},${toCsvField(value)}` ); const outputCsvPath = resolve(process.cwd(), OUTPUT_CSV_FILENAME); await writeFile(outputCsvPath, CSV_HEADER + csvRows.join('\n'), DEFAULT_ENCODING); console.log(`Successfully exported all entries to: ${outputCsvPath}`); } run().catch(err => { console.error("\n[FATAL] An unexpected error occurred:", err); });