Compare commits

...

18 Commits

Author SHA1 Message Date
orangecoding
362166651d adding fredy category 2026-06-14 11:00:58 +02:00
orangecoding
a020117a78 Merge branch 'master' of github.com:orangecoding/fredy 2026-06-13 14:02:53 +02:00
orangecoding
9207280ab4 bugfixes and improvements 2026-06-13 14:02:42 +02:00
orangecoding
94384df36d next release version 2026-06-13 13:34:13 +02:00
orangecoding
730cc52187 when storing settings and something is wrong, show the correct error 2026-06-13 13:33:49 +02:00
orangecoding
e82db5b6db removing annoying map animation 2026-06-13 13:21:49 +02:00
orangecoding
2f8c021819 ability to jump back to main menu when clicking on nav bar and on submenu 2026-06-13 13:17:19 +02:00
orangecoding
72c2c02e49 fixing job state setting when job is disabled 2026-06-13 13:14:07 +02:00
Christian Kellner
48c0360111 Update GitHub link to sponsors page 2026-06-12 14:34:06 +02:00
Christian Kellner
63c947896e Revise sponsorship message and add support links
Updated sponsorship section to improve clarity and added support links.
2026-06-12 14:33:13 +02:00
Christian Kellner
2a814b6bb6 Add Ko-fi funding option 2026-06-12 14:29:48 +02:00
orangecoding
3249881771 ability to restore (soft deleted) listings 2026-06-11 08:24:26 +02:00
orangecoding
3b727ea708 next release version 2026-06-10 17:11:49 +02:00
orangecoding
a2a765f43d new usersetting to blacklist (filter) also on description 2026-06-10 17:10:39 +02:00
Michel
c17a815263 fix: use absolute vite base so SPA deep links don't white-screen (#336)
With base '' the built index.html references assets relatively
(./assets/*). On deep links like /listings/listing/:id the browser
resolves those below the route path, the SPA fallback answers with
index.html and the page dies trying to execute HTML as a JS module.
Notification emails link directly to listing details, so every
'Open in fredy' link landed on a white screen.
2026-06-10 16:44:39 +02:00
Michel
7a2dacaa61 fix(immoscout): map exclusioncriteria swapflat to mobile API value swap_flat (#332)
The web UI encodes the 'no swap flats' filter as exclusioncriteria=swapflat,
but the mobile API only accepts swap_flat. Unknown values are not ignored by
the API - the whole search silently returns 0 results, so any saved search
with this filter never finds a single listing.

Map the value during web-to-mobile conversion and leave all other
exclusioncriteria values (e.g. projectlisting, which both APIs share)
untouched.
2026-06-10 16:43:12 +02:00
Michel
359e00e69f fix: use hash-router URL format for listing links in email adapters (#335)
The UI is served through a HashRouter, and most adapters (telegram,
slack, discord, ntfy, ...) already link to ${baseUrl}/#/listings/listing/:id.
The email adapters (resend, smtp, mailJet, sendGrid) and the http adapter
were missing the /# - the router never saw the route and dumped the user
on the default overview instead of the listing.
2026-06-10 16:38:36 +02:00
orangecoding
bc9c56a224 storing last run in database 2026-06-09 16:52:37 +02:00
55 changed files with 1291 additions and 308 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,4 @@
# These are supported funding model platforms # These are supported funding model platforms
github: [orangecoding] github: [orangecoding]
ko_fi: orangecoding

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ npm-debug.log
.idea .idea
.vscode .vscode
tools/release/config.json tools/release/config.json
.agents

View File

@@ -46,7 +46,7 @@ index.js (startup)
├── runMigrations() ├── runMigrations()
├── getProviders() # lazily imports lib/provider/*.js ├── getProviders() # lazily imports lib/provider/*.js
├── similarityCache.init() # preloads hash cache from DB ├── similarityCache.init() # preloads hash cache from DB
├── api.js # starts restana HTTP server ├── api.js # starts fastify HTTP server
└── initJobExecutionService() # registers event-bus listeners + starts scheduler └── initJobExecutionService() # registers event-bus listeners + starts scheduler
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run

View File

@@ -55,8 +55,11 @@ same listing twice.
## 🤝 Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=❤&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding) ## 🤝 Sponsorship [![](https://img.shields.io/static/v1?label=Sponsor&message=❤&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/orangecoding)
I maintain Fredy and other open-source projects in my free time.\ I maintain Fredy and other open-source projects in my free time, if you find it useful, consider supporting the project ❤️
If you find it useful, consider supporting the project 💙
#### Support me on
[Ko-Fi](https://ko-fi.com/orangecoding) | [Github](https://github.com/sponsors/orangecoding)
----
Fredy is proudly backed by the **JetBrains Open Source Support Program**. Fredy is proudly backed by the **JetBrains Open Source Support Program**.

View File

@@ -38,11 +38,15 @@ import { formatListing } from './utils/formatListing.js';
* 3) Normalize listings to the provider schema * 3) Normalize listings to the provider schema
* 4) Filter out incomplete/blacklisted listings * 4) Filter out incomplete/blacklisted listings
* 5) Identify new listings (vs. previously stored hashes) * 5) Identify new listings (vs. previously stored hashes)
* 6) Persist new listings * 6) Optionally enrich new listings via provider.fetchDetails
* 7) Filter out entries similar to already seen ones * 7) Optionally re-apply the provider blacklist using the (now enriched)
* 8) Filter out entries that do not match the job's specFilter * description — only when the user opted in via
* 9) Filter out entries that do not match the job's spatialFilter * `blacklist_filter_on_provider_details`
* 10) Dispatch notifications * 8) Persist new listings
* 9) Filter out entries similar to already seen ones
* 10) Filter out entries that do not match the job's specFilter
* 11) Filter out entries that do not match the job's spatialFilter
* 12) Dispatch notifications
*/ */
class FredyPipelineExecutioner { class FredyPipelineExecutioner {
/** /**
@@ -86,6 +90,7 @@ class FredyPipelineExecutioner {
.then(this._filter.bind(this)) .then(this._filter.bind(this))
.then(this._findNew.bind(this)) .then(this._findNew.bind(this))
.then(this._fetchDetails.bind(this)) .then(this._fetchDetails.bind(this))
.then(this._filterAfterDetails.bind(this))
.then(this._geocode.bind(this)) .then(this._geocode.bind(this))
.then(this._save.bind(this)) .then(this._save.bind(this))
.then(this._calculateDistance.bind(this)) .then(this._calculateDistance.bind(this))
@@ -259,13 +264,57 @@ class FredyPipelineExecutioner {
listings listings
// this should never filter some listings out, because the normalize function should always extract all fields. // this should never filter some listings out, because the normalize function should always extract all fields.
.filter((item) => requiredKeys.every((key) => key in item)) .filter((item) => requiredKeys.every((key) => key in item))
// Drop listings missing a required identifying field *before* the provider
// filter runs, so provider filter functions never have to defend against a
// null id/link/title.
.filter((item) => requireValues.every((key) => item[key] != null))
// TODO: move blacklist filter to this file, so it will handle for all providers in same way. // TODO: move blacklist filter to this file, so it will handle for all providers in same way.
.filter(this._providerConfig.filter) .filter(this._providerConfig.filter)
// filter out listings that are missing required fields
.filter((item) => requireValues.every((key) => item[key] != null))
); );
} }
/**
* Re-apply the provider's blacklist filter after `_fetchDetails` has had a
* chance to enrich the listings (e.g., load the full description from the
* detail page). The initial `_filter` step only sees the truncated snippet
* exposed on the search results page, so a blacklisted term that lives
* deeper in the listing's full description would otherwise slip through.
*
* Opt-in: gated by the user setting `blacklist_filter_on_provider_details`.
* The full detail description tends to contain a lot of boilerplate (legal,
* exposé contact info, generic marketing copy) which can accidentally match
* a blacklist term and remove otherwise relevant listings. Users who want
* the stricter behavior must enable the setting explicitly.
*
* Throws {@link NoNewListingsWarning} when all listings are filtered out
* so the rest of the pipeline (save + notify) is short-circuited.
*
* @param {ParsedListing[]} listings Enriched listings to re-filter.
* @returns {ParsedListing[]} Listings that still pass the provider's filter.
* @throws {NoNewListingsWarning} When every listing is filtered out.
*/
_filterAfterDetails(listings) {
if (typeof this._providerConfig.filter !== 'function') {
return listings;
}
const userId = getJob(this._jobKey)?.userId;
const enabled = getUserSettings(userId)?.blacklist_filter_on_provider_details === true;
if (!enabled) {
return listings;
}
const kept = listings.filter(this._providerConfig.filter);
const removed = listings.length - kept.length;
if (removed > 0) {
logger.debug(
`Re-filter after detail enrichment removed ${removed} listing(s) by blacklist (Provider: '${this._providerId}')`,
);
}
if (kept.length === 0) {
throw new NoNewListingsWarning();
}
return kept;
}
/** /**
* Determine which listings are new by comparing their IDs against stored hashes. * Determine which listings are new by comparing their IDs against stored hashes.
* *
@@ -275,9 +324,9 @@ class FredyPipelineExecutioner {
*/ */
_findNew(listings) { _findNew(listings) {
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`); logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || []; const knownHashes = new Set(getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || []);
const newListings = listings.filter((o) => !hashes.includes(o.id)); const newListings = listings.filter((o) => !knownHashes.has(o.id));
if (newListings.length === 0) { if (newListings.length === 0) {
throw new NoNewListingsWarning(); throw new NoNewListingsWarning();
} }

View File

@@ -20,6 +20,28 @@ function cap(val) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1); return String(val).charAt(0).toUpperCase() + String(val).slice(1);
} }
/**
* Compute the most recent job trigger timestamp across the given jobs.
*
* Returns `null` when none of the jobs has ever been triggered. The value is
* persisted per-job via `jobs.last_run_at`, so the dashboard reflects the
* scope visible to the current user (own + shared, or all for admins) rather
* than a process-wide in-memory value.
*
* @param {Array<{lastRunAt?: number|null}>} jobs
* @returns {number|null}
*/
function computeLastRun(jobs) {
let lastRun = null;
for (const job of jobs) {
const ts = job.lastRunAt;
if (typeof ts === 'number' && (lastRun == null || ts > lastRun)) {
lastRun = ts;
}
}
return lastRun;
}
/** /**
* @param {import('fastify').FastifyInstance} fastify * @param {import('fastify').FastifyInstance} fastify
*/ */
@@ -46,11 +68,13 @@ export default async function dashboardPlugin(fastify) {
} }
: { labels: [], values: [] }; : { labels: [], values: [] };
const lastRun = computeLastRun(jobs);
return { return {
general: { general: {
interval: settings.interval, interval: settings.interval,
lastRun: settings.lastRun || null, lastRun,
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000, nextRun: lastRun == null ? 0 : lastRun + settings.interval * 60000,
}, },
kpis: { kpis: {
totalJobs, totalJobs,

View File

@@ -29,7 +29,7 @@ export default async function jobPlugin(fastify) {
fastify.get('/', async (request) => { fastify.get('/', async (request) => {
const isUserAdmin = isAdmin(request); const isUserAdmin = isAdmin(request);
return jobStorage return jobStorage
.getJobs() .getJobs({ includeDisabled: true })
.filter( .filter(
(job) => (job) =>
isUserAdmin || isUserAdmin ||

View File

@@ -26,6 +26,7 @@ export default async function listingsPlugin(fastify) {
providerFilter, providerFilter,
watchListFilter, watchListFilter,
statusFilter, statusFilter,
hiddenOnly,
sortfield = null, sortfield = null,
sortdir = 'asc', sortdir = 'asc',
freeTextFilter, freeTextFilter,
@@ -38,6 +39,7 @@ export default async function listingsPlugin(fastify) {
}; };
const normalizedActivity = toBool(activityFilter); const normalizedActivity = toBool(activityFilter);
const normalizedWatch = toBool(watchListFilter); const normalizedWatch = toBool(watchListFilter);
const normalizedHidden = toBool(hiddenOnly) === true;
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none']; const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
const normalizedStatus = const normalizedStatus =
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase()) typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
@@ -62,6 +64,7 @@ export default async function listingsPlugin(fastify) {
providerFilter, providerFilter,
watchListFilter: normalizedWatch, watchListFilter: normalizedWatch,
statusFilter: normalizedStatus, statusFilter: normalizedStatus,
hiddenOnly: normalizedHidden,
sortField: sortfield || null, sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc', sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: request.session.currentUser, userId: request.session.currentUser,
@@ -192,4 +195,21 @@ export default async function listingsPlugin(fastify) {
} }
return reply.send(); return reply.send();
}); });
fastify.post('/restore', async (request, reply) => {
const { ids } = request.body || {};
const settings = await getSettings();
try {
if (settings.demoMode && !isAdminFn(request)) {
return reply.code(403).send({ error: 'Sorry, but you cannot restore listings in demo mode ;)' });
}
if (Array.isArray(ids) && ids.length > 0) {
listingStorage.restoreListingsById(ids);
}
} catch (error) {
logger.error(error);
return reply.code(500).send({ error: error.message });
}
return reply.send();
});
} }

View File

@@ -103,6 +103,28 @@ export default async function userSettingsPlugin(fastify) {
} }
}); });
fastify.post('/blacklist-filter-on-details', async (request, reply) => {
const userId = request.session.currentUser;
const { blacklist_filter_on_provider_details } = request.body;
const globalSettings = await getSettings();
if (globalSettings.demoMode && !isAdmin(request)) {
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
}
if (typeof blacklist_filter_on_provider_details !== 'boolean') {
return reply.code(400).send({ error: 'blacklist_filter_on_provider_details must be a boolean.' });
}
try {
upsertSettings({ blacklist_filter_on_provider_details }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating blacklist-filter-on-details setting', error);
return reply.code(500).send({ error: error.message });
}
});
fastify.post('/listings-view-mode', async (request, reply) => { fastify.post('/listings-view-mode', async (request, reply) => {
const userId = request.session.currentUser; const userId = request.session.currentUser;
const { listings_view_mode } = request.body; const { listings_view_mode } = request.body;

View File

@@ -14,7 +14,7 @@ const mapListing = (listing, baseUrl) => ({
size: listing.size, size: listing.size,
title: listing.title, title: listing.title,
url: listing.link, url: listing.link,
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null, fredyUrl: baseUrl && listing.id ? `${baseUrl}/#/listings/listing/${listing.id}` : null,
}); });
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => { export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {

View File

@@ -53,7 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
jobKey, jobKey,
hasImage: false, hasImage: false,
imageCid: '', imageCid: '',
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
}; };
if (imgUrl) { if (imgUrl) {

View File

@@ -25,7 +25,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
price: l.price || '', price: l.price || '',
image, image,
hasImage: Boolean(image), hasImage: Boolean(image),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
serviceName, serviceName,
jobKey, jobKey,
}; };

View File

@@ -20,7 +20,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
hasImage: Boolean(image), hasImage: Boolean(image),
// optional plain text snippet // optional plain text snippet
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '), snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
serviceName, serviceName,
jobKey, jobKey,
}; };

View File

@@ -25,7 +25,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
price: l.price || '', price: l.price || '',
image, image,
hasImage: Boolean(image), hasImage: Boolean(image),
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null, fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
serviceName, serviceName,
jobKey, jobKey,
}; };

View File

@@ -20,7 +20,7 @@ function normalize(o) {
const link = `${baseUrl}/expose/${o.id}.html`; const link = `${baseUrl}/expose/${o.id}.html`;
const price = normalizePrice(o.price); const price = normalizePrice(o.price);
const id = buildHash(o.id, price); const id = buildHash(o.id, price);
const image = baseUrl + o.image; const image = o.image == null ? null : baseUrl + o.image;
const address = o.address == null ? null : o.address.trim().replaceAll('/', ','); const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
return { return {
id, id,

View File

@@ -198,7 +198,9 @@ function normalize(o) {
* @returns {boolean} * @returns {boolean}
*/ */
function applyBlacklist(o) { function applyBlacklist(o) {
return !isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
} }
/** @type {ProviderConfig} */ /** @type {ProviderConfig} */
const config = { const config = {

View File

@@ -19,7 +19,7 @@ function normalize(o) {
const originalId = o.id.split('/').pop(); const originalId = o.id.split('/').pop();
const id = buildHash(originalId, o.price); const id = buildHash(originalId, o.price);
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link; const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
const [rooms, size] = o.tags.split(' | '); const [rooms, size] = (o.tags || '').split(' | ');
const address = o.address?.replace(' / ', ' ') || null; const address = o.address?.replace(' / ', ' ') || null;
return { return {
id, id,

View File

@@ -42,7 +42,9 @@ function normalize(o) {
* @returns {boolean} * @returns {boolean}
*/ */
function applyBlacklist(o) { function applyBlacklist(o) {
return !isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
return titleNotBlacklisted && descNotBlacklisted;
} }
/** @type {ProviderConfig} */ /** @type {ProviderConfig} */

View File

@@ -21,7 +21,8 @@ function normalize(o) {
const link = o.link != null ? decodeURIComponent(o.link) : config.url; const link = o.link != null ? decodeURIComponent(o.link) : config.url;
const urlReg = new RegExp(/url\((.*?)\)/gim); const urlReg = new RegExp(/url\((.*?)\)/gim);
const image = o.image != null ? urlReg.exec(o.image)[1] : null; const imageMatch = o.image != null ? urlReg.exec(o.image) : null;
const image = imageMatch != null ? imageMatch[1] : null;
return { return {
id, id,
link, link,

View File

@@ -44,6 +44,7 @@ function normalize(o) {
const link = `https://www.wg-gesucht.de${o.link}`; const link = `https://www.wg-gesucht.de${o.link}`;
const image = o.image != null ? o.image.replace('small', 'large') : null; const image = o.image != null ? o.image.replace('small', 'large') : null;
const [rooms, city, road] = o.details?.split(' | ') || []; const [rooms, city, road] = o.details?.split(' | ') || [];
const address = [city, road].filter(Boolean).join(', ') || null;
return { return {
id, id,
link, link,
@@ -51,7 +52,7 @@ function normalize(o) {
price: extractNumber(o.price), price: extractNumber(o.price),
size: extractNumber(o.size), size: extractNumber(o.size),
rooms: extractNumber(rooms), rooms: extractNumber(rooms),
address: `${city}, ${road}`, address,
image, image,
description: o.description, description: o.description,
}; };

View File

@@ -19,7 +19,7 @@ function normalize(o) {
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim()); const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
const address = `${part}, ${city}`; const address = `${part}, ${city}`;
return { return {
id: o.link.split('/').pop(), id: o.link != null ? o.link.split('/').pop() : null,
link: o.link, link: o.link,
title: o.title || '', title: o.title || '',
price: extractNumber(o.price), price: extractNumber(o.price),
@@ -38,7 +38,7 @@ function normalize(o) {
function applyBlacklist(o) { function applyBlacklist(o) {
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList); const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList); const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link); return o.id != null && o.title != null && o.link != null && titleNotBlacklisted && descNotBlacklisted;
} }
/** @type {ProviderConfig} */ /** @type {ProviderConfig} */

View File

@@ -103,6 +103,13 @@ const EQUIPMENT_MAP = {
lodgerflat: 'lodgerflat', lodgerflat: 'lodgerflat',
}; };
// The web UI uses "swapflat", but the mobile API only understands "swap_flat".
// An unknown value is not ignored: the API silently returns 0 results for the
// whole search. Other values (e.g. "projectlisting") are identical on both APIs.
const EXCLUSION_CRITERIA_MAP = {
swapflat: 'swap_flat',
};
const REAL_ESTATE_TYPE = { const REAL_ESTATE_TYPE = {
'haus-mieten': 'houserent', 'haus-mieten': 'houserent',
'wohnung-mieten': 'apartmentrent', 'wohnung-mieten': 'apartmentrent',
@@ -251,6 +258,9 @@ export function convertWebToMobile(webUrl) {
...(currentEquipmentParams ?? []), ...(currentEquipmentParams ?? []),
...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean), ...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean),
]; ];
} else if (key === 'exclusioncriteria') {
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
mobileParams[PARAM_NAME_MAP[key]] = items.map((item) => EXCLUSION_CRITERIA_MAP[item.toLowerCase()] ?? item);
} else { } else {
mobileParams[PARAM_NAME_MAP[key]] = val; mobileParams[PARAM_NAME_MAP[key]] = val;
} }

View File

@@ -104,7 +104,6 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
logger.debug('Working hours set. Skipping as outside of working hours.'); logger.debug('Working hours set. Skipping as outside of working hours.');
return; return;
} }
settings.lastRun = now;
const jobs = jobStorage.getJobs().filter((job) => { const jobs = jobStorage.getJobs().filter((job) => {
if (!context) return true; // startup/cron → all if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all if (context.isAdmin) return true; // admin → all
@@ -150,6 +149,13 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
} }
const acquired = markRunning(job.id); const acquired = markRunning(job.id);
if (!acquired) return; if (!acquired) return;
// Persist the trigger time so the dashboard "last search" KPI can be
// derived per accessible user without an in-memory cache.
try {
jobStorage.updateJobLastRunAt(job.id, Date.now());
} catch (err) {
logger.warn('Failed to persist last_run_at for job', job.id, err);
}
// notify listeners (SSE) that the job started // notify listeners (SSE) that the job started
try { try {
bus.emit('jobs:status', { jobId: job.id, running: true }); bus.emit('jobs:status', { jobId: job.id, running: true });

View File

@@ -17,16 +17,16 @@ const userAgents = [
]; ];
/** /**
* Check if a listing is still active with up to 5 attempts and exponential backoff. * Check if a listing is still active with up to `maxAttempts` attempts and exponential backoff.
* Backoff waits are randomized and capped. * Backoff waits are randomized and capped.
* *
* Rules: * Rules:
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0) * - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
* - HTTP 401/403 => return -1 (most certainly detected as a bot) * - HTTP 401/403 => return -1 (most certainly detected as a bot)
* - HTTP 404 => return 0 * - HTTP 404/410 => return 0
* - Other statuses or network errors => retry until attempts are exhausted * - Other statuses or network errors => retry until attempts are exhausted
* *
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot * @returns {Promise<number>} 1 if active, 0 if not active and -1 if detected as bot
*/ */
export default async function checkIfListingIsActive(link, checkForText = null) { export default async function checkIfListingIsActive(link, checkForText = null) {
await sleep(randomBetween(50, 100)); await sleep(randomBetween(50, 100));

View File

@@ -40,7 +40,8 @@ class SqliteConnection {
} }
/** /**
* Returns a singleton instance of better-sqlite3 Database. * Returns a singleton instance of better-sqlite3 Database.
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db. * Uses the configured `sqlitepath` (from conf/config.json) as the directory,
* defaulting to `/db` (relative to the project root) when unset.
*/ */
static getConnection() { static getConnection() {
if (this.#db) return this.#db; if (this.#db) return this.#db;

View File

@@ -97,6 +97,7 @@ export const getJob = (jobId) => {
j.notification_adapter AS notificationAdapter, j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter, j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter, j.spec_filter AS specFilter,
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j FROM jobs j
WHERE j.id = @id WHERE j.id = @id
@@ -116,6 +117,24 @@ export const getJob = (jobId) => {
}; };
}; };
/**
* Record the timestamp at which a job was last triggered.
*
* Called from the job execution service when a job starts running. The value
* is persisted so that the dashboard "last search" KPI survives restarts and
* can be computed per accessible user.
*
* @param {string} jobId - Job primary key.
* @param {number} timestamp - Epoch milliseconds.
* @returns {void}
*/
export const updateJobLastRunAt = (jobId, timestamp) => {
SqliteConnection.execute(`UPDATE jobs SET last_run_at = @timestamp WHERE id = @id`, {
id: jobId,
timestamp,
});
};
/** /**
* Update job enabled status. * Update job enabled status.
* @param {{jobId: string, status: boolean}} params - Parameters. * @param {{jobId: string, status: boolean}} params - Parameters.
@@ -150,9 +169,17 @@ export const removeJobsByUserId = (userId) => {
/** /**
* Get all jobs. * Get all jobs.
*
* By default only enabled jobs are returned, since most callers (scheduler,
* geocoding cron, tracker, dashboard) operate on active jobs only. The UI,
* however, must also be able to load disabled jobs (e.g. to edit them or view
* their listings), so it passes `includeDisabled: true`.
*
* @param {Object} [params]
* @param {boolean} [params.includeDisabled=false] - When true, disabled jobs are included.
* @returns {Job[]} List of jobs ordered by name (NULLs last). * @returns {Job[]} List of jobs ordered by name (NULLs last).
*/ */
export const getJobs = () => { export const getJobs = ({ includeDisabled = false } = {}) => {
const rows = SqliteConnection.query( const rows = SqliteConnection.query(
`SELECT j.id, `SELECT j.id,
j.user_id AS userId, j.user_id AS userId,
@@ -164,9 +191,10 @@ export const getJobs = () => {
j.notification_adapter AS notificationAdapter, j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter, j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter, j.spec_filter AS specFilter,
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j FROM jobs j
WHERE j.enabled = 1 ${includeDisabled ? '' : 'WHERE j.enabled = 1'}
ORDER BY j.name IS NULL, j.name`, ORDER BY j.name IS NULL, j.name`,
); );
return rows.map((row) => ({ return rows.map((row) => ({
@@ -269,6 +297,7 @@ export const queryJobs = ({
j.notification_adapter AS notificationAdapter, j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter, j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter, j.spec_filter AS specFilter,
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j FROM jobs j
${whereSql} ${whereSql}

View File

@@ -264,6 +264,7 @@ export const storeListings = (jobId, providerId, listings) => {
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms). * @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins). * @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
* @param {boolean} [params.isAdmin=false] - When true, returns all listings. * @param {boolean} [params.isAdmin=false] - When true, returns all listings.
* @param {boolean} [params.hiddenOnly=false] - When true, returns only soft-deleted (manually_deleted = 1) listings.
* @returns {{ totalNumber:number, page:number, result:Object[] }} * @returns {{ totalNumber:number, page:number, result:Object[] }}
*/ */
export const queryListings = ({ export const queryListings = ({
@@ -284,6 +285,7 @@ export const queryListings = ({
maxPrice = null, maxPrice = null,
userId = null, userId = null,
isAdmin = false, isAdmin = false,
hiddenOnly = false,
} = {}) => { } = {}) => {
// sanitize inputs // sanitize inputs
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50; const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
@@ -365,8 +367,8 @@ export const queryListings = ({
whereParts.push('(l.price <= @maxPrice)'); whereParts.push('(l.price <= @maxPrice)');
} }
// Build whereSql (filtering by manually_deleted = 0) // Build whereSql: in normal mode hide soft-deleted; in hiddenOnly mode show only soft-deleted.
whereParts.push('(l.manually_deleted = 0)'); whereParts.push(hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)');
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
@@ -463,6 +465,23 @@ export const deleteListingsById = (ids, hardDelete = false) => {
); );
}; };
/**
* Restore previously soft-deleted listings by clearing their `manually_deleted` flag.
*
* @param {string[]} ids - Array of DB row IDs to restore.
* @returns {any} The result from SqliteConnection.execute.
*/
export const restoreListingsById = (ids) => {
if (!Array.isArray(ids) || ids.length === 0) return;
const placeholders = ids.map(() => '?').join(',');
return SqliteConnection.execute(
`UPDATE listings
SET manually_deleted = 0
WHERE id IN (${placeholders})`,
ids,
);
};
/** /**
* Return all listings that are active, have an address, and do not yet have geocoordinates. * Return all listings that are active, have an address, and do not yet have geocoordinates.
* *

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Migration: add `last_run_at` to the `jobs` table.
*
* Stores the epoch-ms timestamp at which a job was last triggered. Used by the
* dashboard "last search" KPI so the value survives restarts and reflects the
* actual jobs the requesting user can see (own, shared, or all for admins),
* replacing the previous in-memory `settings.lastRun` value.
*
* NULL means the job has not yet been triggered since this column was added.
*/
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN last_run_at INTEGER
`);
}

View File

@@ -14,6 +14,7 @@ import { getSettings } from '../storage/settingsStorage.js';
const deviceId = getUniqueId() || 'N/A'; const deviceId = getUniqueId() || 'N/A';
const version = await getPackageVersion(); const version = await getPackageVersion();
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking'; const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
const TRACKING_CATEGORY = 'fredy';
const isDocker = process.env.IS_DOCKER != null; const isDocker = process.env.IS_DOCKER != null;
const staticTrackingData = { const staticTrackingData = {
@@ -95,6 +96,7 @@ async function enrichTrackingObject(trackingObject) {
const settings = await getSettings(); const settings = await getSettings();
return { return {
category: TRACKING_CATEGORY,
...trackingObject, ...trackingObject,
...staticTrackingData, ...staticTrackingData,
isDemo: settings.demoMode, isDemo: settings.demoMode,

View File

@@ -18,6 +18,7 @@
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection. * @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
* @property {SpecFilter | null} [specFilter] Optional listing specifications. * @property {SpecFilter | null} [specFilter] Optional listing specifications.
* @property {number} [numberOfFoundListings] Count of active listings for this job. * @property {number} [numberOfFoundListings] Count of active listings for this job.
* @property {number | null} [lastRunAt] Epoch ms at which the job was last triggered, or null if never triggered.
*/ */
export {}; export {};

View File

@@ -5,12 +5,13 @@
/** /**
* Extract the first number from a string like "1.234 €" or "70 m²". * Extract the first number from a string like "1.234 €" or "70 m²".
* Removes dots/commas before parsing. Returns null on invalid input. * Removes dots/commas before parsing. Returns null when the input is
* null/undefined or cannot be parsed into a number.
* @param {string|undefined|null} str * @param {string|undefined|null} str
* @returns {number|null} * @returns {number|null}
*/ */
export const extractNumber = (str) => { export const extractNumber = (str) => {
if (str == null) return 0; if (str == null) return null;
if (typeof str === 'number') return str; if (typeof str === 'number') return str;
const cleaned = str.replace(/\./g, '').replace(',', '.'); const cleaned = str.replace(/\./g, '').replace(',', '.');
const num = parseFloat(cleaned); const num = parseFloat(cleaned);

View File

@@ -1,7 +1,7 @@
{ {
"name": "fredy", "name": "fredy",
"version": "22.5.0", "version": "22.9.1",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "Fredy - [F]ind [R]eal [E]state [D]amn Eas[y] - Fredy keeps searching for new apartments, houses, and flats in Germany on platforms like ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht and instantly delivers the results to you via Slack, Telegram, Email, Discord or ntfy, so you can focus on the more important things in life ;)",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
"start:backend": "x-var NODE_ENV=production node index.js", "start:backend": "x-var NODE_ENV=production node index.js",
@@ -42,6 +42,7 @@
"house", "house",
"rent", "rent",
"immoscout", "immoscout",
"kleinanzeigen",
"scraper", "scraper",
"immonet", "immonet",
"immowelt", "immowelt",
@@ -62,9 +63,9 @@
"Firefox ESR" "Firefox ESR"
], ],
"dependencies": { "dependencies": {
"@douyinfe/semi-icons": "^2.99.3", "@douyinfe/semi-icons": "^2.100.0",
"@douyinfe/semi-ui": "2.99.3", "@douyinfe/semi-ui": "2.100.0",
"@douyinfe/semi-ui-19": "^2.99.3", "@douyinfe/semi-ui-19": "^2.100.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2", "@fastify/helmet": "^13.0.2",
"@fastify/session": "^11.1.1", "@fastify/session": "^11.1.1",
@@ -75,7 +76,7 @@
"@turf/boolean-point-in-polygon": "^7.3.5", "@turf/boolean-point-in-polygon": "^7.3.5",
"@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-react": "6.0.2",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.1",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cloakbrowser": "^0.3.31", "cloakbrowser": "^0.3.31",
@@ -86,7 +87,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-mailjet": "6.0.11", "node-mailjet": "6.0.11",
"nodemailer": "^8.0.10", "nodemailer": "^8.0.11",
"p-throttle": "^8.1.0", "p-throttle": "^8.1.0",
"package-up": "^5.0.0", "package-up": "^5.0.0",
"puppeteer-core": "^25.1.0", "puppeteer-core": "^25.1.0",
@@ -95,10 +96,10 @@
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-range-slider-input": "^3.3.5", "react-range-slider-input": "^3.3.5",
"react-router": "7.16.0", "react-router": "7.17.0",
"react-router-dom": "7.16.0", "react-router-dom": "7.17.0",
"resend": "^6.12.4", "resend": "^6.12.4",
"semver": "^7.8.1", "semver": "^7.8.4",
"slack": "11.0.2", "slack": "11.0.2",
"vite": "8.0.16", "vite": "8.0.16",
"x-var": "^3.0.1", "x-var": "^3.0.1",
@@ -111,16 +112,16 @@
"@babel/preset-react": "7.29.7", "@babel/preset-react": "7.29.7",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"eslint": "10.4.1", "eslint": "10.5.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
"globals": "^17.6.0", "globals": "^17.6.0",
"history": "5.3.0", "history": "5.3.0",
"husky": "9.1.7", "husky": "9.1.7",
"less": "4.6.4", "less": "4.6.6",
"lint-staged": "17.0.7", "lint-staged": "17.0.7",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"prettier": "3.8.3", "prettier": "3.8.4",
"vitest": "^4.1.8" "vitest": "^4.1.8"
} }
} }

View File

@@ -17,8 +17,12 @@ export const getGeocoordinatesByAddress = (any) => {
return null; return null;
}; };
let userSettings = null;
export function setUserSettings(settings) {
userSettings = settings;
}
export function getUserSettings(userId) { export function getUserSettings(userId) {
return null; return userSettings;
} }
export async function getSettings() { export async function getSettings() {

View File

@@ -3,9 +3,10 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { expect } from 'vitest'; import { afterEach, expect } from 'vitest';
import { mockFredy } from './utils.js'; import { mockFredy } from './utils.js';
import * as mockStore from './mocks/mockStore.js'; import * as mockStore from './mocks/mockStore.js';
import { get as getLastNotification } from './mocks/mockNotification.js';
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => { describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
it('should call deleteListingsById when listings are filtered by similarity', async () => { it('should call deleteListingsById when listings are filtered by similarity', async () => {
@@ -113,3 +114,223 @@ describe('Issue reproduction: listings filtered by similarity or area should be
expect(mockStore.deletedIds).toContain('2'); expect(mockStore.deletedIds).toContain('2');
}); });
}); });
describe('Blacklist is re-applied after detail enrichment', () => {
afterEach(() => {
mockStore.setUserSettings(null);
});
it('filters out a listing whose blacklisted term only appears in the enriched description', async () => {
const Fredy = await mockFredy();
const providerId = 'test-provider';
mockStore.setUserSettings({
provider_details: [providerId],
blacklist_filter_on_provider_details: true,
});
const mockSimilarityCache = {
checkAndAddEntry: () => false,
};
const blacklist = ['allkauf'];
// The search results page returns a clean snippet (no blacklisted term).
// fetchDetails simulates loading the full detail page and discovers the
// blacklisted term hidden deep in the description.
const providerConfig = {
url: 'http://example.com',
getListings: () =>
Promise.resolve([
{
id: 'kept',
title: 'Nice house',
address: 'Some street',
price: '500000',
link: 'http://example.com/kept',
description: 'Cozy home with garden',
},
{
id: 'blacklisted',
title: 'Eleganz trifft Raumkomfort',
address: 'Other street',
price: '600000',
link: 'http://example.com/blacklisted',
description: 'Eleganz trifft Raumkomfort',
},
]),
normalize: (l) => l,
filter: (l) => {
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
return !blacklist.some((term) => text.includes(term));
},
fetchDetails: (listing) => {
if (listing.id === 'blacklisted') {
return Promise.resolve({
...listing,
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
});
}
return Promise.resolve(listing);
},
crawlFields: {
id: 'id',
title: 'title',
address: 'address',
price: 'price',
link: 'link',
description: 'description',
},
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
};
const mockedJob = {
id: 'blacklist-test-job',
notificationAdapter: null,
specFilter: null,
spatialFilter: null,
};
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
const result = await fredy.execute();
expect(result).toBeInstanceOf(Array);
const ids = result.map((l) => l.id);
expect(ids).toContain('kept');
expect(ids).not.toContain('blacklisted');
const notification = getLastNotification();
const notifiedIds = (notification?.payload ?? []).map((p) => p.id);
expect(notifiedIds).not.toContain('blacklisted');
});
it('short-circuits the pipeline when all listings get blacklisted after enrichment', async () => {
const Fredy = await mockFredy();
const providerId = 'all-blacklisted-provider';
mockStore.setUserSettings({
provider_details: [providerId],
blacklist_filter_on_provider_details: true,
});
const mockSimilarityCache = {
checkAndAddEntry: () => false,
};
const blacklist = ['allkauf'];
const providerConfig = {
url: 'http://example.com',
getListings: () =>
Promise.resolve([
{
id: 'only',
title: 'Eleganz trifft Raumkomfort',
address: 'Some street',
price: '700000',
link: 'http://example.com/only',
description: 'Eleganz trifft Raumkomfort',
},
]),
normalize: (l) => l,
filter: (l) => {
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
return !blacklist.some((term) => text.includes(term));
},
fetchDetails: (listing) =>
Promise.resolve({
...listing,
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
}),
crawlFields: {
id: 'id',
title: 'title',
address: 'address',
price: 'price',
link: 'link',
description: 'description',
},
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
};
const mockedJob = {
id: 'all-blacklisted-job',
notificationAdapter: null,
specFilter: null,
spatialFilter: null,
};
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
// Should resolve to undefined (NoNewListingsWarning is caught in _handleError).
const result = await fredy.execute();
expect(result).toBeUndefined();
});
it('does NOT re-filter when blacklist_filter_on_provider_details is disabled', async () => {
const Fredy = await mockFredy();
const providerId = 'opt-out-provider';
// provider_details enabled (so fetchDetails runs) but blacklist re-filter NOT enabled.
mockStore.setUserSettings({
provider_details: [providerId],
blacklist_filter_on_provider_details: false,
});
const mockSimilarityCache = {
checkAndAddEntry: () => false,
};
const blacklist = ['allkauf'];
const providerConfig = {
url: 'http://example.com',
getListings: () =>
Promise.resolve([
{
id: 'leaks-through',
title: 'Eleganz trifft Raumkomfort',
address: 'Other street',
price: '600000',
link: 'http://example.com/leaks-through',
description: 'Eleganz trifft Raumkomfort',
},
]),
normalize: (l) => l,
filter: (l) => {
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
return !blacklist.some((term) => text.includes(term));
},
fetchDetails: (listing) =>
Promise.resolve({
...listing,
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
}),
crawlFields: {
id: 'id',
title: 'title',
address: 'address',
price: 'price',
link: 'link',
description: 'description',
},
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
};
const mockedJob = {
id: 'opt-out-job',
notificationAdapter: null,
specFilter: null,
spatialFilter: null,
};
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
const result = await fredy.execute();
// Listing leaks through because user has not opted in to the stricter check.
expect(result).toBeInstanceOf(Array);
expect(result.map((l) => l.id)).toContain('leaks-through');
});
});

View File

@@ -57,13 +57,17 @@ describe('#sparkasse testsuite()', () => {
expect(notify.id).toBeTypeOf('string'); expect(notify.id).toBeTypeOf('string');
expect(notify.price).toBeTypeOf('string'); expect(notify.price).toBeTypeOf('string');
expect(notify.price).toContain('€'); expect(notify.price).toContain('€');
expect(notify.size).toBeTypeOf('string'); // Size can legitimately be absent for a card whose layout shifts the
expect(notify.size).toContain('m²'); // value out of the expected slot; when present it must be a formatted
// "… m²" string.
if (notify.size != null) {
expect(notify.size).toBeTypeOf('string');
expect(notify.size).toContain('m²');
}
expect(notify.title).toBeTypeOf('string'); expect(notify.title).toBeTypeOf('string');
expect(notify.link).toBeTypeOf('string'); expect(notify.link).toBeTypeOf('string');
expect(notify.address).toBeTypeOf('string'); expect(notify.address).toBeTypeOf('string');
/** check the values if possible **/ /** check the values if possible **/
expect(notify.size).toBeTypeOf('string');
expect(notify.title).not.toBe(''); expect(notify.title).not.toBe('');
expect(notify.address).not.toBe(''); expect(notify.address).not.toBe('');
}); });

View File

@@ -31,12 +31,35 @@ describe('#immoscout-mobile URL conversion', () => {
const webUrl = const webUrl =
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list'; 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list';
const expectedMobileUrl = const expectedMobileUrl =
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region'; 'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swap_flat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
const actualMobileUrl = convertWebToMobile(webUrl); const actualMobileUrl = convertWebToMobile(webUrl);
expect(actualMobileUrl).toBe(expectedMobileUrl); expect(actualMobileUrl).toBe(expectedMobileUrl);
}); });
// The web UI encodes "no swap flats" as exclusioncriteria=swapflat, but the
// mobile API only understands swap_flat. Unknown values are not ignored by the
// API - the search silently returns 0 results, so the mapping is essential.
it('should map exclusioncriteria=swapflat to the mobile API value swap_flat', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?exclusioncriteria=swapflat&price=-1500.0';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('exclusioncriteria')).toBe('swap_flat');
});
// Values the mobile API shares with the web API (e.g. projectlisting) must
// pass through unchanged, in any combination with mapped values.
it('should keep other exclusioncriteria values untouched', () => {
const webUrl =
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?exclusioncriteria=projectlisting,swapflat';
const converted = convertWebToMobile(webUrl);
const queryParams = new URL(converted).searchParams;
expect(queryParams.get('exclusioncriteria')).toBe('projectlisting,swap_flat');
});
// Test URL conversion of web-only SEO path // Test URL conversion of web-only SEO path
it('should convert a SEO web path to the correct query params', () => { it('should convert a SEO web path to the correct query params', () => {
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mit-balkon-mieten?equipment=garden'; const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mit-balkon-mieten?equipment=garden';

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'node:path';
import Fastify from 'fastify';
describe('api/routes/dashboardRouter.js', () => {
let app;
let state;
async function buildApp() {
const ROOT = path.resolve('.');
const jobStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'jobStorage.js');
const listingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'listingsStorage.js');
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
const securityPath = path.join(ROOT, 'lib', 'api', 'security.js');
vi.resetModules();
vi.doMock(jobStoragePath, () => ({
getJobs: () => state.jobs.slice(),
}));
vi.doMock(listingsStoragePath, () => ({
getListingsKpisForJobIds: () => ({ numberOfActiveListings: 0, medianPriceOfListings: 0 }),
getProviderDistributionForJobIds: () => [],
}));
vi.doMock(settingsStoragePath, () => ({
getSettings: async () => ({ interval: 30 }),
}));
vi.doMock(securityPath, () => ({
isAdmin: () => state.admin,
}));
const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'dashboardRouter.js'));
const plugin = mod.default;
const instance = Fastify({ logger: false });
instance.addHook('onRequest', async (request) => {
request.session = { currentUser: state.currentUser, createdAt: Date.now() };
});
await instance.register(plugin, { prefix: '/api/dashboard' });
await instance.ready();
return instance;
}
beforeEach(() => {
state = {
currentUser: 'u1',
admin: false,
jobs: [],
};
});
afterEach(async () => {
if (app) await app.close();
app = null;
});
it('derives lastRun from the most recent accessible job for a regular user', async () => {
state.jobs = [
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
{ id: 'b', userId: 'u1', shared_with_user: [], lastRunAt: 5000 },
{ id: 'c', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.general.lastRun).toBe(5000);
expect(body.general.nextRun).toBe(5000 + 30 * 60000);
});
it('includes shared jobs in the lastRun calculation', async () => {
state.jobs = [
{ id: 'mine', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
{ id: 'shared', userId: 'someone-else', shared_with_user: ['u1'], lastRunAt: 4000 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
expect(res.json().general.lastRun).toBe(4000);
});
it('admins see lastRun across all jobs', async () => {
state.admin = true;
state.jobs = [
{ id: 'a', userId: 'someone', shared_with_user: [], lastRunAt: 1000 },
{ id: 'b', userId: 'another', shared_with_user: [], lastRunAt: 7000 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
expect(res.json().general.lastRun).toBe(7000);
});
it('returns null lastRun and 0 nextRun when no accessible job has ever run', async () => {
state.jobs = [
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: null },
{ id: 'b', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
const body = res.json();
expect(body.general.lastRun).toBeNull();
expect(body.general.nextRun).toBe(0);
});
});

View File

@@ -29,6 +29,7 @@ describe('services/jobs/jobExecutionService', () => {
vi.doMock(jobStoragePath, () => ({ vi.doMock(jobStoragePath, () => ({
getJob: (id) => state.jobsById[id] || null, getJob: (id) => state.jobsById[id] || null,
getJobs: () => state.jobsList.slice(), getJobs: () => state.jobsList.slice(),
updateJobLastRunAt: (id, timestamp) => calls.lastRunUpdates.push({ id, timestamp }),
})); }));
vi.doMock(userStoragePath, () => ({ vi.doMock(userStoragePath, () => ({
getUsers: () => state.users.slice(), getUsers: () => state.users.slice(),
@@ -65,7 +66,7 @@ describe('services/jobs/jobExecutionService', () => {
beforeEach(() => { beforeEach(() => {
bus = new EventEmitter(); bus = new EventEmitter();
calls = { sent: [], markRunning: [] }; calls = { sent: [], markRunning: [], lastRunUpdates: [] };
state = { state = {
jobsById: {}, jobsById: {},
jobsList: [], jobsList: [],
@@ -119,4 +120,23 @@ describe('services/jobs/jobExecutionService', () => {
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2'])); expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
}); });
it('persists last_run_at when a job is executed', async () => {
state.jobsById['j1'] = { id: 'j1', enabled: true, userId: 'u1', provider: [] };
state.jobsList = [state.jobsById['j1']];
state.users = [{ id: 'u1', isAdmin: false }];
await initService();
const before = Date.now();
bus.emit('jobs:runOne', { jobId: 'j1' });
await new Promise((r) => setTimeout(r, 0));
const after = Date.now();
expect(calls.lastRunUpdates.length).toBe(1);
const [update] = calls.lastRunUpdates;
expect(update.id).toBe('j1');
expect(update.timestamp).toBeGreaterThanOrEqual(before);
expect(update.timestamp).toBeLessThanOrEqual(after);
});
}); });

View File

@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Mock SqliteConnection so we can assert which SQL the storage layer runs
// without spinning up a real SQLite DB.
const calls = {
execute: [],
query: [],
};
const sqliteMock = {
execute: (sql, params) => {
calls.execute.push({ sql, params });
return { changes: 1 };
},
query: (sql, params) => {
calls.query.push({ sql, params });
if (sqliteMock.__queryHandler) return sqliteMock.__queryHandler(sql, params);
return [];
},
__queryHandler: null,
};
vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
default: sqliteMock,
}));
describe('jobStorage.getJobs', () => {
let jobStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
jobStorage = await import('../../lib/services/storage/jobStorage.js');
});
it('filters out disabled jobs by default (WHERE j.enabled = 1)', () => {
jobStorage.getJobs();
expect(calls.query).toHaveLength(1);
expect(calls.query[0].sql).toMatch(/WHERE j\.enabled = 1/);
});
it('includes disabled jobs when includeDisabled is true', () => {
jobStorage.getJobs({ includeDisabled: true });
expect(calls.query).toHaveLength(1);
expect(calls.query[0].sql).not.toMatch(/WHERE j\.enabled = 1/);
});
it('coerces the enabled column to a boolean', () => {
sqliteMock.__queryHandler = () => [
{ id: 'enabled-job', enabled: 1 },
{ id: 'disabled-job', enabled: 0 },
];
const jobs = jobStorage.getJobs({ includeDisabled: true });
expect(jobs.find((j) => j.id === 'enabled-job').enabled).toBe(true);
expect(jobs.find((j) => j.id === 'disabled-job').enabled).toBe(false);
});
});

View File

@@ -120,6 +120,57 @@ describe('listingsStorage.queryListings statusFilter', () => {
}); });
}); });
describe('listingsStorage.queryListings hiddenOnly', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = (sql) => {
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
return [];
};
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('filters by manually_deleted = 0 by default', () => {
listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 0\)/);
});
it('filters by manually_deleted = 1 when hiddenOnly is true', () => {
listingsStorage.queryListings({ userId: 'u1', isAdmin: true, hiddenOnly: true });
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 1\)/);
expect(pageQuery.sql).not.toMatch(/\(l\.manually_deleted = 0\)/);
});
});
describe('listingsStorage.restoreListingsById', () => {
let listingsStorage;
beforeEach(async () => {
calls.execute.length = 0;
calls.query.length = 0;
sqliteMock.__queryHandler = null;
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
});
it('clears the manually_deleted flag for the given ids', () => {
listingsStorage.restoreListingsById(['a', 'b']);
expect(calls.execute).toHaveLength(1);
expect(calls.execute[0].sql).toMatch(/UPDATE listings\s+SET manually_deleted = 0\s+WHERE id IN \(\?,\?\)/);
expect(calls.execute[0].params).toEqual(['a', 'b']);
});
it('is a no-op when ids are missing or empty', () => {
listingsStorage.restoreListingsById([]);
listingsStorage.restoreListingsById(undefined);
expect(calls.execute).toHaveLength(0);
});
});
describe('listingsStorage.getListingById', () => { describe('listingsStorage.getListingById', () => {
let listingsStorage; let listingsStorage;

View File

@@ -185,6 +185,7 @@ const JobGrid = () => {
await xhrPut(`/api/jobs/${jobId}/status`, { status }); await xhrPut(`/api/jobs/${jobId}/status`, { status });
Toast.success(t('jobs.toastStatusChanged')); Toast.success(t('jobs.toastStatusChanged'));
loadData(); loadData();
actions.jobsData.getJobs(); // refresh the jobs slice read by the edit form so its switch isn't stale
} catch (error) { } catch (error) {
Toast.error(error.error); Toast.error(error.error);
} }

View File

@@ -22,9 +22,9 @@ import './ListingsGrid.less';
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx'; import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
/** /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
*/ */
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => { const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHiddenView = false, onStatusChange }) => {
const t = useTranslation(); const t = useTranslation();
const locale = useLocale(); const locale = useLocale();
return ( return (
@@ -126,18 +126,38 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip content={t('listings.tooltipRemove')}> {isHiddenView ? (
<Button <Tooltip content={t('listings.tooltipUndelete')}>
size="small" <Button
icon={<IconDelete />} size="small"
style={{ color: '#fb7185' }} icon={
theme="borderless" <span className="listingsGrid__strike" aria-hidden="true">
onClick={(e) => { <IconDelete />
e.stopPropagation(); </span>
onDelete(item.id); }
}} style={{ color: '#34d399' }}
/> theme="borderless"
</Tooltip> onClick={(e) => {
e.stopPropagation();
onRestore?.(item.id);
}}
aria-label={t('listings.tooltipUndelete')}
/>
</Tooltip>
) : (
<Tooltip content={t('listings.tooltipRemove')}>
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
)}
</div> </div>
</div> </div>
))} ))}

View File

@@ -139,4 +139,23 @@
border-radius: @radius-chip !important; border-radius: @radius-chip !important;
} }
} }
&__strike {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
&::after {
content: '';
position: absolute;
left: -2px;
right: -2px;
top: 50%;
height: 2px;
background: currentColor;
transform: rotate(-45deg);
pointer-events: none;
}
}
} }

View File

@@ -10,7 +10,18 @@ import {
parseString, parseString,
parseNullableBoolean, parseNullableBoolean,
} from '../../hooks/useSearchParamState.js'; } from '../../hooks/useSearchParamState.js';
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19'; import {
Button,
Pagination,
Toast,
Input,
Select,
Empty,
Radio,
RadioGroup,
Tooltip,
Banner,
} from '@douyinfe/semi-ui-19';
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons'; import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../ListingDeletionModal.jsx'; import ListingDeletionModal from '../ListingDeletionModal.jsx';
@@ -50,9 +61,12 @@ const ListingsOverview = ({ mode = 'all' }) => {
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean); const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString); const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString); const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
const [hiddenOnly, setHiddenOnly] = useSearchParamState(sp, 'hidden', false, parseNullableBoolean);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null); const [listingToDelete, setListingToDelete] = useState(null);
const isHiddenView = hiddenOnly === true;
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL. // In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter; const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
@@ -66,9 +80,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
filter: { filter: {
watchListFilter: effectiveWatchListFilter, watchListFilter: effectiveWatchListFilter,
jobNameFilter, jobNameFilter,
activityFilter, activityFilter: isHiddenView ? null : activityFilter,
providerFilter, providerFilter,
statusFilter, statusFilter,
hiddenOnly: isHiddenView ? true : undefined,
}, },
}); });
}; };
@@ -85,6 +100,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
jobNameFilter, jobNameFilter,
watchListFilter, watchListFilter,
statusFilter, statusFilter,
hiddenOnly,
isWatchlistMode, isWatchlistMode,
]); ]);
@@ -138,7 +154,21 @@ const ListingsOverview = ({ mode = 'all' }) => {
setDeleteModalVisible(true); setDeleteModalVisible(true);
}; };
const handleNavigate = (id) => navigate(`/listings/listing/${id}`); const handleRestore = async (id) => {
try {
await actions.listingsData.restoreListings([id]);
Toast.success(t('listings.toastRestored'));
loadData();
} catch (e) {
console.error(e);
Toast.error(t('listings.toastRestoreError'));
}
};
const handleNavigate = (id) => {
if (isHiddenView) return;
navigate(`/listings/listing/${id}`);
};
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => { const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
try { try {
@@ -158,118 +188,161 @@ const ListingsOverview = ({ mode = 'all' }) => {
const listings = listingsData?.result || []; const listings = listingsData?.result || [];
const activityRadioValue = isHiddenView ? 'hidden' : activityFilter === null ? 'all' : String(activityFilter);
return ( return (
<div className="listingsOverview"> <div className="listingsOverview">
<div className="listingsOverview__topbar"> <div className="listingsOverview__topbar">
<Input <Tooltip content={t('listings.filterSearchHelp')} trigger="hover" position="top">
className="listingsOverview__topbar__search" <span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__search">
prefix={<IconSearch />} <Input
showClear prefix={<IconSearch />}
placeholder={t('listings.searchPlaceholder')} showClear
defaultValue={freeTextFilter ?? ''} placeholder={t('listings.searchPlaceholder')}
onChange={handleFilterChange} defaultValue={freeTextFilter ?? ''}
/> onChange={handleFilterChange}
/>
</span>
</Tooltip>
<RadioGroup <Tooltip content={t('listings.filterActivityHelp')} trigger="hover" position="top">
type="button" <span className="listingsOverview__topbar__tooltipWrap">
buttonSize="middle" <RadioGroup
value={activityFilter === null ? 'all' : String(activityFilter)} type="button"
onChange={(e) => { buttonSize="middle"
const v = e.target.value; value={activityRadioValue}
setActivityFilter(v === 'all' ? null : v === 'true'); onChange={(e) => {
setPage(1); const v = e.target.value;
}} if (v === 'hidden') {
> setHiddenOnly(true);
<Radio value="all">{t('listings.filterAll')}</Radio> setActivityFilter(null);
<Radio value="true">{t('listings.filterActive')}</Radio> } else {
<Radio value="false">{t('listings.filterInactive')}</Radio> setHiddenOnly(false);
</RadioGroup> setActivityFilter(v === 'all' ? null : v === 'true');
}
setPage(1);
}}
>
<Radio value="all">{t('listings.filterAll')}</Radio>
<Radio value="true">{t('listings.filterActive')}</Radio>
<Radio value="false">{t('listings.filterInactive')}</Radio>
<Radio value="hidden">{t('listings.filterHidden')}</Radio>
</RadioGroup>
</span>
</Tooltip>
{!isWatchlistMode && ( {!isWatchlistMode && (
<RadioGroup <Tooltip content={t('listings.filterWatchHelp')} trigger="hover" position="top">
type="button" <span className="listingsOverview__topbar__tooltipWrap">
buttonSize="middle" <RadioGroup
value={watchListFilter === null ? 'all' : String(watchListFilter)} type="button"
onChange={(e) => { buttonSize="middle"
const v = e.target.value; value={watchListFilter === null ? 'all' : String(watchListFilter)}
setWatchListFilter(v === 'all' ? null : v === 'true'); onChange={(e) => {
setPage(1); const v = e.target.value;
}} setWatchListFilter(v === 'all' ? null : v === 'true');
> setPage(1);
<Radio value="all">{t('listings.filterAll')}</Radio> }}
<Radio value="true">{t('listings.filterWatched')}</Radio> >
<Radio value="false">{t('listings.filterUnwatched')}</Radio> <Radio value="all">{t('listings.filterAll')}</Radio>
</RadioGroup> <Radio value="true">{t('listings.filterWatched')}</Radio>
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
</RadioGroup>
</span>
</Tooltip>
)} )}
<Select <Tooltip content={t('listings.filterStatusHelp')} trigger="hover" position="top">
placeholder={t('listings.filterStatusPlaceholder')} <span className="listingsOverview__topbar__tooltipWrap">
showClear <Select
onChange={(val) => { placeholder={t('listings.filterStatusPlaceholder')}
setStatusFilter(val ?? null); showClear
setPage(1); onChange={(val) => {
}} setStatusFilter(val ?? null);
value={statusFilter} setPage(1);
style={{ width: 150 }} }}
> value={statusFilter}
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option> style={{ width: 150 }}
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option> >
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option> <Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option> <Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
</Select> <Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
</Select>
</span>
</Tooltip>
<Select <Tooltip content={t('listings.filterProviderHelp')} trigger="hover" position="top">
placeholder={t('listings.filterProviderPlaceholder')} <span className="listingsOverview__topbar__tooltipWrap">
showClear <Select
onChange={(val) => { placeholder={t('listings.filterProviderPlaceholder')}
setProviderFilter(val); showClear
setPage(1); onChange={(val) => {
}} setProviderFilter(val);
value={providerFilter} setPage(1);
style={{ width: 130 }} }}
> value={providerFilter}
{providers?.map((p) => ( style={{ width: 130 }}
<Select.Option key={p.id} value={p.id}> >
{p.name} {providers?.map((p) => (
</Select.Option> <Select.Option key={p.id} value={p.id}>
))} {p.name}
</Select> </Select.Option>
))}
</Select>
</span>
</Tooltip>
<Select <Tooltip content={t('listings.filterJobHelp')} trigger="hover" position="top">
placeholder={t('listings.filterJobPlaceholder')} <span className="listingsOverview__topbar__tooltipWrap">
showClear <Select
onChange={(val) => { placeholder={t('listings.filterJobPlaceholder')}
setJobNameFilter(val); showClear
setPage(1); onChange={(val) => {
}} setJobNameFilter(val);
value={jobNameFilter} setPage(1);
style={{ width: 130 }} }}
> value={jobNameFilter}
{jobs?.map((j) => ( style={{ width: 130 }}
<Select.Option key={j.id} value={j.id}> >
{j.name} {jobs?.map((j) => (
</Select.Option> <Select.Option key={j.id} value={j.id}>
))} {j.name}
</Select> </Select.Option>
))}
</Select>
</span>
</Tooltip>
<Select <Tooltip content={t('listings.filterSortHelp')} trigger="hover" position="top">
prefix={t('listings.sortPrefix')} <span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__sort">
className="listingsOverview__topbar__sort" <Select
style={{ width: 220 }} prefix={t('listings.sortPrefix')}
value={sortField} style={{ width: 220 }}
onChange={(val) => setSortField(val)} value={sortField}
> onChange={(val) => setSortField(val)}
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option> >
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option> <Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option> <Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option> <Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
</Select> <Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
</Select>
</span>
</Tooltip>
<Button <Tooltip
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />} content={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')} trigger="hover"
title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')} position="top"
/> >
<span className="listingsOverview__topbar__tooltipWrap">
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
aria-label={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
/>
</span>
</Tooltip>
<div className="listingsOverview__topbar__view-toggle"> <div className="listingsOverview__topbar__view-toggle">
<Tooltip content={t('listings.tooltipGridView')}> <Tooltip content={t('listings.tooltipGridView')}>
@@ -293,6 +366,16 @@ const ListingsOverview = ({ mode = 'all' }) => {
</div> </div>
</div> </div>
{isHiddenView && (
<Banner
type="info"
fullMode={false}
closeIcon={null}
description={t('listings.hiddenViewBanner')}
style={{ marginBottom: 12 }}
/>
)}
{listings.length === 0 && ( {listings.length === 0 && (
<Empty <Empty
image={<IllustrationNoResult />} image={<IllustrationNoResult />}
@@ -307,6 +390,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
onWatch={handleWatch} onWatch={handleWatch}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onDelete={handleDelete} onDelete={handleDelete}
onRestore={handleRestore}
isHiddenView={isHiddenView}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
/> />
) : ( ) : (
@@ -315,6 +400,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
onWatch={handleWatch} onWatch={handleWatch}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onDelete={handleDelete} onDelete={handleDelete}
onRestore={handleRestore}
isHiddenView={isHiddenView}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
/> />
)} )}

View File

@@ -8,6 +8,15 @@
margin-bottom: @space-4; margin-bottom: @space-4;
flex-wrap: wrap; flex-wrap: wrap;
&__tooltipWrap {
display: inline-flex;
align-items: center;
> * {
width: 100%;
}
}
&__search { &__search {
min-width: 200px; min-width: 200px;
flex: 1; flex: 1;

View File

@@ -92,8 +92,15 @@ export default function Navigation({ isAdmin }) {
items={items} items={items}
isCollapsed={collapsed} isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]} selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => { onClick={({ itemKey }) => {
navigate(key.itemKey); // Use onClick (fires on every click) instead of onSelect (skips the
// already-selected item) so clicking e.g. "Jobs" while on a nested
// route like /jobs/edit/:id still navigates back to the list. Only
// leaf routes navigate; parent items (keys without a leading '/') just
// toggle their submenu.
if (typeof itemKey === 'string' && itemKey.startsWith('/')) {
navigate(itemKey);
}
}} }}
header={ header={
<div className="navigate__header"> <div className="navigate__header">

View File

@@ -22,9 +22,17 @@ import './ListingsTable.less';
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx'; import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
/** /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props * @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
*/ */
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => { const ListingsTable = ({
listings,
onWatch,
onNavigate,
onDelete,
onRestore,
isHiddenView = false,
onStatusChange,
}) => {
const t = useTranslation(); const t = useTranslation();
const locale = useLocale(); const locale = useLocale();
return ( return (
@@ -123,18 +131,38 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
}} }}
/> />
</Tooltip> </Tooltip>
<Tooltip content={t('listings.tooltipRemove')}> {isHiddenView ? (
<Button <Tooltip content={t('listings.tooltipUndelete')}>
size="small" <Button
icon={<IconDelete />} size="small"
style={{ color: '#fb7185' }} icon={
theme="borderless" <span className="listingsTable__strike" aria-hidden="true">
onClick={(e) => { <IconDelete />
e.stopPropagation(); </span>
onDelete(item.id); }
}} style={{ color: '#34d399' }}
/> theme="borderless"
</Tooltip> onClick={(e) => {
e.stopPropagation();
onRestore?.(item.id);
}}
aria-label={t('listings.tooltipUndelete')}
/>
</Tooltip>
) : (
<Tooltip content={t('listings.tooltipRemove')}>
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
)}
</div> </div>
</div> </div>
))} ))}

View File

@@ -5,6 +5,25 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
&__strike {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
&::after {
content: '';
position: absolute;
left: -2px;
right: -2px;
top: 50%;
height: 2px;
background: currentColor;
transform: rotate(-45deg);
pointer-events: none;
}
}
&__row { &__row {
display: grid; display: grid;
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto; grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;

View File

@@ -135,6 +135,7 @@
"listings.filterAll": "Alle", "listings.filterAll": "Alle",
"listings.filterActive": "Aktiv", "listings.filterActive": "Aktiv",
"listings.filterInactive": "Inaktiv", "listings.filterInactive": "Inaktiv",
"listings.filterHidden": "Versteckt",
"listings.filterWatched": "Beobachtet", "listings.filterWatched": "Beobachtet",
"listings.filterUnwatched": "Nicht beobachtet", "listings.filterUnwatched": "Nicht beobachtet",
"listings.filterStatusPlaceholder": "Status", "listings.filterStatusPlaceholder": "Status",
@@ -144,6 +145,17 @@
"listings.filterStatusNone": "Kein Status", "listings.filterStatusNone": "Kein Status",
"listings.filterProviderPlaceholder": "Anbieter", "listings.filterProviderPlaceholder": "Anbieter",
"listings.filterJobPlaceholder": "Job", "listings.filterJobPlaceholder": "Job",
"listings.filterSearchHelp": "Volltextsuche über Titel, Adresse, Anbieter und Link.",
"listings.filterActivityHelp": "Filtert nach Inseratsstatus: 'Alle' zeigt jedes Inserat, 'Aktiv' nur noch online verfügbare, 'Inaktiv' beim Anbieter verschwundene, 'Versteckt' zeigt deine manuell gelöschten (soft-deleted) Inserate, damit du sie wiederherstellen kannst.",
"listings.filterWatchHelp": "Filtert nach Watchlist-Zugehörigkeit: 'Alle' zeigt jedes Inserat, 'Beobachtet' nur die auf deiner Watchlist gespeicherten, 'Nicht beobachtet' die anderen.",
"listings.filterStatusHelp": "Filtert nach dem persönlichen Status (Beworben, Abgelehnt, Angenommen) oder zeigt nur Inserate ohne Status.",
"listings.filterProviderHelp": "Zeigt nur Inserate des ausgewählten Anbieters (ImmoScout24, Kleinanzeigen, ...).",
"listings.filterJobHelp": "Zeigt nur Inserate des ausgewählten Jobs.",
"listings.filterSortHelp": "Wählt das Sortierkriterium. Mit dem Pfeil-Button schaltet man zwischen aufsteigend und absteigend.",
"listings.hiddenViewBanner": "Du siehst gerade versteckte (soft-gelöschte) Inserate. Sie werden in den normalen Ansichten ausgeblendet. Über den Wiederherstellen-Button kannst du sie zurückholen.",
"listings.toastRestored": "Inserat wiederhergestellt",
"listings.toastRestoreError": "Wiederherstellung fehlgeschlagen",
"listings.tooltipUndelete": "Inserat wiederherstellen",
"listings.sortByJobName": "Job-Name", "listings.sortByJobName": "Job-Name",
"listings.sortByDate": "Inserat-Datum", "listings.sortByDate": "Inserat-Datum",
"listings.sortByPrice": "Preis", "listings.sortByPrice": "Preis",
@@ -334,6 +346,11 @@
"settings.providerDetailsPlaceholder": "Anbieter für Detail-Abruf auswählen...", "settings.providerDetailsPlaceholder": "Anbieter für Detail-Abruf auswählen...",
"settings.providerDetailsUpdated": "Anbieter-Detail-Einstellung aktualisiert.", "settings.providerDetailsUpdated": "Anbieter-Detail-Einstellung aktualisiert.",
"settings.providerDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.", "settings.providerDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.",
"settings.blacklistFilterOnProviderDetails": "Blacklist-Filter auf Anbieter-Details anwenden",
"settings.blacklistFilterOnProviderDetailsHelp": "Wenn aktiv, wird die Blacklist zusätzlich gegen die vollständige Beschreibung geprüft, die durch den obigen Anbieter-Details-Schritt geladen wurde. Damit lassen sich Spam-Anbieter (z. B. 'allkauf', 'massa') herausfiltern, die nur tief in der Detail-Seite auftauchen und nicht im kurzen Vorschau-Text der Suchergebnisse stehen. Standardmäßig aus, weil die vollständige Beschreibung oft generischen Boilerplate-Text (Kontaktdaten, rechtliche Hinweise) enthält, der ein Blacklist-Wort versehentlich auslösen und passende Inserate entfernen kann. Hat keine Wirkung auf Anbieter, für die Anbieter-Details nicht aktiviert sind.",
"settings.blacklistFilterOnProviderDetailsEnable": "Blacklist auf die vollständige Detail-Beschreibung anwenden",
"settings.blacklistFilterOnProviderDetailsUpdated": "Einstellung Blacklist-auf-Details aktualisiert.",
"settings.blacklistFilterOnProviderDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.",
"settings.listingDeletion": "Inserate löschen", "settings.listingDeletion": "Inserate löschen",
"settings.listingDeletionHelp": "Wähle den Standard-Löschmodus. Soft Delete blendet Inserate aus ohne erneutes Scraping; Hard Delete entfernt sie aus der Datenbank.", "settings.listingDeletionHelp": "Wähle den Standard-Löschmodus. Soft Delete blendet Inserate aus ohne erneutes Scraping; Hard Delete entfernt sie aus der Datenbank.",
"settings.listingDeletionSoftLabel": "Als gelöscht markieren (Soft Delete)", "settings.listingDeletionSoftLabel": "Als gelöscht markieren (Soft Delete)",

View File

@@ -135,6 +135,7 @@
"listings.filterAll": "All", "listings.filterAll": "All",
"listings.filterActive": "Active", "listings.filterActive": "Active",
"listings.filterInactive": "Inactive", "listings.filterInactive": "Inactive",
"listings.filterHidden": "Hidden",
"listings.filterWatched": "Watched", "listings.filterWatched": "Watched",
"listings.filterUnwatched": "Unwatched", "listings.filterUnwatched": "Unwatched",
"listings.filterStatusPlaceholder": "Status", "listings.filterStatusPlaceholder": "Status",
@@ -144,6 +145,17 @@
"listings.filterStatusNone": "No status", "listings.filterStatusNone": "No status",
"listings.filterProviderPlaceholder": "Provider", "listings.filterProviderPlaceholder": "Provider",
"listings.filterJobPlaceholder": "Job", "listings.filterJobPlaceholder": "Job",
"listings.filterSearchHelp": "Free-text search across title, address, provider and link.",
"listings.filterActivityHelp": "Filter by listing activity: All shows every listing, Active only those still online, Inactive those that disappeared from the provider, Hidden shows your manually deleted (soft-deleted) listings so you can restore them.",
"listings.filterWatchHelp": "Filter by watchlist membership: All shows every listing, Watched only those you saved to your watchlist, Unwatched only those you have not saved.",
"listings.filterStatusHelp": "Filter by the personal status you set on a listing (Applied, Rejected, Accepted) or show only listings with no status yet.",
"listings.filterProviderHelp": "Show only listings coming from the selected real-estate provider (ImmoScout24, Kleinanzeigen, ...).",
"listings.filterJobHelp": "Show only listings produced by the selected job.",
"listings.filterSortHelp": "Choose the column to sort listings by. Use the arrow button to toggle ascending and descending order.",
"listings.hiddenViewBanner": "You are viewing hidden (soft-deleted) listings. They are excluded from the regular views. Use the restore button on a card to bring it back.",
"listings.toastRestored": "Listing restored",
"listings.toastRestoreError": "Failed to restore listing",
"listings.tooltipUndelete": "Restore Listing",
"listings.sortByJobName": "Job Name", "listings.sortByJobName": "Job Name",
"listings.sortByDate": "Listing Date", "listings.sortByDate": "Listing Date",
"listings.sortByPrice": "Price", "listings.sortByPrice": "Price",
@@ -334,6 +346,11 @@
"settings.providerDetailsPlaceholder": "Select providers to fetch details from...", "settings.providerDetailsPlaceholder": "Select providers to fetch details from...",
"settings.providerDetailsUpdated": "Provider details setting updated.", "settings.providerDetailsUpdated": "Provider details setting updated.",
"settings.providerDetailsUpdateError": "Failed to update setting.", "settings.providerDetailsUpdateError": "Failed to update setting.",
"settings.blacklistFilterOnProviderDetails": "Blacklist-Filtering on Provider Details",
"settings.blacklistFilterOnProviderDetailsHelp": "When enabled, the blacklist is re-checked against the full description loaded by the Provider Details step above. This catches spam advertisers (e.g. 'allkauf', 'massa') that only appear deep in the detail page and not in the short search-result snippet. Off by default, because the full description often contains generic boilerplate (contact info, legal text) that may accidentally trigger a blacklist term and remove otherwise relevant listings. Has no effect on providers for which Provider Details is not enabled.",
"settings.blacklistFilterOnProviderDetailsEnable": "Apply blacklist to the full detail description",
"settings.blacklistFilterOnProviderDetailsUpdated": "Blacklist-on-details setting updated.",
"settings.blacklistFilterOnProviderDetailsUpdateError": "Failed to update setting.",
"settings.listingDeletion": "Listing deletion", "settings.listingDeletion": "Listing deletion",
"settings.listingDeletionHelp": "Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database.", "settings.listingDeletionHelp": "Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database.",
"settings.listingDeletionSoftLabel": "Mark as deleted (Soft Delete)", "settings.listingDeletionSoftLabel": "Mark as deleted (Soft Delete)",

View File

@@ -276,6 +276,14 @@ export const useFredyState = create(
throw Exception; throw Exception;
} }
}, },
async restoreListings(ids) {
try {
await xhrPost('/api/listings/restore', { ids });
} catch (Exception) {
console.error('Error while trying to restore listings. Error:', Exception);
throw Exception;
}
},
}, },
userSettings: { userSettings: {
async getUserSettings() { async getUserSettings() {
@@ -337,6 +345,28 @@ export const useFredyState = create(
throw Exception; throw Exception;
} }
}, },
async setBlacklistFilterOnProviderDetails(enabled) {
try {
await xhrPost('/api/user/settings/blacklist-filter-on-details', {
blacklist_filter_on_provider_details: enabled,
});
set((state) => ({
userSettings: {
...state.userSettings,
settings: {
...state.userSettings.settings,
blacklist_filter_on_provider_details: enabled,
},
},
}));
} catch (Exception) {
console.error(
'Error while trying to update blacklist-filter-on-provider-details setting. Error:',
Exception,
);
throw Exception;
}
},
async setListingsViewMode(listings_view_mode) { async setListingsViewMode(listings_view_mode) {
try { try {
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode }); await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });

View File

@@ -130,6 +130,9 @@ const GeneralSettings = function GeneralSettings() {
// User settings state // User settings state
const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
const blacklistFilterOnProviderDetails = useSelector(
(state) => state.userSettings.settings.blacklist_filter_on_provider_details,
);
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference); const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
const allProviders = useSelector((state) => state.provider); const allProviders = useSelector((state) => state.provider);
const [address, setAddress] = useState(homeAddress?.address || ''); const [address, setAddress] = useState(homeAddress?.address || '');
@@ -252,11 +255,11 @@ const GeneralSettings = function GeneralSettings() {
}); });
} catch (exception) { } catch (exception) {
console.error(exception); console.error(exception);
if (exception?.json?.message != null) { // The backend returns the concrete reason in `json.error` (e.g. a 403
Toast.error(exception.json.message); // "Only admins can change these settings."). Fall back to `json.message`
} else { // and finally the generic toast so the user always sees why it failed.
Toast.error(t('settings.toastSaveError')); const serverReason = exception?.json?.error ?? exception?.json?.message;
} Toast.error(serverReason ?? t('settings.toastSaveError'));
return; return;
} }
Toast.success(t('settings.toastSavedReloading')); Toast.success(t('settings.toastSavedReloading'));
@@ -647,6 +650,25 @@ const GeneralSettings = function GeneralSettings() {
/> />
</SegmentPart> </SegmentPart>
<SegmentPart
name={t('settings.blacklistFilterOnProviderDetails')}
helpText={t('settings.blacklistFilterOnProviderDetailsHelp')}
>
<Checkbox
checked={blacklistFilterOnProviderDetails === true}
onChange={async (e) => {
try {
await actions.userSettings.setBlacklistFilterOnProviderDetails(e.target.checked);
Toast.success(t('settings.blacklistFilterOnProviderDetailsUpdated'));
} catch {
Toast.error(t('settings.blacklistFilterOnProviderDetailsUpdateError'));
}
}}
>
{t('settings.blacklistFilterOnProviderDetailsEnable')}
</Checkbox>
</SegmentPart>
<SegmentPart name={t('settings.listingDeletion')} helpText={t('settings.listingDeletionHelp')}> <SegmentPart name={t('settings.listingDeletion')} helpText={t('settings.listingDeletionHelp')}>
<RadioGroup <RadioGroup
value={listingDeleteHard ? 'hard' : 'soft'} value={listingDeleteHard ? 'hard' : 'soft'}

View File

@@ -189,6 +189,10 @@ export default function MapView() {
useEffect(() => { useEffect(() => {
if (!map.current) return; if (!map.current) return;
// Use duration: 0 so the map jumps straight to the target view instead of
// animating from the zoomed-out initial state. This effect re-runs whenever
// listings/filters change, and the fly/zoom animation was distracting on
// every refresh.
if (homeAddress?.coords) { if (homeAddress?.coords) {
if (distanceFilter > 0) { if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter); const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
@@ -196,13 +200,13 @@ export default function MapView() {
map.current.fitBounds(bounds, { map.current.fitBounds(bounds, {
padding: 20, padding: 20,
maxZoom: 15, maxZoom: 15,
duration: 1000, duration: 0,
}); });
} else { } else {
map.current.flyTo({ map.current.flyTo({
center: [homeAddress.coords.lng, homeAddress.coords.lat], center: [homeAddress.coords.lng, homeAddress.coords.lat],
zoom: 12, zoom: 12,
duration: 1000, duration: 0,
}); });
} }
} else { } else {
@@ -216,7 +220,7 @@ export default function MapView() {
map.current.fitBounds(bounds, { map.current.fitBounds(bounds, {
padding: 50, padding: 50,
maxZoom: 15, maxZoom: 15,
duration: 1000, duration: 0,
}); });
} }
} }

View File

@@ -7,7 +7,10 @@ import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: '', // Must be absolute: with a relative base, asset URLs in index.html break on
// deep links like /listings/listing/:id (the SPA fallback serves index.html,
// but ./assets/* then resolves below the route path and loads HTML as JS).
base: '/',
build: { build: {
chunkSizeWarningLimit: 9999999, chunkSizeWarningLimit: 9999999,
outDir: './ui/public', outDir: './ui/public',

201
yarn.lock
View File

@@ -950,34 +950,34 @@
dependencies: dependencies:
tslib "^2.0.0" tslib "^2.0.0"
"@douyinfe/semi-animation-react@2.99.3": "@douyinfe/semi-animation-react@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.99.3.tgz#504e45896db45761d173be8a68cb2aa43157e8cd" resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.100.0.tgz#f53cb41a259f4dfefafd68cab76964635a215736"
integrity sha512-0iUWQRO1t838Q1VaPE7DwOnYWeAuuu98MrNnaFkbD8JncYsct2K/2A5TDfa56DwSZ5iVz53jz2En8dMi7oF8sw== integrity sha512-zp224kBejXu+28z56uxLNasaijDJN55w0Ll+/JN+NaksTeKoUteEa93hx2SZVt6GGwZAM3H3mfDwF1UcE+fvLA==
dependencies: dependencies:
"@douyinfe/semi-animation" "2.99.3" "@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-animation-styled" "2.99.3" "@douyinfe/semi-animation-styled" "2.100.0"
classnames "^2.2.6" classnames "^2.2.6"
"@douyinfe/semi-animation-styled@2.99.3": "@douyinfe/semi-animation-styled@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.99.3.tgz#d810fd4fb1e2fa6c617b479b3bcbf6ef91d1e4d8" resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.100.0.tgz#20a3fde32b94feb4d1fdf7eba037b19ad74c99ba"
integrity sha512-38/ui6SoIJFWRs2jHv1IiNV2CKHaQKhYB4WftCVXCaYYQGL24+0oQ3iLo6qUeaHEWiQK3EcK2Rt7pxtJCJxVOA== integrity sha512-UHluoWLAHPSVYK2OpdreaSHQI3bh300rrp/dP0UCjsl3FngTUHhsOHVqdWPJ3flTWnc3Mg1Flqr2gUmFjHplhw==
"@douyinfe/semi-animation@2.99.3": "@douyinfe/semi-animation@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.99.3.tgz#3544687b8bc1f287c60a0f116494ce23adb42893" resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.100.0.tgz#2f96f57d5c60d732eae5bd02a90ee1e6ec4d23b4"
integrity sha512-Uva9MLF+EjC+m6eBYnX9PFZIQKLxD+iKV6ps/nX/P1FWy17DCDxIsga/cByF0PIsVRLzrSdkCsddj3XETcDw9A== integrity sha512-X9AxxUrrHWhgxxLkM4oJw8ZM/VAXsu7/fkr4dyIkkZHDhQcnMfMc2YtughqaVqkaicm3SV9zRx9npjYe/S5nVw==
dependencies: dependencies:
bezier-easing "^2.1.0" bezier-easing "^2.1.0"
"@douyinfe/semi-foundation@2.99.3": "@douyinfe/semi-foundation@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.99.3.tgz#ac7f9afd4d141711a5aca14b0a1b6e4ffba70417" resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.100.0.tgz#e503dbc31bbc18c2f8862653bcbdc1d5a330fd65"
integrity sha512-HKzrcdNGYoEZD81CKI6fj8jU2MWNrZx8HZ0NDHym+smBxSyhpoE/b0FrVo0PmLjCzbCDnySDdJ31GsK5GScmuw== integrity sha512-D2pjhpqOMOpjgw4M4Hg0Pj8KSnxl/jVsfynrIji5TwW7V2bGgt/aWOnBqdTXlrTLk4CHDmfAXKyr+rxY9aihhw==
dependencies: dependencies:
"@douyinfe/semi-animation" "2.99.3" "@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-json-viewer-core" "2.99.3" "@douyinfe/semi-json-viewer-core" "2.100.0"
"@mdx-js/mdx" "^3.0.1" "@mdx-js/mdx" "^3.0.1"
async-validator "^3.5.0" async-validator "^3.5.0"
classnames "^2.2.6" classnames "^2.2.6"
@@ -991,44 +991,44 @@
remark-gfm "^4.0.0" remark-gfm "^4.0.0"
scroll-into-view-if-needed "^2.2.24" scroll-into-view-if-needed "^2.2.24"
"@douyinfe/semi-icons@2.99.3", "@douyinfe/semi-icons@^2.99.3": "@douyinfe/semi-icons@2.100.0", "@douyinfe/semi-icons@^2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.99.3.tgz#295d4fd79b2bf987bbcd34c0a4e3fb364f96e509" resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.100.0.tgz#b0853f230bfa993acbf90a1c2e9fcbb97321819b"
integrity sha512-Pm5H3Ua/PDumUCCsnJWwN+znVoKiyFCqag6DJy9/cuF6OOdd1+QUnvi0NHNg6+0fx/LHH088UwKFoOiZRkbaSw== integrity sha512-S/UZAOgzhbk2Dpwn0mUz/SrjswRpSTjSupzluLO0QmM8mCVuLSetmJ0Y/HO4MGM1eY9rEUrXON/FV3+SukFzxQ==
dependencies: dependencies:
classnames "^2.2.6" classnames "^2.2.6"
"@douyinfe/semi-illustrations@2.99.3": "@douyinfe/semi-illustrations@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.99.3.tgz#e97d6c30830d44b7ec299d7e05f1a6e3c4938bf7" resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.100.0.tgz#4ca6623eedd1944817f1b7c8eba0095a6a7d2985"
integrity sha512-z1rQPgWOV2xtZS8NkmL8JCK1DltQ8FGiL1qYlXbSHjEs1XkNYruq4W3dKv0IJEpTVLIlPsbDg4VmPAuuwLCCkQ== integrity sha512-SN7plpE328WGBohLHOVpYe6FwWSO6RLS7Xf6LhqEdtarwK52ircr4C/b+OyRqIwcLOzRYMgIoqcWnAQGmowcUw==
"@douyinfe/semi-json-viewer-core@2.99.3": "@douyinfe/semi-json-viewer-core@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.99.3.tgz#a8dee4ea6cbf1bcac85c723696a9430b4faf3152" resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.100.0.tgz#c0c3bf50f722aa51008a8e6acf17ac7842baceeb"
integrity sha512-KEbZEyyM2qqGv9K+Yw/ZvAn4CEgcY2lQfL6a2ASEt80FlPoDAIWA7tGjpYxxM9/NcX9omNtsM/HLgDmrCjjBXQ== integrity sha512-iQ6rX04YBngrsMz7Eds8zBI+W0MXb0mAICvfTaiX8RpoAwau9yFwbyHiCPKOVPSzI0hS8GwdMLSIYxdCOQPNqQ==
dependencies: dependencies:
jsonc-parser "^3.3.1" jsonc-parser "^3.3.1"
"@douyinfe/semi-theme-default@2.99.3": "@douyinfe/semi-theme-default@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.99.3.tgz#e5ee0e4a8eec413ea3f58ed12f93415729c47251" resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.100.0.tgz#919bb12307f6b3258016cf36e320c607717eb8c2"
integrity sha512-r0IIjrN6vQE1bqbky7FIRi4HQ03x4ykzSIRMf4Za04BFp76IFV6CclyYyUg6cLJ6GjWCnEPMFtwTLKP+b8dAYA== integrity sha512-7tJjg5NiuUYtChWr/E5rQ4Kcko3izz8rTxlNDWSS4YR3RQg3S+lQTgG5bD7LMnBqX399erf3wgE35KLwQZKWTg==
"@douyinfe/semi-ui-19@^2.99.3": "@douyinfe/semi-ui-19@^2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.99.3.tgz#236a8894ea38ac3cd9d4a4d9c784dc9712b2105a" resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.100.0.tgz#bee76e0a0eec57b49b64f8dd2d73b9039b7a2c1b"
integrity sha512-HrXK1xIXfzS7OYzkrS+3PQKlMnx6J5HEw7wfYtDvGSIN/riSbjeD8vciHeIvP1tvhEAubFY8DMFwT07ZdmqfxA== integrity sha512-eL4DTJm4CPopWgr4d278dXIa2UwNgUundRJ37ksQ7Ev1TZnWr8SxCWLcmi4exl8kymZurAWV7j2w1sv7BHqtAA==
dependencies: dependencies:
"@dnd-kit/core" "^6.0.8" "@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2" "@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1" "@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.99.3" "@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-animation-react" "2.99.3" "@douyinfe/semi-animation-react" "2.100.0"
"@douyinfe/semi-foundation" "2.99.3" "@douyinfe/semi-foundation" "2.100.0"
"@douyinfe/semi-icons" "2.99.3" "@douyinfe/semi-icons" "2.100.0"
"@douyinfe/semi-illustrations" "2.99.3" "@douyinfe/semi-illustrations" "2.100.0"
"@douyinfe/semi-theme-default" "2.99.3" "@douyinfe/semi-theme-default" "2.100.0"
"@tiptap/core" "^3.10.7" "@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7" "@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7" "@tiptap/extension-hard-break" "^3.10.7"
@@ -1057,20 +1057,20 @@
scroll-into-view-if-needed "^2.2.24" scroll-into-view-if-needed "^2.2.24"
utility-types "^3.10.0" utility-types "^3.10.0"
"@douyinfe/semi-ui@2.99.3": "@douyinfe/semi-ui@2.100.0":
version "2.99.3" version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.99.3.tgz#a183ecc4db0e96c48c714d8733a6d30e9395bc4a" resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.100.0.tgz#2964299a4c4da2501c4ba1fd2699fbfd2daef106"
integrity sha512-6NkeijjZZWzD31omteNVLz+oZuuMKQm3nEcwLI8+44Vv+VUSJPb87WnSFSD3F6eUIt/hZp2vJbCXHWW9SbCpDw== integrity sha512-fTaqS6B1gHLjwMKgcWTcJWdMk9gY96h94I71Y3z9ee6qIXJyjAO8XiE8G6bihEIeVO3vTKXp1DOKiGhlgMVJKQ==
dependencies: dependencies:
"@dnd-kit/core" "^6.0.8" "@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2" "@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1" "@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.99.3" "@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-animation-react" "2.99.3" "@douyinfe/semi-animation-react" "2.100.0"
"@douyinfe/semi-foundation" "2.99.3" "@douyinfe/semi-foundation" "2.100.0"
"@douyinfe/semi-icons" "2.99.3" "@douyinfe/semi-icons" "2.100.0"
"@douyinfe/semi-illustrations" "2.99.3" "@douyinfe/semi-illustrations" "2.100.0"
"@douyinfe/semi-theme-default" "2.99.3" "@douyinfe/semi-theme-default" "2.100.0"
"@tiptap/core" "^3.10.7" "@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7" "@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7" "@tiptap/extension-hard-break" "^3.10.7"
@@ -2636,10 +2636,10 @@ baseline-browser-mapping@^2.9.0:
resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz" resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz"
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
better-sqlite3@^12.10.0: better-sqlite3@^12.10.1:
version "12.10.0" version "12.10.1"
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.0.tgz#bde622d14a18008583a53bc53501ae98f1a12221" resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.1.tgz#1fedf77460210c83d5140fb700c81700964a1a24"
integrity sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ== integrity sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA==
dependencies: dependencies:
bindings "^1.5.0" bindings "^1.5.0"
prebuild-install "^7.1.1" prebuild-install "^7.1.1"
@@ -3560,10 +3560,10 @@ eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.4.1: eslint@10.5.0:
version "10.4.1" version "10.5.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.4.1.tgz#f6640b176e0912246d9ddbf8fcfa5e8b7f02445a" resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.5.0.tgz#5fca69d6b41fe7e00ba22d4100b2e44efe439ad5"
integrity sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw== integrity sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==
dependencies: dependencies:
"@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2" "@eslint-community/regexpp" "^4.12.2"
@@ -4749,10 +4749,10 @@ keyv@^4.5.4:
dependencies: dependencies:
json-buffer "3.0.1" json-buffer "3.0.1"
less@4.6.4: less@4.6.6:
version "4.6.4" version "4.6.6"
resolved "https://registry.yarnpkg.com/less/-/less-4.6.4.tgz#3ff8068e6c8a59f1ece8a6b9227bda28c1ed68a2" resolved "https://registry.yarnpkg.com/less/-/less-4.6.6.tgz#f7854302a3389d2daf96fb3444ba80a54436e66e"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg== integrity sha512-ooPSwQGQ2sVe8Dh1jVsbKKsRR2gd8lFK72BDkeSzjnD1T5aIHL65hCMfO0GVmtriKgDKrQv6xp9UrihUsWuAzA==
dependencies: dependencies:
copy-anything "^3.0.5" copy-anything "^3.0.5"
parse-node-version "^1.0.1" parse-node-version "^1.0.1"
@@ -4760,7 +4760,7 @@ less@4.6.4:
errno "^0.1.1" errno "^0.1.1"
graceful-fs "^4.1.2" graceful-fs "^4.1.2"
image-size "~0.5.0" image-size "~0.5.0"
make-dir "^2.1.0" make-dir "^5.1.0"
mime "^1.4.1" mime "^1.4.1"
needle "^3.1.0" needle "^3.1.0"
source-map "~0.6.0" source-map "~0.6.0"
@@ -4955,13 +4955,10 @@ magic-string@^0.30.21:
dependencies: dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5" "@jridgewell/sourcemap-codec" "^1.5.5"
make-dir@^2.1.0: make-dir@^5.1.0:
version "2.1.0" version "5.1.0"
resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-5.1.0.tgz#59b2d9acf7ffa543d14238617a697458fa8dd5c9"
integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== integrity sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==
dependencies:
pify "^4.0.1"
semver "^5.6.0"
maplibre-gl@^5.24.0: maplibre-gl@^5.24.0:
version "5.24.0" version "5.24.0"
@@ -5795,10 +5792,10 @@ node-releases@^2.0.27:
resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
nodemailer@^8.0.10: nodemailer@^8.0.11:
version "8.0.10" version "8.0.11"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.10.tgz#009d4deaa06f54b6bd7ddc6cac1bf78e3bcb0bf2" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.11.tgz#ce46b7c2c8bbf17b0408122fbfb4e47f4bffc688"
integrity sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ== integrity sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==
nodemon@^3.1.14: nodemon@^3.1.14:
version "3.1.14" version "3.1.14"
@@ -6078,11 +6075,6 @@ picomatch@^4.0.4:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
pify@^4.0.1:
version "4.0.1"
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pino-abstract-transport@^3.0.0: pino-abstract-transport@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23" resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23"
@@ -6180,10 +6172,10 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.8.3: prettier@3.8.4:
version "3.8.3" version "3.8.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411"
integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==
prismjs@^1.29.0: prismjs@^1.29.0:
version "1.30.0" version "1.30.0"
@@ -6525,17 +6517,17 @@ react-resizable@^3.0.5:
prop-types "15.x" prop-types "15.x"
react-draggable "^4.0.3" react-draggable "^4.0.3"
react-router-dom@7.16.0: react-router-dom@7.17.0:
version "7.16.0" version "7.17.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.16.0.tgz#284a7cd021052aa7d0a9240dca4a02eec24eceb5" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.17.0.tgz#e77527b4b7862f7b47ff26dd5b9315fb897b82a7"
integrity sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA== integrity sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==
dependencies: dependencies:
react-router "7.16.0" react-router "7.17.0"
react-router@7.16.0: react-router@7.17.0:
version "7.16.0" version "7.17.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.16.0.tgz#fb41536aef2ccc2c7be12ea6be819a1e56eb6343" resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.17.0.tgz#88bbe817c6e37ab36faf140623b5d4678bf81e41"
integrity sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A== integrity sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==
dependencies: dependencies:
cookie "^1.0.1" cookie "^1.0.1"
set-cookie-parser "^2.6.0" set-cookie-parser "^2.6.0"
@@ -6954,11 +6946,6 @@ secure-json-parse@^4.0.0:
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c" resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c"
integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA== integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==
semver@^5.6.0:
version "5.7.2"
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
semver@^6.3.1: semver@^6.3.1:
version "6.3.1" version "6.3.1"
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
@@ -6974,10 +6961,10 @@ semver@^7.6.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
semver@^7.8.1: semver@^7.8.4:
version "7.8.1" version "7.8.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.4.tgz#c73eceebae0616934be8dff28a7fd70757c8e696"
integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== integrity sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==
send@^1.1.0: send@^1.1.0:
version "1.2.1" version "1.2.1"