Release v2.4.2

This commit is contained in:
lzstealth committed 2026-08-03 12:45:27 +01:00
1 parent 2b9cabdab2
commit e95928f31c
141 files changed
+7694 -5078

No files matched your search

+4 -26
View File
@@ -1,30 +1,8 @@
// RSS feed add page renderer and form defaults.
// RSS feed add page renderer.
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
inUse: Boolean(viewRssFeed.inUse),
pulledItems: viewRssFeed.pulledItems || [],
pullError: viewRssFeed.pullError || ''
});
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
};
@@ -1,6 +1,7 @@
// RSS feed edit page renderer that reuses the add form.
// RSS feed edit page renderer.
const renderRssFeedFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
const viewData = Object.assign({
@@ -8,8 +9,8 @@ module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentU
pullError: ''
}, data || {});
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(Object.assign({}, rssFeed, {
pulledItems: viewData.pulledItems,
pullError: viewData.pullError
}), 'edit', message, currentUser);
}), message, currentUser, viewData, true));
};
@@ -0,0 +1,39 @@
// Shared RSS feed form view-model builder.
function buildDefaultRssFeed() {
return {
id: null,
name: '',
feedUrl: '',
updateIntervalValue: 60,
updateIntervalUnit: 'minutes',
itemLimit: 1
};
}
function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdit) {
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
const viewOptions = options || {};
return {
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
active: 'rss-feeds',
message: message,
currentUser: currentUser || null,
rssFeed: viewRssFeed,
inUse: Boolean(viewRssFeed.inUse),
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse),
formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds',
formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"',
cancelUrl: '/data-sources/rss-feeds',
deleteUrl: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id + '/delete' : ''
};
}
module.exports = {
buildRssFeedFormViewModel: buildRssFeedFormViewModel
};
@@ -0,0 +1,296 @@
// RSS feed route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const rssFeedIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
if (!content || typeof content !== 'object') {
continue;
}
(function walk(value) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
value.forEach(walk);
return;
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
const feedId = Number(value.feed_id);
if (Number.isFinite(feedId)) {
rssFeedIds.add(feedId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
})(content);
}
return rssFeedIds;
}
async function isRssFeedInUse(pool, common, rssFeedId) {
const usageIds = await getDataSourceUsageMaps(pool, common);
return usageIds.has(Number(rssFeedId));
}
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
const taskId = Number(req.query.refresh_task_id);
if (!Number.isFinite(taskId) || taskId <= 0) {
return res.status(400).json({ error: 'Missing refresh task id.' });
}
const task = await dataSourceTasks.getTaskStatusById(taskId);
const expectedKey = 'rss-feed-refresh:' + Number(sourceId);
if (!task || task.key !== expectedKey) {
return res.status(404).json({ error: 'Refresh task not found.' });
}
res.json(task);
}
module.exports = function registerRssFeedRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const dataSourceTasks = deps.dataSourceTasks;
const backgroundTaskQueue = deps.backgroundTaskQueue;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const formatDashboardDate = deps.formatDashboardDate || function (value) {
return value ? String(value) : '';
};
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageIds = await getDataSourceUsageMaps(pool, common);
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
return Object.assign({}, rssFeed, {
inUse: usageIds.has(Number(rssFeed.id))
});
});
res.send(renderRssFeedsPage({
rssFeeds: rssFeeds,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const inUse = (slides || []).some(function (row) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
if (!content || typeof content !== 'object') {
return false;
}
const stack = [content];
while (stack.length) {
const value = stack.pop();
if (!value || typeof value !== 'object') {
continue;
}
if (Array.isArray(value)) {
value.forEach(function (item) { stack.push(item); });
continue;
}
if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) {
return true;
}
Object.keys(value).forEach(function (key) {
stack.push(value[key]);
});
}
return false;
});
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
: [];
res.send(renderRssFeedEditPage(Object.assign({}, rssFeed, {
feedUrl: rssFeed.feed_url,
updateIntervalValue: rssFeed.update_interval_value,
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
itemLimit: rssFeed.item_limit,
inUse: inUse
}), {
pulledItems: pulledItems,
pullError: ''
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
});
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildRssFeedPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
});
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + result.insertId,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: result.insertId,
sourceName: payload.name
}
});
const message = 'RSS feed created. Refresh is running in the background.';
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
const payload = common.buildRssFeedPayload(req, rssFeed);
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
}
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
);
await connection.commit();
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
});
const message = 'RSS feed updated. Refresh is running in the background.';
const refreshTask = await backgroundTaskQueue.enqueueTask({
key: 'rss-feed-refresh:' + rssFeed.id,
title: 'RSS feed refresh',
category: 'data-source',
taskType: 'data-source-refresh',
payload: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name,
actorId: actorId
},
metadata: {
sourceType: 'rss-feed',
sourceId: rssFeed.id,
sourceName: payload.name
}
});
redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
closeUrl: '/data-sources/rss-feeds',
newUrl: '/data-sources/rss-feeds/new',
message: message
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
try {
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
if (!rssFeed) {
return res.status(404).send('RSS feed not found');
}
if (await isRssFeedInUse(pool, common, rssFeed.id)) {
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.'));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
await connection.commit();
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
} catch (error) {
next(error);
}
});
};