u
This commit is contained in:
@@ -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`*
|
||||
|
||||
1
API.md
1
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<void>`
|
||||
- `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>`
|
||||
- `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}>}>>`
|
||||
- `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}>`
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
207
src/store/category/generateImage.js
Normal file
207
src/store/category/generateImage.js
Normal 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));
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user