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

@@ -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 = {

View File

@@ -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
`;

View File

@@ -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

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