mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
7 Commits
14.2.0
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3015b8de7 | ||
|
|
a34b2c93a9 | ||
|
|
96bee7a8c5 | ||
|
|
9c29a3ebbe | ||
|
|
eabade9ba7 | ||
|
|
44242d4e6a | ||
|
|
3f5ef6e053 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,4 +5,3 @@ db/*.db*
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ RUN mkdir -p /db /conf \
|
|||||||
&& ln -s /conf /fredy/conf
|
&& ln -s /conf /fredy/conf
|
||||||
|
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
VOLUME /db
|
|
||||||
VOLUME /conf
|
|
||||||
|
|
||||||
# Start application using PM2 runtime
|
# Start application using PM2 runtime
|
||||||
CMD ["pm2-runtime", "index.js"]
|
CMD ["pm2-runtime", "index.js"]
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -9,18 +9,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|

|
||||||
<a href="https://fredy.orange-coding.net/" target="_blank">Website</a> |
|
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
||||||
<a href="https://demo-fredy.orange-coding.net/" target="_blank">Demo</a>
|

|
||||||
</p>
|

|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="https://github.com/orangecoding/fredy/actions/workflows/test.yml/badge.svg" alt="Tests" />
|
|
||||||
<img src="https://github.com/orangecoding/fredy/actions/workflows/docker.yml/badge.svg" alt="Docker" />
|
|
||||||
<img src="https://github.com/orangecoding/fredy/actions/workflows/check_source.yml/badge.svg" alt="Source" />
|
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fghcr-badge.elias.eu.org%2Fapi%2Forangecoding%2Ffredy%2Ffredy&query=%24.downloadCount&label=Docker%20Pulls" alt="Docker Pulls" />
|
|
||||||
</p>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||||
@@ -29,7 +21,7 @@ Finding an apartment or house in Germany can be stressful and
|
|||||||
time-consuming.\
|
time-consuming.\
|
||||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||||
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
||||||
instantly via **Slack, Telegram, Email, ntfy, discord and more** when new
|
instantly via **Slack, Telegram, Email, ntfy, and more** when new
|
||||||
listings appear.
|
listings appear.
|
||||||
|
|
||||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||||
@@ -43,7 +35,7 @@ same listing twice.
|
|||||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||||
WG-Gesucht**
|
WG-Gesucht**
|
||||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||||
Mailjet), ntfy, discord
|
Mailjet), ntfy
|
||||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||||
- 🖥️ Intuitive **Web UI** to manage searches
|
- 🖥️ Intuitive **Web UI** to manage searches
|
||||||
@@ -115,9 +107,9 @@ yarn run start:frontend # in another terminal
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
| Fredy Main Overview | Job Configuration | Found Listings |
|
| Job Configuration | Job Analytics | Job Overview |
|
||||||
|--------------------------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------------|
|
|-------------------|--------------|--------------|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -137,7 +129,7 @@ picks up the newest listings first.
|
|||||||
### Adapter 📡
|
### Adapter 📡
|
||||||
|
|
||||||
An **adapter** is the channel through which Fredy notifies you (Slack,
|
An **adapter** is the channel through which Fredy notifies you (Slack,
|
||||||
Telegram, Email, ntfy, discord ...).\
|
Telegram, Email, ntfy, ...).\
|
||||||
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
||||||
You can use multiple adapters at once --- Fredy will send new listings
|
You can use multiple adapters at once --- Fredy will send new listings
|
||||||
through all of them.
|
through all of them.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 512 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 372 KiB |
BIN
doc/screenshot_2.png
Normal file
BIN
doc/screenshot_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
BIN
doc/screenshot_3.png
Normal file
BIN
doc/screenshot_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -5,7 +5,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: ghcr.io/orangecoding/fredy
|
image: fredy/fredy
|
||||||
# map existing config and database
|
# map existing config and database
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf:/conf
|
- ./conf:/conf
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Stop and remove old container if it exists
|
|
||||||
if [ "$(docker ps -aq -f name=fredy)" ]; then
|
|
||||||
docker stop fredy || true
|
|
||||||
docker rm fredy || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build image from local Dockerfile, forcing a fresh build without cache
|
|
||||||
docker build --no-cache -t fredy:local .
|
|
||||||
|
|
||||||
# Run container with volumes and port mapping
|
|
||||||
docker run -d --name fredy \
|
|
||||||
-v fredy_conf:/conf \
|
|
||||||
-v fredy_db:/db \
|
|
||||||
-p 9998:9998 \
|
|
||||||
fredy:local
|
|
||||||
9
index.js
9
index.js
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js';
|
import { config, getProviders, refreshConfig } from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||||
import FredyRuntime from './lib/FredyRuntime.js';
|
import FredyRuntime from './lib/FredyRuntime.js';
|
||||||
@@ -16,13 +16,6 @@ import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.j
|
|||||||
// Load configuration before any other startup steps
|
// Load configuration before any other startup steps
|
||||||
await refreshConfig();
|
await refreshConfig();
|
||||||
|
|
||||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
|
||||||
|
|
||||||
if (!isConfigAccessible) {
|
|
||||||
logger.error('Configuration exists, but is not accessible. Please check the file permission');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||||
const rawDir = config.sqlitepath || '/db';
|
const rawDir = config.sqlitepath || '/db';
|
||||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
|
||||||
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
||||||
|
|
||||||
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
||||||
@@ -96,7 +95,6 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_save(newListings) {
|
_save(newListings) {
|
||||||
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
|
||||||
storeListings(this._jobKey, this._providerId, newListings);
|
storeListings(this._jobKey, this._providerId, newListings);
|
||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
@@ -105,9 +103,7 @@ class FredyRuntime {
|
|||||||
const filteredList = listings.filter((listing) => {
|
const filteredList = listings.filter((listing) => {
|
||||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
||||||
if (similar) {
|
if (similar) {
|
||||||
logger.debug(
|
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
|
||||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return !similar;
|
return !similar;
|
||||||
});
|
});
|
||||||
@@ -116,11 +112,7 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_handleError(err) {
|
_handleError(err) {
|
||||||
if (err.name === 'NoNewListingsWarning') {
|
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
||||||
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
|
||||||
} else {
|
|
||||||
logger.error(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,25 +24,9 @@ function doesJobBelongsToUser(job, req) {
|
|||||||
jobRouter.get('/', async (req, res) => {
|
jobRouter.get('/', async (req, res) => {
|
||||||
const isUserAdmin = isAdmin(req);
|
const isUserAdmin = isAdmin(req);
|
||||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
//show only the jobs which belongs to the user (or all of the user is an admin)
|
||||||
res.body = jobStorage
|
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
|
||||||
.getJobs()
|
|
||||||
.filter(
|
|
||||||
(job) =>
|
|
||||||
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
|
||||||
)
|
|
||||||
.map((job) => {
|
|
||||||
return {
|
|
||||||
...job,
|
|
||||||
isOnlyShared:
|
|
||||||
!isUserAdmin &&
|
|
||||||
job.userId !== req.session.currentUser &&
|
|
||||||
job.shared_with_user.includes(req.session.currentUser),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/processingTimes', async (req, res) => {
|
||||||
res.body = {
|
res.body = {
|
||||||
interval: config.interval,
|
interval: config.interval,
|
||||||
@@ -57,15 +41,8 @@ jobRouter.post('/startAll', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.post('/', async (req, res) => {
|
jobRouter.post('/', async (req, res) => {
|
||||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
|
||||||
try {
|
try {
|
||||||
let jobFromDb = jobStorage.getJob(jobId);
|
|
||||||
|
|
||||||
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
|
||||||
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -74,7 +51,6 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
blacklist,
|
blacklist,
|
||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
shareWithUsers,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
@@ -82,7 +58,6 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
try {
|
try {
|
||||||
@@ -117,16 +92,4 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.get('/shareableUserList', async (req, res) => {
|
|
||||||
const currentUser = req.session.currentUser;
|
|
||||||
const users = userStorage.getUsers(false);
|
|
||||||
res.body = users
|
|
||||||
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
|
||||||
.map((user) => ({
|
|
||||||
id: user.id,
|
|
||||||
name: user.username,
|
|
||||||
}));
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
export { jobRouter };
|
export { jobRouter };
|
||||||
|
|||||||
@@ -1,100 +1,23 @@
|
|||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
||||||
import * as watchListStorage from '../../services/storage/watchListStorage.js';
|
|
||||||
import { isAdmin as isAdminFn } from '../security.js';
|
import { isAdmin as isAdminFn } from '../security.js';
|
||||||
import logger from '../../services/logger.js';
|
|
||||||
import { nullOrEmpty } from '../../utils.js';
|
|
||||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|
||||||
const listingsRouter = service.newRouter();
|
const listingsRouter = service.newRouter();
|
||||||
|
|
||||||
listingsRouter.get('/table', async (req, res) => {
|
listingsRouter.get('/table', async (req, res) => {
|
||||||
const {
|
const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
|
||||||
page,
|
|
||||||
pageSize = 50,
|
|
||||||
activityFilter,
|
|
||||||
jobNameFilter,
|
|
||||||
providerFilter,
|
|
||||||
watchListFilter,
|
|
||||||
sortfield = null,
|
|
||||||
sortdir = 'asc',
|
|
||||||
freeTextFilter,
|
|
||||||
} = req.query || {};
|
|
||||||
|
|
||||||
// normalize booleans (accept true, 'true', 1, '1')
|
const result = listingStorage.queryListings({
|
||||||
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
|
||||||
const normalizedActivity = toBool(activityFilter) ? true : null;
|
|
||||||
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
|
||||||
|
|
||||||
let jobFilter = null;
|
|
||||||
let jobIdFilter = null;
|
|
||||||
const jobs = getJobs();
|
|
||||||
if (!nullOrEmpty(jobNameFilter)) {
|
|
||||||
const job = jobs.find((j) => j.id === jobNameFilter);
|
|
||||||
jobFilter = job != null ? job.name : null;
|
|
||||||
jobIdFilter = job != null ? job.id : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.body = listingStorage.queryListings({
|
|
||||||
page: page ? parseInt(page, 10) : 1,
|
page: page ? parseInt(page, 10) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
pageSize: pageSize ? parseInt(pageSize, 10) : 50,
|
||||||
freeTextFilter: freeTextFilter || null,
|
filter: filter || undefined,
|
||||||
activityFilter: normalizedActivity,
|
|
||||||
jobNameFilter: jobFilter,
|
|
||||||
jobIdFilter: jobIdFilter,
|
|
||||||
providerFilter,
|
|
||||||
watchListFilter: normalizedWatch,
|
|
||||||
sortField: sortfield || null,
|
sortField: sortfield || null,
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
isAdmin: isAdminFn(req),
|
isAdmin: isAdminFn(req),
|
||||||
});
|
});
|
||||||
|
res.body = result;
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle watch state for the current user on a listing
|
|
||||||
listingsRouter.post('/watch', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { listingId } = req.body || {};
|
|
||||||
const userId = req.session?.currentUser;
|
|
||||||
if (!listingId || !userId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.body = { message: 'listingId or user not provided' };
|
|
||||||
return res.send();
|
|
||||||
}
|
|
||||||
watchListStorage.toggleWatch(listingId, userId);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.body = { message: 'Failed to toggle watch' };
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
|
||||||
const { jobId } = req.body;
|
|
||||||
try {
|
|
||||||
listingStorage.deleteListingsByJobId(jobId);
|
|
||||||
} catch (error) {
|
|
||||||
res.send(new Error(error));
|
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
listingsRouter.delete('/', async (req, res) => {
|
|
||||||
const { ids } = req.body;
|
|
||||||
try {
|
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
|
||||||
listingStorage.deleteListingsById(ids);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.send(new Error(error));
|
|
||||||
logger.error(error);
|
|
||||||
}
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
export { listingsRouter };
|
export { listingsRouter };
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
|||||||
return req.session.currentUser === userIdToBeRemoved;
|
return req.session.currentUser === userIdToBeRemoved;
|
||||||
}
|
}
|
||||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||||
|
|
||||||
userRouter.get('/', async (req, res) => {
|
userRouter.get('/', async (req, res) => {
|
||||||
res.body = userStorage.getUsers(false);
|
res.body = userStorage.getUsers(false);
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
userRouter.get('/:userId', async (req, res) => {
|
userRouter.get('/:userId', async (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
res.body = userStorage.getUser(userId);
|
res.body = userStorage.getUser(userId);
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ const versionRouter = service.newRouter();
|
|||||||
|
|
||||||
versionRouter.get('/', async (req, res) => {
|
versionRouter.get('/', async (req, res) => {
|
||||||
const versionPayload = await getCurrentVersionFromGithub();
|
const versionPayload = await getCurrentVersionFromGithub();
|
||||||
const localFredyVersion = await getPackageVersion();
|
res.body = versionPayload == null ? { newVersion: false } : versionPayload;
|
||||||
res.body =
|
|
||||||
versionPayload == null
|
|
||||||
? {
|
|
||||||
newVersion: false,
|
|
||||||
localFredyVersion,
|
|
||||||
}
|
|
||||||
: versionPayload;
|
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
const promises = newListings.map((newListing) => {
|
const promises = newListings.map((newListing) => {
|
||||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nink: ${newListing.link}`;
|
||||||
return fetch(server, {
|
return fetch(server, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import fetch from 'node-fetch';
|
|
||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an idempotent decimal color code. The input string-based color code is
|
|
||||||
* generated using the djb2 hash algorithm.
|
|
||||||
*
|
|
||||||
* @param {string} str - Input string as color code base
|
|
||||||
* @returns {number} Generated decimal color code (0 - 16777215)
|
|
||||||
*/
|
|
||||||
const generateColorFromString = (str) => {
|
|
||||||
let hash = 5381; // initial value
|
|
||||||
const input = String(str);
|
|
||||||
|
|
||||||
for (let i = 0; i < input.length; i++) {
|
|
||||||
// hash * 33 + charCode
|
|
||||||
hash = (hash << 5) + hash + input.charCodeAt(i);
|
|
||||||
// Ensure the hash is 32 bit
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let positiveHash = hash >>> 0;
|
|
||||||
const maxColorValue = 16777215;
|
|
||||||
const colorDecimal = positiveHash % maxColorValue;
|
|
||||||
|
|
||||||
return colorDecimal;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates an embed per listing
|
|
||||||
* (-> see https://birdie0.github.io/discord-webhooks-guide/structure/embeds.html).
|
|
||||||
*
|
|
||||||
* @param {string} jobKey - Key of job (used to set embed color)
|
|
||||||
* @param {object} listing - Object holding listing details
|
|
||||||
* @returns {object} Discord webhook embed
|
|
||||||
*/
|
|
||||||
const buildEmbed = (jobKey, listing) => {
|
|
||||||
const maxTitleLength = 252; // Max embed title length is 256 characters
|
|
||||||
let title = String(listing.title ?? 'N/A');
|
|
||||||
if (title.length > maxTitleLength) {
|
|
||||||
title = title.substring(0, maxTitleLength) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = [
|
|
||||||
{
|
|
||||||
name: 'Price',
|
|
||||||
value: String(listing.price ?? 'n/a'),
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Size',
|
|
||||||
value: listing?.size?.replace(/2m/g, 'm²') ?? 'n/a',
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Address',
|
|
||||||
value: String(listing.address ?? 'n/a'),
|
|
||||||
inline: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const embed = {
|
|
||||||
title: title,
|
|
||||||
color: generateColorFromString(jobKey),
|
|
||||||
url: listing.link,
|
|
||||||
fields: fields,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (listing.image) {
|
|
||||||
embed.image = {
|
|
||||||
url: normalizeImageUrl(listing.image),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return embed;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
|
||||||
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
|
||||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
|
||||||
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
|
||||||
|
|
||||||
const job = getJob(jobKey);
|
|
||||||
const jobName = job?.name || jobKey;
|
|
||||||
|
|
||||||
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
|
|
||||||
|
|
||||||
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
|
||||||
const webhookPromises = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < embeds.length; i += maxEmbedsPerMessage) {
|
|
||||||
// Send multiple Discord messages with up to 10 embeds per message
|
|
||||||
const embedChunk = embeds.slice(i, i + maxEmbedsPerMessage);
|
|
||||||
|
|
||||||
const content = i === 0 ? `*${jobName}:* ${serviceName} found **${newListings.length}** new listings.` : '';
|
|
||||||
const body = JSON.stringify({
|
|
||||||
content: content,
|
|
||||||
embeds: embedChunk,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchPromise = fetch(webhookUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(`Error sending Discord webhook for chunk starting at ${i}:`, error);
|
|
||||||
return Promise.reject(new Error(`Webhook failed: ${error.message}`));
|
|
||||||
});
|
|
||||||
|
|
||||||
webhookPromises.push(fetchPromise);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.allSettled(webhookPromises);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
id: 'discord_webhook',
|
|
||||||
name: 'Discord Webhook',
|
|
||||||
readme: markdown2Html('lib/notification/adapter/discord_webhook.md'),
|
|
||||||
description: 'Fredy will send new listings to the Discord channel of your choice.',
|
|
||||||
fields: {
|
|
||||||
webhookUrl: {
|
|
||||||
type: 'text',
|
|
||||||
label: 'Webhook URL',
|
|
||||||
description: 'The URL of the Discord webhook to send messages to.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
### Discord Adapter
|
|
||||||
|
|
||||||
To use the [Discord](https://discord.com/) Adapter, you need to create a webhook on the Discord channel of your choice. You can follow the instructions of _Making A Webhook_ on [this support website](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
|
|
||||||
Once you have created a webhook, copy and paste the webhook URL.
|
|
||||||
@@ -13,10 +13,10 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: {
|
||||||
channel: channel,
|
channel: channel,
|
||||||
text: message,
|
text: message,
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -36,17 +36,7 @@ Link: ${newListing.link}`;
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: message,
|
body: message,
|
||||||
})
|
});
|
||||||
.then((res) => {
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.text();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
// Ensure we reject with an Error object and prevent unhandled rejections
|
|
||||||
throw error instanceof Error ? error : new Error(String(error));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
|
|||||||
@@ -3,14 +3,10 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import pThrottle from 'p-throttle';
|
import pThrottle from 'p-throttle';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
import logger from '../../services/logger.js';
|
|
||||||
|
|
||||||
const RATE_LIMIT_INTERVAL = 1000;
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
const chatThrottleMap = new Map();
|
const chatThrottleMap = new Map();
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes stale throttled call entries to keep memory bounded.
|
|
||||||
*/
|
|
||||||
function cleanupOldThrottles() {
|
function cleanupOldThrottles() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||||
@@ -21,15 +17,6 @@ function cleanupOldThrottles() {
|
|||||||
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a throttled wrapper for a chatId to limit Telegram API calls.
|
|
||||||
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
|
|
||||||
*
|
|
||||||
* @template {Function} T
|
|
||||||
* @param {string|number} chatId
|
|
||||||
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
|
|
||||||
* @returns {T}
|
|
||||||
*/
|
|
||||||
function getThrottled(chatId, call) {
|
function getThrottled(chatId, call) {
|
||||||
cleanupOldThrottles();
|
cleanupOldThrottles();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -43,38 +30,15 @@ function getThrottled(chatId, call) {
|
|||||||
return throttled;
|
return throttled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorten a string to a maximum length with an ellipsis suffix.
|
|
||||||
* @param {string} str
|
|
||||||
* @param {number} [len=90]
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function shorten(str, len = 90) {
|
function shorten(str, len = 90) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape basic HTML entities for Telegram HTML parse mode.
|
|
||||||
* @param {string} [s='']
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function escapeHtml(s = '') {
|
function escapeHtml(s = '') {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
|
|
||||||
* @param {string} jobName
|
|
||||||
* @param {string} serviceName
|
|
||||||
* @param {Object} o - Listing object
|
|
||||||
* @param {string} [o.title]
|
|
||||||
* @param {string} [o.address]
|
|
||||||
* @param {string|number} [o.price]
|
|
||||||
* @param {string|number} [o.size]
|
|
||||||
* @param {string} [o.link]
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function buildCaption(jobName, serviceName, o) {
|
function buildCaption(jobName, serviceName, o) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
@@ -83,13 +47,6 @@ function buildCaption(jobName, serviceName, o) {
|
|||||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a Telegram message text using HTML parse mode.
|
|
||||||
* @param {string} jobName
|
|
||||||
* @param {string} serviceName
|
|
||||||
* @param {Object} o - Listing object
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function buildText(jobName, serviceName, o) {
|
function buildText(jobName, serviceName, o) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
@@ -100,27 +57,8 @@ function buildText(jobName, serviceName, o) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
* Send new listings to Telegram.
|
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
|
||||||
* - Falls back to sendMessage when sendPhoto fails or image is missing.
|
|
||||||
*
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
|
|
||||||
* @param {Array<Object>} params.newListings - Array of new listing objects.
|
|
||||||
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
|
|
||||||
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
|
||||||
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
|
||||||
*/
|
|
||||||
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
|
||||||
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
|
||||||
if (!adapterCfg || !adapterCfg.fields) {
|
|
||||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
|
||||||
}
|
|
||||||
const { token, chatId } = adapterCfg.fields;
|
|
||||||
if (!token || !chatId) {
|
|
||||||
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
|
||||||
}
|
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
@@ -130,16 +68,9 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorBody = await res.text();
|
|
||||||
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
|
|
||||||
}
|
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
|
||||||
|
|
||||||
const promises = newListings.map(async (o) => {
|
const promises = newListings.map(async (o) => {
|
||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
@@ -150,32 +81,28 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!img) {
|
if (!img) {
|
||||||
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
return throttledCall('sendMessage', textPayload);
|
||||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await throttledCall('sendPhoto', {
|
try {
|
||||||
chat_id: chatId,
|
return await throttledCall('sendPhoto', {
|
||||||
photo: img,
|
chat_id: chatId,
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
photo: img,
|
||||||
parse_mode: 'HTML',
|
caption: buildCaption(jobName, serviceName, o),
|
||||||
}).catch(async (e) => {
|
parse_mode: 'HTML',
|
||||||
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
|
||||||
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
|
||||||
logger.error(`Error sending message to Telegram: ${e.message}`);
|
|
||||||
throw e;
|
|
||||||
});
|
});
|
||||||
});
|
} catch (e) {
|
||||||
|
// If we see a timeout due to sending an image, try sending it without
|
||||||
|
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
|
||||||
|
return throttledCall('sendMessage', textPayload);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Telegram notification adapter configuration schema.
|
|
||||||
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}}
|
|
||||||
*/
|
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
name: 'Telegram',
|
name: 'Telegram',
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const config = {
|
|||||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
||||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
||||||
link: 'button@data-base',
|
link: 'button@data-base',
|
||||||
description: 'div[data-testid="cardmfe-description-text-test-id"] | trim',
|
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -26,9 +26,8 @@ const config = {
|
|||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
size: 'div[data-testid="cardmfe-keyfacts-testid"] | removeNewline | trim',
|
||||||
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
title: 'div[data-testid="cardmfe-description-box-text-test-id"] > div:nth-of-type(2)',
|
||||||
link: 'a@href',
|
link: 'a@href',
|
||||||
description: 'div[data-testid="cardmfe-description-text-test-id"] > div:nth-of-type(2) | removeNewline | trim',
|
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
address: 'div[data-testid="cardmfe-description-box-address"] | removeNewline | trim',
|
||||||
image: 'div[data-testid="cardmfe-picture-box-opacity-layer-test-id"] img@src',
|
image: 'div[data-testid="cardMfe-card-pictureBox-opacity"] img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { isOneOf, buildHash } from '../utils.js';
|
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
|
||||||
let appliedBlackList = [];
|
|
||||||
|
|
||||||
function normalize(o) {
|
|
||||||
const originalId = o.id.split('/').pop();
|
|
||||||
const id = buildHash(originalId, o.price);
|
|
||||||
const size = o.size ?? 'N/A m²';
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const address = o.address?.replace(' / ', ' ') || null;
|
|
||||||
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : config.url;
|
|
||||||
return Object.assign(o, { id, size, title, link, address });
|
|
||||||
}
|
|
||||||
function applyBlacklist(o) {
|
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
|
||||||
}
|
|
||||||
const config = {
|
|
||||||
url: null,
|
|
||||||
crawlContainer: 'article[data-testid="propertyCard"]',
|
|
||||||
sortByDateParam: 'sortBy=DATE&sortOn=DESC',
|
|
||||||
waitForSelector: 'ul[data-testid="listsContainer"]',
|
|
||||||
crawlFields: {
|
|
||||||
id: 'h2 a@href',
|
|
||||||
title: 'h2 a | removeNewline | trim',
|
|
||||||
price: 'footer > p:first-of-type | trim',
|
|
||||||
size: 'footer > p:nth-of-type(2) | trim',
|
|
||||||
address: 'div > h2 + p | removeNewline | trim',
|
|
||||||
image: 'img@src',
|
|
||||||
link: 'h2 a@href',
|
|
||||||
},
|
|
||||||
normalize: normalize,
|
|
||||||
filter: applyBlacklist,
|
|
||||||
activeTester: checkIfListingIsActive,
|
|
||||||
};
|
|
||||||
export const init = (sourceConfig, blacklist) => {
|
|
||||||
config.enabled = sourceConfig.enabled;
|
|
||||||
config.url = sourceConfig.url;
|
|
||||||
appliedBlackList = blacklist || [];
|
|
||||||
};
|
|
||||||
export const metaInformation = {
|
|
||||||
name: 'McMakler',
|
|
||||||
baseUrl: 'https://www.mcmakler.de/immobilien/',
|
|
||||||
id: 'mcMakler',
|
|
||||||
};
|
|
||||||
export { config };
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { isOneOf, buildHash } from '../utils.js';
|
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
|
||||||
let appliedBlackList = [];
|
|
||||||
|
|
||||||
function normalize(o) {
|
|
||||||
const id = buildHash(o.id, o.price);
|
|
||||||
const address = o.address?.replace(/^adresse /i, '') ?? null;
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
|
||||||
|
|
||||||
var urlReg = new RegExp(/url\((.*?)\)/gim);
|
|
||||||
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
|
||||||
return Object.assign(o, { id, address, title, link, image });
|
|
||||||
}
|
|
||||||
function applyBlacklist(o) {
|
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
|
||||||
}
|
|
||||||
const config = {
|
|
||||||
url: null,
|
|
||||||
crawlContainer: '.listentry-content',
|
|
||||||
sortByDateParam: null, // sort by date is standard
|
|
||||||
waitForSelector: 'body',
|
|
||||||
crawlFields: {
|
|
||||||
id: '.listentry-iconbar-share@data-sid | trim',
|
|
||||||
title: 'h2 | trim',
|
|
||||||
price: '.listentry-details-price .listentry-details-v | trim',
|
|
||||||
size: '.listentry-details-size .listentry-details-v | trim',
|
|
||||||
address: '.listentry-adress | trim',
|
|
||||||
image: '.listentry-img@style',
|
|
||||||
link: '.shariff@data-url',
|
|
||||||
description: '.listentry-extras | trim',
|
|
||||||
},
|
|
||||||
normalize: normalize,
|
|
||||||
filter: applyBlacklist,
|
|
||||||
activeTester: checkIfListingIsActive,
|
|
||||||
};
|
|
||||||
export const init = (sourceConfig, blacklist) => {
|
|
||||||
config.enabled = sourceConfig.enabled;
|
|
||||||
config.url = sourceConfig.url;
|
|
||||||
appliedBlackList = blacklist || [];
|
|
||||||
};
|
|
||||||
export const metaInformation = {
|
|
||||||
name: 'Regionalimmobilien24',
|
|
||||||
baseUrl: 'https://www.regionalimmobilien24.de/',
|
|
||||||
id: 'regionalimmobilien24',
|
|
||||||
};
|
|
||||||
export { config };
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { isOneOf, buildHash } from '../utils.js';
|
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
|
||||||
let appliedBlackList = [];
|
|
||||||
|
|
||||||
function normalize(o) {
|
|
||||||
const originalId = o.id.split('/').pop().replace('.html', '');
|
|
||||||
const id = buildHash(originalId, o.price);
|
|
||||||
const size = o.size?.replace(' Wohnfläche', '') ?? null;
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const link = o.link != null ? `https://immobilien.sparkasse.de${o.link}` : config.url;
|
|
||||||
return Object.assign(o, { id, size, title, link });
|
|
||||||
}
|
|
||||||
function applyBlacklist(o) {
|
|
||||||
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
|
||||||
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
|
||||||
return titleNotBlacklisted && descNotBlacklisted;
|
|
||||||
}
|
|
||||||
const config = {
|
|
||||||
url: null,
|
|
||||||
crawlContainer: '.estate-list-item-row',
|
|
||||||
sortByDateParam: 'sortBy=date_desc',
|
|
||||||
waitForSelector: 'body',
|
|
||||||
crawlFields: {
|
|
||||||
id: 'div[data-testid="estate-link"] a@href',
|
|
||||||
title: 'h3 | trim',
|
|
||||||
price: '.estate-list-price | trim',
|
|
||||||
size: '.estate-mainfact:first-child span | trim',
|
|
||||||
address: 'h6 | trim',
|
|
||||||
image: '.estate-list-item-image-container img@src',
|
|
||||||
link: 'div[data-testid="estate-link"] a@href',
|
|
||||||
},
|
|
||||||
normalize: normalize,
|
|
||||||
filter: applyBlacklist,
|
|
||||||
activeTester: checkIfListingIsActive,
|
|
||||||
};
|
|
||||||
export const init = (sourceConfig, blacklist) => {
|
|
||||||
config.enabled = sourceConfig.enabled;
|
|
||||||
config.url = sourceConfig.url;
|
|
||||||
appliedBlackList = blacklist || [];
|
|
||||||
};
|
|
||||||
export const metaInformation = {
|
|
||||||
name: 'Sparkasse Immobilien',
|
|
||||||
baseUrl: 'https://immobilien.sparkasse.de/',
|
|
||||||
id: 'sparkasse',
|
|
||||||
};
|
|
||||||
export { config };
|
|
||||||
@@ -16,16 +16,7 @@ import { toJson, fromJson } from '../../utils.js';
|
|||||||
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const upsertJob = ({
|
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||||
jobId,
|
|
||||||
name,
|
|
||||||
blacklist = [],
|
|
||||||
enabled = true,
|
|
||||||
provider,
|
|
||||||
notificationAdapter,
|
|
||||||
userId,
|
|
||||||
shareWithUsers = [],
|
|
||||||
}) => {
|
|
||||||
const id = jobId || nanoid();
|
const id = jobId || nanoid();
|
||||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||||
const ownerId = existing ? existing.user_id : userId;
|
const ownerId = existing ? existing.user_id : userId;
|
||||||
@@ -36,23 +27,21 @@ export const upsertJob = ({
|
|||||||
name = @name,
|
name = @name,
|
||||||
blacklist = @blacklist,
|
blacklist = @blacklist,
|
||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter,
|
notification_adapter = @notification_adapter
|
||||||
shared_with_user = @shareWithUsers
|
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
enabled: enabled ? 1 : 0,
|
enabled: enabled ? 1 : 0,
|
||||||
name: name ?? null,
|
name: name ?? null,
|
||||||
blacklist: toJson(blacklist ?? []),
|
blacklist: toJson(blacklist ?? []),
|
||||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
|
||||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
user_id: ownerId,
|
user_id: ownerId,
|
||||||
@@ -60,7 +49,6 @@ export const upsertJob = ({
|
|||||||
name: name ?? null,
|
name: name ?? null,
|
||||||
blacklist: toJson(blacklist ?? []),
|
blacklist: toJson(blacklist ?? []),
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -141,7 +129,6 @@ export const getJobs = () => {
|
|||||||
j.name,
|
j.name,
|
||||||
j.blacklist,
|
j.blacklist,
|
||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -152,7 +139,6 @@ export const getJobs = () => {
|
|||||||
enabled: !!row.enabled,
|
enabled: !!row.enabled,
|
||||||
blacklist: fromJson(row.blacklist, []),
|
blacklist: fromJson(row.blacklist, []),
|
||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
|||||||
return SqliteConnection.query(
|
return SqliteConnection.query(
|
||||||
`SELECT hash
|
`SELECT hash
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id = @jobId
|
WHERE job_id = @jobId AND provider = @providerId`,
|
||||||
AND provider = @providerId`,
|
|
||||||
{ jobId, providerId },
|
{ jobId, providerId },
|
||||||
).map((r) => r.hash);
|
).map((r) => r.hash);
|
||||||
};
|
};
|
||||||
@@ -64,9 +63,7 @@ export const getActiveOrUnknownListings = () => {
|
|||||||
return SqliteConnection.query(
|
return SqliteConnection.query(
|
||||||
`SELECT *
|
`SELECT *
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active is null
|
WHERE is_active is null OR is_active = 1 ORDER BY provider`,
|
||||||
OR is_active = 1
|
|
||||||
ORDER BY provider`,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,11 +173,7 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {number} [params.pageSize=50]
|
* @param {number} [params.pageSize=50]
|
||||||
* @param {number} [params.page=1]
|
* @param {number} [params.page=1]
|
||||||
* @param {string} [params.freeTextFilter]
|
* @param {string} [params.filter]
|
||||||
* @param {object} [params.activityFilter]
|
|
||||||
* @param {object} [params.jobNameFilter]
|
|
||||||
* @param {object} [params.providerFilter]
|
|
||||||
* @param {object} [params.watchListFilter]
|
|
||||||
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
* @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
|
||||||
* @param {('asc'|'desc')} [params.sortDir='asc']
|
* @param {('asc'|'desc')} [params.sortDir='asc']
|
||||||
* @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).
|
||||||
@@ -190,12 +183,7 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
export const queryListings = ({
|
export const queryListings = ({
|
||||||
pageSize = 50,
|
pageSize = 50,
|
||||||
page = 1,
|
page = 1,
|
||||||
activityFilter,
|
filter,
|
||||||
jobNameFilter,
|
|
||||||
jobIdFilter,
|
|
||||||
providerFilter,
|
|
||||||
watchListFilter,
|
|
||||||
freeTextFilter,
|
|
||||||
sortField = null,
|
sortField = null,
|
||||||
sortDir = 'asc',
|
sortDir = 'asc',
|
||||||
userId = null,
|
userId = null,
|
||||||
@@ -209,39 +197,15 @@ export const queryListings = ({
|
|||||||
// build WHERE filter across common text columns
|
// build WHERE filter across common text columns
|
||||||
const whereParts = [];
|
const whereParts = [];
|
||||||
const params = { limit: safePageSize, offset };
|
const params = { limit: safePageSize, offset };
|
||||||
// always provide userId param for watched-flag evaluation (null -> no matches)
|
|
||||||
params.userId = userId || '__NO_USER__';
|
|
||||||
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
// user scoping (non-admin only): restrict to listings whose job belongs to user
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
|
params.userId = userId || '__NO_USER__';
|
||||||
whereParts.push(`(j.user_id = @userId)`);
|
whereParts.push(`(j.user_id = @userId)`);
|
||||||
}
|
}
|
||||||
if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
|
if (filter && String(filter).trim().length > 0) {
|
||||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
params.filter = `%${String(filter).trim()}%`;
|
||||||
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
||||||
}
|
}
|
||||||
// activityFilter: when true -> only active listings (is_active = 1)
|
|
||||||
if (activityFilter === true) {
|
|
||||||
whereParts.push('(is_active = 1)');
|
|
||||||
}
|
|
||||||
// Prefer filtering by job id when provided (unambiguous and robust)
|
|
||||||
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
|
||||||
params.jobId = String(jobIdFilter).trim();
|
|
||||||
whereParts.push('(l.job_id = @jobId)');
|
|
||||||
} else if (jobNameFilter && String(jobNameFilter).trim().length > 0) {
|
|
||||||
// Fallback to exact job name match
|
|
||||||
params.jobName = String(jobNameFilter).trim();
|
|
||||||
whereParts.push('(j.name = @jobName)');
|
|
||||||
}
|
|
||||||
// providerFilter: when provided as string (assumed provider name), filter listings where provider equals that name (exact match)
|
|
||||||
if (providerFilter && String(providerFilter).trim().length > 0) {
|
|
||||||
params.providerName = String(providerFilter).trim();
|
|
||||||
whereParts.push('(provider = @providerName)');
|
|
||||||
}
|
|
||||||
// watchListFilter: when true -> only watched listings
|
|
||||||
if (watchListFilter === true) {
|
|
||||||
whereParts.push('(wl.id IS NOT NULL)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
const whereSqlWithAlias = whereSql
|
const whereSqlWithAlias = whereSql
|
||||||
.replace(/\btitle\b/g, 'l.title')
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
@@ -249,13 +213,10 @@ export const queryListings = ({
|
|||||||
.replace(/\baddress\b/g, 'l.address')
|
.replace(/\baddress\b/g, 'l.address')
|
||||||
.replace(/\bprovider\b/g, 'l.provider')
|
.replace(/\bprovider\b/g, 'l.provider')
|
||||||
.replace(/\blink\b/g, 'l.link')
|
.replace(/\blink\b/g, 'l.link')
|
||||||
.replace(/\bis_active\b/g, 'l.is_active')
|
.replace(/\bj\.user_id\b/g, 'j.user_id');
|
||||||
.replace(/\bj\.user_id\b/g, 'j.user_id')
|
|
||||||
.replace(/\bj\.name\b/g, 'j.name')
|
|
||||||
.replace(/\bwl\.id\b/g, 'wl.id');
|
|
||||||
|
|
||||||
// whitelist sortable fields to avoid SQL injection
|
// whitelist sortable fields to avoid SQL injection
|
||||||
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active', 'isWatched']);
|
const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
|
||||||
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
|
||||||
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||||
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
|
const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
|
||||||
@@ -265,67 +226,28 @@ export const queryListings = ({
|
|||||||
.replace(/\bsize\b/g, 'l.size')
|
.replace(/\bsize\b/g, 'l.size')
|
||||||
.replace(/\bprovider\b/g, 'l.provider')
|
.replace(/\bprovider\b/g, 'l.provider')
|
||||||
.replace(/\btitle\b/g, 'l.title')
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
.replace(/\bjob_name\b/g, 'j.name')
|
.replace(/\bjob_name\b/g, 'j.name');
|
||||||
// Sort by computed watch flag when requested
|
|
||||||
.replace(/\bisWatched\b/g, 'CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END');
|
|
||||||
|
|
||||||
// count total with same WHERE
|
// count total with same WHERE
|
||||||
const countRow = SqliteConnection.query(
|
const countRow = SqliteConnection.query(
|
||||||
`SELECT COUNT(1) as cnt
|
`SELECT COUNT(1) as cnt
|
||||||
FROM listings l
|
FROM listings l
|
||||||
LEFT JOIN jobs j ON j.id = l.job_id
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
${whereSqlWithAlias}`,
|
||||||
${whereSqlWithAlias}`,
|
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
const totalNumber = countRow?.[0]?.cnt ?? 0;
|
||||||
|
|
||||||
// fetch page
|
// fetch page
|
||||||
const rows = SqliteConnection.query(
|
const rows = SqliteConnection.query(
|
||||||
`SELECT l.*,
|
`SELECT l.*, j.name AS job_name
|
||||||
j.name AS job_name,
|
|
||||||
CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
|
||||||
FROM listings l
|
FROM listings l
|
||||||
LEFT JOIN jobs j ON j.id = l.job_id
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
${whereSqlWithAlias}
|
||||||
${whereSqlWithAlias}
|
${orderSqlWithAlias}
|
||||||
${orderSqlWithAlias}
|
|
||||||
LIMIT @limit OFFSET @offset`,
|
LIMIT @limit OFFSET @offset`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { totalNumber, page: safePage, result: rows };
|
return { totalNumber, page: safePage, result: rows };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all listings for a given job id.
|
|
||||||
*
|
|
||||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
|
||||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
|
||||||
*/
|
|
||||||
export const deleteListingsByJobId = (jobId) => {
|
|
||||||
if (!jobId) return;
|
|
||||||
return SqliteConnection.execute(
|
|
||||||
`DELETE
|
|
||||||
FROM listings
|
|
||||||
WHERE job_id = @jobId`,
|
|
||||||
{ jobId },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete listings by a list of listing IDs.
|
|
||||||
*
|
|
||||||
* @param {string[]} ids - Array of listing IDs to delete.
|
|
||||||
* @returns {any} The result from SqliteConnection.execute.
|
|
||||||
*/
|
|
||||||
export const deleteListingsById = (ids) => {
|
|
||||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
|
||||||
return SqliteConnection.execute(
|
|
||||||
`DELETE
|
|
||||||
FROM listings
|
|
||||||
WHERE id IN (${placeholders})`,
|
|
||||||
ids,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
// Migration: Adding a changeset field to the listings table in preparation for
|
|
||||||
// a price watch feature
|
|
||||||
|
|
||||||
export function up(db) {
|
|
||||||
db.exec(`
|
|
||||||
ALTER TABLE listings ADD COLUMN change_set jsonb;
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
|
||||||
|
|
||||||
export function up(db) {
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS watch_list
|
|
||||||
(
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
listing_id TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (listing_id) REFERENCES listings (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_watch_list ON watch_list (listing_id, user_id);
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
|
||||||
|
|
||||||
export function up(db) {
|
|
||||||
db.exec(`
|
|
||||||
ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import SqliteConnection from './SqliteConnection.js';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a watch entry. Idempotent due to unique index (listing_id, user_id).
|
|
||||||
* @param {string} listingId
|
|
||||||
* @param {string} userId
|
|
||||||
* @returns {{created:boolean}}
|
|
||||||
*/
|
|
||||||
export const createWatch = (listingId, userId) => {
|
|
||||||
if (!listingId || !userId) return { created: false };
|
|
||||||
try {
|
|
||||||
SqliteConnection.execute(
|
|
||||||
`INSERT INTO watch_list (id, listing_id, user_id)
|
|
||||||
VALUES (@id, @listing_id, @user_id)
|
|
||||||
ON CONFLICT(listing_id, user_id) DO NOTHING`,
|
|
||||||
{ id: nanoid(), listing_id: listingId, user_id: userId },
|
|
||||||
);
|
|
||||||
// check whether it exists now
|
|
||||||
const row = SqliteConnection.query(
|
|
||||||
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
|
||||||
{ listing_id: listingId, user_id: userId },
|
|
||||||
);
|
|
||||||
return { created: row.length > 0 };
|
|
||||||
} catch {
|
|
||||||
return { created: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a watch entry.
|
|
||||||
* @param {string} listingId
|
|
||||||
* @param {string} userId
|
|
||||||
* @returns {{deleted:boolean}}
|
|
||||||
*/
|
|
||||||
export const deleteWatch = (listingId, userId) => {
|
|
||||||
if (!listingId || !userId) return { deleted: false };
|
|
||||||
const res = SqliteConnection.execute(`DELETE FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id`, {
|
|
||||||
listing_id: listingId,
|
|
||||||
user_id: userId,
|
|
||||||
});
|
|
||||||
return { deleted: Boolean(res?.changes) };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle a watch entry. If exists -> delete, otherwise create.
|
|
||||||
* @param {string} listingId
|
|
||||||
* @param {string} userId
|
|
||||||
* @returns {{watched:boolean}}
|
|
||||||
*/
|
|
||||||
export const toggleWatch = (listingId, userId) => {
|
|
||||||
if (!listingId || !userId) return { watched: false };
|
|
||||||
const exists =
|
|
||||||
SqliteConnection.query(
|
|
||||||
`SELECT 1 AS ok FROM watch_list WHERE listing_id = @listing_id AND user_id = @user_id LIMIT 1`,
|
|
||||||
{ listing_id: listingId, user_id: userId },
|
|
||||||
).length > 0;
|
|
||||||
if (exists) {
|
|
||||||
deleteWatch(listingId, userId);
|
|
||||||
return { watched: false };
|
|
||||||
}
|
|
||||||
createWatch(listingId, userId);
|
|
||||||
return { watched: true };
|
|
||||||
};
|
|
||||||
17
lib/utils.js
17
lib/utils.js
@@ -180,23 +180,6 @@ function buildHash(...inputs) {
|
|||||||
*/
|
*/
|
||||||
let config = {};
|
let config = {};
|
||||||
|
|
||||||
/**
|
|
||||||
* If the config exists, but cannot be accessed, we quit Fredy as something is fishy here.
|
|
||||||
* @returns {Promise<boolean>}
|
|
||||||
*/
|
|
||||||
export async function checkIfConfigIsAccessible() {
|
|
||||||
const path = new URL('../conf/config.json', import.meta.url);
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(path)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
fs.accessSync(path, fs.constants.R_OK);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read config JSON from disk (conf/config.json) and parse it.
|
* Read config JSON from disk (conf/config.json) and parse it.
|
||||||
* @returns {Promise<any>} Parsed configuration object.
|
* @returns {Promise<any>} Parsed configuration object.
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "14.2.0",
|
"version": "12.2.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"@visactor/react-vchart": "^2.0.5",
|
"@visactor/react-vchart": "^2.0.5",
|
||||||
"@visactor/vchart": "^2.0.5",
|
"@visactor/vchart": "^2.0.5",
|
||||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||||
"@vitejs/plugin-react": "5.0.4",
|
"@vitejs/plugin-react": "5.0.3",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"body-parser": "2.2.0",
|
"body-parser": "2.2.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
@@ -76,19 +76,19 @@
|
|||||||
"node-mailjet": "6.0.9",
|
"node-mailjet": "6.0.9",
|
||||||
"p-throttle": "^8.0.0",
|
"p-throttle": "^8.0.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.23.0",
|
"puppeteer": "^24.22.3",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.9.3",
|
"react-router": "7.9.2",
|
||||||
"react-router-dom": "7.9.3",
|
"react-router-dom": "7.9.2",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.2",
|
||||||
"serve-static": "2.2.0",
|
"serve-static": "2.2.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.1.9",
|
"vite": "7.1.7",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@@ -97,16 +97,16 @@
|
|||||||
"@babel/eslint-parser": "7.28.4",
|
"@babel/eslint-parser": "7.28.4",
|
||||||
"@babel/preset-env": "7.28.3",
|
"@babel/preset-env": "7.28.3",
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.27.1",
|
||||||
"chai": "6.2.0",
|
"chai": "6.0.1",
|
||||||
"eslint": "9.37.0",
|
"eslint": "9.36.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",
|
||||||
"esmock": "2.7.3",
|
"esmock": "2.7.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.4.2",
|
"less": "4.4.1",
|
||||||
"lint-staged": "16.2.3",
|
"lint-staged": "16.2.1",
|
||||||
"mocha": "11.7.4",
|
"mocha": "11.7.2",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"prettier": "3.6.2"
|
"prettier": "3.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,30 +8,31 @@ describe('#immonet testsuite()', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
|
provider.init(providerConfig.immonet, [], []);
|
||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immonet, [], []);
|
return await new Promise((resolve) => {
|
||||||
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||||
|
fredy.execute().then((listing) => {
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
|
const notificationObj = get();
|
||||||
|
expect(notificationObj).to.be.a('object');
|
||||||
|
expect(notificationObj.serviceName).to.equal('immonet');
|
||||||
|
notificationObj.payload.forEach((notify) => {
|
||||||
|
/** check the actual structure **/
|
||||||
|
expect(notify.id).to.be.a('string');
|
||||||
|
expect(notify.price).to.be.a('string');
|
||||||
|
expect(notify.size).to.be.a('string');
|
||||||
|
expect(notify.title).to.be.a('string');
|
||||||
|
expect(notify.link).to.be.a('string');
|
||||||
|
expect(notify.address).to.be.a('string');
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
expect(notify.size).that.does.include('m²');
|
||||||
const listing = await fredy.execute();
|
expect(notify.title).to.be.not.empty;
|
||||||
|
expect(notify.address).to.be.not.empty;
|
||||||
expect(listing).to.be.a('array');
|
});
|
||||||
const notificationObj = get();
|
resolve();
|
||||||
expect(notificationObj).to.be.a('object');
|
});
|
||||||
expect(notificationObj.serviceName).to.equal('immonet');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.title).to.be.a('string');
|
|
||||||
expect(notify.link).to.be.a('string');
|
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.size).that.does.include('m²');
|
|
||||||
expect(notify.title).to.be.not.empty;
|
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,32 +8,33 @@ describe('#immowelt testsuite()', () => {
|
|||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should test immowelt provider', async () => {
|
it('should test immowelt provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||||
const listing = await fredy.execute();
|
fredy.execute().then((listing) => {
|
||||||
|
expect(listing).to.be.a('array');
|
||||||
expect(listing).to.be.a('array');
|
const notificationObj = get();
|
||||||
const notificationObj = get();
|
expect(notificationObj).to.be.a('object');
|
||||||
expect(notificationObj).to.be.a('object');
|
expect(notificationObj.serviceName).to.equal('immowelt');
|
||||||
expect(notificationObj.serviceName).to.equal('immowelt');
|
notificationObj.payload.forEach((notify) => {
|
||||||
notificationObj.payload.forEach((notify) => {
|
/** check the actual structure **/
|
||||||
/** check the actual structure **/
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.id).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
/** check the values if possible **/
|
||||||
/** check the values if possible **/
|
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
||||||
if (notify.size != null && notify.size.trim().toLowerCase() !== 'k.a.') {
|
expect(notify.size).that.does.include('m²');
|
||||||
expect(notify.size).that.does.include('m²');
|
}
|
||||||
}
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.link).that.does.include('https://www.immowelt.de');
|
||||||
expect(notify.link).that.does.include('https://www.immowelt.de');
|
expect(notify.address).to.be.not.empty;
|
||||||
expect(notify.address).to.be.not.empty;
|
});
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
|
||||||
import { get } from '../mocks/mockNotification.js';
|
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
|
||||||
import { expect } from 'chai';
|
|
||||||
import * as provider from '../../lib/provider/mcMakler.js';
|
|
||||||
|
|
||||||
describe('#mcMakler testsuite()', () => {
|
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test mcMakler provider', async () => {
|
|
||||||
const Fredy = await mockFredy();
|
|
||||||
provider.init(providerConfig.mcMakler, []);
|
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
|
||||||
const listing = await fredy.execute();
|
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).to.be.a('object');
|
|
||||||
expect(notificationObj.serviceName).to.equal('mcMakler');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.title).to.be.a('string');
|
|
||||||
expect(notify.link).to.be.a('string');
|
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.size).that.does.include('m²');
|
|
||||||
expect(notify.title).to.be.not.empty;
|
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
|
||||||
import { get } from '../mocks/mockNotification.js';
|
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
|
||||||
import { expect } from 'chai';
|
|
||||||
import * as provider from '../../lib/provider/regionalimmobilien24.js';
|
|
||||||
|
|
||||||
describe('#regionalimmobilien24 testsuite()', () => {
|
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test regionalimmobilien24 provider', async () => {
|
|
||||||
const Fredy = await mockFredy();
|
|
||||||
provider.init(providerConfig.regionalimmobilien24, []);
|
|
||||||
|
|
||||||
const fredy = new Fredy(
|
|
||||||
provider.config,
|
|
||||||
null,
|
|
||||||
provider.metaInformation.id,
|
|
||||||
'regionalimmobilien24',
|
|
||||||
similarityCache,
|
|
||||||
);
|
|
||||||
const listing = await fredy.execute();
|
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).to.be.a('object');
|
|
||||||
expect(notificationObj.serviceName).to.equal('regionalimmobilien24');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.title).to.be.a('string');
|
|
||||||
expect(notify.link).to.be.a('string');
|
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.size).that.does.include('m²');
|
|
||||||
expect(notify.title).to.be.not.empty;
|
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
|
||||||
import { get } from '../mocks/mockNotification.js';
|
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
|
||||||
import { expect } from 'chai';
|
|
||||||
import * as provider from '../../lib/provider/sparkasse.js';
|
|
||||||
|
|
||||||
describe('#sparkasse testsuite()', () => {
|
|
||||||
after(() => {
|
|
||||||
similarityCache.stopCacheCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should test sparkasse provider', async () => {
|
|
||||||
const Fredy = await mockFredy();
|
|
||||||
provider.init(providerConfig.sparkasse, []);
|
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
|
||||||
const listing = await fredy.execute();
|
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
|
||||||
const notificationObj = get();
|
|
||||||
expect(notificationObj).to.be.a('object');
|
|
||||||
expect(notificationObj.serviceName).to.equal('sparkasse');
|
|
||||||
notificationObj.payload.forEach((notify) => {
|
|
||||||
/** check the actual structure **/
|
|
||||||
expect(notify.id).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.title).to.be.a('string');
|
|
||||||
expect(notify.link).to.be.a('string');
|
|
||||||
expect(notify.address).to.be.a('string');
|
|
||||||
/** check the values if possible **/
|
|
||||||
expect(notify.size).that.does.include('m²');
|
|
||||||
expect(notify.title).to.be.not.empty;
|
|
||||||
expect(notify.address).to.be.not.empty;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -28,22 +28,10 @@
|
|||||||
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"mcMakler": {
|
|
||||||
"url": "https://www.mcmakler.de/immobilien/results?placeId=62649&search=Leipzig%252C+Sachsen&propertyTypes=APARTMENT&page=0",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"neubauKompass": {
|
"neubauKompass": {
|
||||||
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
"url": "https://www.neubaukompass.de/neubau-immobilien/duesseldorf-region/eigentumswohnung/",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"regionalimmobilien24": {
|
|
||||||
"url": "https://www.regionalimmobilien24.de/rostock/rostock/kaufen/haus/-/-/-/?rd=5",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"sparkasse": {
|
|
||||||
"url": "https://immobilien.sparkasse.de/immobilien/treffer?marketingType=buy&objectType=flat&perimeter=10&usageType=residential&zipCityEstateId=62782__Hamburg",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"wgGesucht": {
|
"wgGesucht": {
|
||||||
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
"url": "https://www.wg-gesucht.de/wg-zimmer-in-Duesseldorf.30.0.1.0.html",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
147
ui/src/App.jsx
147
ui/src/App.jsx
@@ -8,19 +8,18 @@ import UserMutator from './views/user/mutation/UserMutator';
|
|||||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Logout from './components/logout/Logout';
|
||||||
|
import Logo from './components/logo/Logo';
|
||||||
|
import Menu from './components/menu/Menu';
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
import Users from './views/user/Users';
|
import Users from './views/user/Users';
|
||||||
import Jobs from './views/jobs/Jobs';
|
import Jobs from './views/jobs/Jobs';
|
||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
import { Banner, Divider } from '@douyinfe/semi-ui';
|
import { Banner } from '@douyinfe/semi-ui';
|
||||||
import VersionBanner from './components/version/VersionBanner.jsx';
|
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||||
import Listings from './views/listings/Listings.jsx';
|
import Listings from './views/listings/Listings.jsx';
|
||||||
import Navigation from './components/navigation/Navigation.jsx';
|
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
|
||||||
import FredyFooter from './components/footer/FredyFooter.jsx';
|
|
||||||
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
|
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
@@ -28,7 +27,6 @@ export default function FredyApp() {
|
|||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -37,7 +35,6 @@ export default function FredyApp() {
|
|||||||
await actions.provider.getProvider();
|
await actions.provider.getProvider();
|
||||||
await actions.jobs.getJobs();
|
await actions.jobs.getJobs();
|
||||||
await actions.jobs.getProcessingTimes();
|
await actions.jobs.getProcessingTimes();
|
||||||
await actions.jobs.getSharableUserList();
|
|
||||||
await actions.notificationAdapter.getAdapter();
|
await actions.notificationAdapter.getAdapter();
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
await actions.versionUpdate.getVersionUpdate();
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
@@ -53,7 +50,6 @@ export default function FredyApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
const { Footer, Sider, Content } = Layout;
|
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
return loading ? null : needsLogin() ? (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -61,80 +57,71 @@ export default function FredyApp() {
|
|||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
) : (
|
) : (
|
||||||
<Layout className="app">
|
<div className="app">
|
||||||
<Layout className="app">
|
<div className="app__container">
|
||||||
<Sider>
|
<Logout />
|
||||||
<Navigation isAdmin={isAdmin()} />
|
<Logo width={190} white />
|
||||||
</Sider>
|
<Menu isAdmin={isAdmin()} />
|
||||||
<Content>
|
{versionUpdate?.newVersion && <VersionBanner />}
|
||||||
{versionUpdate?.newVersion && <VersionBanner />}
|
{settings.demoMode && (
|
||||||
{settings.demoMode && (
|
<>
|
||||||
<>
|
<Banner
|
||||||
<Banner
|
fullMode={true}
|
||||||
fullMode={true}
|
type="info"
|
||||||
type="info"
|
bordered
|
||||||
bordered
|
closeIcon={null}
|
||||||
closeIcon={null}
|
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
||||||
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
|
/>
|
||||||
/>
|
<br />
|
||||||
<br />
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
<Routes>
|
||||||
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
<Route path="/403" element={<InsufficientPermission />} />
|
||||||
<Divider />
|
<Route path="/jobs/new" element={<JobMutation />} />
|
||||||
<div className="app__content">
|
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||||
<Routes>
|
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
||||||
<Route path="/403" element={<InsufficientPermission />} />
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
<Route path="/jobs/new" element={<JobMutation />} />
|
<Route path="/listings" element={<Listings />} />
|
||||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
|
||||||
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
|
||||||
<Route path="/listings" element={<Listings />} />
|
|
||||||
|
|
||||||
{/* Permission-aware routes */}
|
{/* Permission-aware routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/users/new"
|
path="/users/new"
|
||||||
element={
|
element={
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
<UserMutator />
|
<UserMutator />
|
||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/users/edit/:userId"
|
path="/users/edit/:userId"
|
||||||
element={
|
element={
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
<UserMutator />
|
<UserMutator />
|
||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/users"
|
path="/users"
|
||||||
element={
|
element={
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
<Users />
|
<Users />
|
||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/generalSettings"
|
path="/generalSettings"
|
||||||
element={
|
element={
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
<GeneralSettings />
|
<GeneralSettings />
|
||||||
</PermissionAwareRoute>
|
</PermissionAwareRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</div>
|
||||||
</Layout>
|
|
||||||
<Footer>
|
|
||||||
<FredyFooter />
|
|
||||||
</Footer>
|
|
||||||
</Layout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
.app {
|
.app {
|
||||||
height: 100%;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&__content {
|
&__container {
|
||||||
margin: 1rem;
|
padding: 1rem 1rem;
|
||||||
|
color: var(--semi-color-text-0);
|
||||||
|
background-color: #232429;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import './FredyFooter.less';
|
|
||||||
import { useSelector } from '../../services/state/store.js';
|
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
export default function FredyFooter() {
|
|
||||||
const { Text } = Typography;
|
|
||||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
|
||||||
return (
|
|
||||||
<div className="fredyFooter">
|
|
||||||
<div className="fredyFooter__version">
|
|
||||||
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
|
|
||||||
</div>
|
|
||||||
<div className="fredyFooter__copyRight">
|
|
||||||
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with ❤️</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
.fredyFooter {
|
|
||||||
background:rgb(53, 54, 60);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
height: 1.7rem;
|
|
||||||
|
|
||||||
&__version {
|
|
||||||
padding-left: .5rem;
|
|
||||||
font-size: small;
|
|
||||||
|
|
||||||
}
|
|
||||||
&__copyRight {
|
|
||||||
padding-right: 1rem;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,22 +2,19 @@ import React from 'react';
|
|||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { IconUser } from '@douyinfe/semi-icons';
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
|
const Logout = function Logout() {
|
||||||
const Logout = function Logout({ text }) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Button
|
||||||
<Button
|
icon={<IconUser />}
|
||||||
icon={<IconUser />}
|
type="danger"
|
||||||
type="danger"
|
theme="solid"
|
||||||
theme="solid"
|
onClick={async () => {
|
||||||
onClick={async () => {
|
await xhrPost('/api/login/logout');
|
||||||
await xhrPost('/api/login/logout');
|
location.reload();
|
||||||
location.reload();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Logout
|
||||||
{text && 'Logout'}
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
65
ui/src/components/menu/Menu.jsx
Normal file
65
ui/src/components/menu/Menu.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons';
|
||||||
|
import './Menu.less';
|
||||||
|
|
||||||
|
function parsePathName(name) {
|
||||||
|
const split = name.split('/').filter((s) => s.length !== 0);
|
||||||
|
return '/' + split[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TopMenu = function TopMenu({ isAdmin }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
|
||||||
|
<TabPane
|
||||||
|
itemKey="/jobs"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<IconTerminal />
|
||||||
|
Jobs
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TabPane
|
||||||
|
itemKey="/listings"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<IconArchive />
|
||||||
|
Found listings
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<TabPane
|
||||||
|
itemKey="/users"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<IconUser />
|
||||||
|
User
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<TabPane
|
||||||
|
itemKey="/generalSettings"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<IconSetting />
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TopMenu;
|
||||||
3
ui/src/components/menu/Menu.less
Normal file
3
ui/src/components/menu/Menu.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.menu {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.navigate {
|
|
||||||
&__logout_Button {
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Nav } from '@douyinfe/semi-ui';
|
|
||||||
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
|
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
|
||||||
import Logout from '../logout/Logout.jsx';
|
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import './Navigate.less';
|
|
||||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
|
||||||
|
|
||||||
export default function Navigation({ isAdmin }) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const width = useScreenWidth();
|
|
||||||
const collapsed = width <= 850;
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
|
||||||
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
|
|
||||||
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePathName(name) {
|
|
||||||
const split = name.split('/').filter((s) => s.length !== 0);
|
|
||||||
return '/' + split[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Nav
|
|
||||||
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
|
|
||||||
items={items}
|
|
||||||
isCollapsed={collapsed}
|
|
||||||
selectedKeys={[parsePathName(location.pathname)]}
|
|
||||||
onSelect={(key) => {
|
|
||||||
navigate(key.itemKey);
|
|
||||||
}}
|
|
||||||
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
|
|
||||||
footer={
|
|
||||||
<div className="navigate__logout_Button">
|
|
||||||
<Logout text={!collapsed} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="segmentParts"
|
|
||||||
title={
|
title={
|
||||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
.segmentParts {
|
.segmentParts {
|
||||||
border: 1px solid #323232 !important;
|
border: 1px solid #323232 !important;
|
||||||
border-radius: 5px !important;
|
border-radius: 5px !important;
|
||||||
color: rgba(var(--semi-grey-8), 1);
|
|
||||||
background: rgb(53, 54, 60);
|
|
||||||
margin: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||||
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './JobTable.less';
|
import './JobTable.less';
|
||||||
@@ -10,20 +10,11 @@ const empty = (
|
|||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description="No jobs available. Why don't you create one? ;)"
|
description={'No jobs available.'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
||||||
|
|
||||||
export default function JobTable({
|
|
||||||
jobs = {},
|
|
||||||
onJobRemoval,
|
|
||||||
onJobStatusChanged,
|
|
||||||
onJobEdit,
|
|
||||||
onJobInsight,
|
|
||||||
onListingRemoval,
|
|
||||||
} = {}) {
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -33,55 +24,29 @@ export default function JobTable({
|
|||||||
title: '',
|
title: '',
|
||||||
dataIndex: '',
|
dataIndex: '',
|
||||||
render: (job) => {
|
render: (job) => {
|
||||||
return (
|
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
||||||
<Switch
|
|
||||||
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
|
||||||
checked={job.enabled}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
render: (name, job) => {
|
|
||||||
if (job.isOnlyShared) {
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
content={getPopoverContent(
|
|
||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
|
||||||
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
|
|
||||||
<IconAlertTriangle />
|
|
||||||
</div>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Listings',
|
title: 'Findings',
|
||||||
dataIndex: 'numberOfFoundListings',
|
dataIndex: 'numberOfFoundListings',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value || 0;
|
return value || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Provider',
|
title: 'Providers',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Notification Adapter',
|
title: 'Notification adapters',
|
||||||
dataIndex: 'notificationAdapter',
|
dataIndex: 'notificationAdapter',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
@@ -93,38 +58,9 @@ export default function JobTable({
|
|||||||
render: (_, job) => {
|
render: (_, job) => {
|
||||||
return (
|
return (
|
||||||
<div className="interactions">
|
<div className="interactions">
|
||||||
<Popover content={getPopoverContent('Job Insights')}>
|
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
||||||
<Button
|
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
||||||
type="primary"
|
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||||
icon={<IconHistogram />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobInsight(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
|
||||||
<Button
|
|
||||||
type="secondary"
|
|
||||||
icon={<IconEdit />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobEdit(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
icon={<IconDescend2 />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onListingRemoval(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
|
||||||
<Button
|
|
||||||
type="danger"
|
|
||||||
icon={<IconDelete />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobRemoval(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jobPopoverContent {
|
|
||||||
padding: 1rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.interactions {
|
.interactions {
|
||||||
flex-direction: initial;
|
flex-direction: initial;
|
||||||
|
|||||||
@@ -1,87 +1,19 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Table, Popover, Input, Descriptions, Tag, Image, Empty, Button, Toast, Divider } from '@douyinfe/semi-ui';
|
import { Table, Popover, Input, Descriptions, Tag, Image } from '@douyinfe/semi-ui';
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
import { useActions, useSelector } from '../../services/state/store.js';
|
||||||
import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
|
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../services/time/timeService.js';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
import no_image from '../../assets/no_image.jpg';
|
||||||
|
|
||||||
import './ListingsTable.less';
|
import './ListingsTable.less';
|
||||||
import { format } from '../../../services/time/timeService.js';
|
import { format } from '../../services/time/timeService.js';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
|
||||||
import ListingsFilter from './ListingsFilter.jsx';
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '#',
|
title: '#',
|
||||||
width: 100,
|
|
||||||
dataIndex: 'isWatched',
|
|
||||||
sorter: true,
|
|
||||||
render: (id, row) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Popover
|
|
||||||
style={{
|
|
||||||
padding: '.4rem',
|
|
||||||
color: 'var(--semi-color-white)',
|
|
||||||
}}
|
|
||||||
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={
|
|
||||||
row.isWatched === 1 ? (
|
|
||||||
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
|
|
||||||
) : (
|
|
||||||
<IconStarStroked />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
theme="borderless"
|
|
||||||
size="small"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await xhrPost('/api/listings/watch', { listingId: row.id });
|
|
||||||
Toast.success(row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
|
||||||
row.reloadTable();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
Toast.error('Failed to operate Watchlist');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<Divider layout="vertical" margin="4px" />
|
|
||||||
<Popover
|
|
||||||
style={{
|
|
||||||
padding: '.4rem',
|
|
||||||
color: 'var(--semi-color-white)',
|
|
||||||
}}
|
|
||||||
content="Delete Listing"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<IconDelete />}
|
|
||||||
theme="borderless"
|
|
||||||
size="small"
|
|
||||||
type="danger"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/listings/', { ids: [row.id] });
|
|
||||||
Toast.success('Listing(s) successfully removed');
|
|
||||||
row.reloadTable();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'State',
|
|
||||||
dataIndex: 'is_active',
|
dataIndex: 'is_active',
|
||||||
width: 84,
|
width: 58,
|
||||||
sorter: true,
|
sorter: true,
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value ? (
|
return value ? (
|
||||||
@@ -91,7 +23,7 @@ const columns = [
|
|||||||
padding: '.4rem',
|
padding: '.4rem',
|
||||||
color: 'var(--semi-color-white)',
|
color: 'var(--semi-color-white)',
|
||||||
}}
|
}}
|
||||||
content="Listing is still active"
|
content="Listing still online"
|
||||||
>
|
>
|
||||||
<IconTick />
|
<IconTick />
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -103,7 +35,7 @@ const columns = [
|
|||||||
padding: '.4rem',
|
padding: '.4rem',
|
||||||
color: 'var(--semi-color-white)',
|
color: 'var(--semi-color-white)',
|
||||||
}}
|
}}
|
||||||
content="Listing is inactive"
|
content="Listing not online anymore"
|
||||||
>
|
>
|
||||||
<IconClose />
|
<IconClose />
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -114,16 +46,15 @@ const columns = [
|
|||||||
{
|
{
|
||||||
title: 'Job-Name',
|
title: 'Job-Name',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
ellipsis: true,
|
|
||||||
dataIndex: 'job_name',
|
dataIndex: 'job_name',
|
||||||
width: 150,
|
width: 170,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Listing date',
|
title: 'Listing date',
|
||||||
width: 130,
|
width: 130,
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
render: (text) => timeService.format(text, false),
|
render: (text) => timeService.format(text),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Provider',
|
title: 'Provider',
|
||||||
@@ -134,7 +65,7 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Price',
|
title: 'Price',
|
||||||
width: 110,
|
width: 100,
|
||||||
dataIndex: 'price',
|
dataIndex: 'price',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
render: (text) => text + ' €',
|
render: (text) => text + ' €',
|
||||||
@@ -149,7 +80,6 @@ const columns = [
|
|||||||
title: 'Title',
|
title: 'Title',
|
||||||
dataIndex: 'title',
|
dataIndex: 'title',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
ellipsis: true,
|
|
||||||
render: (text, row) => {
|
render: (text, row) => {
|
||||||
return (
|
return (
|
||||||
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
<a href={row.url} target="_blank" rel="noopener noreferrer">
|
||||||
@@ -160,31 +90,19 @@ const columns = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const empty = (
|
|
||||||
<Empty
|
|
||||||
image={<IllustrationNoResult />}
|
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
|
||||||
description="No listings available."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function ListingsTable() {
|
export default function ListingsTable() {
|
||||||
const tableData = useSelector((state) => state.listingsTable);
|
const tableData = useSelector((state) => state.listingsTable);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 15;
|
||||||
const [sortData, setSortData] = useState({});
|
const [sortData, setSortData] = useState({});
|
||||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
const [filter, setFilter] = useState(null);
|
||||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
|
||||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
|
||||||
const [providerFilter, setProviderFilter] = useState(null);
|
|
||||||
|
|
||||||
const handlePageChange = (_page) => {
|
const handlePageChange = (_page) => {
|
||||||
setPage(_page);
|
setPage(_page);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadTable = () => {
|
useEffect(() => {
|
||||||
let sortfield = null;
|
let sortfield = null;
|
||||||
let sortdir = null;
|
let sortdir = null;
|
||||||
|
|
||||||
@@ -192,21 +110,10 @@ export default function ListingsTable() {
|
|||||||
sortfield = sortData.field;
|
sortfield = sortData.field;
|
||||||
sortdir = sortData.direction;
|
sortdir = sortData.direction;
|
||||||
}
|
}
|
||||||
actions.listingsTable.getListingsTable({
|
actions.listingsTable.getListingsTable({ page, pageSize, sortfield, sortdir, filter });
|
||||||
page,
|
}, [page, sortData, filter]);
|
||||||
pageSize,
|
|
||||||
sortfield,
|
|
||||||
sortdir,
|
|
||||||
freeTextFilter,
|
|
||||||
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
|
||||||
loadTable();
|
|
||||||
}, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
|
||||||
|
|
||||||
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
|
||||||
|
|
||||||
const expandRowRender = (record) => {
|
const expandRowRender = (record) => {
|
||||||
return (
|
return (
|
||||||
@@ -242,12 +149,6 @@ export default function ListingsTable() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ListingsFilter
|
|
||||||
onActivityFilter={setActivityFilter}
|
|
||||||
onWatchListFilter={setWatchListFilter}
|
|
||||||
onJobNameFilter={setJobNameFilter}
|
|
||||||
onProviderFilter={setProviderFilter}
|
|
||||||
/>
|
|
||||||
<Input
|
<Input
|
||||||
prefix={<IconSearch />}
|
prefix={<IconSearch />}
|
||||||
showClear
|
showClear
|
||||||
@@ -257,17 +158,11 @@ export default function ListingsTable() {
|
|||||||
/>
|
/>
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
empty={empty}
|
|
||||||
hideExpandedColumn={false}
|
hideExpandedColumn={false}
|
||||||
sticky={{ top: 5 }}
|
sticky={{ top: 5 }}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
expandedRowRender={expandRowRender}
|
expandedRowRender={expandRowRender}
|
||||||
dataSource={(tableData?.result || []).map((row) => {
|
dataSource={tableData?.result || []}
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
reloadTable: loadTable,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
onChange={(changeSet) => {
|
onChange={(changeSet) => {
|
||||||
if (changeSet?.extra?.changeType === 'sorter') {
|
if (changeSet?.extra?.changeType === 'sorter') {
|
||||||
setSortData({
|
setSortData({
|
||||||
@@ -7,8 +7,4 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Card, Checkbox, Descriptions, Divider, Select } from '@douyinfe/semi-ui';
|
|
||||||
import React from 'react';
|
|
||||||
import { useSelector } from '../../../services/state/store.js';
|
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
import './ListingsFilter.less';
|
|
||||||
|
|
||||||
export default function ListingsFilter({ onWatchListFilter, onActivityFilter, onJobNameFilter, onProviderFilter }) {
|
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
|
||||||
const provider = useSelector((state) => state.provider);
|
|
||||||
const { Title } = Typography;
|
|
||||||
return (
|
|
||||||
<Card className="listingsFilter">
|
|
||||||
<Title heading={6}>Filter by:</Title>
|
|
||||||
<Divider />
|
|
||||||
<br />
|
|
||||||
<Descriptions row>
|
|
||||||
<Descriptions.Item itemKey="Watch List">
|
|
||||||
<Checkbox onChange={(e) => onWatchListFilter(e.target.checked)}>Only Watch List</Checkbox>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Activity status">
|
|
||||||
<Checkbox onChange={(e) => onActivityFilter(e.target.checked)}>Only Active Listings</Checkbox>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Job Name">
|
|
||||||
<Select showClear placeholder="Select Job to Filter" onChange={(val) => onJobNameFilter(val)}>
|
|
||||||
{jobs != null &&
|
|
||||||
jobs.length > 0 &&
|
|
||||||
jobs.map((job) => {
|
|
||||||
return (
|
|
||||||
<Select.Option value={job.id} key={job.id}>
|
|
||||||
{job.name}
|
|
||||||
</Select.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item itemKey="Provider">
|
|
||||||
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => onProviderFilter(val)}>
|
|
||||||
{provider != null &&
|
|
||||||
provider.length > 0 &&
|
|
||||||
provider.map((prov) => {
|
|
||||||
return (
|
|
||||||
<Select.Option value={prov.id} key={prov.id}>
|
|
||||||
{prov.name}
|
|
||||||
</Select.Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
.listingsFilter {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
background: rgb(53, 54, 60);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
|
import { Banner, Descriptions } from '@douyinfe/semi-ui';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { MarkdownRender } from '@douyinfe/semi-ui';
|
import { MarkdownRender } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
@@ -8,9 +8,12 @@ import './VersionBanner.less';
|
|||||||
export default function VersionBanner() {
|
export default function VersionBanner() {
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
return (
|
return (
|
||||||
<Collapse>
|
<Banner
|
||||||
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
|
className="versionBanner"
|
||||||
<div className="versionBanner__content">
|
type="success"
|
||||||
|
icon={null}
|
||||||
|
description={
|
||||||
|
<div style={{ overflow: 'auto' }}>
|
||||||
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
|
||||||
<Descriptions row size="small">
|
<Descriptions row size="small">
|
||||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||||
@@ -26,9 +29,9 @@ export default function VersionBanner() {
|
|||||||
<small>Release Notes</small>
|
<small>Release Notes</small>
|
||||||
</b>
|
</b>
|
||||||
</p>
|
</p>
|
||||||
<MarkdownRender raw={versionUpdate.body} />
|
<MarkdownRender raw={versionUpdate.body} style={{ height: '200px' }} />
|
||||||
</div>
|
</div>
|
||||||
</Collapse.Panel>
|
}
|
||||||
</Collapse>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
.versionBanner {
|
.versionBanner {
|
||||||
background: rgba(var(--semi-teal-1), 1);
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
&__content {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export function useScreenWidth() {
|
|
||||||
const [width, setWidth] = useState(window.innerWidth);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timeoutId;
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = setTimeout(() => setWidth(window.innerWidth), 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
@@ -67,14 +67,6 @@ export const useFredyState = create(
|
|||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getSharableUserList() {
|
|
||||||
try {
|
|
||||||
const response = await xhrGet('/api/jobs/shareableUserList');
|
|
||||||
set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async getProcessingTimes() {
|
async getProcessingTimes() {
|
||||||
try {
|
try {
|
||||||
const response = await xhrGet('/api/jobs/processingTimes');
|
const response = await xhrGet('/api/jobs/processingTimes');
|
||||||
@@ -140,22 +132,14 @@ export const useFredyState = create(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
listingsTable: {
|
listingsTable: {
|
||||||
async getListingsTable({
|
async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) {
|
||||||
page = 1,
|
|
||||||
pageSize = 20,
|
|
||||||
freeTextFilter = null,
|
|
||||||
sortfield = null,
|
|
||||||
sortdir = 'asc',
|
|
||||||
filter,
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
const qryString = queryString.stringify({
|
const qryString = queryString.stringify({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
freeTextFilter,
|
filter,
|
||||||
sortfield,
|
sortfield,
|
||||||
sortdir,
|
sortdir,
|
||||||
...filter,
|
|
||||||
});
|
});
|
||||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -180,7 +164,7 @@ export const useFredyState = create(
|
|||||||
demoMode: { demoMode: false },
|
demoMode: { demoMode: false },
|
||||||
versionUpdate: {},
|
versionUpdate: {},
|
||||||
provider: [],
|
provider: [],
|
||||||
jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] },
|
jobs: { jobs: [], insights: {}, processingTimes: {} },
|
||||||
user: { users: [], currentUser: null },
|
user: { users: [], currentUser: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
export function format(ts, showSeconds = true) {
|
export function format(ts) {
|
||||||
return new Intl.DateTimeFormat('default', {
|
return new Intl.DateTimeFormat('default', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
...(showSeconds ? { second: 'numeric' } : {}),
|
second: 'numeric',
|
||||||
}).format(ts);
|
}).format(ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);
|
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useActions, useSelector } from '../../services/state/store';
|
|||||||
|
|
||||||
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
|
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui';
|
import { InputNumber } from '@douyinfe/semi-ui';
|
||||||
|
import Headline from '../../components/headline/Headline';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||||
@@ -124,6 +125,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
<div>
|
<div>
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
<Headline text="General Settings" />
|
||||||
<div>
|
<div>
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Interval"
|
name="Interval"
|
||||||
@@ -184,7 +186,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Working hours"
|
name="Working hours"
|
||||||
helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||||
Icon={IconCalendar}
|
Icon={IconCalendar}
|
||||||
>
|
>
|
||||||
<div className="generalSettings__timePickerContainer">
|
<div className="generalSettings__timePickerContainer">
|
||||||
|
|||||||
@@ -4,29 +4,21 @@ import JobTable from '../../components/table/JobTable';
|
|||||||
import { useSelector, useActions } from '../../services/state/store';
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
import { xhrDelete, xhrPut } from '../../services/xhr';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ProcessingTimes from './ProcessingTimes';
|
||||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
import './Jobs.less';
|
import './Jobs.less';
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
|
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
const onJobRemoval = async (jobId) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
Toast.success('Job successfully removed');
|
Toast.success('Job successfully remove');
|
||||||
await actions.jobs.getJobs();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onListingRemoval = async (jobId) => {
|
|
||||||
try {
|
|
||||||
await xhrDelete('/api/listings/job', { jobId });
|
|
||||||
Toast.success('Listings successfully removed');
|
|
||||||
await actions.jobs.getJobs();
|
await actions.jobs.getJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error);
|
Toast.error(error);
|
||||||
@@ -46,6 +38,7 @@ export default function Jobs() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
|
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<IconPlusCircle />}
|
icon={<IconPlusCircle />}
|
||||||
@@ -59,7 +52,6 @@ export default function Jobs() {
|
|||||||
<JobTable
|
<JobTable
|
||||||
jobs={jobs || []}
|
jobs={jobs || []}
|
||||||
onJobRemoval={onJobRemoval}
|
onJobRemoval={onJobRemoval}
|
||||||
onListingRemoval={onListingRemoval}
|
|
||||||
onJobStatusChanged={onJobStatusChanged}
|
onJobStatusChanged={onJobStatusChanged}
|
||||||
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
||||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
.jobs {
|
.jobs {
|
||||||
&__newButton {
|
&__newButton {
|
||||||
margin-top: 1rem !important;
|
margin-top: 1rem !important;
|
||||||
float: left;
|
float: right;
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 1rem !important;
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,47 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
|
import { Button, Descriptions, Toast } from '@douyinfe/semi-ui';
|
||||||
import { IconPlayCircle } from '@douyinfe/semi-icons';
|
import { IconPlayCircle } from '@douyinfe/semi-icons';
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
|
||||||
import './ProsessingTimes.less';
|
|
||||||
|
|
||||||
function InfoCard({ title, value }) {
|
|
||||||
return (
|
|
||||||
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
|
|
||||||
{value}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProcessingTimes({ processingTimes = {} }) {
|
export default function ProcessingTimes({ processingTimes = {} }) {
|
||||||
if (Object.keys(processingTimes).length === 0) {
|
if (Object.keys(processingTimes).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Row>
|
<>
|
||||||
<Col span={6}>
|
<Descriptions
|
||||||
<InfoCard title="Processing Interval" value={`${processingTimes.interval} min`} />
|
row
|
||||||
</Col>
|
size="small"
|
||||||
{processingTimes.lastRun && (
|
style={{
|
||||||
<>
|
backgroundColor: '#35363c',
|
||||||
<Col span={6}>
|
borderRadius: '4px',
|
||||||
<InfoCard title="Last run" value={format(processingTimes.lastRun)} />
|
padding: '10px',
|
||||||
</Col>
|
}}
|
||||||
<Col span={6}>
|
>
|
||||||
<InfoCard title="Next run" value={format(processingTimes.lastRun + processingTimes.interval * 60000)} />
|
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
||||||
</Col>
|
{processingTimes.lastRun && (
|
||||||
</>
|
<>
|
||||||
)}
|
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
||||||
<Col span={6}>
|
<Descriptions.Item itemKey="Next run">
|
||||||
<InfoCard
|
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||||
title="Find Listings Now"
|
</Descriptions.Item>
|
||||||
value={
|
<Descriptions.Item itemKey="Find Listings now">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconPlayCircle />}
|
icon={<IconPlayCircle />}
|
||||||
aria-label="Start now"
|
aria-label="Start now"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await xhrPost('/api/jobs/startAll', null);
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
Toast.success('Successfully triggered Fredy search.');
|
Toast.success('Successfully triggered Fredy search.');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Search now
|
Search now
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</Descriptions.Item>
|
||||||
/>
|
</>
|
||||||
</Col>
|
)}
|
||||||
</Row>
|
</Descriptions>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
.processingTimes {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
@@ -8,14 +8,13 @@ import Headline from '../../../components/headline/Headline';
|
|||||||
import { useActions, useSelector } from '../../../services/state/store';
|
import { useActions, useSelector } from '../../../services/state/store';
|
||||||
import { xhrPost } from '../../../services/xhr';
|
import { xhrPost } from '../../../services/xhr';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
|
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
||||||
import './JobMutation.less';
|
import './JobMutation.less';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
import { IconBell, IconBriefcase, IconPaperclip, IconPlayCircle, IconPlusCircle, IconUser } from '@douyinfe/semi-icons';
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function JobMutator() {
|
export default function JobMutator() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||||
@@ -33,7 +32,6 @@ export default function JobMutator() {
|
|||||||
const [name, setName] = useState(defaultName);
|
const [name, setName] = useState(defaultName);
|
||||||
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
||||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||||
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
|
|
||||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
@@ -47,7 +45,6 @@ export default function JobMutator() {
|
|||||||
await xhrPost('/api/jobs', {
|
await xhrPost('/api/jobs', {
|
||||||
provider: providerData,
|
provider: providerData,
|
||||||
notificationAdapter: notificationAdapterData,
|
notificationAdapter: notificationAdapterData,
|
||||||
shareWithUsers,
|
|
||||||
name,
|
name,
|
||||||
blacklist,
|
blacklist,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -92,9 +89,9 @@ export default function JobMutator() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
|
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
|
||||||
<form>
|
<form>
|
||||||
<SegmentPart name="Name" Icon={IconPaperclip}>
|
<SegmentPart name="Name">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
@@ -108,7 +105,7 @@ export default function JobMutator() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Providers"
|
name="Providers"
|
||||||
Icon={IconBriefcase}
|
icon="briefcase"
|
||||||
helpText={`
|
helpText={`
|
||||||
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
|
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
|
||||||
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
|
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
|
||||||
@@ -133,7 +130,7 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconBell}
|
icon="bell"
|
||||||
name="Notification Adapters"
|
name="Notification Adapters"
|
||||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||||
>
|
>
|
||||||
@@ -160,7 +157,7 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconBell}
|
icon="bell"
|
||||||
name="Blacklist"
|
name="Blacklist"
|
||||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||||
>
|
>
|
||||||
@@ -172,32 +169,7 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconUser}
|
icon="play circle outline"
|
||||||
name="Sharing with user"
|
|
||||||
helpText="You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything."
|
|
||||||
>
|
|
||||||
{shareableUserList.length === 0 ? (
|
|
||||||
<div>No users found to share this Job to. Please create additional non-admin user.</div>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
filter
|
|
||||||
multiple
|
|
||||||
placeholder="Search user"
|
|
||||||
autoClearSearchValue={false}
|
|
||||||
defaultValue={shareWithUsers}
|
|
||||||
onChange={(value) => setShareWithUsers(value)}
|
|
||||||
>
|
|
||||||
{shareableUserList.map((user) => (
|
|
||||||
<Select.Option value={user.id} key={user.id}>
|
|
||||||
{user.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</SegmentPart>
|
|
||||||
<Divider margin="1rem" />
|
|
||||||
<SegmentPart
|
|
||||||
Icon={IconPlayCircle}
|
|
||||||
name="Job activation"
|
name="Job activation"
|
||||||
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
import ListingsTable from '../../components/table/ListingsTable.jsx';
|
||||||
|
|
||||||
export default function Listings() {
|
export default function Listings() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const Users = function Users() {
|
|||||||
icon={<IconPlus />}
|
icon={<IconPlus />}
|
||||||
onClick={() => navigate('/users/new')}
|
onClick={() => navigate('/users/new')}
|
||||||
>
|
>
|
||||||
New User
|
Create new User
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<UserTable
|
<UserTable
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
.users {
|
.users {
|
||||||
&__newButton {
|
&__newButton {
|
||||||
margin-top: 1rem !important;
|
margin-top: 1rem !important;
|
||||||
float: left;
|
float: right;
|
||||||
margin-bottom: 1rem !important;
|
margin-bottom: 1rem !important;
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
yarn.lock
179
yarn.lock
@@ -1176,17 +1176,15 @@
|
|||||||
debug "^4.3.1"
|
debug "^4.3.1"
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
|
|
||||||
"@eslint/config-helpers@^0.4.0":
|
"@eslint/config-helpers@^0.3.1":
|
||||||
version "0.4.0"
|
version "0.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.0.tgz#e9f94ba3b5b875e32205cb83fece18e64486e9e6"
|
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617"
|
||||||
integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==
|
integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==
|
||||||
dependencies:
|
|
||||||
"@eslint/core" "^0.16.0"
|
|
||||||
|
|
||||||
"@eslint/core@^0.16.0":
|
"@eslint/core@^0.15.2":
|
||||||
version "0.16.0"
|
version "0.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.16.0.tgz#490254f275ba9667ddbab344f4f0a6b7a7bd7209"
|
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f"
|
||||||
integrity sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==
|
integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/json-schema" "^7.0.15"
|
"@types/json-schema" "^7.0.15"
|
||||||
|
|
||||||
@@ -1205,22 +1203,22 @@
|
|||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
"@eslint/js@9.37.0":
|
"@eslint/js@9.36.0":
|
||||||
version "9.37.0"
|
version "9.36.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef"
|
||||||
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
|
integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==
|
||||||
|
|
||||||
"@eslint/object-schema@^2.1.6":
|
"@eslint/object-schema@^2.1.6":
|
||||||
version "2.1.6"
|
version "2.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
||||||
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
|
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
|
||||||
|
|
||||||
"@eslint/plugin-kit@^0.4.0":
|
"@eslint/plugin-kit@^0.3.5":
|
||||||
version "0.4.0"
|
version "0.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz#f6a245b42886abf6fc9c7ab7744a932250335ab2"
|
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5"
|
||||||
integrity sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==
|
integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint/core" "^0.16.0"
|
"@eslint/core" "^0.15.2"
|
||||||
levn "^0.4.1"
|
levn "^0.4.1"
|
||||||
|
|
||||||
"@humanfs/core@^0.19.1":
|
"@humanfs/core@^0.19.1":
|
||||||
@@ -1430,10 +1428,10 @@
|
|||||||
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
|
"@resvg/resvg-js-win32-ia32-msvc" "2.4.1"
|
||||||
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
|
"@resvg/resvg-js-win32-x64-msvc" "2.4.1"
|
||||||
|
|
||||||
"@rolldown/pluginutils@1.0.0-beta.38":
|
"@rolldown/pluginutils@1.0.0-beta.35":
|
||||||
version "1.0.0-beta.38"
|
version "1.0.0-beta.35"
|
||||||
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz#95253608c4629eb2a5f3d656009ac9ba031eb292"
|
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz#1a477e7742b154b67519d40e4fc17485de338e7a"
|
||||||
integrity sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==
|
integrity sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.49.0":
|
"@rollup/rollup-android-arm-eabi@4.49.0":
|
||||||
version "4.49.0"
|
version "4.49.0"
|
||||||
@@ -1897,15 +1895,15 @@
|
|||||||
"@turf/invariant" "^6.5.0"
|
"@turf/invariant" "^6.5.0"
|
||||||
eventemitter3 "^4.0.7"
|
eventemitter3 "^4.0.7"
|
||||||
|
|
||||||
"@vitejs/plugin-react@5.0.4":
|
"@vitejs/plugin-react@5.0.3":
|
||||||
version "5.0.4"
|
version "5.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz#d642058e89c5b712655c8cbd13482f5813519602"
|
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz#182ea45406d89e55b4e35c92a4a8c2c8388726c8"
|
||||||
integrity sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==
|
integrity sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/core" "^7.28.4"
|
"@babel/core" "^7.28.4"
|
||||||
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
|
"@babel/plugin-transform-react-jsx-self" "^7.27.1"
|
||||||
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
|
"@babel/plugin-transform-react-jsx-source" "^7.27.1"
|
||||||
"@rolldown/pluginutils" "1.0.0-beta.38"
|
"@rolldown/pluginutils" "1.0.0-beta.35"
|
||||||
"@types/babel__core" "^7.20.5"
|
"@types/babel__core" "^7.20.5"
|
||||||
react-refresh "^0.17.0"
|
react-refresh "^0.17.0"
|
||||||
|
|
||||||
@@ -2364,10 +2362,10 @@ ccount@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||||
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
||||||
|
|
||||||
chai@6.2.0:
|
chai@6.0.1:
|
||||||
version "6.2.0"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.0.tgz#181bca6a219cddb99c3eeefb82483800ffa550ce"
|
resolved "https://registry.yarnpkg.com/chai/-/chai-6.0.1.tgz#88c2b4682fb56050647e222d2cf9d6772f2607b3"
|
||||||
integrity sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==
|
integrity sha512-/JOoU2//6p5vCXh00FpNgtlw0LjvhGttaWc+y7wpW9yjBm3ys0dI8tSKZxIOgNruz5J0RleccatSIC3uxEZP0g==
|
||||||
|
|
||||||
chalk@^4.0.0, chalk@^4.1.0:
|
chalk@^4.0.0, chalk@^4.1.0:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
@@ -2865,10 +2863,10 @@ devlop@^1.0.0, devlop@^1.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal "^2.0.0"
|
dequal "^2.0.0"
|
||||||
|
|
||||||
devtools-protocol@0.0.1508733:
|
devtools-protocol@0.0.1495869:
|
||||||
version "0.0.1508733"
|
version "0.0.1495869"
|
||||||
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz#047deb3531470efda2c7bf43c10b3ae9e4b3d51b"
|
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1495869.tgz#f68daef77a48d5dcbcdd55dbfa3265a51989c91b"
|
||||||
integrity sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==
|
integrity sha512-i+bkd9UYFis40RcnkW7XrOprCujXRAHg62IVh/Ah3G8MmNXpCGt1m0dTFhSdx/AVs8XEMbdOGRwdkR1Bcta8AA==
|
||||||
|
|
||||||
diff@^7.0.0:
|
diff@^7.0.0:
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
@@ -3278,19 +3276,19 @@ eslint-visitor-keys@^4.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||||
|
|
||||||
eslint@9.37.0:
|
eslint@9.36.0:
|
||||||
version "9.37.0"
|
version "9.36.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.37.0.tgz#ac0222127f76b09c0db63036f4fe289562072d74"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088"
|
||||||
integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==
|
integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils" "^4.8.0"
|
"@eslint-community/eslint-utils" "^4.8.0"
|
||||||
"@eslint-community/regexpp" "^4.12.1"
|
"@eslint-community/regexpp" "^4.12.1"
|
||||||
"@eslint/config-array" "^0.21.0"
|
"@eslint/config-array" "^0.21.0"
|
||||||
"@eslint/config-helpers" "^0.4.0"
|
"@eslint/config-helpers" "^0.3.1"
|
||||||
"@eslint/core" "^0.16.0"
|
"@eslint/core" "^0.15.2"
|
||||||
"@eslint/eslintrc" "^3.3.1"
|
"@eslint/eslintrc" "^3.3.1"
|
||||||
"@eslint/js" "9.37.0"
|
"@eslint/js" "9.36.0"
|
||||||
"@eslint/plugin-kit" "^0.4.0"
|
"@eslint/plugin-kit" "^0.3.5"
|
||||||
"@humanfs/node" "^0.16.6"
|
"@humanfs/node" "^0.16.6"
|
||||||
"@humanwhocodes/module-importer" "^1.0.1"
|
"@humanwhocodes/module-importer" "^1.0.1"
|
||||||
"@humanwhocodes/retry" "^0.4.2"
|
"@humanwhocodes/retry" "^0.4.2"
|
||||||
@@ -4276,11 +4274,6 @@ is-number@^7.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
||||||
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
||||||
|
|
||||||
is-path-inside@^3.0.3:
|
|
||||||
version "3.0.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
|
||||||
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
|
||||||
|
|
||||||
is-plain-obj@^2.1.0:
|
is-plain-obj@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
|
||||||
@@ -4536,10 +4529,10 @@ lazy-cache@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
|
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
|
||||||
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
|
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
|
||||||
|
|
||||||
less@4.4.2:
|
less@4.4.1:
|
||||||
version "4.4.2"
|
version "4.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/less/-/less-4.4.2.tgz#fa4291fdb0334de91163622cc038f4bd3eb6b8d7"
|
resolved "https://registry.yarnpkg.com/less/-/less-4.4.1.tgz#2f97168bf887ca6a9957ee69e16cc34f8b007cc7"
|
||||||
integrity sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==
|
integrity sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything "^2.0.1"
|
copy-anything "^2.0.1"
|
||||||
parse-node-version "^1.0.1"
|
parse-node-version "^1.0.1"
|
||||||
@@ -4566,10 +4559,10 @@ lines-and-columns@^1.1.6:
|
|||||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||||
|
|
||||||
lint-staged@16.2.3:
|
lint-staged@16.2.1:
|
||||||
version "16.2.3"
|
version "16.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.3.tgz#790866221d75602510507b5be40b2c7963715960"
|
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-16.2.1.tgz#bb82da8ce10059296b220f321980f0ee1ce40c28"
|
||||||
integrity sha512-1OnJEESB9zZqsp61XHH2fvpS1es3hRCxMplF/AJUDa8Ho8VrscYDIuxGrj3m8KPXbcWZ8fT9XTMUhEQmOVKpKw==
|
integrity sha512-KMeYmH9wKvHsXdUp+z6w7HN3fHKHXwT1pSTQTYxB9kI6ekK1rlL3kLZEoXZCppRPXFK9PFW/wfQctV7XUqMrPQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
commander "^14.0.1"
|
commander "^14.0.1"
|
||||||
listr2 "^9.0.4"
|
listr2 "^9.0.4"
|
||||||
@@ -5377,10 +5370,10 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
|
|||||||
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
|
||||||
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
|
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
|
||||||
|
|
||||||
mocha@11.7.4:
|
mocha@11.7.2:
|
||||||
version "11.7.4"
|
version "11.7.2"
|
||||||
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.4.tgz#f161b17aeccb0762484b33bdb3f7ab9410ba5c82"
|
resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5"
|
||||||
integrity sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w==
|
integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
browser-stdout "^1.3.1"
|
browser-stdout "^1.3.1"
|
||||||
chokidar "^4.0.1"
|
chokidar "^4.0.1"
|
||||||
@@ -5390,7 +5383,6 @@ mocha@11.7.4:
|
|||||||
find-up "^5.0.0"
|
find-up "^5.0.0"
|
||||||
glob "^10.4.5"
|
glob "^10.4.5"
|
||||||
he "^1.2.0"
|
he "^1.2.0"
|
||||||
is-path-inside "^3.0.3"
|
|
||||||
js-yaml "^4.1.0"
|
js-yaml "^4.1.0"
|
||||||
log-symbols "^4.1.0"
|
log-symbols "^4.1.0"
|
||||||
minimatch "^9.0.5"
|
minimatch "^9.0.5"
|
||||||
@@ -5970,17 +5962,17 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
puppeteer-core@24.23.0:
|
puppeteer-core@24.22.3:
|
||||||
version "24.23.0"
|
version "24.22.3"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.23.0.tgz#1f84abafa480358652ae8df340af984438173a14"
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23"
|
||||||
integrity sha512-yl25C59gb14sOdIiSnJ08XiPP+O2RjuyZmEG+RjYmCXO7au0jcLf7fRiyii96dXGUBW7Zwei/mVKfxMx/POeFw==
|
integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.10.10"
|
"@puppeteer/browsers" "2.10.10"
|
||||||
chromium-bidi "9.1.0"
|
chromium-bidi "9.1.0"
|
||||||
debug "^4.4.3"
|
debug "^4.4.3"
|
||||||
devtools-protocol "0.0.1508733"
|
devtools-protocol "0.0.1495869"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
webdriver-bidi-protocol "0.3.6"
|
webdriver-bidi-protocol "0.2.11"
|
||||||
ws "^8.18.3"
|
ws "^8.18.3"
|
||||||
|
|
||||||
puppeteer-extra-plugin-stealth@^2.11.2:
|
puppeteer-extra-plugin-stealth@^2.11.2:
|
||||||
@@ -6030,16 +6022,16 @@ puppeteer-extra@^3.3.6:
|
|||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
puppeteer@^24.23.0:
|
puppeteer@^24.22.3:
|
||||||
version "24.23.0"
|
version "24.22.3"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.23.0.tgz#fa3c1bffc1b40c3d7a59b9463d444ff4be69f5c7"
|
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e"
|
||||||
integrity sha512-BVR1Lg8sJGKXY79JARdIssFWK2F6e1j+RyuJP66w4CUmpaXjENicmA3nNpUXA8lcTdDjAndtP+oNdni3T/qQqA==
|
integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.10.10"
|
"@puppeteer/browsers" "2.10.10"
|
||||||
chromium-bidi "9.1.0"
|
chromium-bidi "9.1.0"
|
||||||
cosmiconfig "^9.0.0"
|
cosmiconfig "^9.0.0"
|
||||||
devtools-protocol "0.0.1508733"
|
devtools-protocol "0.0.1495869"
|
||||||
puppeteer-core "24.23.0"
|
puppeteer-core "24.22.3"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
|
|
||||||
qs@^6.14.0:
|
qs@^6.14.0:
|
||||||
@@ -6129,17 +6121,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.9.3:
|
react-router-dom@7.9.2:
|
||||||
version "7.9.3"
|
version "7.9.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.3.tgz#67ab1655f67b9b6108fe20ed3d4881b53dccf87a"
|
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26"
|
||||||
integrity sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==
|
integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w==
|
||||||
dependencies:
|
dependencies:
|
||||||
react-router "7.9.3"
|
react-router "7.9.2"
|
||||||
|
|
||||||
react-router@7.9.3:
|
react-router@7.9.2:
|
||||||
version "7.9.3"
|
version "7.9.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.3.tgz#f2d5ff6181851de3df3acb4e7364fce0dee5fba2"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6"
|
||||||
integrity sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==
|
integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA==
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie "^1.0.1"
|
cookie "^1.0.1"
|
||||||
set-cookie-parser "^2.6.0"
|
set-cookie-parser "^2.6.0"
|
||||||
@@ -6540,11 +6532,6 @@ semver@^7.3.5, semver@^7.5.3, semver@^7.7.2:
|
|||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
|
||||||
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
|
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
|
||||||
|
|
||||||
semver@^7.7.3:
|
|
||||||
version "7.7.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946"
|
|
||||||
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
|
|
||||||
|
|
||||||
send@^1.2.0:
|
send@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"
|
resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"
|
||||||
@@ -7421,10 +7408,10 @@ vfile@^6.0.0:
|
|||||||
"@types/unist" "^3.0.0"
|
"@types/unist" "^3.0.0"
|
||||||
vfile-message "^4.0.0"
|
vfile-message "^4.0.0"
|
||||||
|
|
||||||
vite@7.1.9:
|
vite@7.1.7:
|
||||||
version "7.1.9"
|
version "7.1.7"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.9.tgz#ba844410e5d0c0f2a4eaf17a52af60ebea322cbf"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.7.tgz#ed3f9f06e21d6574fe1ad425f6b0912d027ffc13"
|
||||||
integrity sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==
|
integrity sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild "^0.25.0"
|
esbuild "^0.25.0"
|
||||||
fdir "^6.5.0"
|
fdir "^6.5.0"
|
||||||
@@ -7440,10 +7427,10 @@ web-streams-polyfill@^3.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
||||||
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.3.6:
|
webdriver-bidi-protocol@0.2.11:
|
||||||
version "0.3.6"
|
version "0.2.11"
|
||||||
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.6.tgz#55ad4ff9697532e3e04fb0446bb6dd4c158b3ad5"
|
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.2.11.tgz#dba18d9b0a33aed33fab272dbd6e42411ac753cc"
|
||||||
integrity sha512-mlGndEOA9yK9YAbvtxaPTqdi/kaCWYYfwrZvGzcmkr/3lWM+tQj53BxtpVd6qbC6+E5OnHXgCcAhre6AkXzxjA==
|
integrity sha512-Y9E1/oi4XMxcR8AT0ZC4OvYntl34SPgwjmELH+owjBr0korAX4jKgZULBWILGCVGdVCQ0dodTToIETozhG8zvA==
|
||||||
|
|
||||||
whatwg-encoding@^3.1.1:
|
whatwg-encoding@^3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user