mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
642 lines
19 KiB
TypeScript
642 lines
19 KiB
TypeScript
import * as lib from "@clusterio/lib";
|
|
import { BaseControllerPlugin } from "@clusterio/controller";
|
|
import {
|
|
TileDataEvent,
|
|
GetRawTileRequest,
|
|
GetChartTagsRequest,
|
|
ChartData,
|
|
ChartTagDataEvent,
|
|
ChartTagData,
|
|
} from "./messages";
|
|
import * as fs from "fs-extra";
|
|
import * as path from "path";
|
|
import * as zlib from "zlib";
|
|
import { promisify } from "util";
|
|
import sharp from "sharp";
|
|
import {
|
|
renderChunkToPixels,
|
|
extractChunkFromTile,
|
|
renderTileToPixels,
|
|
pixelsToRGBA
|
|
} from "./tile-utils";
|
|
|
|
const CHUNK_SIZE = 32;
|
|
const inflateAsync = promisify(zlib.inflate);
|
|
|
|
interface PixelChange {
|
|
x: number;
|
|
y: number;
|
|
newColor: number;
|
|
oldColor: number;
|
|
}
|
|
|
|
export class ControllerPlugin extends BaseControllerPlugin {
|
|
private tilesPath: string = "";
|
|
private chartTagsPath: string = "";
|
|
private chunkSavingQueue = new Map<string, Map<string, { data: ChartData, tick: number }>>();
|
|
private chartTagQueues = new Map<string, ChartTagData[]>();
|
|
private lastSeenTagContent = new Map<string, Map<number, {
|
|
position: [number, number];
|
|
text: string;
|
|
icon?: any;
|
|
last_user?: string;
|
|
}>>();
|
|
private savingTiles: boolean = false;
|
|
private savingChartTags = new Set<string>();
|
|
private saveInterval: NodeJS.Timeout | null = null;
|
|
|
|
async init() {
|
|
this.tilesPath = path.resolve(
|
|
this.controller.config.get("controller.database_directory"),
|
|
"minimap_tiles"
|
|
);
|
|
this.chartTagsPath = path.resolve(
|
|
this.controller.config.get("controller.database_directory"),
|
|
"minimap_chart_tags"
|
|
);
|
|
await fs.ensureDir(this.tilesPath);
|
|
await fs.ensureDir(this.chartTagsPath);
|
|
|
|
this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
|
|
this.controller.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this));
|
|
this.controller.handle(GetRawTileRequest, this.handleGetRawTileRequest.bind(this));
|
|
this.controller.handle(GetChartTagsRequest, this.handleGetChartTagsRequest.bind(this));
|
|
|
|
// Set up HTTP routes for serving tiles
|
|
this.setupTileRoutes();
|
|
|
|
// Load existing tag numbers for deduplication
|
|
await this.loadExistingTagContent();
|
|
|
|
this.saveInterval = setInterval(() => {
|
|
this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`));
|
|
this.saveChartTags().catch(err => this.logger.error(`Error saving chart tags: ${err}`));
|
|
}, 5000);
|
|
}
|
|
|
|
async onShutdown() {
|
|
if (this.saveInterval) {
|
|
clearInterval(this.saveInterval);
|
|
this.saveInterval = null;
|
|
}
|
|
|
|
// Perform final save before shutdown
|
|
await this.saveTiles();
|
|
await this.saveChartTags();
|
|
}
|
|
|
|
// Compare two pixel arrays and return changes
|
|
private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] {
|
|
const changes: PixelChange[] = [];
|
|
|
|
for (let y = 0; y < 32; y++) {
|
|
for (let x = 0; x < 32; x++) {
|
|
const index = y * 32 + x;
|
|
const oldColor = oldPixels[index];
|
|
const newColor = newPixels[index];
|
|
|
|
if (oldColor !== newColor) {
|
|
// Calculate absolute position within the 256x256 tile
|
|
const absoluteX = chunkX * 32 + x;
|
|
const absoluteY = chunkY * 32 + y;
|
|
|
|
changes.push({
|
|
x: absoluteX,
|
|
y: absoluteY,
|
|
newColor,
|
|
oldColor,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
// Create pixel changeset data buffer
|
|
private createPixelChangeset(changes: PixelChange[], tick: number): Buffer {
|
|
if (changes.length === 0) {
|
|
return Buffer.alloc(0);
|
|
}
|
|
|
|
// Type (1) + Tick (4) + Length (2) + Pixel data (6 bytes per pixel)
|
|
const headerSize = 7;
|
|
const pixelDataSize = changes.length * 6; // 6 bytes per pixel change
|
|
const totalSize = headerSize + pixelDataSize;
|
|
const buffer = Buffer.alloc(totalSize);
|
|
|
|
let offset = 0;
|
|
|
|
// Type 2 for pixels
|
|
buffer.writeUInt8(2, offset);
|
|
offset += 1;
|
|
|
|
// Tick (converted to seconds)
|
|
buffer.writeUInt32BE(Math.floor(tick / 60), offset);
|
|
offset += 4;
|
|
|
|
// Length (number of pixel changes, not bytes)
|
|
buffer.writeUInt16BE(changes.length, offset);
|
|
offset += 2;
|
|
|
|
// Write pixel changes
|
|
for (const change of changes) {
|
|
buffer.writeUInt8(change.x, offset);
|
|
offset += 1;
|
|
buffer.writeUInt8(change.y, offset);
|
|
offset += 1;
|
|
buffer.writeUInt16BE(change.newColor, offset);
|
|
offset += 2;
|
|
buffer.writeUInt16BE(change.oldColor, offset);
|
|
offset += 2;
|
|
}
|
|
|
|
return buffer;
|
|
}
|
|
|
|
private setupTileRoutes() {
|
|
const app = this.controller.app;
|
|
|
|
app.get("/api/minimap/tile/:instanceId/:surface/:force/:z/:x/:y.png", async (req, res) => {
|
|
try {
|
|
const { instanceId, surface, force, z, x, y } = req.params;
|
|
const tileX = parseInt(x);
|
|
const tileY = parseInt(y);
|
|
|
|
const tileName = `${instanceId}_${surface}_${force}_${tileX}_${tileY}.bin`;
|
|
const tilePath = path.join(this.tilesPath, tileName);
|
|
|
|
if (!await fs.pathExists(tilePath)) {
|
|
res.status(404).send("Tile not found");
|
|
return;
|
|
}
|
|
|
|
const tileData = await fs.readFile(tilePath);
|
|
const image = await this.renderTile(tileData);
|
|
res.setHeader("Content-Type", "image/png");
|
|
res.send(image);
|
|
|
|
} catch (err) {
|
|
this.logger.error(`Error serving tile: ${err}`);
|
|
res.status(500).send("Error serving tile");
|
|
}
|
|
});
|
|
|
|
app.get("/api/minimap/surfaces", async (req, res) => {
|
|
try {
|
|
const files = await fs.readdir(this.tilesPath);
|
|
const surfaces = new Set<string>();
|
|
const forces = new Set<string>();
|
|
|
|
for (const file of files) {
|
|
if (file.endsWith(".bin")) {
|
|
const parts = file.split("_");
|
|
if (parts.length === 5) {
|
|
surfaces.add(parts[1]);
|
|
forces.add(parts[2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
surfaces: Array.from(surfaces),
|
|
forces: Array.from(forces),
|
|
});
|
|
} catch (err) {
|
|
this.logger.error(`Error listing surfaces and forces: ${err}`);
|
|
res.status(500).json({ error: "Failed to list surfaces and forces" });
|
|
}
|
|
});
|
|
}
|
|
|
|
async renderTile(tileData: Buffer): Promise<Buffer> {
|
|
// Use renderTileToPixels to get the current state, then convert to image
|
|
const currentPixels = await renderTileToPixels(tileData);
|
|
|
|
// Convert RGB565 pixel data to RGBA buffer for Sharp
|
|
const rgbaData = pixelsToRGBA(currentPixels);
|
|
const raw = Buffer.from(rgbaData);
|
|
|
|
// Create and return the image directly
|
|
return sharp(raw, {
|
|
raw: { width: 256, height: 256, channels: 4 }
|
|
}).png().toBuffer();
|
|
}
|
|
|
|
async saveTiles() {
|
|
if (this.savingTiles) {
|
|
return;
|
|
}
|
|
|
|
const queue = this.chunkSavingQueue;
|
|
if (queue.size === 0) {
|
|
return;
|
|
}
|
|
this.logger.info(`Starting save for ${queue.size} tiles.`);
|
|
|
|
this.savingTiles = true;
|
|
this.chunkSavingQueue = new Map();
|
|
|
|
try {
|
|
const savePromises = [];
|
|
for (const [tileName, chunks] of queue) {
|
|
const promise = (async () => {
|
|
const tilePath = path.join(this.tilesPath, tileName);
|
|
let existingTile = Buffer.alloc(0);
|
|
if (await fs.pathExists(tilePath)) {
|
|
existingTile = await fs.readFile(tilePath);
|
|
}
|
|
const newTile = await this.updateTile(existingTile, chunks);
|
|
if (newTile && newTile.length > 0) {
|
|
await fs.writeFile(tilePath, newTile);
|
|
}
|
|
})();
|
|
savePromises.push(promise);
|
|
}
|
|
await Promise.all(savePromises);
|
|
} catch (err) {
|
|
this.logger.error(`Error saving tiles: ${err}`);
|
|
} finally {
|
|
this.savingTiles = false;
|
|
}
|
|
}
|
|
|
|
async updateTile(existingTile: Buffer, newChunks: Map<string, { data: ChartData, tick: number }>): Promise<Buffer | undefined> {
|
|
const existingChunks = new Map<string, { tick: number, data: Buffer }>();
|
|
|
|
// Parse existing tile data to find existing chunks
|
|
let offset = 0;
|
|
while (offset < existingTile.length) {
|
|
if (offset + 1 > existingTile.length) {
|
|
this.logger.error(`Invalid tile data: cannot read type at offset ${offset}`);
|
|
break;
|
|
}
|
|
|
|
const type = existingTile.readUInt8(offset);
|
|
offset += 1;
|
|
|
|
if (type === 1) { // Chunk
|
|
if (offset + 7 > existingTile.length) {
|
|
this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`);
|
|
break;
|
|
}
|
|
|
|
const tick = existingTile.readUInt32BE(offset);
|
|
offset += 4;
|
|
const chunkCoordsByte = existingTile.readUInt8(offset);
|
|
offset += 1;
|
|
const length = existingTile.readUInt16BE(offset);
|
|
offset += 2;
|
|
|
|
if (offset + length > existingTile.length) {
|
|
this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`);
|
|
break;
|
|
}
|
|
|
|
const chunkX = chunkCoordsByte >> 4;
|
|
const chunkY = chunkCoordsByte & 0x0F;
|
|
const chunkName = `${chunkX}_${chunkY}`;
|
|
const data = existingTile.slice(offset, offset + length);
|
|
offset += length;
|
|
existingChunks.set(chunkName, { tick, data });
|
|
|
|
} else if (type === 2) { // Pixels
|
|
if (offset + 6 > existingTile.length) {
|
|
this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`);
|
|
break;
|
|
}
|
|
|
|
const tick = existingTile.readUInt32BE(offset);
|
|
offset += 4;
|
|
const pixelCount = existingTile.readUInt16BE(offset);
|
|
offset += 2;
|
|
const pixelDataLength = pixelCount * 6;
|
|
|
|
if (offset + pixelDataLength > existingTile.length) {
|
|
this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`);
|
|
break;
|
|
}
|
|
|
|
// Skip pixel data since we're not rebuilding the file
|
|
offset += pixelDataLength;
|
|
} else {
|
|
this.logger.error(`Unknown tile data type: ${type} at offset ${offset}`);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Render the current state of the entire tile if we have existing data
|
|
let currentTilePixels: Uint16Array | null = null;
|
|
if (existingTile.length > 0) {
|
|
currentTilePixels = await renderTileToPixels(existingTile);
|
|
}
|
|
|
|
// Process new chunks and determine what to append
|
|
const appendBuffers: Buffer[] = [];
|
|
let hasChanges = false;
|
|
|
|
for (const [chunkName, newChunk] of newChunks) {
|
|
const newChunkData = Buffer.from(newChunk.data.chart_data, "base64");
|
|
const [chunkX, chunkY] = chunkName.split("_").map(Number);
|
|
|
|
if (!existingChunks.has(chunkName)) {
|
|
// New chunk - append as chunk data (type 1)
|
|
this.logger.info(`Adding new chunk ${chunkName} to tile`);
|
|
const header = Buffer.alloc(8);
|
|
header.writeUInt8(1, 0); // Type 1 (chunk)
|
|
header.writeUInt32BE(Math.floor(newChunk.tick / 60), 1); // Tick
|
|
header.writeUInt8((chunkX << 4) | chunkY, 5); // Coords
|
|
header.writeUInt16BE(newChunkData.length, 6); // Length
|
|
appendBuffers.push(header, newChunkData);
|
|
hasChanges = true;
|
|
} else {
|
|
// Existing chunk - compare current tile state with new chunk data
|
|
const newPixels = await renderChunkToPixels(newChunkData);
|
|
|
|
let oldPixels: Uint16Array;
|
|
if (currentTilePixels) {
|
|
// Extract current chunk area from the rendered tile
|
|
oldPixels = extractChunkFromTile(currentTilePixels, chunkX, chunkY);
|
|
} else {
|
|
// Fallback to black pixels if tile couldn't be rendered
|
|
oldPixels = new Uint16Array(32 * 32);
|
|
}
|
|
|
|
const changes = this.comparePixels(oldPixels, newPixels, chunkX, chunkY);
|
|
if (changes.length > 0) {
|
|
this.logger.info(`Adding ${changes.length} pixel changes for chunk ${chunkName} to tile`);
|
|
const changeset = this.createPixelChangeset(changes, newChunk.tick);
|
|
if (changeset.length > 0) {
|
|
appendBuffers.push(changeset);
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasChanges) {
|
|
return;
|
|
}
|
|
|
|
// Append new data to existing tile (never modify existing data)
|
|
return Buffer.concat([existingTile, ...appendBuffers]);
|
|
}
|
|
|
|
async loadExistingTagContent() {
|
|
try {
|
|
if (!await fs.pathExists(this.chartTagsPath)) {
|
|
return;
|
|
}
|
|
|
|
const files = await fs.readdir(this.chartTagsPath);
|
|
const tagFiles = files.filter(file => file.endsWith('_chart_tags.json'));
|
|
|
|
for (const file of tagFiles) {
|
|
const fileKey = file.replace('_chart_tags.json', '');
|
|
const filePath = path.join(this.chartTagsPath, file);
|
|
|
|
if (!this.lastSeenTagContent.has(fileKey)) {
|
|
this.lastSeenTagContent.set(fileKey, new Map());
|
|
}
|
|
const seenTags = this.lastSeenTagContent.get(fileKey)!;
|
|
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
|
|
for (const line of lines) {
|
|
try {
|
|
const tagData = JSON.parse(line);
|
|
if (typeof tagData.tag_number === 'number' && !tagData.end_tick) {
|
|
// Only store the latest content for tags that haven't been deleted
|
|
seenTags.set(tagData.tag_number, {
|
|
position: tagData.position,
|
|
text: tagData.text,
|
|
icon: tagData.icon,
|
|
last_user: tagData.last_user
|
|
});
|
|
}
|
|
} catch (parseErr) {
|
|
this.logger.warn(`Failed to parse chart tag line in ${file}: ${parseErr}`);
|
|
}
|
|
}
|
|
|
|
this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`);
|
|
} catch (readErr) {
|
|
this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(`Error loading existing tag content: ${err}`);
|
|
}
|
|
}
|
|
|
|
async saveChartTags() {
|
|
if (this.chartTagQueues.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const savePromises = [];
|
|
for (const [fileKey, tags] of this.chartTagQueues) {
|
|
if (tags.length === 0 || this.savingChartTags.has(fileKey)) {
|
|
// Remove empty queues to prevent repeated processing
|
|
if (tags.length === 0 && !this.savingChartTags.has(fileKey)) {
|
|
this.chartTagQueues.delete(fileKey);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const promise = (async () => {
|
|
this.savingChartTags.add(fileKey);
|
|
const tagsToSave = [...tags];
|
|
tags.length = 0; // Clear the queue
|
|
|
|
try {
|
|
const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`);
|
|
|
|
// Convert chart tags to newline-delimited JSON format
|
|
const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join('\n');
|
|
|
|
await fs.appendFile(filePath, jsonLines + '\n');
|
|
|
|
// Mark tags as successfully saved
|
|
if (!this.lastSeenTagContent.has(fileKey)) {
|
|
this.lastSeenTagContent.set(fileKey, new Map());
|
|
}
|
|
const seenTags = this.lastSeenTagContent.get(fileKey)!;
|
|
for (const tag of tagsToSave) {
|
|
seenTags.set(tag.tag_number, {
|
|
position: tag.position,
|
|
text: tag.text,
|
|
icon: tag.icon,
|
|
last_user: tag.last_user
|
|
});
|
|
}
|
|
|
|
this.logger.info(`Saved ${tagsToSave.length} chart tag entries to ${fileKey}`);
|
|
} catch (err) {
|
|
this.logger.error(`Error saving chart tags for ${fileKey}: ${err}`);
|
|
// Re-queue the failed tags
|
|
tags.unshift(...tagsToSave);
|
|
} finally {
|
|
this.savingChartTags.delete(fileKey);
|
|
}
|
|
})();
|
|
savePromises.push(promise);
|
|
}
|
|
|
|
await Promise.all(savePromises);
|
|
}
|
|
|
|
async handleChartTagDataEvent(event: ChartTagDataEvent) {
|
|
try {
|
|
const { instance_id, tag_data } = event;
|
|
|
|
// Create file key based on instance, surface, and force
|
|
const fileKey = `${instance_id}_${tag_data.surface}_${tag_data.force}`;
|
|
|
|
// Initialize seen tags map if it doesn't exist
|
|
if (!this.lastSeenTagContent.has(fileKey)) {
|
|
this.lastSeenTagContent.set(fileKey, new Map());
|
|
}
|
|
|
|
// Check for duplicate content, but allow deletion events (end_tick set) even if we've seen the tag before
|
|
const seenTags = this.lastSeenTagContent.get(fileKey)!;
|
|
const lastSeenContent = seenTags.get(tag_data.tag_number);
|
|
|
|
// For deletion events (end_tick set), always allow through
|
|
if (tag_data.end_tick !== undefined) {
|
|
// Don't skip deletion events
|
|
} else if (lastSeenContent) {
|
|
// For creation/modification events, check if content has actually changed
|
|
const contentUnchanged =
|
|
lastSeenContent.position[0] === tag_data.position[0] &&
|
|
lastSeenContent.position[1] === tag_data.position[1] &&
|
|
lastSeenContent.text === tag_data.text &&
|
|
JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon) &&
|
|
lastSeenContent.last_user === tag_data.last_user;
|
|
|
|
if (contentUnchanged) {
|
|
return; // Skip if content hasn't changed
|
|
}
|
|
}
|
|
|
|
// Add instance_id to the tag data for storage
|
|
const enrichedTagData = {
|
|
...tag_data,
|
|
instance_id
|
|
};
|
|
|
|
// Initialize queue if it doesn't exist
|
|
if (!this.chartTagQueues.has(fileKey)) {
|
|
this.chartTagQueues.set(fileKey, []);
|
|
}
|
|
|
|
// Queue for batch saving
|
|
this.chartTagQueues.get(fileKey)!.push(enrichedTagData);
|
|
|
|
// Send live update to connected web clients
|
|
const updateEvent = new ChartTagDataEvent(instance_id, tag_data);
|
|
|
|
// Broadcast to web clients (overriding the formal src/dst routing)
|
|
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent);
|
|
|
|
} catch (err) {
|
|
this.logger.error(`Error processing chart tag data: ${err}`);
|
|
}
|
|
}
|
|
|
|
async handleTileDataEvent(event: TileDataEvent) {
|
|
try {
|
|
const { instance_id, surface, force, x, y, tick, chunk } = event;
|
|
|
|
// Calculate tile coordinates for 8x8 chunks per tile (256x256 pixels)
|
|
const tileX = Math.floor(x / 32 / 8);
|
|
const tileY = Math.floor(y / 32 / 8);
|
|
const tileName = `${instance_id}_${surface}_${force}_${tileX}_${tileY}.bin`;
|
|
|
|
// Fix negative modulo results by using proper modulo operation (0-7 range for 8x8)
|
|
const chunkX = ((Math.floor(x / 32) % 8) + 8) % 8;
|
|
const chunkY = ((Math.floor(y / 32) % 8) + 8) % 8;
|
|
const chunkName = `${chunkX}_${chunkY}`;
|
|
|
|
if (!this.chunkSavingQueue.has(tileName)) {
|
|
this.chunkSavingQueue.set(tileName, new Map());
|
|
}
|
|
this.chunkSavingQueue.get(tileName)!.set(chunkName, { data: chunk, tick });
|
|
|
|
// Send live update to connected web clients
|
|
const updateEvent = new TileDataEvent(
|
|
instance_id,
|
|
surface,
|
|
force,
|
|
x,
|
|
y,
|
|
tick,
|
|
chunk
|
|
);
|
|
|
|
// Broadcast to web clients (overriding the formal src/dst routing)
|
|
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent);
|
|
|
|
} catch (err) {
|
|
this.logger.error(`Error processing tile data: ${err}`);
|
|
}
|
|
}
|
|
|
|
async handleGetRawTileRequest(request: GetRawTileRequest) {
|
|
const { instance_id, surface, force, tile_x, tile_y, tick } = request;
|
|
|
|
const tileName = `${instance_id}_${surface}_${force}_${tile_x}_${tile_y}.bin`;
|
|
const tilePath = path.join(this.tilesPath, tileName);
|
|
|
|
if (!await fs.pathExists(tilePath)) {
|
|
return { tile_data: null, tick: tick || 0 };
|
|
}
|
|
|
|
try {
|
|
const tileData = await fs.readFile(tilePath);
|
|
|
|
const base64Data = tileData.toString('base64');
|
|
|
|
return {
|
|
tile_data: base64Data
|
|
};
|
|
} catch (err) {
|
|
this.logger.error(`Error reading tile file: ${err}`);
|
|
return { tile_data: null };
|
|
}
|
|
}
|
|
|
|
async handleGetChartTagsRequest(request: GetChartTagsRequest) {
|
|
const { instance_id, surface, force } = request;
|
|
|
|
const fileKey = `${instance_id}_${surface}_${force}`;
|
|
const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`);
|
|
|
|
if (!await fs.pathExists(filePath)) {
|
|
return { chart_tags: [] };
|
|
}
|
|
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const lines = content.trim().split('\n').filter(line => line.trim());
|
|
|
|
const chartTags: ChartTagData[] = [];
|
|
for (const line of lines) {
|
|
try {
|
|
const tagData = JSON.parse(line);
|
|
chartTags.push(tagData);
|
|
} catch (parseErr) {
|
|
this.logger.warn(`Failed to parse chart tag line: ${parseErr}`);
|
|
}
|
|
}
|
|
|
|
return { chart_tags: chartTags };
|
|
} catch (err) {
|
|
this.logger.error(`Error reading chart tag file: ${err}`);
|
|
return { chart_tags: [] };
|
|
}
|
|
}
|
|
}
|