inital
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.idea
|
||||
.env
|
||||
205
control.lua
Normal file
205
control.lua
Normal file
@@ -0,0 +1,205 @@
|
||||
local EXPORTER_NAME = "signal-exporter"
|
||||
local EXPORT_INTERVAL = 18000 -- Ticks between file writes (18000 = 5 minutes)
|
||||
|
||||
local function init_global()
|
||||
global.exporters = global.exporters or {}
|
||||
global.open_guis = global.open_guis or {}
|
||||
end
|
||||
|
||||
script.on_init(init_global)
|
||||
script.on_configuration_changed(init_global)
|
||||
|
||||
local function export_combinator(unit_number, data)
|
||||
local entity = data.entity
|
||||
if not (entity and entity.valid) then
|
||||
global.exporters[unit_number] = nil
|
||||
return
|
||||
end
|
||||
|
||||
local signals = {
|
||||
circuit_network = {
|
||||
red = {},
|
||||
green = {}
|
||||
},
|
||||
logistic_network = {}
|
||||
}
|
||||
|
||||
local wires = {
|
||||
[defines.wire_type.red] = "red",
|
||||
[defines.wire_type.green] = "green"
|
||||
}
|
||||
|
||||
for wire_type, color in pairs(wires) do
|
||||
local network = entity.get_circuit_network(wire_type)
|
||||
if network and network.signals then
|
||||
for _, signal in ipairs(network.signals) do
|
||||
local name = signal.signal.name
|
||||
if name then
|
||||
signals.circuit_network[color][name] = (signals.circuit_network[color][name] or 0) + signal.count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local logistic_network = entity.surface.find_logistic_network_by_position(entity.position, entity.force)
|
||||
if logistic_network then
|
||||
local contents = logistic_network.get_contents()
|
||||
for name, count in pairs(contents) do
|
||||
signals.logistic_network[name] = count
|
||||
end
|
||||
end
|
||||
|
||||
local json_string = game.table_to_json(signals)
|
||||
game.write_file("signal_export/" .. data.name .. ".json", json_string, false)
|
||||
end
|
||||
|
||||
local function on_created(event)
|
||||
local entity = event.created_entity or event.entity or event.destination
|
||||
if not (entity and entity.valid) then return end
|
||||
|
||||
if entity.name == EXPORTER_NAME then
|
||||
global.exporters[entity.unit_number] = {
|
||||
entity = entity,
|
||||
name = "combinator_" .. tostring(entity.unit_number)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
local function on_destroyed(event)
|
||||
local entity = event.entity
|
||||
if not (entity and entity.valid) then return end
|
||||
|
||||
if entity.name == EXPORTER_NAME then
|
||||
global.exporters[entity.unit_number] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local build_events = {
|
||||
defines.events.on_built_entity,
|
||||
defines.events.on_robot_built_entity,
|
||||
defines.events.script_raised_built,
|
||||
defines.events.script_raised_revive
|
||||
}
|
||||
for _, e in ipairs(build_events) do
|
||||
script.on_event(e, on_created)
|
||||
end
|
||||
|
||||
local destroy_events = {
|
||||
defines.events.on_player_mined_entity,
|
||||
defines.events.on_robot_mined_entity,
|
||||
defines.events.on_entity_died,
|
||||
defines.events.script_raised_destroy
|
||||
}
|
||||
for _, e in ipairs(destroy_events) do
|
||||
script.on_event(e, on_destroyed)
|
||||
end
|
||||
|
||||
script.on_nth_tick(EXPORT_INTERVAL, function()
|
||||
for unit_number, data in pairs(global.exporters) do
|
||||
export_combinator(unit_number, data)
|
||||
end
|
||||
end)
|
||||
|
||||
script.on_event(defines.events.on_gui_opened, function(event)
|
||||
local entity = event.entity
|
||||
if entity and entity.valid and entity.name == EXPORTER_NAME then
|
||||
local player = game.players[event.player_index]
|
||||
local data = global.exporters[entity.unit_number]
|
||||
if not data then return end
|
||||
|
||||
local screen = player.gui.screen
|
||||
if screen["signal_exporter_gui"] then
|
||||
screen["signal_exporter_gui"].destroy()
|
||||
end
|
||||
|
||||
local frame = screen.add{type = "frame", name = "signal_exporter_gui", caption = "Configure Signal Exporter", direction = "vertical"}
|
||||
frame.force_auto_center()
|
||||
|
||||
local name_flow = frame.add{type = "flow", name = "name_flow", direction = "horizontal"}
|
||||
name_flow.style.vertical_align = "center"
|
||||
name_flow.style.bottom_margin = 8
|
||||
name_flow.add{type = "label", caption = "Combinator Name: "}
|
||||
name_flow.add{type = "textfield", name = "exporter_name_input", text = data.name}
|
||||
|
||||
local button_flow = frame.add{type = "flow", name = "button_flow", direction = "horizontal"}
|
||||
button_flow.add{type = "button", name = "exporter_save_button", caption = "Save & Close"}
|
||||
button_flow.add{type = "button", name = "exporter_refresh_button", caption = "Export Now"}
|
||||
|
||||
player.opened = frame
|
||||
global.open_guis[player.index] = entity.unit_number
|
||||
end
|
||||
end)
|
||||
|
||||
script.on_event(defines.events.on_gui_click, function(event)
|
||||
local element = event.element
|
||||
if element.name == "exporter_save_button" or element.name == "exporter_refresh_button" then
|
||||
local player = game.players[event.player_index]
|
||||
local unit_number = global.open_guis[player.index]
|
||||
|
||||
if unit_number and global.exporters[unit_number] then
|
||||
local frame = player.gui.screen["signal_exporter_gui"]
|
||||
if frame then
|
||||
local new_name = frame.name_flow.exporter_name_input.text
|
||||
global.exporters[unit_number].name = new_name
|
||||
end
|
||||
|
||||
if element.name == "exporter_refresh_button" then
|
||||
export_combinator(unit_number, global.exporters[unit_number])
|
||||
player.print("Data manually exported to signals_" .. global.exporters[unit_number].name .. ".json")
|
||||
end
|
||||
end
|
||||
|
||||
if element.name == "exporter_save_button" then
|
||||
if player.gui.screen["signal_exporter_gui"] then
|
||||
player.gui.screen["signal_exporter_gui"].destroy()
|
||||
end
|
||||
global.open_guis[player.index] = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
script.on_event(defines.events.on_gui_closed, function(event)
|
||||
if event.element and event.element.name == "signal_exporter_gui" then
|
||||
event.element.destroy()
|
||||
global.open_guis[event.player_index] = nil
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
-- Function to export all item prototypes to a CSV file.
|
||||
local function export_item_names_csv(event)
|
||||
local player = game.players[event.player_index]
|
||||
local csv_lines = { '"item_key","localised_name"' }
|
||||
|
||||
-- Helper function to safely escape double quotes for CSV format.
|
||||
local function escape_csv_field(value)
|
||||
if value == nil then return "" end
|
||||
return string.gsub(tostring(value), '"', '""')
|
||||
end
|
||||
|
||||
-- Iterate through all registered item prototypes.
|
||||
for _, item_prototype in pairs(game.item_prototypes) do
|
||||
local key = item_prototype.name
|
||||
|
||||
-- Use player.localise() to resolve the LocalisedString object (item_prototype.localised_name)
|
||||
-- into the final, human-readable text for the current player's language.
|
||||
local resolved_name = player.localise(item_prototype.localised_name)
|
||||
|
||||
local escaped_key = escape_csv_field(key)
|
||||
local escaped_name = escape_csv_field(resolved_name)
|
||||
|
||||
table.insert(csv_lines, '"' .. escaped_key .. '","' .. escaped_name .. '"')
|
||||
end
|
||||
|
||||
local csv_content = table.concat(csv_lines, "\n")
|
||||
game.write_file("item_names.csv", csv_content, false, player.index)
|
||||
|
||||
player.print("Export complete. File saved to 'script-output/item_names.csv'.")
|
||||
end
|
||||
|
||||
-- Register a console command to trigger the export.
|
||||
commands.add_command(
|
||||
"export-item-names",
|
||||
"Exports the internal name (key) and localised name for all game items to a CSV file.",
|
||||
export_item_names_csv
|
||||
)
|
||||
63
data.lua
Normal file
63
data.lua
Normal file
@@ -0,0 +1,63 @@
|
||||
local entity = table.deepcopy(data.raw["constant-combinator"]["constant-combinator"])
|
||||
entity.name = "signal-exporter"
|
||||
entity.minable.result = "signal-exporter"
|
||||
|
||||
local purple = { r = 0.6, g = 0.1, b = 0.9, a = 1.0 }
|
||||
|
||||
-- Recursively tints all layers of an animation definition, including any high-resolution versions.
|
||||
local function apply_tint_recursively(animation)
|
||||
if not animation then return end
|
||||
|
||||
if animation.layers then
|
||||
for _, layer in ipairs(animation.layers) do
|
||||
layer.tint = purple
|
||||
end
|
||||
end
|
||||
|
||||
if animation.hr_version then
|
||||
apply_tint_recursively(animation.hr_version)
|
||||
end
|
||||
end
|
||||
|
||||
-- Tints an Animation4Way structure by applying a tint to each of the four directions.
|
||||
local function tint_animation_4_way(animations)
|
||||
if not animations then return end
|
||||
for _, direction_animation in pairs(animations) do
|
||||
apply_tint_recursively(direction_animation)
|
||||
end
|
||||
end
|
||||
|
||||
tint_animation_4_way(entity.sprites)
|
||||
tint_animation_4_way(entity.activity_led_sprites)
|
||||
|
||||
if entity.activity_led_light then
|
||||
entity.activity_led_light.color = purple
|
||||
end
|
||||
|
||||
local item = table.deepcopy(data.raw["item"]["constant-combinator"])
|
||||
item.name = "signal-exporter"
|
||||
item.place_result = "signal-exporter"
|
||||
item.order = "c[combinators]-z[signal-exporter]"
|
||||
|
||||
-- Replace the simple 'icon' with a tinted 'icons' table to color the UI icon.
|
||||
item.icon = nil
|
||||
item.icons = {
|
||||
{
|
||||
icon = "__base__/graphics/icons/constant-combinator.png",
|
||||
icon_size = 64,
|
||||
icon_mipmaps = 4,
|
||||
tint = purple
|
||||
}
|
||||
}
|
||||
|
||||
local recipe = table.deepcopy(data.raw["recipe"]["constant-combinator"])
|
||||
recipe.name = "signal-exporter"
|
||||
recipe.result = "signal-exporter"
|
||||
|
||||
-- Unlock this recipe with the same technology that unlocks the vanilla constant combinator.
|
||||
table.insert(data.raw.technology["circuit-network"].effects, {
|
||||
type = "unlock-recipe",
|
||||
recipe = "signal-exporter"
|
||||
})
|
||||
|
||||
data:extend({entity, item, recipe})
|
||||
9
info.json
Normal file
9
info.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "SignalExporter",
|
||||
"version": "1.0.0",
|
||||
"title": "Signal Exporter",
|
||||
"author": "Expert Engineer",
|
||||
"factorio_version": "1.1",
|
||||
"dependencies": ["base >= 1.1.0"],
|
||||
"description": "Provides a custom combinator that reads both connected circuit and logistic networks, writing the data to a JSON file."
|
||||
}
|
||||
8
locale/en/locale.cfg
Normal file
8
locale/en/locale.cfg
Normal file
@@ -0,0 +1,8 @@
|
||||
[entity-name]
|
||||
signal-exporter=Signal Exporter
|
||||
|
||||
[item-name]
|
||||
signal-exporter=Signal Exporter
|
||||
|
||||
[recipe-name]
|
||||
signal-exporter=Signal Exporter
|
||||
1126
n8n/Factorio Item Import.json
Normal file
1126
n8n/Factorio Item Import.json
Normal file
File diff suppressed because it is too large
Load Diff
181
node/extract.ts
Normal file
181
node/extract.ts
Normal 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);
|
||||
});
|
||||
1770
node/factorio_english_items.csv
Normal file
1770
node/factorio_english_items.csv
Normal file
File diff suppressed because it is too large
Load Diff
1664
node/factorio_german_items.csv
Normal file
1664
node/factorio_german_items.csv
Normal file
File diff suppressed because it is too large
Load Diff
21
node/package-lock.json
generated
Normal file
21
node/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
node/package.json
Normal file
5
node/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.17"
|
||||
}
|
||||
}
|
||||
140
node/watch.ts
Normal file
140
node/watch.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { watch, readFileSync } from 'fs';
|
||||
import { readdir, readFile, stat } from 'fs/promises';
|
||||
import { resolve, basename, extname, join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadEnv() {
|
||||
const envPath = resolve(__dirname, '.env');
|
||||
try {
|
||||
const envFileContent = readFileSync(envPath, 'utf-8');
|
||||
envFileContent.split(/\r?\n/).forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
||||
const parts = trimmedLine.split('=', 2);
|
||||
if (parts.length === 2) {
|
||||
const [key, value] = parts;
|
||||
process.env[key.trim()] = value.trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Successfully loaded configuration from .env file.');
|
||||
} catch (error) {
|
||||
console.error('FATAL: Could not find or read the .env file. Please ensure it exists in the same directory as this script.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
loadEnv();
|
||||
|
||||
const { WEBHOOK_BASE_URL, BASIC_AUTH_USERNAME, BASIC_AUTH_PASSWORD } = process.env;
|
||||
|
||||
if (!WEBHOOK_BASE_URL || !BASIC_AUTH_USERNAME || !BASIC_AUTH_PASSWORD) {
|
||||
console.error('FATAL: Please define WEBHOOK_BASE_URL, BASIC_AUTH_USERNAME, and BASIC_AUTH_PASSWORD in your .env file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const WATCH_PATH = resolve(__dirname, '..', '..', '..', 'script-output', 'signal_export');
|
||||
const DEBOUNCE_DELAY_MS = 250;
|
||||
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
// --- Core Logic ---
|
||||
|
||||
async function sendToWebhook(filePath: string) {
|
||||
try {
|
||||
const combinatorName = basename(filePath, extname(filePath));
|
||||
const url = `${WEBHOOK_BASE_URL}/${encodeURIComponent(combinatorName)}`;
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Silently ignore empty files, which can occur during file writing.
|
||||
if (!content.trim()) return;
|
||||
JSON.parse(content);
|
||||
|
||||
const authHeader = 'Basic ' + Buffer.from(`${BASIC_AUTH_USERNAME}:${BASIC_AUTH_PASSWORD}`).toString('base64');
|
||||
|
||||
console.log(`Sending data for '${combinatorName}'...`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST', // Using POST to send a request body, as is standard for webhooks.
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: content,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully sent data for '${combinatorName}'. Server responded with ${response.status}.`);
|
||||
} catch (err: any) {
|
||||
const fileName = basename(filePath);
|
||||
if (err instanceof SyntaxError) {
|
||||
console.error(`Skipping ${fileName}: Invalid JSON content. The file might be incomplete.`);
|
||||
} else {
|
||||
console.error(`Error sending webhook for ${fileName}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(filename: string | null) {
|
||||
if (!filename || !filename.endsWith('.json')) return;
|
||||
|
||||
if (debounceTimers.has(filename)) {
|
||||
clearTimeout(debounceTimers.get(filename)!);
|
||||
}
|
||||
|
||||
const newTimer = setTimeout(async () => {
|
||||
const fullPath = join(WATCH_PATH, filename);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
if (stats.isFile()) {
|
||||
await sendToWebhook(fullPath);
|
||||
}
|
||||
} catch {
|
||||
// Error (e.g., file deleted before processing), ignore.
|
||||
} finally {
|
||||
debounceTimers.delete(filename);
|
||||
}
|
||||
}, DEBOUNCE_DELAY_MS);
|
||||
|
||||
debounceTimers.set(filename, newTimer);
|
||||
}
|
||||
|
||||
async function initialScanAndProcess() {
|
||||
console.log(`Performing initial scan of directory: ${WATCH_PATH}`);
|
||||
try {
|
||||
const files = await readdir(WATCH_PATH);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
console.log(`Found ${jsonFiles.length} existing JSON file(s). Processing...`);
|
||||
for (const file of jsonFiles) {
|
||||
await sendToWebhook(join(WATCH_PATH, file));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error during initial scan: ${err.message}. Ensure the directory exists.`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Script Execution ---
|
||||
|
||||
(async () => {
|
||||
await initialScanAndProcess();
|
||||
|
||||
console.log(`\nWatching for file changes in: ${WATCH_PATH}`);
|
||||
try {
|
||||
watch(WATCH_PATH, (eventType, filename) => {
|
||||
if (filename) {
|
||||
console.log(`[${new Date().toLocaleTimeString()}] Event '${eventType}' on '${filename}'`);
|
||||
handleFileChange(filename);
|
||||
}
|
||||
});
|
||||
console.log("Watcher is now running. Press Ctrl+C to stop.");
|
||||
} catch (err: any) {
|
||||
console.error(`FATAL: Failed to start watcher. Please check if the path is correct. Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user