node-red-contrib-file-template
Version:
A Node-RED node that loads HTML template content from the filesystem with automatic file watching, reloading, and built-in file editor
434 lines (374 loc) • 13.4 kB
JavaScript
module.exports = function (RED)
{
"use strict";
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
function FileTemplateNode(config)
{
RED.nodes.createNode(this, config);
const node = this;
// Configuration
node.filename = config.filename || "";
node.format = config.format || "handlebars";
node.syntax = config.syntax || "mustache";
node.template = config.template || "";
node.field = config.field || "payload";
node.fieldType = config.fieldType || "msg";
node.output = config.output || "str";
node.name = config.name;
node.loadOnBoot = config.loadOnBoot || false;
node.bootData = config.bootData || "{}";
node.bootDelay = parseInt(config.bootDelay) || 1000;
// File watching
let watcher = null;
let fileContent = "";
let lastModified = 0;
// Status tracking
function updateStatus(text, color = "grey")
{
node.status({
fill: color,
shape: "dot",
text: text
});
}
// Load template from file
function loadTemplate()
{
if (!node.filename)
{
updateStatus("No file specified", "red");
return;
}
const fullPath = path.resolve(node.filename);
try
{
// Check if file exists
if (!fs.existsSync(fullPath))
{
updateStatus(`File not found: ${node.filename}`, "red");
node.error(`Template file not found: ${fullPath}`);
return;
}
// Get file stats
const stats = fs.statSync(fullPath);
const modTime = stats.mtime.getTime();
// Only reload if file has changed
if (modTime > lastModified)
{
fileContent = fs.readFileSync(fullPath, 'utf8');
lastModified = modTime;
updateStatus(`Loaded: ${path.basename(node.filename)} (${fileContent.length} chars)`, "green");
node.log(`Template loaded from: ${fullPath} (${fileContent.length} characters)`);
}
} catch (err)
{
updateStatus(`Error loading file`, "red");
node.error(`Error loading template file ${fullPath}: ${err.message}`);
}
}
// Setup file watching
function setupWatcher()
{
if (!node.filename) return;
const fullPath = path.resolve(node.filename);
try
{
// Clean up existing watcher
if (watcher)
{
watcher.close();
}
// Watch the file for changes
watcher = chokidar.watch(fullPath, {
persistent: true,
ignoreInitial: false
});
watcher.on('change', () =>
{
node.log(`Template file changed: ${fullPath}`);
loadTemplate();
});
watcher.on('error', (error) =>
{
node.error(`File watcher error: ${error.message}`);
updateStatus("Watcher error", "red");
});
watcher.on('unlink', () =>
{
updateStatus("File deleted", "red");
node.warn(`Template file deleted: ${fullPath}`);
});
} catch (err)
{
node.error(`Error setting up file watcher: ${err.message}`);
updateStatus("Watcher failed", "red");
}
}
// Process template on boot
function processOnBoot()
{
if (!node.loadOnBoot) return;
try
{
// Parse boot data
let bootData = {};
if (node.bootData && node.bootData.trim())
{
try
{
bootData = JSON.parse(node.bootData);
} catch (e)
{
node.error(`Invalid boot data JSON: ${e.message}`);
bootData = {};
}
}
// Create synthetic message
const bootMsg = {
payload: bootData,
topic: "boot",
_bootTriggered: true
};
// Process using internal method
const result = processMessage(bootMsg);
if (result.error)
{
node.error(`Boot processing error: ${result.error.message}`);
updateStatus("Boot error", "red");
} else
{
node.log(`Template processed on boot with data: ${JSON.stringify(bootData)}`);
updateStatus("Boot complete", "green");
}
} catch (err)
{
node.error(`Boot processing error: ${err.message}`);
updateStatus("Boot error", "red");
}
}
// Internal message processing function
function processMessage(msg)
{
try
{
// If no file content, try to load it
if (!fileContent && node.filename)
{
loadTemplate();
}
// If still no content, use inline template or error
let templateContent = fileContent;
if (!templateContent)
{
if (node.template)
{
templateContent = node.template;
updateStatus("Using inline template", "yellow");
} else
{
throw new Error("No template content available");
}
}
// Get the data to template
let templateData = {};
// Extract data based on field configuration
if (node.fieldType === "msg")
{
templateData = RED.util.getMessageProperty(msg, node.field) || {};
} else if (node.fieldType === "flow")
{
templateData = node.context().flow.get(node.field) || {};
} else if (node.fieldType === "global")
{
templateData = node.context().global.get(node.field) || {};
} else
{
templateData = msg.payload || {};
}
// Process template based on format
let result = templateContent;
if (node.format === "handlebars" || node.format === "mustache")
{
// Simple mustache-style template processing
result = templateContent.replace(/\{\{([^}]+)\}\}/g, function (match, key)
{
const keys = key.trim().split('.');
let value = templateData;
for (let k of keys)
{
if (value && typeof value === 'object' && k in value)
{
value = value[k];
} else
{
return match; // Return original if key not found
}
}
return value !== null && value !== undefined ? value : '';
});
}
// Set output based on configuration
if (node.output === "str")
{
msg.template = result;
} else if (node.output === "parsed")
{
try
{
msg.template = JSON.parse(result);
} catch (e)
{
msg.template = result; // Fall back to string if not valid JSON
}
}
// Add metadata
msg._fileTemplate = {
filename: node.filename,
lastModified: lastModified,
length: result.length,
format: node.format
};
return { success: true, message: msg };
} catch (err)
{
updateStatus("Template error", "red");
return { success: false, error: err };
}
}
// Initialize
if (node.filename)
{
loadTemplate();
setupWatcher();
// Schedule boot processing if enabled
if (node.loadOnBoot)
{
// Use setImmediate to ensure Node-RED has fully initialized
setImmediate(() =>
{
setTimeout(() =>
{
// Double-check that the node is still valid and RED is available
if (node && RED && RED.util)
{
processOnBoot();
} else
{
node.warn("Node-RED not fully initialized, skipping boot processing");
}
}, node.bootDelay);
});
}
} else
{
updateStatus("No file specified", "grey");
}
// Handle incoming messages
node.on('input', function (msg, send, done)
{
// Backwards compatibility
send = send || function () { node.send.apply(node, arguments); };
done = done || function (error)
{
if (error)
{
node.error(error, msg);
}
};
try
{
// Process using internal method
const result = processMessage(msg);
if (result.error)
{
node.error(`Template processing error: ${result.error.message}`);
done(result.error);
} else
{
send(result.message);
done();
}
} catch (err)
{
updateStatus("Template error", "red");
done(err);
}
});
// Cleanup on node removal
node.on('close', function (removed, done)
{
if (watcher)
{
watcher.close();
}
updateStatus("", "grey");
done();
});
}
// Register the node
RED.nodes.registerType("file-template", FileTemplateNode);
// NEW: HTTP endpoints for file editing functionality
RED.httpAdmin.post('/file-template/load-file', function (req, res)
{
const filename = req.body.filename;
if (!filename)
{
return res.json({ error: 'No filename provided' });
}
try
{
const fullPath = path.resolve(filename);
// Check if file exists
if (!fs.existsSync(fullPath))
{
return res.json({ error: `File not found: ${filename}` });
}
// Read file content and stats
const content = fs.readFileSync(fullPath, 'utf8');
const stats = fs.statSync(fullPath);
res.json({
content: content,
size: content.length,
mtime: stats.mtime.getTime(),
filename: filename
});
} catch (err)
{
res.json({ error: `Error reading file: ${err.message}` });
}
});
RED.httpAdmin.post('/file-template/save-file', function (req, res)
{
const { filename, content } = req.body;
if (!filename)
{
return res.json({ error: 'No filename provided' });
}
if (content === undefined)
{
return res.json({ error: 'No content provided' });
}
try
{
const fullPath = path.resolve(filename);
// Ensure directory exists
const dir = path.dirname(fullPath);
if (!fs.existsSync(dir))
{
fs.mkdirSync(dir, { recursive: true });
}
// Write file content
fs.writeFileSync(fullPath, content, 'utf8');
res.json({
success: true,
size: content.length,
filename: filename
});
} catch (err)
{
res.json({ error: `Error writing file: ${err.message}` });
}
});
};