This commit is contained in:
seb
2026-10-24 06:19:37 +02:00
parent 1da82e2eb9
commit 62d0643b99
7 changed files with 365 additions and 0 deletions

View File

@@ -142,6 +142,37 @@ const id = await store.category.ensureByPath({ googleAuthId: 42, parentId: 5, pa
// => 12 // => 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` ## `category.getCategoryArticles`
*Source: `src/store/category/getCategoryArticles.js`* *Source: `src/store/category/getCategoryArticles.js`*

1
API.md
View File

@@ -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<void>` - `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>` - `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<number>` - `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `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<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, smallThumbW: number|null, smallThumbH: number|null, smallThumbBinary: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, warehouses: Array<{id: number, name: string, stock: number}>}>>` - `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, smallThumbW: number|null, smallThumbH: number|null, smallThumbBinary: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, articlewarehousesummary: Array<{id: number, articleId: number, warehouseId: number, warehouseName: string, stock: number}>, 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}>` - `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}>` - `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>`

View File

@@ -18,6 +18,7 @@ import categoryOrder from './store/category/order.js';
import categoryDelete from './store/category/delete.js'; import categoryDelete from './store/category/delete.js';
import categoryAddImage from './store/category/addImage.js'; import categoryAddImage from './store/category/addImage.js';
import categoryDeleteImage from './store/category/deleteImage.js'; import categoryDeleteImage from './store/category/deleteImage.js';
import categoryGenerateImage from './store/category/generateImage.js';
import articleDelete from './store/article/delete.js'; import articleDelete from './store/article/delete.js';
import articleDeleteImage from './store/article/deleteImage.js'; import articleDeleteImage from './store/article/deleteImage.js';
import articleOrderImages from './store/article/orderImages.js'; import articleOrderImages from './store/article/orderImages.js';
@@ -321,6 +322,7 @@ export default class LibDashStore {
delete: categoryDelete(provider), delete: categoryDelete(provider),
addImage: categoryAddImage(provider), addImage: categoryAddImage(provider),
deleteImage: categoryDeleteImage(provider), deleteImage: categoryDeleteImage(provider),
generateImage: categoryGenerateImage(provider),
}; };
this.article = { this.article = {

View File

@@ -178,3 +178,31 @@ export const DELETE = `
DELETE FROM category DELETE FROM category
WHERE id = @id 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
`;

View File

@@ -7,9 +7,29 @@ export const {
CREATE, DELETE, GET_BY_PARENT_AND_NAME, CREATE, DELETE, GET_BY_PARENT_AND_NAME,
GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES, GET_CATEGORY_ARTICLE_WAREHOUSE_SUMMARIES,
GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS, GET_NEXT_SIBLING_SORT, LIST_CHILD_IDS,
LIST_CHILD_CATEGORIES,
MOVE, UPDATE_SORT, MOVE, UPDATE_SORT,
} = queries; } = 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 = ` export const RESOLVE_CATEGORY_ACCESS = `
WITH RECURSIVE ancestors AS ( WITH RECURSIVE ancestors AS (
SELECT id, parent_id SELECT id, parent_id

View File

@@ -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));
};
}

View File

@@ -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);
});
});
});