genesis
This commit is contained in:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
DB_HOST=10.10.10.34
|
||||||
|
DB_USER=sa
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_DATABASE=eazybusiness
|
||||||
|
REDIS_PREFIX=shop_api_
|
||||||
|
CACHE_LOCATION=./cache
|
||||||
|
ROOT_CATEGORY_ID=209
|
||||||
|
JTL_SHOP_ID=0
|
||||||
|
JTL_SPRACHE_ID=1
|
||||||
|
JTL_PLATTFORM_ID=1
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
cache/
|
||||||
266
category-syncer.js
Normal file
266
category-syncer.js
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
CategorySyncer.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let tree = this._buildTree(categories, names, articleCounts, images);
|
||||||
|
|
||||||
|
// Keep unpruned tree for image sync
|
||||||
|
const unprunedTree = tree;
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
|
||||||
|
const treeString = JSON.stringify(tree, null, 2);
|
||||||
|
const changed = this.lastTreeString !== treeString;
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
// Generate translation template BEFORE pruning (to include all categories)
|
||||||
|
const translationTemplate = this._buildTranslationTemplate(tree);
|
||||||
|
const templatePath = path.join(this.cacheDir, 'categories_translation_template.txt');
|
||||||
|
await fs.writeFile(templatePath, this._formatTranslationTemplate(translationTemplate));
|
||||||
|
console.log(`💾 Translation template saved to ${templatePath}`);
|
||||||
|
|
||||||
|
// Now prune for the main tree
|
||||||
|
tree = this._pruneTree(tree);
|
||||||
|
|
||||||
|
const filePath = path.join(this.cacheDir, 'category_tree.json');
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(tree, null, 2));
|
||||||
|
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) {
|
||||||
|
// 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) {
|
||||||
|
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;
|
||||||
20
database.js
Normal file
20
database.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import sql from 'mssql';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ quiet: true });
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
server: process.env.DB_HOST,
|
||||||
|
database: process.env.DB_DATABASE,
|
||||||
|
options: {
|
||||||
|
encrypt: false, // Adjust based on server config
|
||||||
|
trustServerCertificate: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createConnection() {
|
||||||
|
const pool = new sql.ConnectionPool(config);
|
||||||
|
return await pool.connect();
|
||||||
|
}
|
||||||
46
index.js
Normal file
46
index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import categorySyncer from './category-syncer.js';
|
||||||
|
import pictureSyncer from './picture-syncer.js';
|
||||||
|
|
||||||
|
categorySyncer.on('synced', async ({ tree, unprunedTree, changed }) => {
|
||||||
|
if (changed) {
|
||||||
|
console.log('🎉 Event received: Category tree updated! Root nodes:', tree.length);
|
||||||
|
} else {
|
||||||
|
console.log('🎉 Event received: Sync finished (no changes). Checking images...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all kBild IDs from unpruned tree (includes all categories)
|
||||||
|
const imageIds = [];
|
||||||
|
const traverse = (nodes) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.kBild) {
|
||||||
|
imageIds.push(node.kBild);
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
traverse(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverse(unprunedTree);
|
||||||
|
|
||||||
|
console.log(`🔍 Found ${imageIds.length} images in category tree.`);
|
||||||
|
await pictureSyncer.syncImages(imageIds, 'categories');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger immediate sync
|
||||||
|
categorySyncer.triggerSync();
|
||||||
|
|
||||||
|
// Check if running interactively
|
||||||
|
if (process.stdout.isTTY) {
|
||||||
|
console.log('🤖 Interactive mode: Syncing every minute. Press Ctrl-C to exit.');
|
||||||
|
|
||||||
|
// Schedule periodic sync
|
||||||
|
setInterval(() => {
|
||||||
|
categorySyncer.triggerSync();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n👋 Bye!');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
2576
package-lock.json
generated
Normal file
2576
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "category-syncer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Library to sync JTL categories to local cache",
|
||||||
|
"main": "category-syncer.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"mssql": "^12.1.0",
|
||||||
|
"openai": "^6.9.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"socket.io": "^4.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
97
picture-syncer.js
Normal file
97
picture-syncer.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { createConnection } from './database.js';
|
||||||
|
|
||||||
|
class PictureSyncer {
|
||||||
|
constructor() {
|
||||||
|
if (PictureSyncer.instance) {
|
||||||
|
return PictureSyncer.instance;
|
||||||
|
}
|
||||||
|
this.cacheBaseDir = process.env.CACHE_LOCATION || '.';
|
||||||
|
PictureSyncer.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncImages(imageIds, groupName) {
|
||||||
|
const groupDir = path.join(this.cacheBaseDir, 'img', groupName);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(groupDir, { recursive: true });
|
||||||
|
|
||||||
|
// Get existing files
|
||||||
|
let existingFiles = [];
|
||||||
|
try {
|
||||||
|
existingFiles = await fs.readdir(groupDir);
|
||||||
|
} catch (err) {
|
||||||
|
// Directory might be empty or new
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for image files (assuming we save as {id}.jpg or similar, but let's check just by ID prefix)
|
||||||
|
// Actually, let's assume we save as `${id}.jpg`
|
||||||
|
const existingIds = existingFiles
|
||||||
|
.filter(f => f.endsWith('.jpg'))
|
||||||
|
.map(f => parseInt(f.replace('.jpg', '')));
|
||||||
|
|
||||||
|
const validIds = new Set(imageIds.filter(id => id !== null && id !== undefined));
|
||||||
|
|
||||||
|
// 1. Delete obsolete images
|
||||||
|
const toDelete = existingIds.filter(id => !validIds.has(id));
|
||||||
|
for (const id of toDelete) {
|
||||||
|
const filePath = path.join(groupDir, `${id}.jpg`);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
console.log(`🗑️ Deleted obsolete image: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Download missing images
|
||||||
|
const toDownload = imageIds.filter(id => id !== null && id !== undefined && !existingIds.includes(id));
|
||||||
|
|
||||||
|
if (toDownload.length > 0) {
|
||||||
|
console.log(`📥 Downloading ${toDownload.length} new images for group '${groupName}'...`);
|
||||||
|
await this._downloadImages(toDownload, groupDir);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ No new images to download for group '${groupName}'.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _downloadImages(ids, dir) {
|
||||||
|
let pool;
|
||||||
|
try {
|
||||||
|
pool = await createConnection();
|
||||||
|
|
||||||
|
// Process in chunks to avoid huge queries
|
||||||
|
const chunkSize = 50;
|
||||||
|
for (let i = 0; i < ids.length; i += chunkSize) {
|
||||||
|
const chunk = ids.slice(i, i + chunkSize);
|
||||||
|
const list = chunk.join(',');
|
||||||
|
|
||||||
|
const result = await pool.request().query(`
|
||||||
|
SELECT kBild, bBild
|
||||||
|
FROM tBild
|
||||||
|
WHERE kBild IN (${list})
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const record of result.recordset) {
|
||||||
|
if (record.bBild) {
|
||||||
|
const filePath = path.join(dir, `${record.kBild}.jpg`);
|
||||||
|
await fs.writeFile(filePath, record.bBild);
|
||||||
|
// console.log(`💾 Saved image: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const processed = Math.min(i + chunkSize, ids.length);
|
||||||
|
if (processed === ids.length) {
|
||||||
|
console.log(`✅ Processed ${processed}/${ids.length} images.`);
|
||||||
|
} else {
|
||||||
|
console.log(`⏳ Processed ${processed}/${ids.length} images...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error downloading images:', err);
|
||||||
|
} finally {
|
||||||
|
if (pool) {
|
||||||
|
await pool.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new PictureSyncer();
|
||||||
|
export default instance;
|
||||||
Reference in New Issue
Block a user