restructure: move mod files into plugin/ directory
Some checks failed
Build & Push / build (push) Failing after 1m41s

This commit is contained in:
Sebastian Seedorf
2026-05-19 13:44:17 +02:00
parent 3af0af01e0
commit cd35b8e85e
12 changed files with 0 additions and 0 deletions

4
plugin/node/.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Webhook configuration for the Factorio signal watcher
INGEST_BASE_URL=http://localhost:3000
API_TOKEN=password

4
plugin/node/.env.local Normal file
View File

@@ -0,0 +1,4 @@
# Webhook configuration for the Factorio signal watcher
INGEST_BASE_URL=http://localhost:3000
API_TOKEN=change-me

181
plugin/node/extract.ts Normal file
View File

@@ -0,0 +1,181 @@
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<string, string> = {
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<string, { section: string; key: string; value: string }> {
const items = new Map<string, { section: string; key: string; value: string }>();
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<void> {
const allItems = new Map<string, { section: string; key: string; value: string }>();
const { installPath, userdataPath } = getFactorioPaths();
const mergeItems = (source: Map<string, { section: string; key: string; value: string }>) => {
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);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

21
plugin/node/package-lock.json generated Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "node",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"adm-zip": "^0.5.17"
}
},
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
}
}
}
}

6
plugin/node/package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"type": "module",
"dependencies": {
"adm-zip": "^0.5.17"
}
}

128
plugin/node/watch.ts Normal file
View File

@@ -0,0 +1,128 @@
import { watch, readFileSync } from 'fs';
import { readdir, readFile, stat } from 'fs/promises';
import { resolve, basename, extname, join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadEnv(): void {
const envPath = resolve(__dirname, '.env');
try {
const content = readFileSync(envPath, 'utf-8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [key, value] = trimmed.split('=', 2);
if (key && value !== undefined) {
process.env[key.trim()] = value.trim().replace(/^"|"$/g, '');
}
}
console.log('Loaded configuration from .env');
} catch {
console.error('FATAL: Could not read .env file.');
process.exit(1);
}
}
loadEnv();
const { INGEST_BASE_URL, API_TOKEN } = process.env;
if (!INGEST_BASE_URL || !API_TOKEN) {
console.error('FATAL: INGEST_BASE_URL and API_TOKEN must be defined in .env');
process.exit(1);
}
const WATCH_PATH = resolve(__dirname, '..', '..', '..', 'script-output', 'signal_export');
const DEBOUNCE_DELAY_MS = 250;
const debounceTimers = new Map<string, NodeJS.Timeout>();
async function sendToWebhook(filePath: string, mtimeMs: number): Promise<void> {
const combinatorName = basename(filePath, extname(filePath));
const url = `${INGEST_BASE_URL}/api/ingest/${encodeURIComponent(combinatorName)}?token=${API_TOKEN}`;
try {
const content = await readFile(filePath, 'utf-8');
if (!content.trim()) return;
JSON.parse(content); // validate before sending
console.log(`Sending '${combinatorName}' (mtime: ${new Date(mtimeMs).toISOString()})`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-File-Mtime': String(mtimeMs),
},
body: content,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
console.log(`OK '${combinatorName}' → ${response.status}`);
} catch (err: any) {
if (err instanceof SyntaxError) {
console.error(`Skip '${combinatorName}': invalid JSON`);
} else {
console.error(`Error '${combinatorName}':`, err.message);
}
}
}
function handleFileChange(filename: string | null): void {
if (!filename?.endsWith('.json')) return;
const existing = debounceTimers.get(filename);
if (existing) clearTimeout(existing);
const timer = setTimeout(async () => {
const fullPath = join(WATCH_PATH, filename);
try {
const stats = await stat(fullPath);
if (stats.isFile()) {
await sendToWebhook(fullPath, stats.mtimeMs);
}
} catch {
// file removed before processing — ignore
} finally {
debounceTimers.delete(filename);
}
}, DEBOUNCE_DELAY_MS);
debounceTimers.set(filename, timer);
}
async function initialScan(): Promise<void> {
console.log(`Initial scan: ${WATCH_PATH}`);
try {
const files = (await readdir(WATCH_PATH)).filter(f => f.endsWith('.json'));
console.log(`Found ${files.length} file(s)`);
for (const file of files) {
const fullPath = join(WATCH_PATH, file);
const stats = await stat(fullPath);
await sendToWebhook(fullPath, stats.mtimeMs);
}
} catch (err: any) {
console.error(`Initial scan failed: ${err.message}`);
}
}
(async () => {
await initialScan();
console.log(`Watching: ${WATCH_PATH}`);
try {
watch(WATCH_PATH, (eventType, filename) => {
if (filename) {
console.log(`[${new Date().toLocaleTimeString()}] ${eventType}: ${filename}`);
handleFileChange(filename);
}
});
console.log('Watcher running. Ctrl+C to stop.');
} catch (err: any) {
console.error(`FATAL: Watcher failed: ${err.message}`);
process.exit(1);
}
})();