From 62d0643b9949dd0c4f90897498e59f0e0ee8088f Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 24 Oct 2026 06:19:37 +0200 Subject: [PATCH] u --- API.category.md | 31 ++++ API.md | 1 + src/index.js | 2 + src/providers/mssql/queries/category.js | 28 +++ src/providers/sqlite3/queries/category.js | 20 +++ src/store/category/generateImage.js | 207 ++++++++++++++++++++++ test/category.test.js | 76 ++++++++ 7 files changed, 365 insertions(+) create mode 100644 src/store/category/generateImage.js diff --git a/API.category.md b/API.category.md index 7be08f9..c30c796 100644 --- a/API.category.md +++ b/API.category.md @@ -142,6 +142,37 @@ const id = await store.category.ensureByPath({ googleAuthId: 42, parentId: 5, pa // => 12 ``` +## `category.generateImage` + +*Source: `src/store/category/generateImage.js`* + +Generates a mosaic image directly from the category's and its subcategories' articles (grouped by subcategory) and sets it as the new main picture for the category. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `inputOrCategoryId` | `number|object` | The id of the category or an options object. | +| `[maybeOptsOrSize={}]` | `object|number` | Options object or numeric size. | +| `[maybeOptsOrSize.size]` | `number` | Grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3 (falls back to 2 if < 7 images). | +| `[maybeTx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>` — The new main image info. + +**Throws** + +`Error` — If the category is not found, not authorized, size is invalid, or no images are found. + +**Example** + +```js +const result = await store.category.generateImage(42, 5, { size: 3 }); +// => { id: 5, fullHash, thumbW, thumbH, thumbHash, smallThumbW, smallThumbH, size: 3, grid: '3x3', articleCount: 12 } +``` + ## `category.getCategoryArticles` *Source: `src/store/category/getCategoryArticles.js`* diff --git a/API.md b/API.md index f596e62..062c794 100644 --- a/API.md +++ b/API.md @@ -99,6 +99,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise` - `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>` - `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise` + - `generateImage(googleAuthId: number, inputOrCategoryId: number|object, maybeOptsOrSize?: {size?: number}, maybeTx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>` - `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise, warehouses: Array<{id: number, name: string, stock: number}>}>>` - `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<{id: number, parentId: number|null, sort: number, name: string|null, slug: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, articleCount: number, posterminalIds: number[]}>, articleCount: number}>` - `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>` diff --git a/src/index.js b/src/index.js index 95b1ec7..f6df0ae 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ import categoryOrder from './store/category/order.js'; import categoryDelete from './store/category/delete.js'; import categoryAddImage from './store/category/addImage.js'; import categoryDeleteImage from './store/category/deleteImage.js'; +import categoryGenerateImage from './store/category/generateImage.js'; import articleDelete from './store/article/delete.js'; import articleDeleteImage from './store/article/deleteImage.js'; import articleOrderImages from './store/article/orderImages.js'; @@ -321,6 +322,7 @@ export default class LibDashStore { delete: categoryDelete(provider), addImage: categoryAddImage(provider), deleteImage: categoryDeleteImage(provider), + generateImage: categoryGenerateImage(provider), }; this.article = { diff --git a/src/providers/mssql/queries/category.js b/src/providers/mssql/queries/category.js index 9905728..1818071 100644 --- a/src/providers/mssql/queries/category.js +++ b/src/providers/mssql/queries/category.js @@ -178,3 +178,31 @@ export const DELETE = ` DELETE FROM category WHERE id = @id `; + +export const LIST_CHILD_CATEGORIES = ` +SELECT id +FROM category +WHERE parent_id = @parentId +ORDER BY sort ASC, id ASC +`; + +export const GET_CATEGORY_ARTICLE_PICS = ` +SELECT + a.id AS articleId, + p.id AS picId, + p.hash, + p.fullbinary AS buffer +FROM categoryarticle ca +INNER JOIN article a ON a.id = ca.article_id +OUTER APPLY ( + SELECT TOP 1 ap.pic_id + FROM articlepic ap + WHERE ap.article_id = a.id + ORDER BY ap.sort ASC, ap.id ASC +) first_pic +INNER JOIN pic p ON p.id = first_pic.pic_id +WHERE ca.category_id = @categoryId + AND a.parentarticle_id IS NULL +ORDER BY a.id ASC +`; + diff --git a/src/providers/sqlite3/queries/category.js b/src/providers/sqlite3/queries/category.js index 3f2453a..75d52cf 100644 --- a/src/providers/sqlite3/queries/category.js +++ b/src/providers/sqlite3/queries/category.js @@ -7,9 +7,29 @@ export const { CREATE, DELETE, GET_BY_PARENT_AND_NAME, GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES, GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS, + LIST_CHILD_CATEGORIES, MOVE, UPDATE_SORT, } = queries; +export const GET_CATEGORY_ARTICLE_PICS = ` +SELECT + a.id AS articleId, + p.id AS picId, + p.hash, + NULL AS buffer +FROM categoryarticle ca +INNER JOIN article a ON a.id = ca.article_id +INNER JOIN pic p ON p.id = ( + SELECT ap.pic_id FROM articlepic ap + WHERE ap.article_id = a.id + ORDER BY ap.sort ASC, ap.id ASC + LIMIT 1 +) +WHERE ca.category_id = @categoryId + AND a.parentarticle_id IS NULL +ORDER BY a.id ASC +`; + export const RESOLVE_CATEGORY_ACCESS = ` WITH RECURSIVE ancestors AS ( SELECT id, parent_id diff --git a/src/store/category/generateImage.js b/src/store/category/generateImage.js new file mode 100644 index 0000000..32cc192 --- /dev/null +++ b/src/store/category/generateImage.js @@ -0,0 +1,207 @@ +import { + GET_CATEGORY_ARTICLE_PICS as MSSQL_GET_CATEGORY_ARTICLE_PICS, + LIST_CHILD_CATEGORIES as MSSQL_LIST_CHILD_CATEGORIES, +} from '../../providers/mssql/queries/category.js'; +import { + GET_CATEGORY_ARTICLE_PICS as SQLITE_GET_CATEGORY_ARTICLE_PICS, + LIST_CHILD_CATEGORIES as SQLITE_LIST_CHILD_CATEGORIES, +} from '../../providers/sqlite3/queries/category.js'; +import resolveCategoryAccess from './resolveCategoryAccess.js'; +import addImage from './addImage.js'; +import makeRunner from '../runner.js'; +import sharp from 'sharp'; + +export default function generateImage(provider) { + const access = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getPicsSql = isSqlite ? SQLITE_GET_CATEGORY_ARTICLE_PICS : MSSQL_GET_CATEGORY_ARTICLE_PICS; + const listChildrenSql = isSqlite ? SQLITE_LIST_CHILD_CATEGORIES : MSSQL_LIST_CHILD_CATEGORIES; + const addCategoryPic = addImage(provider); + + /** + * Generates a mosaic image directly from the category's and its subcategories' articles + * (grouped by subcategory) and sets it as the new main picture for the category. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number|object} inputOrCategoryId - The id of the category or an options object. + * @param {object|number} [maybeOptsOrSize={}] - Options object or numeric size. + * @param {number} [maybeOptsOrSize.size] - Grid size (2 for 2x2, 3 for 3x3, 4 for 4x4). Defaults to 3 (falls back to 2 if < 7 images). + * @param {object} [maybeTx] - Optional transaction to run within. + * @returns {Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string, smallThumbW: number, smallThumbH: number, size: number, grid: string, articleCount: number}>} The new main image info. + * @throws {Error} If the category is not found, not authorized, size is invalid, or no images are found. + * + * @example + * const result = await store.category.generateImage(42, 5, { size: 3 }); + * // => { id: 5, fullHash, thumbW, thumbH, thumbHash, smallThumbW, smallThumbH, size: 3, grid: '3x3', articleCount: 12 } + */ + return async (googleAuthId, inputOrCategoryId, maybeOptsOrSize = {}, maybeTx) => { + let categoryId; + let size = null; + let tx; + + if (typeof inputOrCategoryId === 'object' && inputOrCategoryId !== null) { + categoryId = inputOrCategoryId.categoryId ?? inputOrCategoryId.id; + size = inputOrCategoryId.size ?? null; + tx = maybeOptsOrSize; + } else { + categoryId = inputOrCategoryId; + if (typeof maybeOptsOrSize === 'number') { + size = maybeOptsOrSize; + tx = maybeTx; + } else if (typeof maybeOptsOrSize === 'object' && maybeOptsOrSize !== null) { + size = maybeOptsOrSize.size ?? null; + tx = maybeTx; + } else { + tx = maybeTx; + } + } + + if (size != null) { + size = Number(size); + if (![2, 3, 4].includes(size)) { + throw new Error('Invalid size, must be 2, 3, or 4'); + } + } + + const runGenerate = async (runner) => { + await access(googleAuthId, categoryId, runner); + + // Collect category and descendant subcategories in DFS order + async function getCategoryTreeIds(parentId) { + const result = [parentId]; + const children = await runner.query(listChildrenSql, { parentId }); + for (const child of children.rows) { + const sub = await getCategoryTreeIds(child.id); + result.push(...sub); + } + return result; + } + + const categoryIds = await getCategoryTreeIds(categoryId); + + // Collect distinct articles with images grouped by subcategory + const seenArticleIds = new Set(); + const seenHashes = new Set(); + const candidates = []; + + for (const catId of categoryIds) { + const articlePics = await runner.query(getPicsSql, { categoryId: catId }); + for (const row of articlePics.rows) { + if (!row.hash) continue; + if (seenArticleIds.has(row.articleId) || seenHashes.has(row.hash)) continue; + seenArticleIds.add(row.articleId); + seenHashes.add(row.hash); + candidates.push({ + articleId: row.articleId, + picId: row.picId, + hash: row.hash, + buffer: row.buffer, + }); + } + } + + if (candidates.length === 0) { + throw new Error('No images found for category articles'); + } + + // Determine matrix size: + // Default to 3x3, fallback to 2x2 if fewer than 7 images are available. + let targetSize = size; + if (!targetSize) { + targetSize = candidates.length < 7 ? 2 : 3; + } else if (targetSize === 3 && candidates.length < 7) { + targetSize = 2; + } + + const cellCount = targetSize * targetSize; + const selected = candidates.length >= cellCount + ? candidates.slice(0, cellCount) + : Array.from({ length: cellCount }, (_, i) => candidates[i % candidates.length]); + + // Load image buffers for the selected items + const loaded = await Promise.all( + selected.map(async (item) => { + let buf = item.buffer; + if (!buf || !buf.length) { + if (isSqlite) { + buf = runner.readPicFull ? await runner.readPicFull(item.hash) : await provider?.readPicFull?.(item.hash); + if (!buf) { + buf = runner.readPicThumb ? await runner.readPicThumb(item.hash) : await provider?.readPicThumb?.(item.hash); + } + } else { + const picRes = await runner.query( + 'SELECT fullbinary, thumbbinary FROM pic WHERE hash = @hash', + { hash: item.hash }, + ); + buf = picRes.rows[0]?.fullbinary ?? picRes.rows[0]?.thumbbinary; + } + } + return { ...item, buffer: buf }; + }), + ); + + const validPics = loaded.filter( + (p) => p.buffer && Buffer.isBuffer(p.buffer) && p.buffer.length > 0, + ); + + if (validPics.length === 0) { + throw new Error('No images found for category articles'); + } + + const finalPics = Array.from( + { length: cellCount }, + (_, i) => validPics[i % validPics.length], + ); + + const TOTAL_SIZE = 1200; + const tileSize = TOTAL_SIZE / targetSize; + + const composites = await Promise.all( + finalPics.map(async (pic, idx) => { + const row = Math.floor(idx / targetSize); + const col = idx % targetSize; + const tileBuffer = await sharp(pic.buffer) + .resize(tileSize, tileSize, { fit: 'cover', position: 'center' }) + .toBuffer(); + return { + input: tileBuffer, + left: col * tileSize, + top: row * tileSize, + }; + }), + ); + + const mosaicBuffer = await sharp({ + create: { + width: TOTAL_SIZE, + height: TOTAL_SIZE, + channels: 4, + background: { r: 255, g: 255, b: 255, alpha: 1 }, + }, + }) + .composite(composites) + .png() + .toBuffer(); + + // Clear existing categorypic rows so the new mosaic becomes the main (first) image + await runner.query('DELETE FROM categorypic WHERE category_id = @categoryId', { categoryId }); + + const added = await addCategoryPic(googleAuthId, categoryId, mosaicBuffer, runner); + + return { + ...added, + size: targetSize, + grid: `${targetSize}x${targetSize}`, + articleCount: candidates.length, + }; + }; + + if (tx) { + return runGenerate(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runGenerate); + } + return runGenerate(makeRunner(provider)); + }; +} diff --git a/test/category.test.js b/test/category.test.js index 4a10012..86c6902 100644 --- a/test/category.test.js +++ b/test/category.test.js @@ -345,4 +345,80 @@ describe('store.category', () => { ); }); }); + + describe('generateImage', () => { + it('rejects invalid size', async () => { + const cat = await create('Gen Invalid Size'); + await assert.rejects( + () => store.category.generateImage(tenant.googleAuthId, cat.id, { size: 5 }), + { message: 'Invalid size, must be 2, 3, or 4' }, + ); + }); + + it('rejects category without any article images', async () => { + const cat = await create('Gen Empty Cat'); + await assert.rejects( + () => store.category.generateImage(tenant.googleAuthId, cat.id), + { message: 'No images found for category articles' }, + ); + }); + + it('falls back to 2x2 when fewer than 7 images are available', async () => { + const cat = await create('Mosaic Parent Cat'); + const sub = await create('Mosaic Sub Cat', cat.id); + + // Create 2 articles in parent, 2 articles in subcategory with images + for (let i = 0; i < 2; i++) { + const art = await createArticle(store, tenant, { parentId: cat.id, name: `Parent Art ${i}` }); + await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 50 * i, g: 100, b: 150 })); + } + for (let i = 0; i < 2; i++) { + const art = await createArticle(store, tenant, { parentId: sub.id, name: `Sub Art ${i}` }); + await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 150, g: 50 * i, b: 200 })); + } + + // 4 images available (< 7), default without size should fall back to 2x2 + const res = await store.category.generateImage(tenant.googleAuthId, cat.id); + assert.equal(res.id, cat.id); + assert.equal(res.size, 2); + assert.equal(res.grid, '2x2'); + assert.equal(res.articleCount, 4); + assert.ok(res.fullHash); + assert.ok(res.thumbW > 0); + + // Category tree exposes the new mosaic image + const treeCat = await findInTree(cat.id); + assert.equal(treeCat.fullHash, res.fullHash); + }); + + it('uses 3x3 when 7 or more images are available or explicit size=3', async () => { + const cat = await create('Mosaic 3x3 Cat'); + for (let i = 0; i < 7; i++) { + const art = await createArticle(store, tenant, { parentId: cat.id, name: `3x3 Art ${i}` }); + await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 30 * i, g: 70, b: 90 })); + } + + const res = await store.category.generateImage(tenant.googleAuthId, cat.id); + assert.equal(res.id, cat.id); + assert.equal(res.size, 3); + assert.equal(res.grid, '3x3'); + assert.equal(res.articleCount, 7); + assert.ok(res.fullHash); + }); + + it('supports explicit size parameter (e.g. size=4)', async () => { + const cat = await create('Mosaic 4x4 Cat'); + for (let i = 0; i < 4; i++) { + const art = await createArticle(store, tenant, { parentId: cat.id, name: `4x4 Art ${i}` }); + await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 40 * i, g: 120, b: 50 })); + } + + const res = await store.category.generateImage(tenant.googleAuthId, cat.id, { size: 4 }); + assert.equal(res.id, cat.id); + assert.equal(res.size, 4); + assert.equal(res.grid, '4x4'); + assert.ok(res.fullHash); + }); + }); }); +