diff --git a/package-lock.json b/package-lock.json index a6f5add..e640ee8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor", "selfsigned": "^5.5.0", "sharp": "^0.35.5", + "skelehash-wasm": "^0.1.0", "swagger-ui-dist": "^5.33.1", "vm2": "^3.12.2" }, @@ -66,7 +67,6 @@ } }, "../llmorbiter": { - "name": "llmorbiter", "version": "0.1.0", "license": "0BSD", "dependencies": { @@ -5474,6 +5474,12 @@ "simple-concat": "^1.0.0" } }, + "node_modules/skelehash-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/skelehash-wasm/-/skelehash-wasm-0.1.0.tgz", + "integrity": "sha512-QdxF9YsJvKlT93O70dXFaWnsKVX7KDD0RgYf9cwgOT6kGnSMOdsO0rsdge+5Tg+vv6BjivbJ/2o5P1KcQsavEw==", + "license": "MIT" + }, "node_modules/slice-ansi": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.1.tgz", diff --git a/package.json b/package.json index 4589e7e..9de7075 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor", "selfsigned": "^5.5.0", "sharp": "^0.35.5", + "skelehash-wasm": "^0.1.0", "swagger-ui-dist": "^5.33.1", "vm2": "^3.12.2" }, diff --git a/src/api/routes/company.js b/src/api/routes/company.js index 82e71ed..92f39f7 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -571,6 +571,9 @@ export function register(app, basePath = '') { onProgress: (phase, current, total, item) => { sseWrite('progress', { phase, current, total, item }); }, + onImage: (imageData) => { + sseWrite('image', imageData); + }, }); sseWrite('done', outcome); diff --git a/src/services/companyImport.js b/src/services/companyImport.js index 99fe268..025d47f 100644 --- a/src/services/companyImport.js +++ b/src/services/companyImport.js @@ -1,10 +1,40 @@ import fs from 'fs'; import path from 'path'; +import sharp from 'sharp'; +import { encodeRGBA } from 'skelehash-wasm'; import { getStore } from '../db/store.js'; import { createLogger } from '../util/logger.js'; const log = createLogger('import'); +/** + * Computes SkeleHash base62 hash for an image buffer. + * + * @param {Buffer} buffer + * @returns {Promise<{base62: string, width: number, height: number, hashLength: number}|null>} + */ +async function computeSkeleHash(buffer) { + try { + const { data, info } = await sharp(buffer) + .rotate() + .resize({ width: 200, height: 200, fit: 'inside' }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + const result = await encodeRGBA(info.width, info.height, data); + return { + base62: result.base62, + width: info.width, + height: info.height, + hashLength: result.hash?.length ?? 0, + }; + } catch (err) { + log.warn('Failed to compute skelehash for image:', err.message); + return null; + } +} + /** * Parses RFC 4180 CSV text into an array of objects keyed by header. * @@ -166,6 +196,7 @@ export function extractVariantValue(parentName, childName, childSku, parentSku) * @param {number} opts.companyId * @param {string} opts.importDir * @param {function} [opts.onProgress] - (phase, current, total, item) => void + * @param {function} [opts.onImage] - ({ type, current, total, sku, name, brand, categoryId, brandId, path, filename, skelehash, width, height, hashLength }) => void * @returns {Promise} Summary outcome */ export async function executeCompanyImport({ @@ -173,6 +204,7 @@ export async function executeCompanyImport({ companyId, importDir, onProgress = () => {}, + onImage = () => {}, }) { const store = await getStore(); @@ -306,6 +338,19 @@ export async function executeCompanyImport({ const imgBuf = fs.readFileSync(picPath); await store.category.addImage(googleAuthId, dashCatId, imgBuf); catPicCount++; + + const skele = await computeSkeleHash(imgBuf); + onImage({ + type: 'category', + categoryId: dashCatId, + name, + path: picRel, + filename: path.basename(picRel), + skelehash: skele?.base62 || null, + width: skele?.width || null, + height: skele?.height || null, + hashLength: skele?.hashLength || 0, + }); } catch (err) { console.warn(`[Import] Category image error (${picRel}):`, err.message); } @@ -387,6 +432,19 @@ export async function executeCompanyImport({ const imgBuf = fs.readFileSync(picPath); await store.brand.addImage(googleAuthId, brand.id, imgBuf); brandPicCount++; + + const skele = await computeSkeleHash(imgBuf); + onImage({ + type: 'brand', + brandId: brand.id, + brand: brandName, + path: picRel, + filename: path.basename(picRel), + skelehash: skele?.base62 || null, + width: skele?.width || null, + height: skele?.height || null, + hashLength: skele?.hashLength || 0, + }); break; // 1 primary logo per brand } catch (err) { log.warn(`Brand image error for "${brandName}" (${picRel}):`, err.message); @@ -742,18 +800,37 @@ export async function executeCompanyImport({ let totalImagesImported = 0; for (let i = 0; i < imageTasks.length; i++) { const { sku, relPath } = imageTasks[i]; + const current = i + 1; + const total = imageTasks.length; + const filename = path.basename(relPath); const imgPath = path.join(importDir, relPath); + let skele = null; + if (fs.existsSync(imgPath)) { try { const imgBuf = fs.readFileSync(imgPath); await store.article.addImage(googleAuthId, sku, imgBuf); totalImagesImported++; + + skele = await computeSkeleHash(imgBuf); } catch (err) { // If already attached or format issue, log and continue console.warn(`[Import] Image add failed for SKU ${sku} (${relPath}):`, err.message); } } - onProgress('images', i + 1, imageTasks.length, `${sku} (${path.basename(relPath)})`); + + onImage({ + type: 'article', + current, + total, + sku, + path: relPath, + filename, + skelehash: skele?.base62 || null, + width: skele?.width || null, + height: skele?.height || null, + hashLength: skele?.hashLength || 0, + }); } console.log(`[Import] Completed article pictures: ${totalImagesImported} images processed`);