Compare commits

..

11 Commits

Author SHA1 Message Date
orangecoding
33120ebeca ability to share jobs with users 2025-10-07 21:06:59 +02:00
orangecoding
de2dd05c70 reverting docker file change 2025-10-07 07:18:45 +02:00
orangecoding
e4784e5960 reverting docker file change 2025-10-06 20:21:26 +02:00
orangecoding
2e537ce0be improving ntfy error handling 2025-10-06 20:19:53 +02:00
orangecoding
f0f1244baa using docker without root 2025-10-06 19:55:37 +02:00
orangecoding
b858529f06 next release version 2025-10-05 18:57:52 +02:00
orangecoding
c9bd5dc161 fixing delete listings 2025-10-05 18:57:27 +02:00
orangecoding
daa4a7b8f1 refine telegram adapter 2025-10-05 18:53:17 +02:00
Thomas Brockmöller
035f0e9f83 Check Telegram response (#205) (#211)
* Add error handling and logging to Telegram message sending

* Add debug logging for new listings
2025-10-05 17:06:57 +02:00
Christian Kellner
a5efd9af32 New Feature: Watch Listings (#215)
* adding new feature: watch listings for changes

* adding todo for watch feature

* sort by watch
2025-10-05 14:23:32 +02:00
orangecoding
9f1e27d011 check if fredy config exists and is accessible 2025-10-03 17:23:46 +02:00
28 changed files with 733 additions and 147 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

After

Width:  |  Height:  |  Size: 512 KiB

6
docker-test.sh Normal file → Executable file
View File

@@ -7,12 +7,12 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
docker rm fredy || true
fi
# Build image from local Dockerfile
docker build -t fredy:local .
# Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local .
# Run container with volumes and port mapping
docker run -d --name fredy \
-v fredy_conf:/conf \
-v fredy_db:/db \
-p 9998:9998 \
fredy:local
fredy:local

View File

@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { config, getProviders, refreshConfig } from './lib/utils.js';
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyRuntime from './lib/FredyRuntime.js';
@@ -16,6 +16,13 @@ import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.j
// Load configuration before any other startup steps
await refreshConfig();
const isConfigAccessible = await checkIfConfigIsAccessible();
if (!isConfigAccessible) {
logger.error('Configuration exists, but is not accessible. Please check the file permission');
process.exit(1);
}
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
const rawDir = config.sqlitepath || '/db';
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;

View File

@@ -77,6 +77,7 @@ class FredyRuntime {
}
_findNew(listings) {
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
const newListings = listings.filter((o) => !hashes.includes(o.id));
@@ -95,6 +96,7 @@ class FredyRuntime {
}
_save(newListings) {
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
storeListings(this._jobKey, this._providerId, newListings);
return newListings;
}
@@ -103,7 +105,9 @@ class FredyRuntime {
const filteredList = listings.filter((listing) => {
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
if (similar) {
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
logger.debug(
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
);
}
return !similar;
});
@@ -112,7 +116,11 @@ class FredyRuntime {
}
_handleError(err) {
if (err.name !== 'NoNewListingsWarning') logger.error(err);
if (err.name === 'NoNewListingsWarning') {
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
} else {
logger.error(err);
}
}
}

View File

@@ -24,9 +24,25 @@ function doesJobBelongsToUser(job, req) {
jobRouter.get('/', async (req, res) => {
const isUserAdmin = isAdmin(req);
//show only the jobs which belongs to the user (or all of the user is an admin)
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
res.body = jobStorage
.getJobs()
.filter(
(job) =>
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
)
.map((job) => {
return {
...job,
isOnlyShared:
!isUserAdmin &&
job.userId !== req.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser),
};
});
res.send();
});
jobRouter.get('/processingTimes', async (req, res) => {
res.body = {
interval: config.interval,
@@ -41,8 +57,15 @@ jobRouter.post('/startAll', async (req, res) => {
});
jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
try {
let jobFromDb = jobStorage.getJob(jobId);
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
res.send(new Error('You are trying to change a job that is not associated to your user.'));
return;
}
jobStorage.upsertJob({
userId: req.session.currentUser,
jobId,
@@ -51,6 +74,7 @@ jobRouter.post('/', async (req, res) => {
blacklist,
provider,
notificationAdapter,
shareWithUsers,
});
} catch (error) {
res.send(new Error(error));
@@ -58,6 +82,7 @@ jobRouter.post('/', async (req, res) => {
}
res.send();
});
jobRouter.delete('', async (req, res) => {
const { jobId } = req.body;
try {
@@ -92,4 +117,16 @@ jobRouter.put('/:jobId/status', async (req, res) => {
}
res.send();
});
jobRouter.get('/shareableUserList', async (req, res) => {
const currentUser = req.session.currentUser;
const users = userStorage.getUsers(false);
res.body = users
.filter((user) => !user.isAdmin && user.id !== currentUser)
.map((user) => ({
id: user.id,
name: user.username,
}));
res.send();
});
export { jobRouter };

View File

@@ -1,19 +1,51 @@
import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js';
import * as watchListStorage from '../../services/storage/watchListStorage.js';
import { isAdmin as isAdminFn } from '../security.js';
import logger from '../../services/logger.js';
import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js';
const service = restana();
const listingsRouter = service.newRouter();
listingsRouter.get('/table', async (req, res) => {
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
const {
page,
pageSize = 50,
activityFilter,
jobNameFilter,
providerFilter,
watchListFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = req.query || {};
// normalize booleans (accept true, 'true', 1, '1')
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
const normalizedActivity = toBool(activityFilter) ? true : null;
const normalizedWatch = toBool(watchListFilter) ? true : null;
let jobFilter = null;
let jobIdFilter = null;
const jobs = getJobs();
if (!nullOrEmpty(jobNameFilter)) {
const job = jobs.find((j) => j.id === jobNameFilter);
jobFilter = job != null ? job.name : null;
jobIdFilter = job != null ? job.id : null;
}
res.body = listingStorage.queryListings({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
filter: filter || undefined,
freeTextFilter: freeTextFilter || null,
activityFilter: normalizedActivity,
jobNameFilter: jobFilter,
jobIdFilter: jobIdFilter,
providerFilter,
watchListFilter: normalizedWatch,
sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser,
@@ -22,6 +54,25 @@ listingsRouter.get('/table', async (req, res) => {
res.send();
});
// Toggle watch state for the current user on a listing
listingsRouter.post('/watch', async (req, res) => {
try {
const { listingId } = req.body || {};
const userId = req.session?.currentUser;
if (!listingId || !userId) {
res.statusCode = 400;
res.body = { message: 'listingId or user not provided' };
return res.send();
}
watchListStorage.toggleWatch(listingId, userId);
} catch (error) {
logger.error(error);
res.statusCode = 500;
res.body = { message: 'Failed to toggle watch' };
}
res.send();
});
listingsRouter.delete('/job', async (req, res) => {
const { jobId } = req.body;
try {

View File

@@ -11,10 +11,12 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
return req.session.currentUser === userIdToBeRemoved;
}
const nullOrEmpty = (str) => str == null || str.length === 0;
userRouter.get('/', async (req, res) => {
res.body = userStorage.getUsers(false);
res.send();
});
userRouter.get('/:userId', async (req, res) => {
const { userId } = req.params;
res.body = userStorage.getUser(userId);

View File

@@ -36,7 +36,17 @@ Link: ${newListing.link}`;
method: 'POST',
headers,
body: message,
});
})
.then((res) => {
if (!res.ok) {
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
}
return res.text();
})
.catch((error) => {
// Ensure we reject with an Error object and prevent unhandled rejections
throw error instanceof Error ? error : new Error(String(error));
});
});
return Promise.all(promises);

View File

@@ -3,10 +3,14 @@ import { getJob } from '../../services/storage/jobStorage.js';
import fetch from 'node-fetch';
import pThrottle from 'p-throttle';
import { normalizeImageUrl } from '../../utils.js';
import logger from '../../services/logger.js';
const RATE_LIMIT_INTERVAL = 1000;
const chatThrottleMap = new Map();
/**
* Removes stale throttled call entries to keep memory bounded.
*/
function cleanupOldThrottles() {
const now = Date.now();
const maxAge = RATE_LIMIT_INTERVAL + 1000;
@@ -17,6 +21,15 @@ function cleanupOldThrottles() {
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
}
/**
* Return a throttled wrapper for a chatId to limit Telegram API calls.
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
*
* @template {Function} T
* @param {string|number} chatId
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
* @returns {T}
*/
function getThrottled(chatId, call) {
cleanupOldThrottles();
const now = Date.now();
@@ -30,15 +43,38 @@ function getThrottled(chatId, call) {
return throttled;
}
/**
* Shorten a string to a maximum length with an ellipsis suffix.
* @param {string} str
* @param {number} [len=90]
* @returns {string}
*/
function shorten(str, len = 90) {
if (!str) return '';
return str.length > len ? str.substring(0, len).trim() + '...' : str;
}
/**
* Escape basic HTML entities for Telegram HTML parse mode.
* @param {string} [s='']
* @returns {string}
*/
function escapeHtml(s = '') {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/**
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @param {string} [o.title]
* @param {string} [o.address]
* @param {string|number} [o.price]
* @param {string|number} [o.size]
* @param {string} [o.link]
* @returns {string}
*/
function buildCaption(jobName, serviceName, o) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
@@ -47,6 +83,13 @@ function buildCaption(jobName, serviceName, o) {
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
}
/**
* Build a Telegram message text using HTML parse mode.
* @param {string} jobName
* @param {string} serviceName
* @param {Object} o - Listing object
* @returns {string}
*/
function buildText(jobName, serviceName, o) {
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
@@ -57,8 +100,27 @@ function buildText(jobName, serviceName, o) {
);
}
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
/**
* Send new listings to Telegram.
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
* - Falls back to sendMessage when sendPhoto fails or image is missing.
*
* @param {Object} params
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
* @param {Array<Object>} params.newListings - Array of new listing objects.
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
*/
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
if (!adapterCfg || !adapterCfg.fields) {
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
}
const { token, chatId } = adapterCfg.fields;
if (!token || !chatId) {
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
}
const job = getJob(jobKey);
const jobName = job == null ? jobKey : job.name;
@@ -68,9 +130,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) {
const errorBody = await res.text();
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
}
return res;
});
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
const promises = newListings.map(async (o) => {
const img = normalizeImageUrl(o.image);
const textPayload = {
@@ -81,28 +150,32 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
};
if (!img) {
return throttledCall('sendMessage', textPayload);
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
});
}
try {
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
return await throttledCall('sendPhoto', {
chat_id: chatId,
photo: img,
caption: buildCaption(jobName, serviceName, o),
parse_mode: 'HTML',
}).catch(async (e) => {
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
return await throttledCall('sendMessage', textPayload).catch((e) => {
logger.error(`Error sending message to Telegram: ${e.message}`);
throw e;
});
} catch (e) {
// If we see a timeout due to sending an image, try sending it without
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
return throttledCall('sendMessage', textPayload);
}
throw e;
}
});
});
return Promise.all(promises);
};
/**
* Telegram notification adapter configuration schema.
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}}
*/
export const config = {
id: 'telegram',
name: 'Telegram',

View File

@@ -16,7 +16,16 @@ import { toJson, fromJson } from '../../utils.js';
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
* @returns {void}
*/
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
export const upsertJob = ({
jobId,
name,
blacklist = [],
enabled = true,
provider,
notificationAdapter,
userId,
shareWithUsers = [],
}) => {
const id = jobId || nanoid();
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
const ownerId = existing ? existing.user_id : userId;
@@ -27,21 +36,23 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
name = @name,
blacklist = @blacklist,
provider = @provider,
notification_adapter = @notification_adapter
notification_adapter = @notification_adapter,
shared_with_user = @shareWithUsers
WHERE id = @id`,
{
id,
enabled: enabled ? 1 : 0,
name: name ?? null,
blacklist: toJson(blacklist ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
provider: toJson(provider ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
},
);
} else {
SqliteConnection.execute(
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
{
id,
user_id: ownerId,
@@ -49,6 +60,7 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
name: name ?? null,
blacklist: toJson(blacklist ?? []),
provider: toJson(provider ?? []),
shareWithUsers: toJson(shareWithUsers ?? []),
notification_adapter: toJson(notificationAdapter ?? []),
},
);
@@ -129,6 +141,7 @@ export const getJobs = () => {
j.name,
j.blacklist,
j.provider,
j.shared_with_user,
j.notification_adapter AS notificationAdapter,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
FROM jobs j
@@ -139,6 +152,7 @@ export const getJobs = () => {
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
}));
};

View File

@@ -48,7 +48,8 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
return SqliteConnection.query(
`SELECT hash
FROM listings
WHERE job_id = @jobId AND provider = @providerId`,
WHERE job_id = @jobId
AND provider = @providerId`,
{ jobId, providerId },
).map((r) => r.hash);
};
@@ -63,7 +64,9 @@ export const getActiveOrUnknownListings = () => {
return SqliteConnection.query(
`SELECT *
FROM listings
WHERE is_active is null OR is_active = 1 ORDER BY provider`,
WHERE is_active is null
OR is_active = 1
ORDER BY provider`,
);
};
@@ -173,7 +176,11 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {Object} params
* @param {number} [params.pageSize=50]
* @param {number} [params.page=1]
* @param {string} [params.filter]
* @param {string} [params.freeTextFilter]
* @param {object} [params.activityFilter]
* @param {object} [params.jobNameFilter]
* @param {object} [params.providerFilter]
* @param {object} [params.watchListFilter]
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
* @param {('asc'|'desc')} [params.sortDir='asc']
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
@@ -183,7 +190,12 @@ export const storeListings = (jobId, providerId, listings) => {
export const queryListings = ({
pageSize = 50,
page = 1,
filter,
activityFilter,
jobNameFilter,
jobIdFilter,
providerFilter,
watchListFilter,
freeTextFilter,
sortField = null,
sortDir = 'asc',
userId = null,
@@ -197,15 +209,39 @@ export const queryListings = ({
// build WHERE filter across common text columns
const whereParts = [];
const params = { limit: safePageSize, offset };
// always provide userId param for watched-flag evaluation (null -> no matches)
params.userId = userId || '__NO_USER__';
// user scoping (non-admin only): restrict to listings whose job belongs to user
if (!isAdmin) {
params.userId = userId || '__NO_USER__';
whereParts.push(`(j.user_id = @userId)`);
}
if (filter && String(filter).trim().length > 0) {
params.filter = `%${String(filter).trim()}%`;
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
}
// activityFilter: when true -> only active listings (is_active = 1)
if (activityFilter === true) {
whereParts.push('(is_active = 1)');
}
// Prefer filtering by job id when provided (unambiguous and robust)
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
params.jobId = String(jobIdFilter).trim();
whereParts.push('(l.job_id = @jobId)');
} else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
// Fallback to exact job name match
params.jobName = String(jobNameFilter).trim();
whereParts.push('(j.name = @jobName)');
}
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
if (providerFilter && String(providerFilter).trim().length > 0) {
params.providerName = String(providerFilter).trim();
whereParts.push('(provider = @providerName)');
}
// watchListFilter: when true -> only watched listings
if (watchListFilter === true) {
whereParts.push('(wl.id IS NOT NULL)');
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const whereSqlWithAlias = whereSql
.replace(/\btitle\b/g, 'l.title')
@@ -213,10 +249,13 @@ export const queryListings = ({
.replace(/\baddress\b/g, 'l.address')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\blink\b/g, 'l.link')
.replace(/\bj\.user_id\b/g, 'j.user_id');
.replace(/\bis_active\b/g, 'l.is_active')
.replace(/\bj\.user_id\b/g, 'j.user_id')
.replace(/\bj\.name\b/g, 'j.name')
.replace(/\bwl\.id\b/g, 'wl.id');
// whitelist sortable fields to avoid SQL injection
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
@@ -226,25 +265,31 @@ export const queryListings = ({
.replace(/\bsize\b/g, 'l.size')
.replace(/\bprovider\b/g, 'l.provider')
.replace(/\btitle\b/g, 'l.title')
.replace(/\bjob_name\b/g, 'j.name');
.replace(/\bjob_name\b/g, 'j.name')
// Sort by computed watch flag when requested
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
// count total with same WHERE
const countRow = SqliteConnection.query(
`SELECT COUNT(1) as cnt
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
${whereSqlWithAlias}`,
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
${whereSqlWithAlias}`,
params,
);
const totalNumber = countRow?.[0]?.cnt ?? 0;
// fetch page
const rows = SqliteConnection.query(
`SELECT l.*, j.name AS job_name
`SELECT l.*,
j.name AS job_name,
CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
${whereSqlWithAlias}
${orderSqlWithAlias}
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
${whereSqlWithAlias}
${orderSqlWithAlias}
LIMIT @limit OFFSET @offset`,
params,
);
@@ -260,7 +305,12 @@ export const queryListings = ({
*/
export const deleteListingsByJobId = (jobId) => {
if (!jobId) return;
return SqliteConnection.execute(`DELETE FROM listings WHERE job_id = @jobId`, { jobId });
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE job_id = @jobId`,
{ jobId },
);
};
/**
@@ -272,5 +322,10 @@ export const deleteListingsByJobId = (jobId) => {
export const deleteListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(`DELETE FROM listings WHERE id IN (${placeholders})`, ids);
return SqliteConnection.execute(
`DELETE
FROM listings
WHERE id IN (${placeholders})`,
ids,
);
};

View File

@@ -0,0 +1,8 @@
// Migration: Adding a changeset field to the listings table in preparation for
// a price watch feature
export function up(db) {
db.exec(`
ALTER TABLE listings ADD COLUMN change_set jsonb;
`);
}

View File

@@ -0,0 +1,15 @@
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS watch_list
(
id TEXT PRIMARY KEY,
listing_id TEXT NOT NULL,
user_id TEXT NOT NULL,
FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id);
`);
}

View File

@@ -0,0 +1,7 @@
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]'
`);
}

View File

@@ -0,0 +1,64 @@
import SqliteConnection from './SqliteConnection.js';
import { nanoid } from 'nanoid';
/**
* Create a watch entry. Idempotent due to unique index (listing_id, user_id).
* @param {string} listingId
* @param {string} userId
* @returns {{created:boolean}}
*/
export const createWatch = (listingId, userId) => {
if (!listingId || !userId) return { created: false };
try {
SqliteConnection.execute(
`INSERT INTO watch_list (id, listing_id, user_id)
VALUES (@id, @listing_id, @user_id)
ON CONFLICT(listing_id, user_id) DO NOTHING`,
{ id: nanoid(), listing_id: listingId, user_id: userId },
);
// check whether it exists now
const row = SqliteConnection.query(
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
{ listing_id: listingId, user_id: userId },
);
return { created: row.length > 0 };
} catch {
return { created: false };
}
};
/**
* Delete a watch entry.
* @param {string} listingId
* @param {string} userId
* @returns {{deleted:boolean}}
*/
export const deleteWatch = (listingId, userId) => {
if (!listingId || !userId) return { deleted: false };
const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, {
listing_id: listingId,
user_id: userId,
});
return { deleted: Boolean(res?.changes) };
};
/**
* Toggle a watch entry. If exists -> delete, otherwise create.
* @param {string} listingId
* @param {string} userId
* @returns {{watched:boolean}}
*/
export const toggleWatch = (listingId, userId) => {
if (!listingId || !userId) return { watched: false };
const exists =
SqliteConnection.query(
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
{ listing_id: listingId, user_id: userId },
).length > 0;
if (exists) {
deleteWatch(listingId, userId);
return { watched: false };
}
createWatch(listingId, userId);
return { watched: true };
};

View File

@@ -180,6 +180,23 @@ function buildHash(...inputs) {
*/
let config = {};
/**
* If the config exists, but cannot be accessed, we quit Fredy as something is fishy here.
* @returns {Promise<boolean>}
*/
export async function checkIfConfigIsAccessible() {
const path = new URL('../conf/config.json', import.meta.url);
try {
if (!fs.existsSync(path)) {
return true;
}
fs.accessSync(path, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
/**
* Read config JSON from disk (conf/config.json) and parse it.
* @returns {Promise<any>} Parsed configuration object.

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "14.0.1",
"version": "14.2.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -85,7 +85,7 @@
"react-router": "7.9.3",
"react-router-dom": "7.9.3",
"restana": "5.1.0",
"semver": "^7.7.2",
"semver": "^7.7.3",
"serve-static": "2.2.0",
"slack": "11.0.2",
"vite": "7.1.9",
@@ -98,13 +98,13 @@
"@babel/preset-env": "7.28.3",
"@babel/preset-react": "7.27.1",
"chai": "6.2.0",
"eslint": "9.36.0",
"eslint": "9.37.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
"esmock": "2.7.3",
"history": "5.3.0",
"husky": "9.1.7",
"less": "4.4.1",
"less": "4.4.2",
"lint-staged": "16.2.3",
"mocha": "11.7.4",
"nodemon": "^3.1.10",

View File

@@ -37,6 +37,7 @@ export default function FredyApp() {
await actions.provider.getProvider();
await actions.jobs.getJobs();
await actions.jobs.getProcessingTimes();
await actions.jobs.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.versionUpdate.getVersionUpdate();

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobTable.less';
@@ -33,12 +33,38 @@ export default function JobTable({
title: '',
dataIndex: '',
render: (job) => {
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
return (
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
checked={job.enabled}
disabled={job.isOnlyShared}
/>
);
},
},
{
title: 'Name',
dataIndex: 'name',
render: (name, job) => {
if (job.isOnlyShared) {
return (
<Popover
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<div style={{ display: 'flex', gap: '.3rem' }}>
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
<IconAlertTriangle />
</div>
{name}
</div>
</Popover>
);
} else {
return name;
}
},
},
{
title: 'Listings',
@@ -48,14 +74,14 @@ export default function JobTable({
},
},
{
title: 'Providers',
title: 'Provider',
dataIndex: 'provider',
render: (value) => {
return value.length || 0;
},
},
{
title: 'Notification adapters',
title: 'Notification Adapter',
dataIndex: 'notificationAdapter',
render: (value) => {
return value.length || 0;
@@ -68,16 +94,36 @@ export default function JobTable({
return (
<div className="interactions">
<Popover content={getPopoverContent('Job Insights')}>
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
<Button
type="primary"
icon={<IconHistogram />}
disabled={job.isOnlyShared}
onClick={() => onJobInsight(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
<Button
type="secondary"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => onJobEdit(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button type="danger" icon={<IconDescend2 />} onClick={() => onListingRemoval(job.id)} />
<Button
type="danger"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
<Button
type="danger"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
</Popover>
</div>
);

View File

@@ -0,0 +1,53 @@
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
import React from 'react';
import { useSelector } from '../../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
import './ListingsFilter.less';
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
const jobs = useSelector((state) => state.jobs.jobs);
const provider = useSelector((state) => state.provider);
const { Title } = Typography;
return (
<Card className="listingsFilter">
<Title heading={6}>Filter by:</Title>
<Divider />
<br />
<Descriptions row>
<Descriptions.Item itemKey="Watch List">
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
</Descriptions.Item>
<Descriptions.Item itemKey="Activity status">
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
</Descriptions.Item>
<Descriptions.Item itemKey="Job Name">
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
{jobs != null &&
jobs.length > 0 &&
jobs.map((job) => {
return (
<Select.Option value={job.id} key={job.id}>
{job.name}
</Select.Option>
);
})}
</Select>
</Descriptions.Item>
<Descriptions.Item itemKey="Provider">
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
{provider != null &&
provider.length > 0 &&
provider.map((prov) => {
return (
<Select.Option value={prov.id} key={prov.id}>
{prov.name}
</Select.Option>
);
})}
</Select>
</Descriptions.Item>
</Descriptions>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
.listingsFilter {
margin-bottom: 1rem;
background: rgb(53, 54, 60);
}

View File

@@ -1,21 +1,87 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Card, Toast } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../services/state/store.js';
import { IconClose, IconDelete, IconSearch, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../services/time/timeService.js';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../../services/state/store.js';
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../../services/time/timeService.js';
import debounce from 'lodash/debounce';
import no_image from '../../assets/no_image.jpg';
import no_image from '../../../assets/no_image.jpg';
import './ListingsTable.less';
import { format } from '../../services/time/timeService.js';
import { format } from '../../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { xhrDelete } from '../../services/xhr.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
import ListingsFilter from './ListingsFilter.jsx';
const columns = [
{
title: '#',
width: 100,
dataIndex: 'isWatched',
sorter: true,
render: (id, row) => {
return (
<div>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
>
<Button
icon={
row.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
theme="borderless"
size="small"
onClick={async () => {
try {
await xhrPost('/api/listings/watch', { listingId: row.id });
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
row.reloadTable();
} catch (e) {
console.error(e);
Toast.error('Failed to operate Watchlist');
}
}}
/>
</Popover>
<Divider layout="vertical" margin="4px" />
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Delete Listing"
>
<Button
icon={<IconDelete />}
theme="borderless"
size="small"
type="danger"
onClick={async () => {
try {
await xhrDelete('/api/listings/', { ids: [row.id] });
Toast.success('Listing(s) successfully removed');
row.reloadTable();
} catch (error) {
Toast.error(error);
}
}}
/>
</Popover>
</div>
);
},
},
{
title: 'State',
dataIndex: 'is_active',
width: 58,
width: 84,
sorter: true,
render: (value) => {
return value ? (
@@ -25,7 +91,7 @@ const columns = [
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing still online"
content="Listing is still active"
>
<IconTick />
</Popover>
@@ -37,7 +103,7 @@ const columns = [
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing not online anymore"
content="Listing is inactive"
>
<IconClose />
</Popover>
@@ -48,15 +114,16 @@ const columns = [
{
title: 'Job-Name',
sorter: true,
ellipsis: true,
dataIndex: 'job_name',
width: 170,
width: 150,
},
{
title: 'Listing date',
width: 130,
dataIndex: 'created_at',
sorter: true,
render: (text) => timeService.format(text),
render: (text) => timeService.format(text, false),
},
{
title: 'Provider',
@@ -107,8 +174,11 @@ export default function ListingsTable() {
const [page, setPage] = useState(1);
const pageSize = 10;
const [sortData, setSortData] = useState({});
const [filter, setFilter] = useState(null);
const [selectedKeys, setSelectedKeys] = useState([]);
const [freeTextFilter, setFreeTextFilter] = useState(null);
const [watchListFilter, setWatchListFilter] = useState(null);
const [jobNameFilter, setJobNameFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null);
const [providerFilter, setProviderFilter] = useState(null);
const handlePageChange = (_page) => {
setPage(_page);
@@ -122,20 +192,21 @@ export default function ListingsTable() {
sortfield = sortData.field;
sortdir = sortData.direction;
}
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
actions.listingsTable.getListingsTable({
page,
pageSize,
sortfield,
sortdir,
freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
});
};
useEffect(() => {
loadTable();
}, [page, sortData, filter]);
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
const rowSelection = {
onChange: (selectedRowKeys) => {
setSelectedKeys(selectedRowKeys);
},
};
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
const expandRowRender = (record) => {
return (
@@ -169,20 +240,14 @@ export default function ListingsTable() {
);
};
const onRemoveSelectedListings = async () => {
if (selectedKeys != null && selectedKeys.length > 0) {
try {
await xhrDelete('/api/listings/', { ids: selectedKeys });
Toast.success('Listing(s) successfully removed');
loadTable();
} catch (error) {
Toast.error(error);
}
}
};
return (
<div>
<ListingsFilter
onActivityFilter={setActivityFilter}
onWatchListFilter={setWatchListFilter}
onJobNameFilter={setJobNameFilter}
onProviderFilter={setProviderFilter}
/>
<Input
prefix={<IconSearch />}
showClear
@@ -190,22 +255,19 @@ export default function ListingsTable() {
placeholder="Search"
onChange={handleFilterChange}
/>
{selectedKeys != null && selectedKeys.length > 0 && (
<Card className="listingsTable__toolbar">
<Button type="danger" icon={<IconDelete />} onClick={() => onRemoveSelectedListings()}>
Remove selected Listings
</Button>
</Card>
)}
<Table
rowKey="id"
empty={empty}
hideExpandedColumn={false}
sticky={{ top: 5 }}
columns={columns}
rowSelection={rowSelection}
expandedRowRender={expandRowRender}
dataSource={tableData?.result || []}
dataSource={(tableData?.result || []).map((row) => {
return {
...row,
reloadTable: loadTable,
};
})}
onChange={(changeSet) => {
if (changeSet?.extra?.changeType === 'sorter') {
setSortData({

View File

@@ -67,6 +67,14 @@ export const useFredyState = create(
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
async getSharableUserList() {
try {
const response = await xhrGet('/api/jobs/shareableUserList');
set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
async getProcessingTimes() {
try {
const response = await xhrGet('/api/jobs/processingTimes');
@@ -132,14 +140,22 @@ export const useFredyState = create(
},
},
listingsTable: {
async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) {
async getListingsTable({
page = 1,
pageSize = 20,
freeTextFilter = null,
sortfield = null,
sortdir = 'asc',
filter,
}) {
try {
const qryString = queryString.stringify({
page,
pageSize,
filter,
freeTextFilter,
sortfield,
sortdir,
...filter,
});
const response = await xhrGet(`/api/listings/table?${qryString}`);
set((state) => ({
@@ -164,7 +180,7 @@ export const useFredyState = create(
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
jobs: { jobs: [], insights: {}, processingTimes: {} },
jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] },
user: { users: [], currentUser: null },
};

View File

@@ -1,11 +1,12 @@
export function format(ts) {
export function format(ts, showSeconds = true) {
return new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
...(showSeconds ? { second: 'numeric' } : {}),
}).format(ts);
}
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);

View File

@@ -8,13 +8,14 @@ import Headline from '../../../components/headline/Headline';
import { useActions, useSelector } from '../../../services/state/store';
import { xhrPost } from '../../../services/xhr';
import { useNavigate, useParams } from 'react-router-dom';
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import { IconBell, IconBriefcase, IconPaperclip, IconPlayCircle, IconPlusCircle, IconUser } from '@douyinfe/semi-icons';
export default function JobMutator() {
const jobs = useSelector((state) => state.jobs.jobs);
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
const params = useParams();
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
@@ -32,6 +33,7 @@ export default function JobMutator() {
const [name, setName] = useState(defaultName);
const [blacklist, setBlacklist] = useState(defaultBlacklist);
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
const [enabled, setEnabled] = useState(defaultEnabled);
const navigate = useNavigate();
const actions = useActions();
@@ -45,6 +47,7 @@ export default function JobMutator() {
await xhrPost('/api/jobs', {
provider: providerData,
notificationAdapter: notificationAdapterData,
shareWithUsers,
name,
blacklist,
enabled,
@@ -91,7 +94,7 @@ export default function JobMutator() {
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
<form>
<SegmentPart name="Name">
<SegmentPart name="Name" Icon={IconPaperclip}>
<Input
autoFocus
type="text"
@@ -105,7 +108,7 @@ export default function JobMutator() {
<Divider margin="1rem" />
<SegmentPart
name="Providers"
icon="briefcase"
Icon={IconBriefcase}
helpText={`
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
@@ -130,7 +133,7 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
icon="bell"
Icon={IconBell}
name="Notification Adapters"
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
>
@@ -157,7 +160,7 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
icon="bell"
Icon={IconBell}
name="Blacklist"
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
>
@@ -169,7 +172,32 @@ export default function JobMutator() {
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
icon="play circle outline"
Icon={IconUser}
name="Sharing with user"
helpText="You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything."
>
{shareableUserList.length === 0 ? (
<div>No users found to share this Job to. Please create additional non-admin user.</div>
) : (
<Select
filter
multiple
placeholder="Search user"
autoClearSearchValue={false}
defaultValue={shareWithUsers}
onChange={(value) => setShareWithUsers(value)}
>
{shareableUserList.map((user) => (
<Select.Option value={user.id} key={user.id}>
{user.name}
</Select.Option>
))}
</Select>
)}
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
Icon={IconPlayCircle}
name="Job activation"
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import ListingsTable from '../../components/table/ListingsTable.jsx';
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
export default function Listings() {
return (

View File

@@ -1176,15 +1176,17 @@
debug "^4.3.1"
minimatch "^3.1.2"
"@eslint/config-helpers@^0.3.1":
version "0.3.1"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617"
integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==
"@eslint/config-helpers@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.0.tgz#e9f94ba3b5b875e32205cb83fece18e64486e9e6"
integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==
dependencies:
"@eslint/core" "^0.16.0"
"@eslint/core@^0.15.2":
version "0.15.2"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f"
integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==
"@eslint/core@^0.16.0":
version "0.16.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.16.0.tgz#490254f275ba9667ddbab344f4f0a6b7a7bd7209"
integrity sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==
dependencies:
"@types/json-schema" "^7.0.15"
@@ -1203,22 +1205,22 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.36.0":
version "9.36.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef"
integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==
"@eslint/js@9.37.0":
version "9.37.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
"@eslint/object-schema@^2.1.6":
version "2.1.6"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
"@eslint/plugin-kit@^0.3.5":
version "0.3.5"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5"
integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==
"@eslint/plugin-kit@^0.4.0":
version "0.4.0"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz#f6a245b42886abf6fc9c7ab7744a932250335ab2"
integrity sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==
dependencies:
"@eslint/core" "^0.15.2"
"@eslint/core" "^0.16.0"
levn "^0.4.1"
"@humanfs/core@^0.19.1":
@@ -3276,19 +3278,19 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
eslint@9.36.0:
version "9.36.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088"
integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==
eslint@9.37.0:
version "9.37.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.37.0.tgz#ac0222127f76b09c0db63036f4fe289562072d74"
integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.1"
"@eslint/config-array" "^0.21.0"
"@eslint/config-helpers" "^0.3.1"
"@eslint/core" "^0.15.2"
"@eslint/config-helpers" "^0.4.0"
"@eslint/core" "^0.16.0"
"@eslint/eslintrc" "^3.3.1"
"@eslint/js" "9.36.0"
"@eslint/plugin-kit" "^0.3.5"
"@eslint/js" "9.37.0"
"@eslint/plugin-kit" "^0.4.0"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
@@ -4534,10 +4536,10 @@ lazy-cache@^1.0.3:
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
less@4.4.1:
version "4.4.1"
resolved "https://registry.yarnpkg.com/less/-/less-4.4.1.tgz#2f97168bf887ca6a9957ee69e16cc34f8b007cc7"
integrity sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==
less@4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/less/-/less-4.4.2.tgz#fa4291fdb0334de91163622cc038f4bd3eb6b8d7"
integrity sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==
dependencies:
copy-anything "^2.0.1"
parse-node-version "^1.0.1"
@@ -6538,6 +6540,11 @@ semver@^7.3.5, semver@^7.5.3, semver@^7.7.2:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
semver@^7.7.3:
version "7.7.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946"
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
send@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"