Files
pulse-signage/src/data/utils.js
T
2026-08-05 19:38:16 +01:00

404 lines
10 KiB
JavaScript

// Shared helpers for parsing JSON, reading form arrays, and duplicate-name checks.
function parseJsonSafe(value) {
if (!value) {
return null;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function readFormArray(body, key) {
if (Array.isArray(body[key])) {
return body[key];
}
if (body[key] === undefined) {
return [];
}
return [body[key]];
}
function validateMaxLength(value, maxLength, fieldName) {
const text = String(value || '').trim();
const limit = Number(maxLength);
if (Number.isFinite(limit) && limit > 0 && text.length > limit) {
const error = new Error(fieldName + ' must be ' + limit + ' characters or fewer.');
error.statusCode = 400;
throw error;
}
return text;
}
function truncateToMaxLength(value, maxLength) {
const text = String(value || '').trim();
const limit = Number(maxLength);
if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
return text;
}
return text.slice(0, limit);
}
function normalizePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function escapeLikeValue(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedSortDirection = normalizeSortDirection(sortDirection);
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
if (!sortExpression) {
return {
clause: '',
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
const orderBySql = expressions.map(function (expression) {
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
}).join(', ');
return {
clause: ` ORDER BY ${orderBySql}`,
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
function findTopLevelOrderByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastOrderByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[oO]/.test(character)) {
const remaining = text.slice(index);
if (/^order\s+by\b/i.test(remaining)) {
lastOrderByIndex = index;
}
}
}
return lastOrderByIndex;
}
function findTopLevelWhereIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastWhereIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[wW]/.test(character)) {
const remaining = text.slice(index);
if (/^where\b/i.test(remaining)) {
lastWhereIndex = index;
}
}
}
return lastWhereIndex;
}
function findTopLevelGroupByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastGroupByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[gG]/.test(character)) {
const remaining = text.slice(index);
if (/^group\s+by\b/i.test(remaining)) {
lastGroupByIndex = index;
}
}
}
return lastGroupByIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
}).filter(Boolean) : [];
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
if (!columns.length || !normalizedSearchTerm) {
return {
clause: '',
params: []
};
}
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
return {
clause: ` WHERE (${columns.map(function (column) {
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
}).join(' OR ')})`,
params: columns.map(function () {
return likeValue;
})
};
}
async function fetchPagedRows(pool, options) {
const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim();
const params = Array.isArray(options && options.params) ? options.params : [];
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
const currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.');
}
const orderByIndex = findTopLevelOrderByIndex(selectSql);
const groupByIndex = findTopLevelGroupByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
let groupBySql = '';
if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(orderByIndex).trim();
}
if (groupByIndex >= 0 && (orderByIndex < 0 || groupByIndex < orderByIndex)) {
baseSelectSql = selectSql.slice(0, groupByIndex).trim();
groupBySql = selectSql.slice(groupByIndex, orderByIndex >= 0 ? orderByIndex : selectSql.length).trim();
}
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
const filteredSelectSql = `${baseSelectSql}${searchClause}${groupBySql ? ' ' + groupBySql : ''}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
const [countRows] = await pool.query(countQuery, countParams);
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize;
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
return {
rows: rows || [],
totalItems: totalItems,
totalPages: totalPages,
currentPage: safeCurrentPage,
pageSize: pageSize
};
}
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
const normalizedName = String(name || '').trim();
if (!normalizedName) {
return null;
}
const normalizedColumnName = String(columnName || 'name').trim() || 'name';
const params = [normalizedName];
let sql = `SELECT id, \`${normalizedColumnName}\` AS name FROM \`${tableName}\` WHERE LOWER(TRIM(\`${normalizedColumnName}\`)) = LOWER(TRIM(?))`;
if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?';
params.push(excludeId);
}
sql += ' LIMIT 1';
const [rows] = await pool.query(sql, params);
return rows[0] || null;
}
module.exports = {
parseJsonSafe,
readFormArray,
validateMaxLength,
truncateToMaxLength,
normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
findTopLevelGroupByIndex,
buildSearchFilter,
fetchPagedRows,
fetchDuplicateName
};