import { EventEmitter } from 'events'; import fs from 'fs/promises'; import path from 'path'; import { createConnection } from './database.js'; class CategorySyncer extends EventEmitter { constructor() { super(); if (CategorySyncer.instance) { return CategorySyncer.instance; } this.isSyncing = false; this.queuedSync = false; this.cacheDir = process.env.CACHE_LOCATION || '.'; this.lastTreeString = null; this.lastTemplateString = null; // Load existing template if it exists this._loadExistingTemplate(); CategorySyncer.instance = this; } async _loadExistingTemplate() { try { const templatePath = path.join(this.cacheDir, 'categories_translation_template.txt'); this.lastTemplateString = await fs.readFile(templatePath, 'utf-8'); } catch (err) { // File doesn't exist yet, that's fine } try { const treePath = path.join(this.cacheDir, 'category_tree.json'); const treeContent = await fs.readFile(treePath, 'utf-8'); this.lastTreeString = treeContent; } catch (err) { // File doesn't exist yet, that's fine } } async triggerSync() { if (this.isSyncing) { if (this.queuedSync) { console.log('🚫 Sync already in progress and next sync already queued. Ignoring.'); return; } console.log('⏳ Sync already in progress. Queuing next sync.'); this.queuedSync = true; return; } await this._doSync(); } async _doSync() { this.isSyncing = true; const startTime = Date.now(); console.log('🚀 Starting sync...'); try { await this._syncFromDb(); const duration = Date.now() - startTime; console.log(`✅ Sync completed successfully in ${duration}ms.`); } catch (err) { console.error('❌ Sync failed:', err); } finally { this.isSyncing = false; if (this.queuedSync) { console.log('🔄 Processing queued sync...'); this.queuedSync = false; // Use setImmediate to allow stack to clear/event loop to tick setImmediate(() => this.triggerSync()); } } } async _syncFromDb() { let pool; try { pool = await createConnection(); // Fetch categories const categoriesResult = await pool.request().query(` SELECT kKategorie, kOberKategorie, nSort FROM tkategorie `); // Fetch names const namesResult = await pool.request().query(` SELECT kKategorie, cName FROM tKategorieSprache WHERE kSprache = ${process.env.JTL_SPRACHE_ID} AND kShop = ${process.env.JTL_SHOP_ID} `); // Fetch article counts const articleCountsResult = await pool.request().query(` SELECT ka.kKategorie, COUNT(a.kArtikel) as count FROM tkategorieartikel ka JOIN tArtikel a ON ka.kArtikel = a.kArtikel WHERE a.cAktiv = 'Y' GROUP BY ka.kKategorie `); // Fetch images (kBild) const imagesResult = await pool.request().query(` SELECT kKategorie, kBild FROM ( SELECT kKategorie, kBild, ROW_NUMBER() OVER (PARTITION BY kKategorie ORDER BY nNr ASC) as rn FROM tKategoriebildPlattform WHERE kShop = ${process.env.JTL_SHOP_ID} AND kPlattform = ${process.env.JTL_PLATTFORM_ID} ) t WHERE rn = 1 `); const categories = categoriesResult.recordset; const names = namesResult.recordset; const articleCounts = articleCountsResult.recordset; const images = imagesResult.recordset; // Build tree with ROOT_CATEGORY_ID filter (if set) // This gives us the subtree we're interested in let tree = this._buildTree(categories, names, articleCounts, images, true); // Deep copy tree for unpruned version (before pruning modifies it) const unprunedTree = JSON.parse(JSON.stringify(tree)); // Generate translation template BEFORE pruning (to include all categories) const translationTemplate = this._buildTranslationTemplate(tree); const templateString = this._formatTranslationTemplate(translationTemplate); // Now prune for the main tree tree = this._pruneTree(tree); // Ensure directory exists await fs.mkdir(this.cacheDir, { recursive: true }); // Compare pruned tree const treeString = JSON.stringify(tree, null, 2); const changed = this.lastTreeString !== treeString; if (changed) { // Save template if it changed if (this.lastTemplateString !== templateString) { const templatePath = path.join(this.cacheDir, 'categories_translation_template.txt'); await fs.writeFile(templatePath, templateString); console.log(`💾 Translation template saved to ${templatePath}`); this.lastTemplateString = templateString; } const filePath = path.join(this.cacheDir, 'category_tree.json'); await fs.writeFile(filePath, treeString); console.log(`💾 Category tree saved to ${filePath}`); this.lastTreeString = treeString; console.log('📢 Tree updated.'); } else { console.log('🤷 No changes detected in category tree.'); } this.emit('synced', { tree, unprunedTree, changed }); } finally { if (pool) { await pool.close(); } } } _buildTree(categories, names, articleCounts, images, applyRootFilter = true) { // Create a map for quick lookup of names const nameMap = new Map(); names.forEach(n => nameMap.set(n.kKategorie, n.cName)); // Create a map for article counts const countMap = new Map(); articleCounts.forEach(c => countMap.set(c.kKategorie, c.count)); // Create a map for images const imageMap = new Map(); images.forEach(i => imageMap.set(i.kKategorie, i.kBild)); // Create a map for category nodes const categoryMap = new Map(); // Initialize all nodes categories.forEach(cat => { categoryMap.set(cat.kKategorie, { kKategorie: cat.kKategorie, cName: nameMap.get(cat.kKategorie) || `Unknown (${cat.kKategorie})`, // Fallback if name missing articleCount: countMap.get(cat.kKategorie) || 0, kBild: imageMap.get(cat.kKategorie) || null, children: [], nSort: cat.nSort || 0 // Store nSort temporarily }); }); const rootNodes = []; // Build hierarchy categories.forEach(cat => { const node = categoryMap.get(cat.kKategorie); if (cat.kOberKategorie === 0) { rootNodes.push(node); } else { const parent = categoryMap.get(cat.kOberKategorie); if (parent) { parent.children.push(node); } else { // Handle orphan nodes if necessary, or ignore // console.warn(`Orphan category found: ${cat.kKategorie}`); } } }); const rootId = process.env.ROOT_CATEGORY_ID ? parseInt(process.env.ROOT_CATEGORY_ID) : null; let resultNodes = rootNodes; if (rootId && applyRootFilter) { const specificRoot = categoryMap.get(rootId); // Return the children of the specified root, not the root itself resultNodes = specificRoot ? specificRoot.children : []; } // Sort children and remove nSort for (const node of categoryMap.values()) { node.children.sort((a, b) => a.nSort - b.nSort); } // Sort root nodes if returning multiple resultNodes.sort((a, b) => a.nSort - b.nSort); // Remove nSort property from all nodes for (const node of categoryMap.values()) { delete node.nSort; } return resultNodes; } _pruneTree(nodes) { // Filter out nodes that are empty (no articles) and have no valid children return nodes.filter(node => { // Recursively prune children if (node.children && node.children.length > 0) { node.children = this._pruneTree(node.children); } // Keep node if it has articles OR has remaining children const hasArticles = node.articleCount > 0; const hasChildren = node.children && node.children.length > 0; return hasArticles || hasChildren; }); } _buildTranslationTemplate(nodes) { return nodes.map(node => { const result = { name: node.cName }; if (node.children && node.children.length > 0) { result.children = this._buildTranslationTemplate(node.children); } return result; }); } _formatTranslationTemplate(nodes, indent = 0) { const spaces = ' '.repeat(indent); const innerSpaces = ' '.repeat(indent + 1); if (nodes.length === 0) return '[]'; const lines = ['[']; nodes.forEach((node, index) => { const isLast = index === nodes.length - 1; if (node.children && node.children.length > 0) { // Node with children - multi-line format lines.push(`${innerSpaces}{`); lines.push(`${innerSpaces} "name": "${node.name}",`); lines.push(`${innerSpaces} "children": ${this._formatTranslationTemplate(node.children, indent + 2)}`); lines.push(`${innerSpaces}}${isLast ? '' : ','}`); } else { // Leaf node - single line format lines.push(`${innerSpaces}{ "name": "${node.name}" }${isLast ? '' : ','}`); } }); lines.push(`${spaces}]`); return lines.join('\n'); } } const instance = new CategorySyncer(); export default instance;