mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a460b813c1 | ||
|
|
4596442f64 | ||
|
|
0bcfa1d4ad | ||
|
|
0cad05124a | ||
|
|
eb53b68d45 | ||
|
|
ba0732e1f6 | ||
|
|
aa67647bbb | ||
|
|
7a9d49899b | ||
|
|
9a87c58d3e | ||
|
|
fdd7e835e8 | ||
|
|
00d6a12b30 | ||
|
|
05218800d2 | ||
|
|
19d4721f9f | ||
|
|
a794645393 | ||
|
|
fd7e228972 | ||
|
|
b86e351007 | ||
|
|
19c4860da7 | ||
|
|
d98e06cfdf | ||
|
|
6ae0c9749b | ||
|
|
10e40e038e | ||
|
|
4ba6828939 | ||
|
|
d09770dae2 | ||
|
|
248e4d2562 | ||
|
|
7b8e961b49 | ||
|
|
f66ceccbb4 | ||
|
|
a3db725af6 | ||
|
|
0663bd945f | ||
|
|
bc355fb5fe | ||
|
|
797421f0d5 | ||
|
|
0b2b42fc75 | ||
|
|
472169693f | ||
|
|
3117044139 | ||
|
|
7879d0e94a | ||
|
|
afd1048c9e | ||
|
|
acbaab05ed | ||
|
|
72fffc526b | ||
|
|
9e5989ece3 | ||
|
|
afc200c9e1 | ||
|
|
59226491f2 | ||
|
|
28f7760120 | ||
|
|
2465514b7a | ||
|
|
9dde377fe6 | ||
|
|
28a3a7f372 | ||
|
|
e859250545 | ||
|
|
4dd0370ec1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ npm-debug.log
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
tools/release/config.json
|
||||||
|
|||||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -1,94 +0,0 @@
|
|||||||
Newer release changelog see https://github.com/orangecoding/fredy/releases
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
###### [V5.5.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- fixing provider
|
|
||||||
- allow multiple instances of 1 provider
|
|
||||||
- **BREAKING**: Minimum node version is now 16
|
|
||||||
|
|
||||||
###### [V5.4.6]
|
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
|
||||||
-
|
|
||||||
|
|
||||||
###### [V5.4.5]
|
|
||||||
|
|
||||||
- Adding Instana node.js monitoring
|
|
||||||
|
|
||||||
###### [V5.4.4]
|
|
||||||
|
|
||||||
- Add support for Immo Südwest Presse (immo.swp.de)
|
|
||||||
- Telegram: Use job name instead of ID and link in title
|
|
||||||
- Fix race condition if user ID is in session but not in user store
|
|
||||||
- Allow visiting the original provider URL
|
|
||||||
|
|
||||||
###### [V5.4.3]
|
|
||||||
|
|
||||||
- re-writing readme
|
|
||||||
- improving docker build
|
|
||||||
- using github's actions to build docker and test automatically
|
|
||||||
|
|
||||||
###### [V5.4.2]
|
|
||||||
|
|
||||||
- Fixing prod build
|
|
||||||
|
|
||||||
###### [V5.4.1]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- Provider urls are now automagically been changed to include the correct sort order for search results
|
|
||||||
|
|
||||||
```
|
|
||||||
Note: It has been an point of confusion since the very beginning of Fredy, that people simply copied the url, but
|
|
||||||
did not take care of sorting the search results by date. If this is not done, Fredy will most likely not see the latest
|
|
||||||
results, thus cannot report them. This release fixes it by adding the necessary params (or replaces them).
|
|
||||||
```
|
|
||||||
|
|
||||||
###### [V5.3.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- It's now possible to send mails to multiple receiver using comma separation for MailJet & Sendgrid
|
|
||||||
- Fixing Immowelt scraping
|
|
||||||
|
|
||||||
###### [V5.2.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- Adding new similarity check layer (Duplicates are being removed now)
|
|
||||||
- Adding paging for search results
|
|
||||||
|
|
||||||
###### [V5.1.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- NodeJS 12.13 is now the minimum supported version
|
|
||||||
- Adding general settings as new configuration page to ui
|
|
||||||
- Adding new feature working hours
|
|
||||||
|
|
||||||
###### [V5.0.0]
|
|
||||||
|
|
||||||
- Upgrading dependencies
|
|
||||||
- NodeJS 12 is now the minimum supported version
|
|
||||||
|
|
||||||
###### [V4.0.0]
|
|
||||||
|
|
||||||
Bringing back Immoscout :tada:
|
|
||||||
|
|
||||||
###### [V3.0.0]
|
|
||||||
|
|
||||||
This is basically a re-write, your old config file will not be compatible anymore. Please re-created your search jobs
|
|
||||||
on the new ui and use the values from your previous config file if needed.
|
|
||||||
|
|
||||||
```
|
|
||||||
- We're getting rid of manual config changes, Fredy, now ships with a UI so that it's easy for you to create and edit jobs
|
|
||||||
```
|
|
||||||
|
|
||||||
###### [V2.0.0]
|
|
||||||
|
|
||||||
```
|
|
||||||
- Fredy can now run multiple search job on one instance
|
|
||||||
- Changed lot's of the structure of Fredy to make this happen
|
|
||||||
[BREAKING CHANGES]
|
|
||||||
- The config has been changed, the config of V1.x will not work any longer
|
|
||||||
- Sources have been renamed to provider
|
|
||||||
```
|
|
||||||
@@ -35,6 +35,7 @@ WORKDIR /fredy
|
|||||||
RUN apk add --no-cache chromium curl
|
RUN apk add --no-cache chromium curl
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
IS_DOCKER=true \
|
||||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
|
||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 531 KiB |
@@ -8,20 +8,20 @@ import js from '@eslint/js';
|
|||||||
import prettier from 'eslint-config-prettier';
|
import prettier from 'eslint-config-prettier';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import react from 'eslint-plugin-react';
|
import react from 'eslint-plugin-react';
|
||||||
import babelParser from '@babel/eslint-parser';
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
|
||||||
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
|
||||||
prettier,
|
|
||||||
{
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: babelParser,
|
ecmaVersion: 'latest',
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2021,
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
@@ -32,70 +32,14 @@ export default [
|
|||||||
after: 'readonly',
|
after: 'readonly',
|
||||||
it: 'readonly',
|
it: 'readonly',
|
||||||
},
|
},
|
||||||
parserOptions: { requireConfigFile: false },
|
|
||||||
},
|
},
|
||||||
plugins: { react },
|
plugins: { react },
|
||||||
rules: {
|
|
||||||
eqeqeq: [2, 'allow-null'],
|
|
||||||
strict: 0,
|
|
||||||
'no-redeclare': [2, { builtinGlobals: false }],
|
|
||||||
'class-methods-use-this': 'off',
|
|
||||||
indent: ['off', 2],
|
|
||||||
'linebreak-style': ['error', 'unix'],
|
|
||||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
|
|
||||||
semi: ['error', 'always'],
|
|
||||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
|
||||||
'jsx-quotes': ['error', 'prefer-double'],
|
|
||||||
'react/display-name': 'off',
|
|
||||||
'react/forbid-prop-types': 'off',
|
|
||||||
'react/jsx-closing-bracket-location': 'off',
|
|
||||||
'react/jsx-curly-spacing': 'off',
|
|
||||||
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
|
|
||||||
'react/jsx-indent-props': 'off',
|
|
||||||
'react/jsx-key': 'off',
|
|
||||||
'react/jsx-max-props-per-line': 'off',
|
|
||||||
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
|
|
||||||
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
|
|
||||||
'react/jsx-no-literals': 'off',
|
|
||||||
'react/jsx-no-undef': 'error',
|
|
||||||
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
|
|
||||||
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
|
|
||||||
'react/jsx-sort-prop-types': 'off',
|
|
||||||
'react/jsx-sort-props': 'off',
|
|
||||||
'react/jsx-uses-react': 'error',
|
|
||||||
'react/jsx-uses-vars': 'error',
|
|
||||||
'react/no-danger': 'warn',
|
|
||||||
'react/no-deprecated': 'error',
|
|
||||||
'react/no-did-mount-set-state': 'error',
|
|
||||||
'react/no-did-update-set-state': 'warn',
|
|
||||||
'react/no-direct-mutation-state': 'off',
|
|
||||||
'react/no-is-mounted': 'error',
|
|
||||||
'react/no-set-state': 'off',
|
|
||||||
'react/no-string-refs': 'warn',
|
|
||||||
'react/no-unknown-property': 'error',
|
|
||||||
'react/prop-types': ['error', { ignore: [], customValidators: [], skipUndeclared: true }],
|
|
||||||
'react/react-in-jsx-scope': 'error',
|
|
||||||
'react/require-extension': 'off',
|
|
||||||
'react/require-render-return': 'error',
|
|
||||||
'react/self-closing-comp': 'warn',
|
|
||||||
'react/sort-comp': 'off',
|
|
||||||
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
|
|
||||||
'react/wrap-multilines': 'off',
|
|
||||||
'react/jsx-first-prop-new-line': 'off',
|
|
||||||
'react/jsx-equals-spacing': ['warn', 'never'],
|
|
||||||
'react/jsx-no-target-blank': 'error',
|
|
||||||
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
|
||||||
'react/jsx-no-comment-textnodes': 'error',
|
|
||||||
'react/no-comment-textnodes': 'off',
|
|
||||||
'react/no-render-return-value': 'error',
|
|
||||||
'react/require-optimization': ['off', { allowDecorators: [] }],
|
|
||||||
'react/no-find-dom-node': 'warn',
|
|
||||||
'react/forbid-component-props': ['off', { forbid: [] }],
|
|
||||||
'react/no-danger-with-children': 'error',
|
|
||||||
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
|
|
||||||
'react/style-prop-object': 'error',
|
|
||||||
'react/no-children-prop': 'warn',
|
|
||||||
},
|
|
||||||
settings: { react: { version: 'detect' } },
|
settings: { react: { version: 'detect' } },
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
prettier,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
|
||||||
/>
|
/>
|
||||||
<meta name="google" content="notranslate" />
|
<meta name="google" content="notranslate" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|
||||||
<title>Fredy || Real Estate Finder</title>
|
<title>Fredy || Real Estate Finder</title>
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -8,7 +8,6 @@ import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/ut
|
|||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
|
||||||
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
|
||||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.js';
|
import logger from './lib/services/logger.js';
|
||||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
@@ -54,7 +53,6 @@ await import('./lib/api/api.js');
|
|||||||
|
|
||||||
if (settings.demoMode) {
|
if (settings.demoMode) {
|
||||||
logger.info('Running in demo mode');
|
logger.info('Running in demo mode');
|
||||||
cleanupDemoAtMidnight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAdminUserExists();
|
ensureAdminUserExists();
|
||||||
|
|||||||
@@ -4,12 +4,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NoNewListingsWarning } from './errors.js';
|
import { NoNewListingsWarning } from './errors.js';
|
||||||
import { storeListings, getKnownListingHashesForJobAndProvider } from './services/storage/listingsStorage.js';
|
import {
|
||||||
|
storeListings,
|
||||||
|
getKnownListingHashesForJobAndProvider,
|
||||||
|
deleteListingsById,
|
||||||
|
} from './services/storage/listingsStorage.js';
|
||||||
|
import { getJob } from './services/storage/jobStorage.js';
|
||||||
import * as notify from './notification/notify.js';
|
import * as notify from './notification/notify.js';
|
||||||
import Extractor from './services/extractor/extractor.js';
|
import Extractor from './services/extractor/extractor.js';
|
||||||
import urlModifier from './services/queryStringMutator.js';
|
import urlModifier from './services/queryStringMutator.js';
|
||||||
import logger from './services/logger.js';
|
import logger from './services/logger.js';
|
||||||
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||||
|
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||||
|
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||||
|
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||||
|
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Listing
|
* @typedef {Object} Listing
|
||||||
@@ -54,18 +63,21 @@ class FredyPipelineExecutioner {
|
|||||||
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
* @param {(raw:any)=>Listing} providerConfig.normalize Function to convert raw scraped data into a Listing shape.
|
||||||
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
* @param {(listing:Listing)=>boolean} providerConfig.filter Function to filter out unwanted listings.
|
||||||
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
* @param {(url:string, waitForSelector?:string)=>Promise<void>|Promise<Listing[]>} [providerConfig.getListings] Optional override to fetch listings.
|
||||||
*
|
|
||||||
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
* @param {Object} notificationConfig Notification configuration passed to notification adapters.
|
||||||
|
* @param {Object} spatialFilter Optional spatial filter configuration.
|
||||||
* @param {string} providerId The ID of the provider currently in use.
|
* @param {string} providerId The ID of the provider currently in use.
|
||||||
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
* @param {string} jobKey Key of the job that is currently running (from within the config).
|
||||||
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
* @param {SimilarityCache} similarityCache Cache instance for checking similar entries.
|
||||||
|
* @param browser
|
||||||
*/
|
*/
|
||||||
constructor(providerConfig, notificationConfig, providerId, jobKey, similarityCache) {
|
constructor(providerConfig, notificationConfig, spatialFilter, providerId, jobKey, similarityCache, browser) {
|
||||||
this._providerConfig = providerConfig;
|
this._providerConfig = providerConfig;
|
||||||
this._notificationConfig = notificationConfig;
|
this._notificationConfig = notificationConfig;
|
||||||
|
this._spatialFilter = spatialFilter;
|
||||||
this._providerId = providerId;
|
this._providerId = providerId;
|
||||||
this._jobKey = jobKey;
|
this._jobKey = jobKey;
|
||||||
this._similarityCache = similarityCache;
|
this._similarityCache = similarityCache;
|
||||||
|
this._browser = browser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +94,9 @@ class FredyPipelineExecutioner {
|
|||||||
.then(this._findNew.bind(this))
|
.then(this._findNew.bind(this))
|
||||||
.then(this._geocode.bind(this))
|
.then(this._geocode.bind(this))
|
||||||
.then(this._save.bind(this))
|
.then(this._save.bind(this))
|
||||||
|
.then(this._calculateDistance.bind(this))
|
||||||
.then(this._filterBySimilarListings.bind(this))
|
.then(this._filterBySimilarListings.bind(this))
|
||||||
|
.then(this._filterByArea.bind(this))
|
||||||
.then(this._notify.bind(this))
|
.then(this._notify.bind(this))
|
||||||
.catch(this._handleError.bind(this));
|
.catch(this._handleError.bind(this));
|
||||||
}
|
}
|
||||||
@@ -106,6 +120,47 @@ class FredyPipelineExecutioner {
|
|||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter listings by area using the provider's area filter if available.
|
||||||
|
* Only filters if areaFilter is set on the provider AND the listing has coordinates.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} newListings New listings to filter by area.
|
||||||
|
* @returns {Promise<Listing[]>} Resolves with listings that are within the area (or not filtered if no area is set).
|
||||||
|
*/
|
||||||
|
_filterByArea(newListings) {
|
||||||
|
const polygonFeatures = this._spatialFilter?.features?.filter((f) => f.geometry?.type === 'Polygon');
|
||||||
|
|
||||||
|
// If no area filter is set, return all listings
|
||||||
|
if (!polygonFeatures?.length) {
|
||||||
|
return newListings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredIds = [];
|
||||||
|
// Filter listings by area - keep only those within the polygon
|
||||||
|
const keptListings = newListings.filter((listing) => {
|
||||||
|
// If listing doesn't have coordinates, keep it (don't filter out)
|
||||||
|
if (listing.latitude == null || listing.longitude == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the point is inside the polygons
|
||||||
|
const point = [listing.longitude, listing.latitude]; // GeoJSON format: [lon, lat]
|
||||||
|
const isInPolygon = polygonFeatures.some((feature) => booleanPointInPolygon(point, feature));
|
||||||
|
|
||||||
|
if (!isInPolygon) {
|
||||||
|
filteredIds.push(listing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isInPolygon;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filteredIds.length > 0) {
|
||||||
|
deleteListingsById(filteredIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keptListings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch listings from the provider, using the default Extractor flow unless
|
* Fetch listings from the provider, using the default Extractor flow unless
|
||||||
* a provider-specific getListings override is supplied.
|
* a provider-specific getListings override is supplied.
|
||||||
@@ -114,7 +169,7 @@ class FredyPipelineExecutioner {
|
|||||||
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
* @returns {Promise<Listing[]>} Resolves with an array of listings (empty when none found).
|
||||||
*/
|
*/
|
||||||
_getListings(url) {
|
_getListings(url) {
|
||||||
const extractor = new Extractor();
|
const extractor = new Extractor({ ...this._providerConfig.puppeteerOptions, browser: this._browser });
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
extractor
|
extractor
|
||||||
.execute(url, this._providerConfig.waitForSelector)
|
.execute(url, this._providerConfig.waitForSelector)
|
||||||
@@ -201,6 +256,42 @@ class FredyPipelineExecutioner {
|
|||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance for new listings.
|
||||||
|
*
|
||||||
|
* @param {Listing[]} listings
|
||||||
|
* @returns {Listing[]}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_calculateDistance(listings) {
|
||||||
|
if (listings.length === 0) return [];
|
||||||
|
|
||||||
|
const job = getJob(this._jobKey);
|
||||||
|
const userId = job?.userId;
|
||||||
|
|
||||||
|
if (userId == null || typeof userId !== 'string') {
|
||||||
|
logger.debug('Skipping distance calculation: userId is missing or invalid');
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSettings = getUserSettings(userId);
|
||||||
|
const homeAddress = userSettings?.home_address;
|
||||||
|
|
||||||
|
if (!homeAddress || !homeAddress.coords) {
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lat, lng } = homeAddress.coords;
|
||||||
|
for (const listing of listings) {
|
||||||
|
if (listing.latitude != null && listing.longitude != null) {
|
||||||
|
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
|
||||||
|
updateListingDistance(listing.id, dist);
|
||||||
|
listing.distance_to_destination = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove listings that are similar to already known entries according to the similarity cache.
|
* Remove listings that are similar to already known entries according to the similarity cache.
|
||||||
* Adds the remaining listings to the cache.
|
* Adds the remaining listings to the cache.
|
||||||
@@ -209,7 +300,8 @@ class FredyPipelineExecutioner {
|
|||||||
* @returns {Listing[]} Listings considered unique enough to keep.
|
* @returns {Listing[]} Listings considered unique enough to keep.
|
||||||
*/
|
*/
|
||||||
_filterBySimilarListings(listings) {
|
_filterBySimilarListings(listings) {
|
||||||
return listings.filter((listing) => {
|
const filteredIds = [];
|
||||||
|
const keptListings = listings.filter((listing) => {
|
||||||
const similar = this._similarityCache.checkAndAddEntry({
|
const similar = this._similarityCache.checkAndAddEntry({
|
||||||
title: listing.title,
|
title: listing.title,
|
||||||
address: listing.address,
|
address: listing.address,
|
||||||
@@ -219,9 +311,16 @@ class FredyPipelineExecutioner {
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||||
);
|
);
|
||||||
|
filteredIds.push(listing.id);
|
||||||
}
|
}
|
||||||
return !similar;
|
return !similar;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (filteredIds.length > 0) {
|
||||||
|
deleteListingsById(filteredIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return keptListings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
10
lib/TRACKING_POIS.js
Normal file
10
lib/TRACKING_POIS.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TRACKING_POIS = {
|
||||||
|
DISTANCE_ADDRESS_ENTERED: 'DISTANCE_ADDRESS_ENTERED',
|
||||||
|
WELCOME_FINISHED: 'WELCOME_FINISHED',
|
||||||
|
WELCOME_SKIPPED: 'WELCOME_SKIPPED',
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import { providerRouter } from './routes/providerRouter.js';
|
|||||||
import { versionRouter } from './routes/versionRouter.js';
|
import { versionRouter } from './routes/versionRouter.js';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
import { loginRouter } from './routes/loginRoute.js';
|
||||||
import { userRouter } from './routes/userRoute.js';
|
import { userRouter } from './routes/userRoute.js';
|
||||||
|
import { userSettingsRouter } from './routes/userSettingsRoute.js';
|
||||||
import { jobRouter } from './routes/jobRouter.js';
|
import { jobRouter } from './routes/jobRouter.js';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
@@ -20,9 +21,9 @@ import { demoRouter } from './routes/demoRouter.js';
|
|||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
import { listingsRouter } from './routes/listingsRouter.js';
|
import { listingsRouter } from './routes/listingsRouter.js';
|
||||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||||
import { featureRouter } from './routes/featureRouter.js';
|
|
||||||
import { dashboardRouter } from './routes/dashboardRouter.js';
|
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||||
import { backupRouter } from './routes/backupRouter.js';
|
import { backupRouter } from './routes/backupRouter.js';
|
||||||
|
import { trackingRouter } from './routes/trackingRoute.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = (await getSettings()).port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
@@ -35,7 +36,8 @@ service.use('/api/jobs', authInterceptor());
|
|||||||
service.use('/api/version', authInterceptor());
|
service.use('/api/version', authInterceptor());
|
||||||
service.use('/api/listings', authInterceptor());
|
service.use('/api/listings', authInterceptor());
|
||||||
service.use('/api/dashboard', authInterceptor());
|
service.use('/api/dashboard', authInterceptor());
|
||||||
service.use('/api/features', authInterceptor());
|
service.use('/api/user/settings', authInterceptor());
|
||||||
|
service.use('/api/tracking', authInterceptor());
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
// /admin can only be accessed when user is having admin permissions
|
||||||
service.use('/api/admin', adminInterceptor());
|
service.use('/api/admin', adminInterceptor());
|
||||||
@@ -44,12 +46,13 @@ service.use('/api/admin/generalSettings', generalSettingsRouter);
|
|||||||
service.use('/api/admin/backup', backupRouter);
|
service.use('/api/admin/backup', backupRouter);
|
||||||
service.use('/api/jobs/provider', providerRouter);
|
service.use('/api/jobs/provider', providerRouter);
|
||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
|
service.use('/api/user/settings', userSettingsRouter);
|
||||||
service.use('/api/version', versionRouter);
|
service.use('/api/version', versionRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
service.use('/api/listings', listingsRouter);
|
service.use('/api/listings', listingsRouter);
|
||||||
service.use('/api/features', featureRouter);
|
|
||||||
service.use('/api/dashboard', dashboardRouter);
|
service.use('/api/dashboard', dashboardRouter);
|
||||||
|
service.use('/api/tracking', trackingRouter);
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
service.use('/api/demo', demoRouter);
|
service.use('/api/demo', demoRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import getFeatures from '../../features.js';
|
|
||||||
const service = restana();
|
|
||||||
const featureRouter = service.newRouter();
|
|
||||||
|
|
||||||
featureRouter.get('/', async (req, res) => {
|
|
||||||
const features = getFeatures();
|
|
||||||
res.body = Object.assign({}, { features });
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
|
|
||||||
export { featureRouter };
|
|
||||||
@@ -11,10 +11,13 @@ import logger from '../../services/logger.js';
|
|||||||
import { bus } from '../../services/events/event-bus.js';
|
import { bus } from '../../services/events/event-bus.js';
|
||||||
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
||||||
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
|
|
||||||
|
const DEMO_JOB_NAME = 'Demo-Job';
|
||||||
|
|
||||||
function doesJobBelongsToUser(job, req) {
|
function doesJobBelongsToUser(job, req) {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
@@ -160,7 +163,17 @@ jobRouter.post('/:jobId/run', 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,
|
||||||
|
shareWithUsers = [],
|
||||||
|
spatialFilter = null,
|
||||||
|
} = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
let jobFromDb = jobStorage.getJob(jobId);
|
let jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
@@ -169,6 +182,11 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -178,6 +196,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
shareWithUsers,
|
shareWithUsers,
|
||||||
|
spatialFilter,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
@@ -188,8 +207,14 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
const job = jobStorage.getJob(jobId);
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (!doesJobBelongsToUser(job, req)) {
|
||||||
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
res.send(new Error('You are trying to remove a job that is not associated to your user'));
|
||||||
} else {
|
} else {
|
||||||
@@ -204,8 +229,15 @@ jobRouter.delete('', async (req, res) => {
|
|||||||
jobRouter.put('/:jobId/status', async (req, res) => {
|
jobRouter.put('/:jobId/status', async (req, res) => {
|
||||||
const { status } = req.body;
|
const { status } = req.body;
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
const job = jobStorage.getJob(jobId);
|
const job = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||||
|
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!doesJobBelongsToUser(job, req)) {
|
if (!doesJobBelongsToUser(job, req)) {
|
||||||
res.send(new Error('You are trying change a job that is not associated to your user'));
|
res.send(new Error('You are trying change a job that is not associated to your user'));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { isAdmin as isAdminFn } from '../security.js';
|
|||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { nullOrEmpty } from '../../utils.js';
|
import { nullOrEmpty } from '../../utils.js';
|
||||||
import { getJobs } from '../../services/storage/jobStorage.js';
|
import { getJobs } from '../../services/storage/jobStorage.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
|
|
||||||
@@ -74,6 +75,18 @@ listingsRouter.get('/map', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
listingsRouter.get('/:listingId', async (req, res) => {
|
||||||
|
const { listingId } = req.params;
|
||||||
|
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
|
||||||
|
if (!listing) {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.body = { message: 'Listing not found' };
|
||||||
|
return res.send();
|
||||||
|
}
|
||||||
|
res.body = listing;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
// Toggle watch state for the current user on a listing
|
// Toggle watch state for the current user on a listing
|
||||||
listingsRouter.post('/watch', async (req, res) => {
|
listingsRouter.post('/watch', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -94,9 +107,15 @@ listingsRouter.post('/watch', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.delete('/job', async (req, res) => {
|
listingsRouter.delete('/job', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId, hardDelete = false } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
try {
|
try {
|
||||||
listingStorage.deleteListingsByJobId(jobId);
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listingStorage.deleteListingsByJobId(jobId, hardDelete);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -105,10 +124,10 @@ listingsRouter.delete('/job', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
listingsRouter.delete('/', async (req, res) => {
|
listingsRouter.delete('/', async (req, res) => {
|
||||||
const { ids } = req.body;
|
const { ids, hardDelete = false } = req.body;
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(ids) && ids.length > 0) {
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
listingStorage.deleteListingsById(ids);
|
listingStorage.deleteListingsById(ids, hardDelete);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import restana from 'restana';
|
import restana from 'restana';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const notificationAdapterRouter = service.newRouter();
|
const notificationAdapterRouter = service.newRouter();
|
||||||
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
|
||||||
@@ -34,11 +36,14 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
|||||||
serviceName: 'TestCall',
|
serviceName: 'TestCall',
|
||||||
newListings: [
|
newListings: [
|
||||||
{
|
{
|
||||||
price: '42 €',
|
address: 'Heidestrasse 17, 51147 Köln',
|
||||||
title: 'This is a test listing',
|
description: exampleDescription,
|
||||||
address: 'some address',
|
id: '1',
|
||||||
size: '666 2m',
|
imageUrl: 'https://placehold.co/600x400/png',
|
||||||
link: 'https://www.orange-coding.net',
|
price: '1.000 €',
|
||||||
|
size: '76 m²',
|
||||||
|
title: 'Stilvolle gepflegte 3-Raum-Wohnung mit gehobener Innenausstattung',
|
||||||
|
url: 'https://www.orange-coding.net',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
notificationConfig,
|
notificationConfig,
|
||||||
@@ -46,6 +51,7 @@ notificationAdapterRouter.post('/try', async (req, res) => {
|
|||||||
});
|
});
|
||||||
res.send();
|
res.send();
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
|
logger.error('Error during notification adapter test:', Exception);
|
||||||
res.send(new Error(Exception));
|
res.send(new Error(Exception));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -54,3 +60,51 @@ notificationAdapterRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
export { notificationAdapterRouter };
|
export { notificationAdapterRouter };
|
||||||
|
|
||||||
|
const exampleDescription = `
|
||||||
|
Wohnungstyp: Etagenwohnung
|
||||||
|
Nutzfläche: 76 m²
|
||||||
|
Etage: 2 von 3
|
||||||
|
Schlafzimmer: 1
|
||||||
|
Badezimmer: 1
|
||||||
|
Bezugsfrei ab: 1.4.2026
|
||||||
|
Haustiere: Nein
|
||||||
|
Garage/Stellplatz: Tiefgarage
|
||||||
|
Anzahl Garage/Stellplatz: 1
|
||||||
|
Kaltmiete (zzgl. Nebenkosten): 1.000 €
|
||||||
|
Preis/m²: 13,16 €/m²
|
||||||
|
Nebenkosten: 230 €
|
||||||
|
Heizkosten in Nebenkosten enthalten: Ja
|
||||||
|
Gesamtmiete: 1.230 €
|
||||||
|
Kaution: 3.000,00
|
||||||
|
Preis pro Parkfläche: 60 €
|
||||||
|
Baujahr: 2000
|
||||||
|
Objektzustand: Modernisiert
|
||||||
|
Qualität der Ausstattung: Gehoben
|
||||||
|
Heizungsart: Fernwärme
|
||||||
|
Energieausweistyp: Verbrauchsausweis
|
||||||
|
Energieausweis: liegt vor
|
||||||
|
Endenergieverbrauch: 72 kWh/(m²∙a)
|
||||||
|
Baujahr laut Energieausweis: 2000
|
||||||
|
|
||||||
|
Diese moderne 3-Zimmer-Wohnung liegt direkt neben einem Park und nur wenige Minuten von der S-Bahn-Haltestelle entfernt. Das Stadtzentrum sowie Freizeiteinrichtungen sind 1,5 km entfernt.
|
||||||
|
|
||||||
|
Die Wohnung ist ideal für Paare oder kleine Familien geeignet.
|
||||||
|
|
||||||
|
Ausstattung:
|
||||||
|
- neuer hochwertiger Bodenbelag (Holzoptik) in allen Räumen außer Bad/Küche
|
||||||
|
- sonniger Balkon (Süd)
|
||||||
|
- Tiefgaragenstellplatz
|
||||||
|
- Kellerabteil
|
||||||
|
- gepflegtes Mehrfamilienhaus
|
||||||
|
|
||||||
|
Die Küche ist vom Mieter nach eigenen Wünschen einzurichten.
|
||||||
|
|
||||||
|
Vermietung direkt vom Eigentümer - provisionsfrei!
|
||||||
|
|
||||||
|
Lage:
|
||||||
|
• Park: 1 Minute zu Fuß
|
||||||
|
• S-Bahn Station: 2 Minuten zu Fuß
|
||||||
|
• Supermärkte, Restaurants, täglicher Bedarf in der Nähe
|
||||||
|
• Gute Anbindung Richtung Großstadt und Flughafen
|
||||||
|
`;
|
||||||
|
|||||||
37
lib/api/routes/trackingRoute.js
Normal file
37
lib/api/routes/trackingRoute.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||||
|
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
const trackingRouter = service.newRouter();
|
||||||
|
|
||||||
|
trackingRouter.get('/trackingPois', async (req, res) => {
|
||||||
|
res.body = TRACKING_POIS;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
trackingRouter.post('/poi', async (req, res) => {
|
||||||
|
const { poi } = req.body;
|
||||||
|
if (!poi) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.send({ error: 'Feature name is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await trackPoi(poi);
|
||||||
|
res.send({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error tracking feature', error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { trackingRouter };
|
||||||
121
lib/api/routes/userSettingsRoute.js
Normal file
121
lib/api/routes/userSettingsRoute.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||||
|
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||||
|
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||||
|
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||||
|
import { fromJson } from '../../utils.js';
|
||||||
|
import { trackPoi } from '../../services/tracking/Tracker.js';
|
||||||
|
import { TRACKING_POIS } from '../../TRACKING_POIS.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
const userSettingsRouter = service.newRouter();
|
||||||
|
|
||||||
|
userSettingsRouter.get('/', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
|
||||||
|
const settings = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
settings[r.name] = fromJson(r.value, null);
|
||||||
|
}
|
||||||
|
res.body = settings;
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.get('/autocomplete', async (req, res) => {
|
||||||
|
const { q } = req.query;
|
||||||
|
try {
|
||||||
|
const results = await autocompleteAddress(q);
|
||||||
|
res.body = results;
|
||||||
|
res.send();
|
||||||
|
} catch (error) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.post('/home-address', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const { home_address } = req.body;
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (settings.demoMode) {
|
||||||
|
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (home_address) {
|
||||||
|
await trackPoi(TRACKING_POIS.DISTANCE_ADDRESS_ENTERED);
|
||||||
|
const coords = await geocodeAddress(home_address);
|
||||||
|
if (coords && coords.lat !== -1) {
|
||||||
|
upsertSettings({ home_address: { address: home_address, coords } }, userId);
|
||||||
|
resetGeocoordinatesAndDistanceForUser(userId);
|
||||||
|
//we do NOT wait for this to finish, as we don't want to block the response
|
||||||
|
runGeoCordTask();
|
||||||
|
res.send({ success: true, coords });
|
||||||
|
} else {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.send({ error: 'Could not geocode address' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
upsertSettings({ home_address: null }, userId);
|
||||||
|
res.send({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating home address settings', error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const { news_hash } = req.body;
|
||||||
|
|
||||||
|
const globalSettings = await getSettings();
|
||||||
|
if (globalSettings.demoMode) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ news_hash }, userId);
|
||||||
|
res.send({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating news hash', error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
userSettingsRouter.post('/immoscout-details', async (req, res) => {
|
||||||
|
const userId = req.session.currentUser;
|
||||||
|
const { immoscout_details } = req.body;
|
||||||
|
|
||||||
|
const globalSettings = await getSettings();
|
||||||
|
if (globalSettings.demoMode) {
|
||||||
|
res.statusCode = 403;
|
||||||
|
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ immoscout_details: !!immoscout_details }, userId);
|
||||||
|
res.send({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating immoscout details setting', error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { userSettingsRouter };
|
||||||
@@ -16,8 +16,8 @@ const mapListing = (listing) => ({
|
|||||||
url: listing.link,
|
url: listing.link,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { authToken, endpointUrl } = notificationConfig.find((a) => a.id === config.id).fields;
|
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||||
|
|
||||||
const listings = newListings.map(mapListing);
|
const listings = newListings.map(mapListing);
|
||||||
const body = {
|
const body = {
|
||||||
@@ -34,11 +34,20 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
headers['Authorization'] = `Bearer ${authToken}`;
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(endpointUrl, {
|
let fetchOptions = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers,
|
headers,
|
||||||
|
timeout: 10000,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (selfSignedCerts === true) {
|
||||||
|
fetchOptions.dispatcher = new (await import('undici')).Agent({
|
||||||
|
connect: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(endpointUrl, fetchOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
@@ -52,6 +61,10 @@ export const config = {
|
|||||||
label: 'Endpoint URL',
|
label: 'Endpoint URL',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
},
|
},
|
||||||
|
selfSignedCerts: {
|
||||||
|
label: 'Self-signed certificates',
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
authToken: {
|
authToken: {
|
||||||
description: "Your application's auth token, if required by your endpoint.",
|
description: "Your application's auth token, if required by your endpoint.",
|
||||||
label: 'Auth token (optional)',
|
label: 'Auth token (optional)',
|
||||||
|
|||||||
88
lib/notification/adapter/resend.js
Executable file
88
lib/notification/adapter/resend.js
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getDirName, normalizeImageUrl } from '../../utils.js';
|
||||||
|
|
||||||
|
const __dirname = getDirName();
|
||||||
|
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||||
|
const emailTemplate = Handlebars.compile(template);
|
||||||
|
|
||||||
|
const mapListings = (serviceName, jobKey, listings) =>
|
||||||
|
listings.map((l) => {
|
||||||
|
const image = normalizeImageUrl(l.image);
|
||||||
|
return {
|
||||||
|
title: l.title || '',
|
||||||
|
link: l.link || '',
|
||||||
|
address: l.address || '',
|
||||||
|
size: l.size || '',
|
||||||
|
price: l.price || '',
|
||||||
|
image,
|
||||||
|
hasImage: Boolean(image),
|
||||||
|
serviceName,
|
||||||
|
jobKey,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
|
||||||
|
const to = receiver
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const resend = new Resend(apiKey);
|
||||||
|
|
||||||
|
const listings = mapListings(serviceName, jobKey, newListings);
|
||||||
|
|
||||||
|
const html = emailTemplate({
|
||||||
|
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||||
|
numberOfListings: listings.length,
|
||||||
|
listings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error } = await resend.emails.send({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: `Fredy found ${listings.length} new listing(s) for ${serviceName}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return Promise.resolve();
|
||||||
|
} else {
|
||||||
|
return Promise.reject(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
id: 'resend',
|
||||||
|
name: 'Resend',
|
||||||
|
description: 'Resend is being used to send new listings via mail.',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/resend.md'),
|
||||||
|
fields: {
|
||||||
|
apiKey: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Api Key',
|
||||||
|
description: 'The Resend API key used to send emails.',
|
||||||
|
},
|
||||||
|
receiver: {
|
||||||
|
type: 'email',
|
||||||
|
label: 'Receiver Email',
|
||||||
|
description: 'Comma-separated email addresses Fredy will send notifications to.',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
type: 'email',
|
||||||
|
label: 'Sender Email',
|
||||||
|
description: 'The verified email address or domain you send from in Resend.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
17
lib/notification/adapter/resend.md
Normal file
17
lib/notification/adapter/resend.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
### Resend Adapter
|
||||||
|
|
||||||
|
Resend is a modern email delivery service that Fredy can use to send notifications.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
- Create a Resend account: https://resend.com/
|
||||||
|
- Create an API key and add it to Fredy's configuration.
|
||||||
|
- Choose the sender address (e.g., you@yourdomain.com). Verify the domain (https://resend.com/domains/) in Resend before using it.
|
||||||
|
- Optional for local testing: you can use `onboarding@resend.dev`, but Resend may restrict who you can send to when using test domains.
|
||||||
|
|
||||||
|
Multiple recipients:
|
||||||
|
- Separate email addresses with commas (e.g., some@email.com, someOther@email.com).
|
||||||
|
|
||||||
|
Notes & Troubleshooting:
|
||||||
|
- Ensure the `from` address is verified or belongs to a verified domain in Resend.
|
||||||
|
- If emails don't arrive, check your spam folder and Resend dashboard logs.
|
||||||
|
- The template displays listing images via their public URLs; make sure images are reachable.
|
||||||
@@ -9,7 +9,9 @@ import checkIfListingIsActive from '../services/listings/listingActiveTester.js'
|
|||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
|
||||||
function shortenLink(link) {
|
function shortenLink(link) {
|
||||||
return link.substring(0, link.indexOf('?'));
|
if (!link) return '';
|
||||||
|
const index = link.indexOf('?');
|
||||||
|
return index === -1 ? link : link.substring(0, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseId(shortenedLink) {
|
function parseId(shortenedLink) {
|
||||||
@@ -23,7 +25,7 @@ function normalize(o) {
|
|||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
const address = o.address || null;
|
const address = o.address || null;
|
||||||
const shortLink = shortenLink(o.link);
|
const shortLink = shortenLink(o.link);
|
||||||
const link = `${baseUrl}/${shortLink}`;
|
const link = baseUrl + shortLink;
|
||||||
const image = baseUrl + o.image;
|
const image = baseUrl + o.image;
|
||||||
const id = buildHash(parseId(shortLink), o.price);
|
const id = buildHash(parseId(shortLink), o.price);
|
||||||
return Object.assign(o, { id, price, size, title, address, link, image });
|
return Object.assign(o, { id, price, size, title, address, link, image });
|
||||||
@@ -37,18 +39,18 @@ function applyBlacklist(o) {
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '._ref',
|
crawlContainer: 'a:has(div.list_entry)',
|
||||||
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
sortByDateParam: 'sort_col=*created_ts&sort_dir=desc',
|
||||||
waitForSelector: 'body',
|
waitForSelector: 'body',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@href', //will be transformed later
|
id: '@href', //will be transformed later
|
||||||
price: '.list_entry .immo_preis .label_info',
|
price: '.immo_preis .label_info',
|
||||||
size: '.list_entry .flaeche .label_info | removeNewline | trim',
|
size: '.flaeche .label_info | removeNewline | trim',
|
||||||
title: '.list_entry .part_text h3 span',
|
title: 'h3 span',
|
||||||
description: '.list_entry .description | trim',
|
description: '.description | trim',
|
||||||
link: '@href',
|
link: '@href',
|
||||||
address: '.list_entry .place',
|
address: '.place',
|
||||||
image: '.list_entry img@src',
|
image: 'img@src',
|
||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { isOneOf, buildHash } from '../utils.js';
|
|
||||||
import checkIfListingIsActive from '../services/listings/listingActiveTester.js';
|
|
||||||
let appliedBlackList = [];
|
|
||||||
|
|
||||||
function normalize(o) {
|
|
||||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
|
||||||
const price = o.price.replace('Kaufpreis ', '');
|
|
||||||
const address = o.address?.split(' • ')?.pop() ?? null;
|
|
||||||
const title = o.title || 'No title available';
|
|
||||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
|
||||||
const id = buildHash(title, price);
|
|
||||||
return Object.assign(o, { id, address, price, 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: 'div[data-testid="serp-core-classified-card-testid"]',
|
|
||||||
sortByDateParam: 'sortby=19',
|
|
||||||
waitForSelector: 'div[data-testid="serp-gridcontainer-testid"]',
|
|
||||||
crawlFields: {
|
|
||||||
id: 'button@title |trim',
|
|
||||||
title: 'button@title |trim',
|
|
||||||
price: 'div[data-testid="cardmfe-price-testid"] | trim',
|
|
||||||
size: 'div[data-testid="cardmfe-keyfacts-testid"] | trim',
|
|
||||||
address: 'div[data-testid="cardmfe-description-box-address"] | trim',
|
|
||||||
image: 'div[data-testid="cardmfe-picture-box-test-id"] img@src',
|
|
||||||
link: 'button@data-base',
|
|
||||||
description: 'div[data-testid="cardmfe-description-text-test-id"] | 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: 'Immonet',
|
|
||||||
baseUrl: 'https://www.immonet.de/',
|
|
||||||
id: 'immonet',
|
|
||||||
};
|
|
||||||
export { config };
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
*
|
*
|
||||||
* The mobile API provides the following endpoints:
|
* The mobile API provides the following endpoints:
|
||||||
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
* - GET /search/total?{search parameters}: Returns the total number of listings for the given query
|
||||||
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin `
|
||||||
*
|
*
|
||||||
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
* - POST /search/list?{search parameters}: Actually retrieves the listings. Body is json encoded and contains
|
||||||
* data specifying additional results (advertisements) to return. The format is as follows:
|
* data specifying additional results (advertisements) to return. The format is as follows:
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
* ```
|
* ```
|
||||||
* It is not necessary to provide data for the specified keys.
|
* It is not necessary to provide data for the specified keys.
|
||||||
*
|
*
|
||||||
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.3_26.0_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
* Example: `curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' -H "Connection: keep-alive" -H "User-Agent: ImmoScout_27.12_26.2_._" -H "Accept: application/json" -H "Content-Type: application/json" -d '{"supportedResultListType": [], "userData": {}}'`
|
||||||
|
|
||||||
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
* - GET /expose/{id} - Returns the details of a listing. The response contains additional details not included in the
|
||||||
* listing response.
|
* listing response.
|
||||||
*
|
*
|
||||||
* Example: `curl -H "User-Agent: ImmoScout_27.3_26.0_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
* Example: `curl -H "User-Agent: ImmoScout_27.12_26.2_._" "https://api.mobile.immobilienscout24.de/expose/158382494"`
|
||||||
*
|
*
|
||||||
*
|
*
|
||||||
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
* It is necessary to set the correct User Agent (see `getListings`) in the request header.
|
||||||
@@ -46,13 +46,15 @@ import {
|
|||||||
convertWebToMobile,
|
convertWebToMobile,
|
||||||
} from '../services/immoscout/immoscout-web-translator.js';
|
} from '../services/immoscout/immoscout-web-translator.js';
|
||||||
import logger from '../services/logger.js';
|
import logger from '../services/logger.js';
|
||||||
|
import { getUserSettings } from '../services/storage/settingsStorage.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
|
let currentUserId = null;
|
||||||
|
|
||||||
async function getListings(url) {
|
async function getListings(url) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -66,29 +68,92 @@ async function getListings(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = await response.json();
|
const responseBody = await response.json();
|
||||||
return responseBody.resultListItems
|
return Promise.all(
|
||||||
.filter((item) => item.type === 'EXPOSE_RESULT')
|
responseBody.resultListItems
|
||||||
.map((expose) => {
|
.filter((item) => item.type === 'EXPOSE_RESULT')
|
||||||
const item = expose.item;
|
.map(async (expose) => {
|
||||||
const [price, size] = item.attributes;
|
const item = expose.item;
|
||||||
const image = item?.titlePicture?.preview ?? null;
|
const [price, size] = item.attributes;
|
||||||
return {
|
const image = item?.titlePicture?.full ?? item?.titlePicture?.preview ?? null;
|
||||||
id: item.id,
|
let listing = {
|
||||||
price: price?.value,
|
id: item.id,
|
||||||
size: size?.value,
|
price: price?.value,
|
||||||
title: item.title,
|
size: size?.value,
|
||||||
description: item.description,
|
title: item.title,
|
||||||
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
link: `${metaInformation.baseUrl}expose/${item.id}`,
|
||||||
address: item.address?.line,
|
address: item.address?.line,
|
||||||
image,
|
image,
|
||||||
};
|
};
|
||||||
});
|
if (currentUserId) {
|
||||||
|
const userSettings = getUserSettings(currentUserId);
|
||||||
|
if (userSettings.immoscout_details) {
|
||||||
|
return await pushDetails(listing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return listing;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushDetails(listing) {
|
||||||
|
const detailed = await fetch(`https://api.mobile.immobilienscout24.de/expose/${listing.id}`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!detailed.ok) {
|
||||||
|
logger.error('Error fetching listing details from ImmoScout Mobile API:', detailed.statusText);
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
const detailBody = await detailed.json();
|
||||||
|
|
||||||
|
listing.description = buildDescription(detailBody);
|
||||||
|
|
||||||
|
return listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDescription(detailBody) {
|
||||||
|
const sections = detailBody.sections || [];
|
||||||
|
const contact = detailBody.contact || {};
|
||||||
|
const cData = contact?.contactData || {};
|
||||||
|
const agentName = cData?.agent?.name || '';
|
||||||
|
const agentCompany = cData?.agent?.company || '';
|
||||||
|
const stars = cData?.agent?.rating?.numberOfStars || '';
|
||||||
|
const phoneNumbers = contact?.phoneNumbers || [];
|
||||||
|
const phoneNumbersMapped = phoneNumbers
|
||||||
|
.map((p) => `${p.label}: ${p.text}`)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const attributes = sections
|
||||||
|
.filter((s) => s.type === 'ATTRIBUTE_LIST')
|
||||||
|
.flatMap((s) => s.attributes)
|
||||||
|
.filter((attr) => attr.label && attr.text)
|
||||||
|
.map((attr) => `${attr.label} ${attr.text}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const freeText = sections
|
||||||
|
.filter((s) => s.type === 'TEXT_AREA')
|
||||||
|
.map((s) => {
|
||||||
|
return `${s.title}\n${s.text}`;
|
||||||
|
})
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
return (
|
||||||
|
`Agent: ${agentName ? agentName : 'Unbekannt'} ${agentCompany ? `(${agentCompany}) ` : ''}${stars ? `- ${stars} stars` : ''}\n` +
|
||||||
|
(phoneNumbersMapped ? `Phone Numbers:\n${phoneNumbersMapped}` : '') +
|
||||||
|
'\n\n' +
|
||||||
|
attributes.trim() +
|
||||||
|
'\n\n' +
|
||||||
|
freeText.trim()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isListingActive(link) {
|
async function isListingActive(link) {
|
||||||
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
const result = await fetch(convertImmoscoutListingToMobileListing(link), {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,6 +202,7 @@ export const init = (sourceConfig, blacklist) => {
|
|||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
config.url = convertWebToMobile(sourceConfig.url);
|
config.url = convertWebToMobile(sourceConfig.url);
|
||||||
appliedBlackList = blacklist || [];
|
appliedBlackList = blacklist || [];
|
||||||
|
currentUserId = sourceConfig.userId || null;
|
||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Immoscout',
|
name: 'Immoscout',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const config = {
|
|||||||
},
|
},
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
filter: applyBlacklist,
|
filter: applyBlacklist,
|
||||||
activeTester: checkIfListingIsActive,
|
activeTester: (url) => checkIfListingIsActive(url, 'Angebot nicht gefunden'),
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist) => {
|
export const init = (sourceConfig, blacklist) => {
|
||||||
config.enabled = sourceConfig.enabled;
|
config.enabled = sourceConfig.enabled;
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { removeJobsByUserId } from '../storage/jobStorage.js';
|
|
||||||
import { getUsers } from '../storage/userStorage.js';
|
|
||||||
import logger from '../logger.js';
|
|
||||||
import cron from 'node-cron';
|
|
||||||
import { getSettings } from '../storage/settingsStorage.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
|
||||||
*/
|
|
||||||
export function cleanupDemoAtMidnight() {
|
|
||||||
cron.schedule('0 0 * * *', cleanup);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanup() {
|
|
||||||
const settings = await getSettings();
|
|
||||||
if (settings.demoMode) {
|
|
||||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
|
||||||
if (demoUser == null) {
|
|
||||||
logger.error('Demo user not found, cannot remove Jobs');
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
removeJobsByUserId(demoUser.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,28 +6,41 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
|
import { getListingsToGeocode, updateListingGeocoordinates } from '../storage/listingsStorage.js';
|
||||||
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
import { geocodeAddress, isGeocodingPaused } from '../geocoding/geoCodingService.js';
|
||||||
|
import { getJobs } from '../storage/jobStorage.js';
|
||||||
|
import { calculateDistanceForJob } from '../geocoding/distanceService.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
|
||||||
async function runTask() {
|
export async function runGeoCordTask() {
|
||||||
const listings = getListingsToGeocode();
|
const listings = getListingsToGeocode();
|
||||||
if (listings.length === 0) {
|
if (listings.length > 0) {
|
||||||
return;
|
for (const listing of listings) {
|
||||||
|
if (isGeocodingPaused()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = await geocodeAddress(listing.address);
|
||||||
|
if (coords) {
|
||||||
|
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const listing of listings) {
|
//additional run
|
||||||
if (isGeocodingPaused()) {
|
const jobs = getJobs();
|
||||||
break;
|
for (const job of jobs) {
|
||||||
}
|
calculateDistanceForJob(job.id, job.userId);
|
||||||
|
|
||||||
const coords = await geocodeAddress(listing.address);
|
|
||||||
if (coords) {
|
|
||||||
updateListingGeocoordinates(listing.id, coords.lat, coords.lng);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initGeocodingCron() {
|
export async function initGeocodingCron() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
|
logger.info('Do not start geo service as we are in demo mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// run directly on start
|
// run directly on start
|
||||||
await runTask();
|
await runGeoCordTask();
|
||||||
// then every 6 hours
|
// then every 6 hours
|
||||||
cron.schedule('0 */6 * * *', runTask);
|
cron.schedule('0 */6 * * *', runGeoCordTask);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,19 @@
|
|||||||
|
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import runActiveChecker from '../listings/listingActiveService.js';
|
import runActiveChecker from '../listings/listingActiveService.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
import { getSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
async function runTask() {
|
async function runTask() {
|
||||||
await runActiveChecker();
|
await runActiveChecker();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initActiveCheckerCron() {
|
export async function initActiveCheckerCron() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (settings.demoMode) {
|
||||||
|
logger.info('Do not start listing active checker as we are in demo mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
//run directly on start
|
//run directly on start
|
||||||
await runTask();
|
await runTask();
|
||||||
// then every day at 1 am
|
// then every day at 1 am
|
||||||
|
|||||||
@@ -19,52 +19,80 @@ import path from 'path';
|
|||||||
|
|
||||||
puppeteer.use(StealthPlugin());
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
export default async function execute(url, waitForSelector, options) {
|
export async function launchBrowser(url, options) {
|
||||||
let browser;
|
const preCfg = getPreLaunchConfig(url, options || {});
|
||||||
let page;
|
const launchArgs = [
|
||||||
let result = null;
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-crash-reporter',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
preCfg.langArg,
|
||||||
|
preCfg.windowSizeArg,
|
||||||
|
...preCfg.extraArgs,
|
||||||
|
];
|
||||||
|
if (options?.proxyUrl) {
|
||||||
|
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
let userDataDir;
|
let userDataDir;
|
||||||
let removeUserDataDir = false;
|
let removeUserDataDir = false;
|
||||||
|
if (options && options.userDataDir) {
|
||||||
|
userDataDir = options.userDataDir;
|
||||||
|
} else {
|
||||||
|
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
||||||
|
userDataDir = fs.mkdtempSync(prefix);
|
||||||
|
removeUserDataDir = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: options?.puppeteerHeadless ?? true,
|
||||||
|
args: launchArgs,
|
||||||
|
timeout: options?.puppeteerTimeout || 45_000,
|
||||||
|
userDataDir,
|
||||||
|
executablePath: options?.executablePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
browser.__fredy_userDataDir = userDataDir;
|
||||||
|
browser.__fredy_removeUserDataDir = removeUserDataDir;
|
||||||
|
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeBrowser(browser) {
|
||||||
|
if (!browser) return;
|
||||||
|
const userDataDir = browser.__fredy_userDataDir;
|
||||||
|
const removeUserDataDir = browser.__fredy_removeUserDataDir;
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (removeUserDataDir && userDataDir) {
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function execute(url, waitForSelector, options) {
|
||||||
|
let browser = options?.browser;
|
||||||
|
let isExternalBrowser = !!browser;
|
||||||
|
let page;
|
||||||
|
let result;
|
||||||
try {
|
try {
|
||||||
debug(`Sending request to ${url} using Puppeteer.`);
|
debug(`Sending request to ${url} using Puppeteer.`);
|
||||||
|
|
||||||
// Prepare a dedicated temporary userDataDir to avoid leaking /tmp/.org.chromium.* dirs
|
if (!isExternalBrowser) {
|
||||||
if (options && options.userDataDir) {
|
browser = await launchBrowser(url, options);
|
||||||
userDataDir = options.userDataDir;
|
|
||||||
removeUserDataDir = !!options.cleanupUserDataDir;
|
|
||||||
} else {
|
|
||||||
const prefix = path.join(os.tmpdir(), 'puppeteer-fredy-');
|
|
||||||
userDataDir = fs.mkdtempSync(prefix);
|
|
||||||
removeUserDataDir = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchArgs = [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-gpu',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
'--disable-crash-reporter',
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
];
|
|
||||||
if (options?.proxyUrl) {
|
|
||||||
launchArgs.push(`--proxy-server=${options.proxyUrl}`);
|
|
||||||
}
|
|
||||||
// Prepare bot prevention pre-launch config
|
|
||||||
const preCfg = getPreLaunchConfig(url, options || {});
|
|
||||||
launchArgs.push(preCfg.langArg);
|
|
||||||
launchArgs.push(preCfg.windowSizeArg);
|
|
||||||
launchArgs.push(...preCfg.extraArgs);
|
|
||||||
|
|
||||||
browser = await puppeteer.launch({
|
|
||||||
headless: options?.puppeteerHeadless ?? true,
|
|
||||||
args: launchArgs,
|
|
||||||
timeout: options?.puppeteerTimeout || 30_000,
|
|
||||||
userDataDir,
|
|
||||||
executablePath: options?.executablePath, // allow using system Chrome
|
|
||||||
});
|
|
||||||
|
|
||||||
page = await browser.newPage();
|
page = await browser.newPage();
|
||||||
|
const preCfg = getPreLaunchConfig(url, options || {});
|
||||||
await applyBotPreventionToPage(page, preCfg);
|
await applyBotPreventionToPage(page, preCfg);
|
||||||
// Provide languages value before navigation
|
// Provide languages value before navigation
|
||||||
await applyLanguagePersistence(page, preCfg);
|
await applyLanguagePersistence(page, preCfg);
|
||||||
@@ -104,7 +132,7 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
result = pageSource || (await page.content());
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.message?.includes('Timeout')) {
|
if (error?.name?.includes('Timeout')) {
|
||||||
logger.debug('Error executing with puppeteer executor', error);
|
logger.debug('Error executing with puppeteer executor', error);
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Error executing with puppeteer executor', error);
|
logger.warn('Error executing with puppeteer executor', error);
|
||||||
@@ -118,19 +146,8 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
if (browser != null && !isExternalBrowser) {
|
||||||
if (browser != null) {
|
await closeBrowser(browser);
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (removeUserDataDir && userDataDir) {
|
|
||||||
await fs.promises.rm(userDataDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
26
lib/services/geocoding/autocompleteService.js
Normal file
26
lib/services/geocoding/autocompleteService.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { autocomplete as nominatimAutocomplete } from './client/nominatimClient.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocompletes an address using Nominatim.
|
||||||
|
*
|
||||||
|
* @param {string} query - The search query.
|
||||||
|
* @returns {Promise<string[]>} List of matching addresses.
|
||||||
|
*/
|
||||||
|
export async function autocompleteAddress(query) {
|
||||||
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await nominatimAutocomplete(query);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during address autocomplete:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ async function doGeocode(address) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
agent,
|
agent,
|
||||||
|
timeout: 60000,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': userAgent,
|
'User-Agent': userAgent,
|
||||||
},
|
},
|
||||||
@@ -100,6 +101,53 @@ async function doGeocode(address) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autocompletes an address using Nominatim.
|
||||||
|
*
|
||||||
|
* @param {string} query - The search query.
|
||||||
|
* @returns {Promise<string[]>} List of matching addresses.
|
||||||
|
*/
|
||||||
|
async function doAutocomplete(query) {
|
||||||
|
if (Date.now() - last429 < PAUSE_DURATION) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
agent,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
logger.warn('Nominatim rate limit hit. Pausing for 1 hour.');
|
||||||
|
last429 = Date.now();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(`Nominatim API error: ${response.status} ${response.statusText}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((item) => item.display_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during Nominatim autocomplete:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const geocode = throttle(doGeocode);
|
export const geocode = throttle(doGeocode);
|
||||||
|
|
||||||
|
export const autocomplete = throttle(doAutocomplete);
|
||||||
|
|
||||||
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;
|
export const isPaused = () => Date.now() - last429 < PAUSE_DURATION;
|
||||||
|
|||||||
61
lib/services/geocoding/distanceService.js
Normal file
61
lib/services/geocoding/distanceService.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { distanceMeters } from '../listings/distanceCalculator.js';
|
||||||
|
import {
|
||||||
|
getListingsToCalculateDistance,
|
||||||
|
getListingsForUserToCalculateDistance,
|
||||||
|
updateListingDistance,
|
||||||
|
} from '../storage/listingsStorage.js';
|
||||||
|
import { getUserSettings } from '../storage/settingsStorage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates and updates distances for listings of a specific job.
|
||||||
|
* Only processes listings where distance_to_destination is null.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function calculateDistanceForJob(jobId, userId) {
|
||||||
|
const userSettings = getUserSettings(userId);
|
||||||
|
const homeAddress = userSettings.home_address;
|
||||||
|
|
||||||
|
if (!homeAddress || !homeAddress.coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listings = getListingsToCalculateDistance(jobId);
|
||||||
|
const { lat, lng } = homeAddress.coords;
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
|
||||||
|
updateListingDistance(listing.id, dist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates and updates distances for all active listings of a user.
|
||||||
|
* Usually called when the user updates their home address.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function calculateDistanceForUser(userId) {
|
||||||
|
const userSettings = getUserSettings(userId);
|
||||||
|
const homeAddress = userSettings.home_address;
|
||||||
|
|
||||||
|
if (!homeAddress || !homeAddress.coords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listings = getListingsForUserToCalculateDistance(userId);
|
||||||
|
const { lat, lng } = homeAddress.coords;
|
||||||
|
|
||||||
|
for (const listing of listings) {
|
||||||
|
const dist = distanceMeters(lat, lng, listing.latitude, listing.longitude);
|
||||||
|
updateListingDistance(listing.id, dist);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ const PARAM_NAME_MAP = {
|
|||||||
shape: 'shape',
|
shape: 'shape',
|
||||||
sorting: 'sorting',
|
sorting: 'sorting',
|
||||||
newbuilding: 'newbuilding',
|
newbuilding: 'newbuilding',
|
||||||
|
fulltext: 'fulltext',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EQUIPMENT_MAP = {
|
const EQUIPMENT_MAP = {
|
||||||
@@ -103,13 +104,17 @@ const REAL_ESTATE_TYPE = {
|
|||||||
'haus-mieten': 'houserent',
|
'haus-mieten': 'houserent',
|
||||||
'wohnung-mieten': 'apartmentrent',
|
'wohnung-mieten': 'apartmentrent',
|
||||||
'wohnung-kaufen': 'apartmentbuy',
|
'wohnung-kaufen': 'apartmentbuy',
|
||||||
|
'wohnung-kaufen-mit-balkon': 'apartmentbuy',
|
||||||
|
'eigentumswohnung-mit-garten': 'apartmentbuy',
|
||||||
'haus-kaufen': 'housebuy',
|
'haus-kaufen': 'housebuy',
|
||||||
};
|
};
|
||||||
|
|
||||||
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
const WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP = {
|
||||||
// Category "Balkon/Terrasse"
|
// Category "Balkon/Terrasse"
|
||||||
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
|
'wohnung-mit-balkon-mieten': { equipment: ['balcony'] },
|
||||||
|
'wohnung-kaufen-mit-balkon': { equipment: ['balcony'] },
|
||||||
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
|
'wohnung-mit-garten-mieten': { equipment: ['garden'] },
|
||||||
|
'eigentumswohnung-mit-garten': { equipment: ['garden'] },
|
||||||
// Category "Wohnungstyp"
|
// Category "Wohnungstyp"
|
||||||
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
|
'souterrainwohnung-mieten': { apartmenttypes: ['halfbasement'] },
|
||||||
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
|
'erdgeschosswohnung-mieten': { apartmenttypes: ['groundfloor'] },
|
||||||
@@ -144,7 +149,7 @@ export function convertWebToMobile(webUrl) {
|
|||||||
|
|
||||||
const realTypeKey = segments.at(-1);
|
const realTypeKey = segments.at(-1);
|
||||||
let realType = REAL_ESTATE_TYPE[realTypeKey];
|
let realType = REAL_ESTATE_TYPE[realTypeKey];
|
||||||
let additionalParamsFromWebPath;
|
let additionalParamsFromWebPath = WEB_PATH_TO_APARTMENT_EQUIPMENT_MAP[realTypeKey] || null;
|
||||||
|
|
||||||
if (!realType) {
|
if (!realType) {
|
||||||
// Test for seo optimized apartment path (only used on the ImmoScout web app)
|
// Test for seo optimized apartment path (only used on the ImmoScout web app)
|
||||||
@@ -165,7 +170,7 @@ export function convertWebToMobile(webUrl) {
|
|||||||
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
Object.entries(rawParams).filter(([key]) => key !== 'enteredFrom' && PARAM_NAME_MAP[key]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const geocodes = `/${segments.slice(2, 5).join('/')}`;
|
const geocodes = `/${segments.slice(2, segments.length - 1).join('/')}`;
|
||||||
const isRadius = segments.includes('radius');
|
const isRadius = segments.includes('radius');
|
||||||
const mobileParams = {
|
const mobileParams = {
|
||||||
searchType: isRadius ? 'radius' : 'region',
|
searchType: isRadius ? 'radius' : 'region',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
|
|||||||
import * as similarityCache from '../similarity-check/similarityCache.js';
|
import * as similarityCache from '../similarity-check/similarityCache.js';
|
||||||
import { isRunning, markFinished, markRunning } from './run-state.js';
|
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||||
import { sendToUsers } from '../sse/sse-broker.js';
|
import { sendToUsers } from '../sse/sse-broker.js';
|
||||||
|
import * as puppeteerExtractor from '../extractor/puppeteerExtractor.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the job execution service.
|
* Initializes the job execution service.
|
||||||
@@ -94,7 +95,7 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function runAll(respectWorkingHours = true, context = undefined) {
|
async function runAll(respectWorkingHours = true, context = undefined) {
|
||||||
if (settings.demoMode) return;
|
if (settings.demoMode) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
||||||
@@ -103,15 +104,18 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.lastRun = now;
|
settings.lastRun = now;
|
||||||
jobStorage
|
const jobs = jobStorage
|
||||||
.getJobs()
|
.getJobs()
|
||||||
.filter((job) => job.enabled)
|
.filter((job) => job.enabled)
|
||||||
.filter((job) => {
|
.filter((job) => {
|
||||||
if (!context) return true; // startup/cron → all
|
if (!context) return true; // startup/cron → all
|
||||||
if (context.isAdmin) return true; // admin → all
|
if (context.isAdmin) return true; // admin → all
|
||||||
return context.userId ? job.userId === context.userId : false; // user → own
|
return context.userId ? job.userId === context.userId : false; // user → own
|
||||||
})
|
});
|
||||||
.forEach((job) => executeJob(job));
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
await executeJob(job);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,28 +158,43 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to emit start status for job', job.id, err);
|
logger.warn('Failed to emit start status for job', job.id, err);
|
||||||
}
|
}
|
||||||
|
let browser;
|
||||||
try {
|
try {
|
||||||
const jobProviders = job.provider.filter(
|
const jobProviders = job.provider.filter(
|
||||||
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||||
);
|
);
|
||||||
const executions = jobProviders.map(async (prov) => {
|
for (const prov of jobProviders) {
|
||||||
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
try {
|
||||||
matchedProvider.init(prov, job.blacklist);
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
await new FredyPipelineExecutioner(
|
matchedProvider.init({ ...prov, userId: job.userId }, job.blacklist);
|
||||||
matchedProvider.config,
|
|
||||||
job.notificationAdapter,
|
if (browser && !browser.isConnected()) {
|
||||||
prov.id,
|
logger.debug('Browser is disconnected, nullifying to launch a new one.');
|
||||||
job.id,
|
await puppeteerExtractor.closeBrowser(browser);
|
||||||
similarityCache,
|
browser = null;
|
||||||
).execute();
|
}
|
||||||
});
|
|
||||||
const results = await Promise.allSettled(executions);
|
if (!browser && matchedProvider.config.getListings == null) {
|
||||||
for (const r of results) {
|
browser = await puppeteerExtractor.launchBrowser(matchedProvider.config.url, {});
|
||||||
if (r.status === 'rejected') {
|
}
|
||||||
logger.error(r.reason);
|
|
||||||
|
await new FredyPipelineExecutioner(
|
||||||
|
matchedProvider.config,
|
||||||
|
job.notificationAdapter,
|
||||||
|
job.spatialFilter,
|
||||||
|
prov.id,
|
||||||
|
job.id,
|
||||||
|
similarityCache,
|
||||||
|
browser,
|
||||||
|
).execute();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (browser) {
|
||||||
|
await puppeteerExtractor.closeBrowser(browser);
|
||||||
|
}
|
||||||
markFinished(job.id);
|
markFinished(job.id);
|
||||||
try {
|
try {
|
||||||
bus.emit('jobs:status', { jobId: job.id, running: false });
|
bus.emit('jobs:status', { jobId: job.id, running: false });
|
||||||
|
|||||||
35
lib/services/listings/distanceCalculator.js
Normal file
35
lib/services/listings/distanceCalculator.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
const R = 6371000; // Earth radius in meters
|
||||||
|
/**
|
||||||
|
* Calculate the great-circle distance between two points on Earth using the Haversine formula.
|
||||||
|
* This is to calculate the distance between the listing address & the address provided by the user. I know, it is only
|
||||||
|
* a rough estimation as this calculates the distance as a straight line, but it's more convenient than using an external
|
||||||
|
* service and still gives a good approximation for sorting purposes.
|
||||||
|
* Returns distance in meters.
|
||||||
|
*
|
||||||
|
* @param {number} lat1
|
||||||
|
* @param {number} lon1
|
||||||
|
* @param {number} lat2
|
||||||
|
* @param {number} lon2
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function distanceMeters(lat1, lon1, lat2, lon2) {
|
||||||
|
const toRad = (deg) => (deg * Math.PI) / 180;
|
||||||
|
|
||||||
|
const phi1 = toRad(lat1);
|
||||||
|
const phi2 = toRad(lat2);
|
||||||
|
const dPhi = toRad(lat2 - lat1);
|
||||||
|
const dLambda = toRad(lon2 - lon1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dPhi / 2) * Math.sin(dPhi / 2) +
|
||||||
|
Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return Math.round(R * c * 10) / 10;
|
||||||
|
}
|
||||||
@@ -8,38 +8,71 @@ import { randomBetween, sleep } from '../../utils.js';
|
|||||||
|
|
||||||
const maxAttempts = 3;
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
const userAgents = [
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15',
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a listing is still active with up to 3 attempts and exponential backoff.
|
* Check if a listing is still active with up to 5 attempts and exponential backoff.
|
||||||
* Backoff waits are capped and the last wait is at most 2000 ms.
|
* Backoff waits are randomized and capped.
|
||||||
*
|
*
|
||||||
* Rules:
|
* Rules:
|
||||||
* - HTTP 200 => return 1
|
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
|
||||||
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||||
* - HTTP 404 => return 0
|
* - HTTP 404 => return 0
|
||||||
* - Other statuses or network errors => retry until attempts are exhausted
|
* - Other statuses or network errors => retry until attempts are exhausted
|
||||||
*
|
*
|
||||||
* @returns {Promise<Integer>} 1 if active, o if not active and -1 if detected as bot
|
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
|
||||||
*/
|
*/
|
||||||
export default async function checkIfListingIsActive(link) {
|
export default async function checkIfListingIsActive(link, checkForText = null) {
|
||||||
await sleep(randomBetween(50, 100));
|
await sleep(randomBetween(50, 100));
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
const userAgent = userAgents[Math.floor(Math.random() * userAgents.length)];
|
||||||
const res = await fetch(link, {
|
const res = await fetch(link, {
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'User-Agent': userAgent,
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
|
Accept:
|
||||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||||
|
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||||
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
|
'Cache-Control': 'max-age=0',
|
||||||
|
'Sec-Ch-Ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
||||||
|
'Sec-Ch-Ua-Mobile': '?0',
|
||||||
|
'Sec-Ch-Ua-Platform': '"macOS"',
|
||||||
|
'Sec-Fetch-Dest': 'document',
|
||||||
|
'Sec-Fetch-Mode': 'navigate',
|
||||||
|
'Sec-Fetch-Site': 'none',
|
||||||
|
'Sec-Fetch-User': '?1',
|
||||||
|
'Upgrade-Insecure-Requests': '1',
|
||||||
|
Referer: 'https://www.google.com/',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
|
if (checkForText) {
|
||||||
|
const htmText = await res.text();
|
||||||
|
if (htmText.includes(checkForText)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (res.status === 401) return -1;
|
if (res.status === 401 || res.status === 403) {
|
||||||
if (res.status === 403) return -1;
|
if (attempt < maxAttempts) {
|
||||||
if (res.status === 404) return 0;
|
await sleep(backoffDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (res.status === 404 || res.status === 410) return 0;
|
||||||
|
|
||||||
// For any other status, only retry if attempts remain
|
// For any other status, only retry if attempts remain
|
||||||
if (attempt < maxAttempts) {
|
if (attempt < maxAttempts) {
|
||||||
@@ -62,13 +95,13 @@ export default async function checkIfListingIsActive(link) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exponential backoff delay with cap.
|
* Exponential backoff delay with cap and jitter.
|
||||||
* attempt: 1 -> 500ms, 2 -> 1000ms, 3 -> 2000ms (cap)
|
|
||||||
* @param {number} attempt 1-based attempt index
|
* @param {number} attempt 1-based attempt index
|
||||||
* @returns {number} delay in ms
|
* @returns {number} delay in ms
|
||||||
*/
|
*/
|
||||||
function backoffDelay(attempt) {
|
function backoffDelay(attempt) {
|
||||||
const base = 500;
|
const base = 500;
|
||||||
const cap = 2000;
|
const cap = 2000;
|
||||||
return Math.min(base * 2 ** (attempt - 1), cap);
|
const delay = Math.min(base * 2 ** (attempt - 1), cap);
|
||||||
|
return delay + randomBetween(0, 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const upsertJob = ({
|
|||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
userId,
|
userId,
|
||||||
shareWithUsers = [],
|
shareWithUsers = [],
|
||||||
|
spatialFilter = null,
|
||||||
}) => {
|
}) => {
|
||||||
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];
|
||||||
@@ -37,12 +38,13 @@ export const upsertJob = ({
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`UPDATE jobs
|
`UPDATE jobs
|
||||||
SET enabled = @enabled,
|
SET enabled = @enabled,
|
||||||
name = @name,
|
name = @name,
|
||||||
blacklist = @blacklist,
|
blacklist = @blacklist,
|
||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter,
|
notification_adapter = @notification_adapter,
|
||||||
shared_with_user = @shareWithUsers
|
shared_with_user = @shareWithUsers,
|
||||||
|
spatial_filter = @spatialFilter
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -52,12 +54,13 @@ export const upsertJob = ({
|
|||||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
|
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} 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, shared_with_user, spatial_filter)
|
||||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers, @spatialFilter)`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
user_id: ownerId,
|
user_id: ownerId,
|
||||||
@@ -67,6 +70,7 @@ export const upsertJob = ({
|
|||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
shareWithUsers: toJson(shareWithUsers ?? []),
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
|
spatialFilter: spatialFilter ? toJson(spatialFilter) : null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,10 +91,11 @@ export const getJob = (jobId) => {
|
|||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
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
|
j.spatial_filter AS spatialFilter,
|
||||||
FROM jobs j
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
WHERE j.id = @id
|
FROM jobs j
|
||||||
LIMIT 1`,
|
WHERE j.id = @id
|
||||||
|
LIMIT 1`,
|
||||||
{ id: jobId },
|
{ id: jobId },
|
||||||
)[0];
|
)[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -101,6 +106,7 @@ export const getJob = (jobId) => {
|
|||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,9 +156,11 @@ export const getJobs = () => {
|
|||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
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
|
j.spatial_filter AS spatialFilter,
|
||||||
FROM jobs j
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
ORDER BY j.name IS NULL, j.name`,
|
FROM jobs j
|
||||||
|
WHERE j.enabled = 1
|
||||||
|
ORDER BY j.name IS NULL, j.name`,
|
||||||
);
|
);
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -161,6 +169,7 @@ export const getJobs = () => {
|
|||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -250,11 +259,12 @@ export const queryJobs = ({
|
|||||||
j.provider,
|
j.provider,
|
||||||
j.shared_with_user,
|
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
|
j.spatial_filter AS spatialFilter,
|
||||||
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
${whereSql}
|
${whereSql}
|
||||||
${orderSql}
|
${orderSql}
|
||||||
LIMIT @limit OFFSET @offset`,
|
LIMIT @limit OFFSET @offset`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -265,6 +275,7 @@ export const queryJobs = ({
|
|||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
shared_with_user: fromJson(row.shared_with_user, []),
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
|
spatialFilter: fromJson(row.spatialFilter, null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { totalNumber, page: safePage, result };
|
return { totalNumber, page: safePage, result };
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
|
|||||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
||||||
AVG(price) AS avgPrice
|
AVG(price) AS avgPrice
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id IN (${placeholders})`,
|
WHERE job_id IN (${placeholders})
|
||||||
|
AND manually_deleted = 0`,
|
||||||
jobIds,
|
jobIds,
|
||||||
)[0] || {};
|
)[0] || {};
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
|
|||||||
`SELECT provider, COUNT(*) AS cnt
|
`SELECT provider, COUNT(*) AS cnt
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id IN (${placeholders})
|
WHERE job_id IN (${placeholders})
|
||||||
|
AND manually_deleted = 0
|
||||||
GROUP BY provider
|
GROUP BY provider
|
||||||
ORDER BY cnt DESC`,
|
ORDER BY cnt DESC`,
|
||||||
jobIds,
|
jobIds,
|
||||||
@@ -118,8 +120,8 @@ 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)
|
||||||
OR is_active = 1
|
AND manually_deleted = 0
|
||||||
ORDER BY provider`,
|
ORDER BY provider`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -306,6 +308,9 @@ export const queryListings = ({
|
|||||||
whereParts.push('(wl.id IS NULL)');
|
whereParts.push('(wl.id IS NULL)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build whereSql (filtering by manually_deleted = 0)
|
||||||
|
whereParts.push('(l.manually_deleted = 0)');
|
||||||
|
|
||||||
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')
|
||||||
@@ -365,13 +370,21 @@ export const queryListings = ({
|
|||||||
* Delete all listings for a given job id.
|
* Delete all listings for a given job id.
|
||||||
*
|
*
|
||||||
* @param {string} jobId - The job identifier whose listings should be removed.
|
* @param {string} jobId - The job identifier whose listings should be removed.
|
||||||
* @returns {any} The result from SqliteConnection.execute (may contain changes count).
|
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
*/
|
*/
|
||||||
export const deleteListingsByJobId = (jobId) => {
|
export const deleteListingsByJobId = (jobId, hardDelete = false) => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
if (hardDelete) {
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE FROM listings
|
||||||
|
WHERE job_id = @jobId`,
|
||||||
|
{ jobId },
|
||||||
|
);
|
||||||
|
}
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`DELETE
|
`UPDATE listings
|
||||||
FROM listings
|
SET manually_deleted = 1
|
||||||
WHERE job_id = @jobId`,
|
WHERE job_id = @jobId`,
|
||||||
{ jobId },
|
{ jobId },
|
||||||
);
|
);
|
||||||
@@ -381,15 +394,23 @@ export const deleteListingsByJobId = (jobId) => {
|
|||||||
* Delete listings by a list of listing IDs.
|
* Delete listings by a list of listing IDs.
|
||||||
*
|
*
|
||||||
* @param {string[]} ids - Array of listing IDs to delete.
|
* @param {string[]} ids - Array of listing IDs to delete.
|
||||||
|
* @param {boolean} [hardDelete=false] - Whether to hard delete from DB or just mark as deleted.
|
||||||
* @returns {any} The result from SqliteConnection.execute.
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
*/
|
*/
|
||||||
export const deleteListingsById = (ids) => {
|
export const deleteListingsById = (ids, hardDelete = false) => {
|
||||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
if (hardDelete) {
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`DELETE FROM listings
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
}
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`DELETE
|
`UPDATE listings
|
||||||
FROM listings
|
SET manually_deleted = 1
|
||||||
WHERE id IN (${placeholders})`,
|
WHERE id IN (${placeholders})`,
|
||||||
ids,
|
ids,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -404,6 +425,7 @@ export const getListingsToGeocode = () => {
|
|||||||
`SELECT id, address
|
`SELECT id, address
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
|
AND manually_deleted = 0
|
||||||
AND address IS NOT NULL
|
AND address IS NOT NULL
|
||||||
AND (latitude IS NULL OR longitude IS NULL)`,
|
AND (latitude IS NULL OR longitude IS NULL)`,
|
||||||
);
|
);
|
||||||
@@ -443,6 +465,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
|
|||||||
'l.latitude != -1',
|
'l.latitude != -1',
|
||||||
'l.longitude != -1',
|
'l.longitude != -1',
|
||||||
'l.is_active = 1',
|
'l.is_active = 1',
|
||||||
|
'l.manually_deleted = 0',
|
||||||
];
|
];
|
||||||
const params = { userId: userId || '__NO_USER__' };
|
const params = { userId: userId || '__NO_USER__' };
|
||||||
|
|
||||||
@@ -479,7 +502,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
|
|||||||
* @returns {{title: string|null, address: string|null, price: number|null}[]}
|
* @returns {{title: string|null, address: string|null, price: number|null}[]}
|
||||||
*/
|
*/
|
||||||
export const getAllEntriesFromListings = () => {
|
export const getAllEntriesFromListings = () => {
|
||||||
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
|
return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,6 +516,7 @@ export const getGeocoordinatesByAddress = (address) => {
|
|||||||
`SELECT latitude, longitude
|
`SELECT latitude, longitude
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE address = @address
|
WHERE address = @address
|
||||||
|
AND manually_deleted = 0
|
||||||
AND latitude IS NOT NULL
|
AND latitude IS NOT NULL
|
||||||
AND longitude IS NOT NULL
|
AND longitude IS NOT NULL
|
||||||
AND latitude != -1
|
AND latitude != -1
|
||||||
@@ -502,3 +526,105 @@ export const getGeocoordinatesByAddress = (address) => {
|
|||||||
)[0];
|
)[0];
|
||||||
return row ? { lat: row.latitude, lng: row.longitude } : null;
|
return row ? { lat: row.latitude, lng: row.longitude } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all active listings for a given job that have geocoordinates but no distance set.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {Object[]}
|
||||||
|
*/
|
||||||
|
export const getListingsToCalculateDistance = (jobId) => {
|
||||||
|
return SqliteConnection.query(
|
||||||
|
`SELECT id, latitude, longitude
|
||||||
|
FROM listings
|
||||||
|
WHERE job_id = @jobId
|
||||||
|
AND is_active = 1
|
||||||
|
AND manually_deleted = 0
|
||||||
|
AND latitude IS NOT NULL
|
||||||
|
AND longitude IS NOT NULL
|
||||||
|
AND distance_to_destination IS NULL`,
|
||||||
|
{ jobId },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all active listings for a given user (across all jobs) that have geocoordinates.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Object[]}
|
||||||
|
*/
|
||||||
|
export const getListingsForUserToCalculateDistance = (userId) => {
|
||||||
|
return SqliteConnection.query(
|
||||||
|
`SELECT l.id, l.latitude, l.longitude
|
||||||
|
FROM listings l
|
||||||
|
JOIN jobs j ON l.job_id = j.id
|
||||||
|
WHERE j.user_id = @userId
|
||||||
|
AND l.is_active = 1
|
||||||
|
AND l.manually_deleted = 0
|
||||||
|
AND l.latitude IS NOT NULL
|
||||||
|
AND l.longitude IS NOT NULL`,
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the distance to destination for a listing.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {number} distance
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const updateListingDistance = (id, distance) => {
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`UPDATE listings
|
||||||
|
SET distance_to_destination = @distance
|
||||||
|
WHERE id = @id`,
|
||||||
|
{ id, distance },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a single listing by id.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {boolean} isAdmin
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
export const getListingById = (id, userId = null, isAdmin = false) => {
|
||||||
|
const params = { id, userId: userId || '__NO_USER__' };
|
||||||
|
let whereScoping = '';
|
||||||
|
if (!isAdmin) {
|
||||||
|
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
SqliteConnection.query(
|
||||||
|
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
|
||||||
|
FROM listings l
|
||||||
|
LEFT JOIN jobs j ON j.id = l.job_id
|
||||||
|
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
|
||||||
|
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
|
||||||
|
params,
|
||||||
|
)[0] || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets geocoordinates and distance for all listings related to a user.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const resetGeocoordinatesAndDistanceForUser = (userId) => {
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`UPDATE listings
|
||||||
|
SET latitude = NULL,
|
||||||
|
longitude = NULL,
|
||||||
|
distance_to_destination = NULL
|
||||||
|
WHERE job_id IN (
|
||||||
|
SELECT id FROM jobs j
|
||||||
|
WHERE j.user_id = @userId
|
||||||
|
)`,
|
||||||
|
{ userId },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function up(db) {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If parsing fails, let it throw to rollback the migration
|
// If parsing fails, let it throw to rollback the migration
|
||||||
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`);
|
throw new Error(`Failed to import users from ${usersJsonPath}: ${e.message}`, { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export function up(db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`);
|
throw new Error(`Failed to import jobs from ${jobsJsonPath}: ${e.message}`, { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
// 1. Add manually_deleted column
|
||||||
|
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
|
||||||
|
|
||||||
|
// 2. Remove change_set column
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
|
||||||
|
} catch {
|
||||||
|
// if column does not exists for whatever reason
|
||||||
|
}
|
||||||
|
}
|
||||||
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
11
lib/services/storage/migrations/sql/11.add-spatial-filter.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Migration: Add spatial_filter column to jobs table for storing GeoJSON-based spatial filters
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE jobs ADD COLUMN spatial_filter JSONB DEFAULT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
12
lib/services/storage/migrations/sql/8.distances.js
Normal file
12
lib/services/storage/migrations/sql/8.distances.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Migration: Removing city field and adding distance field
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE listings ADD COLUMN distance_to_destination INTEGER;
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
// 1. Remove old unique index
|
||||||
|
db.exec(`DROP INDEX IF EXISTS idx_settings_name;`);
|
||||||
|
|
||||||
|
// 2. Add new unique index for name and user_id.
|
||||||
|
// Since user_id can be NULL, we need a special index or use coalesce for the index.
|
||||||
|
// In SQLite, multiple NULLs are allowed in a UNIQUE index, which is fine for our global settings (user_id IS NULL).
|
||||||
|
// But we want only one global setting for a given name.
|
||||||
|
// Actually, in SQLite, UNIQUE allows multiple NULL values.
|
||||||
|
// To have only one NULL user_id for a name, we can use a partial index or COALESCE.
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE UNIQUE INDEX idx_settings_name_user_id ON settings (name, IFNULL(user_id, 'GLOBAL_SETTING'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -37,12 +37,25 @@ function compileSettings(rows, configValues) {
|
|||||||
* @returns {Record<string, any>}
|
* @returns {Record<string, any>}
|
||||||
*/
|
*/
|
||||||
export async function refreshSettingsCache() {
|
export async function refreshSettingsCache() {
|
||||||
const rows = SqliteConnection.query(`SELECT name, value FROM settings`);
|
const rows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id IS NULL`);
|
||||||
const configValues = await readConfigFromStorage();
|
const configValues = await readConfigFromStorage();
|
||||||
cachedSettingsConfig = compileSettings(rows, configValues);
|
cachedSettingsConfig = compileSettings(rows, configValues);
|
||||||
return cachedSettingsConfig;
|
return cachedSettingsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves user-specific settings from the database.
|
||||||
|
* @param {string} userId
|
||||||
|
* @returns {Record<string, any>}
|
||||||
|
*/
|
||||||
|
export function getUserSettings(userId) {
|
||||||
|
if (!userId || typeof userId !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const userRows = SqliteConnection.query(`SELECT name, value FROM settings WHERE user_id = @userId`, { userId });
|
||||||
|
return compileSettings(userRows, {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the compiled settings config. Loads it once and caches the result.
|
* Get the compiled settings config. Loads it once and caches the result.
|
||||||
* @returns {Record<string, any>}
|
* @returns {Record<string, any>}
|
||||||
@@ -77,16 +90,28 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
|
|||||||
: Object.entries(settingsMapOrEntry || {});
|
: Object.entries(settingsMapOrEntry || {});
|
||||||
|
|
||||||
for (const [name, rawValue] of entries) {
|
for (const [name, rawValue] of entries) {
|
||||||
const id = nanoid();
|
if (rawValue === null) {
|
||||||
const create_date = Date.now();
|
SqliteConnection.execute(
|
||||||
const json = toJson(rawValue);
|
`DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
|
||||||
SqliteConnection.execute(
|
{
|
||||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
name,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const id = nanoid();
|
||||||
|
const create_date = Date.now();
|
||||||
|
const json = toJson(rawValue);
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||||
VALUES (@id, @create_date, @name, @value, @userId)
|
VALUES (@id, @create_date, @name, @value, @userId)
|
||||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value`,
|
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
|
||||||
{ id, create_date, name, value: json, userId },
|
{ id, create_date, name, value: json, userId },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// keep cache in sync (only for global settings)
|
||||||
|
if (userId == null) {
|
||||||
|
refreshSettingsCache();
|
||||||
}
|
}
|
||||||
// keep cache in sync
|
|
||||||
refreshSettingsCache();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as hasher from '../security/hash.js';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import SqliteConnection from './SqliteConnection.js';
|
import SqliteConnection from './SqliteConnection.js';
|
||||||
import { getSettings } from './settingsStorage.js';
|
import { getSettings } from './settingsStorage.js';
|
||||||
|
import { inDevMode } from '../../utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all users.
|
* Get all users.
|
||||||
@@ -137,8 +138,12 @@ export const removeUser = (userId) => {
|
|||||||
export const ensureDemoUserExists = async () => {
|
export const ensureDemoUserExists = async () => {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (!settings.demoMode) {
|
if (!settings.demoMode) {
|
||||||
// Remove demo user (and cascade delete their jobs/listings)
|
if (!inDevMode()) {
|
||||||
SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`);
|
// Remove demo user (and cascade delete their jobs/listings)
|
||||||
|
SqliteConnection.execute(`DELETE
|
||||||
|
FROM users
|
||||||
|
WHERE username = 'demo'`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Ensure demo user exists when demo mode is on
|
// Ensure demo user exists when demo mode is on
|
||||||
|
|||||||
@@ -14,73 +14,89 @@ import { getSettings } from '../storage/settingsStorage.js';
|
|||||||
const deviceId = getUniqueId() || 'N/A';
|
const deviceId = getUniqueId() || 'N/A';
|
||||||
const version = await getPackageVersion();
|
const version = await getPackageVersion();
|
||||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||||
|
const isDocker = process.env.IS_DOCKER != null;
|
||||||
|
|
||||||
export const trackMainEvent = async () => {
|
const staticTrackingData = {
|
||||||
|
operatingSystem: os.platform(),
|
||||||
|
osVersion: os.release(),
|
||||||
|
isDocker,
|
||||||
|
arch: process.arch,
|
||||||
|
language: process.env.LANG || 'en',
|
||||||
|
nodeVersion: process.version || 'N/A',
|
||||||
|
deviceId,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldTrack = async () => {
|
||||||
|
const settings = await getSettings();
|
||||||
|
return settings.analyticsEnabled && !inDevMode();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTrackingData = async (endpoint, payload) => {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const response = await fetch(`${FREDY_TRACKING_URL}${endpoint}`, {
|
||||||
if (settings.analyticsEnabled && !inDevMode()) {
|
method: 'POST',
|
||||||
const activeProvider = new Set();
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const activeAdapter = new Set();
|
body: payload ? JSON.stringify(payload) : undefined,
|
||||||
|
});
|
||||||
const jobs = getJobs();
|
if (!response.ok) {
|
||||||
|
logger.warn(`Error sending tracking data to ${endpoint}. Status: ${response.status}`);
|
||||||
if (jobs != null && jobs.length > 0) {
|
|
||||||
jobs.forEach((job) => {
|
|
||||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
|
||||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const trackingObj = enrichTrackingObject({
|
|
||||||
adapter: Array.from(activeAdapter),
|
|
||||||
provider: Array.from(activeProvider),
|
|
||||||
});
|
|
||||||
|
|
||||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(trackingObj),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Error sending tracking data', error);
|
logger.warn(`Error sending tracking data to ${endpoint}`, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const trackMainEvent = async () => {
|
||||||
|
if (!(await shouldTrack())) return;
|
||||||
|
|
||||||
|
const activeProvider = new Set();
|
||||||
|
const activeAdapter = new Set();
|
||||||
|
|
||||||
|
const jobs = getJobs();
|
||||||
|
|
||||||
|
if (jobs != null && jobs.length > 0) {
|
||||||
|
jobs.forEach((job) => {
|
||||||
|
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||||
|
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackingObj = await enrichTrackingObject({
|
||||||
|
adapter: Array.from(activeAdapter),
|
||||||
|
provider: Array.from(activeProvider),
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendTrackingData('/main', trackingObj);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const trackPoi = async (poi) => {
|
||||||
|
if (!(await shouldTrack())) return;
|
||||||
|
|
||||||
|
const trackingObj = await enrichTrackingObject({
|
||||||
|
feature: poi,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendTrackingData('/feature', trackingObj);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note, this will only be used when Fredy runs in demo mode
|
* Note, this will only be used when Fredy runs in demo mode
|
||||||
*/
|
*/
|
||||||
export async function trackDemoAccessed() {
|
export async function trackDemoAccessed() {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) {
|
||||||
try {
|
const trackingObj = await enrichTrackingObject({});
|
||||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
await sendTrackingData('/demo/accessed', trackingObj);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Error sending tracking data', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichTrackingObject(trackingObject) {
|
async function enrichTrackingObject(trackingObject) {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const operatingSystem = os.platform();
|
|
||||||
const osVersion = os.release();
|
|
||||||
const arch = process.arch;
|
|
||||||
const language = process.env.LANG || 'en';
|
|
||||||
const nodeVersion = process.version || 'N/A';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...trackingObject,
|
...trackingObject,
|
||||||
|
...staticTrackingData,
|
||||||
isDemo: settings.demoMode,
|
isDemo: settings.demoMode,
|
||||||
operatingSystem,
|
|
||||||
osVersion,
|
|
||||||
arch,
|
|
||||||
nodeVersion,
|
|
||||||
language,
|
|
||||||
deviceId,
|
|
||||||
version,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function isOneOf(word, arr) {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0 || val === 'null' || val === 'undefined';
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13467
package-lock.json
generated
13467
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "18.0.2",
|
"version": "19.6.0",
|
||||||
"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",
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||||
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
"migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js",
|
||||||
"copyright": "node ./copyright.js"
|
"copyright": "node ./copyright.js",
|
||||||
|
"release": "node ./tools/release/release.js"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
@@ -59,59 +60,66 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.90.13",
|
"@douyinfe/semi-icons": "^2.92.2",
|
||||||
"@douyinfe/semi-ui": "2.90.13",
|
"@douyinfe/semi-ui": "2.92.2",
|
||||||
|
"@douyinfe/semi-ui-19": "^2.92.2",
|
||||||
|
"@mapbox/mapbox-gl-draw": "^1.5.1",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"body-parser": "2.2.2",
|
"body-parser": "2.2.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.2.0",
|
||||||
|
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.23",
|
||||||
"maplibre-gl": "^5.16.0",
|
"maplibre-gl": "^5.19.0",
|
||||||
"nanoid": "5.1.6",
|
"nanoid": "5.1.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.35.0",
|
"puppeteer": "^24.38.0",
|
||||||
"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": "19.2.4",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "19.2.4",
|
||||||
"react-range-slider-input": "^3.3.2",
|
"react-range-slider-input": "^3.3.2",
|
||||||
"react-router": "7.12.0",
|
"react-router": "7.13.1",
|
||||||
"react-router-dom": "7.12.0",
|
"react-router-dom": "7.13.1",
|
||||||
|
"resend": "^6.9.3",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.4",
|
||||||
"serve-static": "2.2.1",
|
"serve-static": "2.2.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.6",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/eslint-parser": "7.28.6",
|
"@babel/eslint-parser": "7.28.6",
|
||||||
"@babel/preset-env": "7.28.6",
|
"@babel/preset-env": "7.29.0",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"chai": "6.2.2",
|
"chai": "6.2.2",
|
||||||
"eslint": "9.39.2",
|
"chalk": "^5.6.2",
|
||||||
|
"eslint": "10.0.3",
|
||||||
"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",
|
||||||
|
"globals": "^17.4.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.5.1",
|
"less": "4.5.1",
|
||||||
"lint-staged": "16.2.7",
|
"lint-staged": "16.3.2",
|
||||||
"mocha": "11.7.5",
|
"mocha": "11.7.5",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.0"
|
"prettier": "3.8.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Challenges:
|
|||||||
_Returns the total number of listings for the given query._
|
_Returns the total number of listings for the given query._
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
|
"https://api.mobile.immobilienscout24.de/search/total?searchType=region&realestatetype=apartmentrent&pricetype=calculatedtotalrent&geocodes=%2Fde%2Fberlin%2Fberlin"
|
||||||
```
|
```
|
||||||
@@ -63,7 +63,7 @@ _The body is json encoded and contains data specifying additional results (adver
|
|||||||
```
|
```
|
||||||
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region&geocodes=%2Fde%2Fberlin%2Fberlin&pagenumber=1' \
|
||||||
-H "Connection: keep-alive" \
|
-H "Connection: keep-alive" \
|
||||||
-H "User-Agent: ImmoScout_27.3_26.0_._" \
|
-H "User-Agent: ImmoScout_27.12_26.2_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"supportedResultListType":[],"userData":{}}'
|
-d '{"supportedResultListType":[],"userData":{}}'
|
||||||
@@ -78,7 +78,7 @@ curl -X POST 'https://api.mobile.immobilienscout24.de/search/list?pricetype=calc
|
|||||||
The response contains additional details not included in the listing response.
|
The response contains additional details not included in the listing response.
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -H "User-Agent: ImmoScout_27.3_26.0_._" \
|
curl -H "User-Agent: ImmoScout_27.12_26.2_._" \
|
||||||
-H "Accept: application/json" \
|
-H "Accept: application/json" \
|
||||||
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
"https://api.mobile.immobilienscout24.de/expose/158382494"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
const db = {};
|
const db = {};
|
||||||
export const storeListings = (jobKey, providerId, listings) => {
|
export const storeListings = (jobKey, providerId, listings) => {
|
||||||
if (!Array.isArray(listings)) throw Error('Not a valid array');
|
if (!Array.isArray(listings)) throw Error('Not a valid array');
|
||||||
@@ -11,3 +12,20 @@ export const storeListings = (jobKey, providerId, listings) => {
|
|||||||
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
|
export const getKnownListingHashesForJobAndProvider = (jobKey, providerId) => {
|
||||||
return db[providerId] || [];
|
return db[providerId] || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getGeocoordinatesByAddress = (any) => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getUserSettings(userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateListingDistance = (id, distance) => {
|
||||||
|
// noop
|
||||||
|
};
|
||||||
|
export const deletedIds = [];
|
||||||
|
export const deleteListingsById = (ids) => {
|
||||||
|
deletedIds.push(...ids);
|
||||||
|
};
|
||||||
|
/* eslint-enable no-unused-vars */
|
||||||
|
|||||||
89
test/pipeline_filtering.test.js
Normal file
89
test/pipeline_filtering.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect } from 'chai';
|
||||||
|
import { mockFredy } from './utils.js';
|
||||||
|
import * as mockStore from './mocks/mockStore.js';
|
||||||
|
|
||||||
|
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
|
||||||
|
it('should call deleteListingsById when listings are filtered by similarity', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
|
||||||
|
const mockSimilarityCache = {
|
||||||
|
checkAndAddEntry: () => true, // always similar
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerConfig = {
|
||||||
|
url: 'http://example.com',
|
||||||
|
getListings: () => Promise.resolve([{ id: '1', title: 'test', address: 'addr', price: '100' }]),
|
||||||
|
normalize: (l) => l,
|
||||||
|
filter: () => true,
|
||||||
|
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, null, null, 'test-provider', 'test-job', mockSimilarityCache);
|
||||||
|
|
||||||
|
// Clear deletedIds before test
|
||||||
|
mockStore.deletedIds.length = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fredy.execute();
|
||||||
|
} catch {
|
||||||
|
// Might throw NoNewListingsWarning if all are filtered out
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockStore.deletedIds).to.include('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call deleteListingsById when listings are filtered by area', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
|
||||||
|
const mockSimilarityCache = {
|
||||||
|
checkAndAddEntry: () => false, // never similar
|
||||||
|
};
|
||||||
|
|
||||||
|
const spatialFilter = {
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[0, 1],
|
||||||
|
[1, 1],
|
||||||
|
[1, 0],
|
||||||
|
[0, 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerConfig = {
|
||||||
|
url: 'http://example.com',
|
||||||
|
getListings: () =>
|
||||||
|
Promise.resolve([{ id: '2', title: 'test', address: 'addr', price: '100', latitude: 2, longitude: 2 }]), // outside polygon
|
||||||
|
normalize: (l) => l,
|
||||||
|
filter: () => true,
|
||||||
|
crawlFields: { id: 'id', title: 'title', address: 'address', price: 'price' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, null, spatialFilter, 'test-provider', 'test-job', mockSimilarityCache);
|
||||||
|
|
||||||
|
// Clear deletedIds before test
|
||||||
|
mockStore.deletedIds.length = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fredy.execute();
|
||||||
|
} catch {
|
||||||
|
// Might throw NoNewListingsWarning if all are filtered out
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockStore.deletedIds).to.include('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,14 @@ describe('#einsAImmobilien testsuite()', () => {
|
|||||||
it('should test einsAImmobilien provider', async () => {
|
it('should test einsAImmobilien provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'einsAImmobilien', similarityCache);
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
'einsAImmobilien',
|
||||||
|
similarityCache,
|
||||||
|
);
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
expect(listings).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#immobilien.de testsuite()', () => {
|
|||||||
it('should test immobilien.de provider', async () => {
|
it('should test immobilien.de provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'test1', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'test1', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2026 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
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/immonet.js';
|
|
||||||
|
|
||||||
describe('#immonet testsuite()', () => {
|
|
||||||
it('should test immonet provider', async () => {
|
|
||||||
const Fredy = await mockFredy();
|
|
||||||
provider.init(providerConfig.immonet, [], []);
|
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', 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('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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,7 +14,7 @@ describe('#immoscout provider testsuite()', () => {
|
|||||||
it('should test immoscout provider', async () => {
|
it('should test immoscout provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, '', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, '', similarityCache);
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
expect(listings).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#immoswp testsuite()', () => {
|
|||||||
it('should test immoswp provider', async () => {
|
it('should test immoswp provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immoswp', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#immowelt testsuite()', () => {
|
|||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.immowelt, [], []);
|
provider.init(providerConfig.immowelt, [], []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'immowelt', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ describe('#kleinanzeigen testsuite()', () => {
|
|||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.kleinanzeigen, [], []);
|
provider.init(providerConfig.kleinanzeigen, [], []);
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'kleinanzeigen', similarityCache);
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
'kleinanzeigen',
|
||||||
|
similarityCache,
|
||||||
|
);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#mcMakler testsuite()', () => {
|
|||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.mcMakler, []);
|
provider.init(providerConfig.mcMakler, []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'mcMakler', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ describe('#neubauKompass testsuite()', () => {
|
|||||||
it('should test neubauKompass provider', async () => {
|
it('should test neubauKompass provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'neubauKompass', similarityCache);
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
'neubauKompass',
|
||||||
|
similarityCache,
|
||||||
|
);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#ohneMakler testsuite()', () => {
|
|||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.ohneMakler, []);
|
provider.init(providerConfig.ohneMakler, []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'ohneMakler', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe('#regionalimmobilien24 testsuite()', () => {
|
|||||||
const fredy = new Fredy(
|
const fredy = new Fredy(
|
||||||
provider.config,
|
provider.config,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
provider.metaInformation.id,
|
provider.metaInformation.id,
|
||||||
'regionalimmobilien24',
|
'regionalimmobilien24',
|
||||||
similarityCache,
|
similarityCache,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#sparkasse testsuite()', () => {
|
|||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
provider.init(providerConfig.sparkasse, []);
|
provider.init(providerConfig.sparkasse, []);
|
||||||
|
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'sparkasse', similarityCache);
|
||||||
const listing = await fredy.execute();
|
const listing = await fredy.execute();
|
||||||
|
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
"url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
|
"url": "https://www.immobilien.de/Wohnen/Suchergebnisse-51797.html?search._digest=true&search._filter=wohnen&search.flaeche_von=50&search.objektart=wohnung&search.preis_bis=1200&search.typ=mieten&search.umkreis=15&search.wo=district%3A2434%2C2695%2C2621%2C2700%2C2967%2C2734%2C2909%2C2955%2C2392%2C2746%2C2767%2C2982%2C2904%2C2612%2C2892%2C2587%2C2871%2C2975%2C2591%2C2887%2C2569%2C2640%2C2735&sort_col=*created_ts&sort_dir=desc",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immonet": {
|
|
||||||
"url": "https://www.immonet.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2112&order=Default&m=homepage_new_search_classified_search_result",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"immowelt": {
|
"immowelt": {
|
||||||
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
"url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('#wgGesucht testsuite()', () => {
|
|||||||
it('should test wgGesucht provider', async () => {
|
it('should test wgGesucht provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
const fredy = new Fredy(provider.config, null, null, provider.metaInformation.id, 'wgGesucht', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ describe('#wohnungsboerse testsuite()', () => {
|
|||||||
it('should test wohnungsboerse provider', async () => {
|
it('should test wohnungsboerse provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'wohnungsboerse', similarityCache);
|
const fredy = new Fredy(
|
||||||
|
provider.config,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
provider.metaInformation.id,
|
||||||
|
'wohnungsboerse',
|
||||||
|
similarityCache,
|
||||||
|
);
|
||||||
fredy.execute().then((listings) => {
|
fredy.execute().then((listings) => {
|
||||||
expect(listings).to.be.a('array');
|
expect(listings).to.be.a('array');
|
||||||
const notificationObj = get();
|
const notificationObj = get();
|
||||||
|
|||||||
@@ -14,12 +14,6 @@
|
|||||||
"shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0",
|
"shouldBecome": "https://www.wg-gesucht.de/1-zimmer-wohnungen-in-Dusseldorf.30.1.1.0.html?sort_column=0&sort_order=0",
|
||||||
"id": "wgGesucht"
|
"id": "wgGesucht"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?sortby=0&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
|
|
||||||
"shouldBecome": "https://www.immonet.de/immobiliensuche/sel.do?sortby=19&suchart=1&objecttype=1&marketingtype=2&parentcat=1&locationname=d%C3%BCsseldorf",
|
|
||||||
"id": "immonet"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
|
"url": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/",
|
||||||
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",
|
"shouldBecome": "https://www.neubaukompass.de/neubau-immobilien/berlin-region/?Sortierung=Id&Richtung=DESC",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'ImmoScout_27.3_26.0_._',
|
'User-Agent': 'ImmoScout_27.12_26.2_._',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -75,7 +75,9 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
expect(responseBody.totalResults).to.be.greaterThan(0);
|
||||||
expect(responseBody.totalResults).to.be.greaterThan(0);
|
expect(responseBody.totalResults).to.be.greaterThan(0);
|
||||||
expect(responseBody.resultListItems.length).to.greaterThan(0);
|
expect(responseBody.resultListItems.length).to.greaterThan(0);
|
||||||
expect(responseBody.resultListItems[0].item.realEstateType).to.equal(type);
|
expect(responseBody.resultListItems.filter((r) => r.type === 'EXPOSE_RESULT')[0].item.realEstateType).to.equal(
|
||||||
|
type,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ export const mockFredy = async () => {
|
|||||||
'../lib/services/storage/listingsStorage.js': {
|
'../lib/services/storage/listingsStorage.js': {
|
||||||
...mockStore,
|
...mockStore,
|
||||||
},
|
},
|
||||||
|
'../lib/services/storage/settingsStorage.js': {
|
||||||
|
...mockStore,
|
||||||
|
},
|
||||||
|
'../lib/services/geocoding/geoCodingService.js': {
|
||||||
|
geocodeAddress: mockStore.getGeocoordinatesByAddress,
|
||||||
|
},
|
||||||
|
'../lib/services/storage/jobStorage.js': {
|
||||||
|
getJob: (jobKey) => ({ id: jobKey, userId: 'user1' }),
|
||||||
|
},
|
||||||
'../lib/notification/notify.js': {
|
'../lib/notification/notify.js': {
|
||||||
send,
|
send,
|
||||||
},
|
},
|
||||||
|
|||||||
196
tools/release/release.js
Normal file
196
tools/release/release.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { execSync, spawn } from 'child_process';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release Tool for Fredy
|
||||||
|
*
|
||||||
|
* This tool automates the process of creating a GitHub release.
|
||||||
|
* It fetches the latest release, compares it with the current master branch,
|
||||||
|
* allows manual editing of commit messages, and creates a new release on GitHub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Define __dirname for ESM
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Configuration and Paths
|
||||||
|
const CONFIG_PATH = path.join(__dirname, 'config.json');
|
||||||
|
const PACKAGE_JSON_PATH = path.join(__dirname, '../../package.json');
|
||||||
|
const REPO = 'orangecoding/fredy';
|
||||||
|
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||||
|
const GITHUB_TOKEN = config.github_token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to execute the release process
|
||||||
|
*/
|
||||||
|
async function createRelease() {
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
try {
|
||||||
|
console.log(chalk.cyan('🚀 Starting release process...'));
|
||||||
|
|
||||||
|
// 1. Load Configuration
|
||||||
|
if (!fs.existsSync(CONFIG_PATH)) {
|
||||||
|
console.error(chalk.red('❌ Error: config.json not found in tools/release/'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!GITHUB_TOKEN) {
|
||||||
|
console.error(chalk.red('❌ Error: GitHub token not configured.'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get current version from package.json
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8'));
|
||||||
|
const version = packageJson.version;
|
||||||
|
const tag = version; // Using version as tag
|
||||||
|
|
||||||
|
console.log(chalk.blue(`📦 Target version: ${version}`));
|
||||||
|
|
||||||
|
// 3. Check if release already exists
|
||||||
|
console.log(chalk.yellow('🔍 Checking if release already exists...'));
|
||||||
|
const existingReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITHUB_TOKEN}`,
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingReleaseResponse.status === 200) {
|
||||||
|
console.error(chalk.red(`❌ Error: A release with tag ${tag} already exists.`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch latest release to find the starting point for the diff
|
||||||
|
console.log(chalk.yellow('📡 Fetching latest release from GitHub...'));
|
||||||
|
const latestReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITHUB_TOKEN}`,
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latestReleaseResponse.ok) {
|
||||||
|
console.error(chalk.red('❌ Error fetching latest release.'));
|
||||||
|
const errorData = await latestReleaseResponse.json();
|
||||||
|
console.error(chalk.red(JSON.stringify(errorData)));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestRelease = await latestReleaseResponse.json();
|
||||||
|
const latestTag = latestRelease.tag_name;
|
||||||
|
console.log(chalk.green(`✅ Latest release found: ${latestTag}`));
|
||||||
|
|
||||||
|
// 5. Ensure the latest tag is available locally
|
||||||
|
console.log(chalk.yellow(`📡 Fetching tag ${latestTag} from remote...`));
|
||||||
|
try {
|
||||||
|
execSync(`git fetch origin tag ${latestTag} --no-tags`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.red(`❌ Error fetching tag ${latestTag} from origin.`));
|
||||||
|
console.error(error.message);
|
||||||
|
// We don't exit here, maybe it's already there but fetch failed for some reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Get commit messages between latest tag and current HEAD
|
||||||
|
console.log(chalk.yellow(`Git diff: ${latestTag} .. HEAD`));
|
||||||
|
let commitMessages;
|
||||||
|
try {
|
||||||
|
commitMessages = execSync(`git log ${latestTag}..HEAD --pretty=format:"- %s"`).toString().trim();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.red('❌ Error running git log. Make sure the latest tag is available locally.'), error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commitMessages) {
|
||||||
|
console.log(chalk.magenta('⚠️ No new commits found since last release.'));
|
||||||
|
commitMessages = '- No changes recorded';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Open commit messages in editor for manual adjustment
|
||||||
|
const tempFilePath = path.join(__dirname, 'CHANGELOG_EDIT.tmp');
|
||||||
|
const initialContent = `# Release Notes for ${version}\n# Edit the messages below. Lines starting with # will be ignored.\n\n${commitMessages}`;
|
||||||
|
fs.writeFileSync(tempFilePath, initialContent);
|
||||||
|
|
||||||
|
console.log(chalk.blue('📝 Opening editor for release notes (using nano or $EDITOR)...'));
|
||||||
|
await openInEditor(tempFilePath);
|
||||||
|
|
||||||
|
// 8. Read edited content
|
||||||
|
let editedContent = fs
|
||||||
|
.readFileSync(tempFilePath, 'utf8')
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => !line.startsWith('#'))
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
fs.unlinkSync(tempFilePath); // Clean up temp file
|
||||||
|
|
||||||
|
if (!editedContent) {
|
||||||
|
console.error(chalk.red('❌ Release notes are empty. Aborting release.'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Create the new release
|
||||||
|
console.log(chalk.cyan(`🚀 Creating release ${version} on GitHub...`));
|
||||||
|
const createResponse = await fetch(`https://api.github.com/repos/${REPO}/releases`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITHUB_TOKEN}`,
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tag_name: tag,
|
||||||
|
name: version,
|
||||||
|
body: editedContent,
|
||||||
|
draft: false,
|
||||||
|
prerelease: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createResponse.status === 201) {
|
||||||
|
const data = await createResponse.json();
|
||||||
|
console.log(chalk.green('🎉 Release successfully created!'));
|
||||||
|
console.log(chalk.green(`🔗 URL: ${data.html_url}`));
|
||||||
|
} else {
|
||||||
|
const errorData = await createResponse.json();
|
||||||
|
console.error(chalk.red('❌ Failed to create release.'));
|
||||||
|
console.error(chalk.red(JSON.stringify(errorData, null, 2)));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.red('💥 An unexpected error occurred:'));
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to open a file in a terminal editor
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
function openInEditor(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const editor = process.env.EDITOR || 'nano';
|
||||||
|
const child = spawn(editor, [filePath], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Editor exited with code ${code}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createRelease();
|
||||||
|
/* eslint-enable no-console */
|
||||||
120
ui/src/App.jsx
120
ui/src/App.jsx
@@ -8,6 +8,7 @@ import React, { useEffect } from 'react';
|
|||||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
|
import UserSettings from './views/userSettings/UserSettings';
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
@@ -18,15 +19,17 @@ 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-19';
|
||||||
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 MapView from './views/listings/Map.jsx';
|
import MapView from './views/listings/Map.jsx';
|
||||||
import Navigation from './components/navigation/Navigation.jsx';
|
import Navigation from './components/navigation/Navigation.jsx';
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
import { Layout } from '@douyinfe/semi-ui-19';
|
||||||
import FredyFooter from './components/footer/FredyFooter.jsx';
|
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||||
import Dashboard from './views/dashboard/Dashboard.jsx';
|
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||||
|
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||||
|
import NewsModal from './components/news/NewsModal.jsx';
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
@@ -39,13 +42,14 @@ export default function FredyApp() {
|
|||||||
async function init() {
|
async function init() {
|
||||||
await actions.user.getCurrentUser();
|
await actions.user.getCurrentUser();
|
||||||
if (!needsLogin()) {
|
if (!needsLogin()) {
|
||||||
await actions.features.getFeatures();
|
|
||||||
await actions.provider.getProvider();
|
await actions.provider.getProvider();
|
||||||
await actions.jobsData.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
await actions.jobsData.getSharableUserList();
|
await actions.jobsData.getSharableUserList();
|
||||||
await actions.notificationAdapter.getAdapter();
|
await actions.notificationAdapter.getAdapter();
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
|
await actions.userSettings.getUserSettings();
|
||||||
await actions.versionUpdate.getVersionUpdate();
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
|
await actions.tracking.getTrackingPois();
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -58,7 +62,7 @@ export default function FredyApp() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
const { Footer, Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
return loading ? null : needsLogin() ? (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -67,11 +71,11 @@ export default function FredyApp() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
) : (
|
) : (
|
||||||
<Layout className="app">
|
<Layout className="app">
|
||||||
<Layout className="app">
|
<Sider>
|
||||||
<Sider>
|
<Navigation isAdmin={isAdmin()} />
|
||||||
<Navigation isAdmin={isAdmin()} />
|
</Sider>
|
||||||
</Sider>
|
<Layout className="app__main">
|
||||||
<Content>
|
<Content className="app__content">
|
||||||
{versionUpdate?.newVersion && <VersionBanner />}
|
{versionUpdate?.newVersion && <VersionBanner />}
|
||||||
{settings.demoMode && (
|
{settings.demoMode && (
|
||||||
<>
|
<>
|
||||||
@@ -86,60 +90,58 @@ export default function FredyApp() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||||
<Divider />
|
{!settings.demoMode && <NewsModal />}
|
||||||
<div className="app__content">
|
<Routes>
|
||||||
<Routes>
|
<Route path="/403" element={<InsufficientPermission />} />
|
||||||
<Route path="/403" element={<InsufficientPermission />} />
|
<Route path="/jobs/new" element={<JobMutation />} />
|
||||||
<Route path="/jobs/new" element={<JobMutation />} />
|
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
<Route path="/listings" element={<Listings />} />
|
||||||
<Route path="/listings" element={<Listings />} />
|
<Route path="/listings/listing/:listingId" element={<ListingDetail />} />
|
||||||
<Route path="/map" element={<MapView />} />
|
<Route path="/map" element={<MapView />} />
|
||||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||||
|
|
||||||
{/* 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="/userSettings" element={<UserSettings />} />
|
||||||
path="/generalSettings"
|
<Route
|
||||||
element={
|
path="/generalSettings"
|
||||||
<PermissionAwareRoute currentUser={currentUser}>
|
element={
|
||||||
<GeneralSettings />
|
<PermissionAwareRoute currentUser={currentUser}>
|
||||||
</PermissionAwareRoute>
|
<GeneralSettings />
|
||||||
}
|
</PermissionAwareRoute>
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
|
||||||
<Footer>
|
|
||||||
<FredyFooter />
|
<FredyFooter />
|
||||||
</Footer>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,31 @@
|
|||||||
.app {
|
.app {
|
||||||
height: 100%;
|
height: 100vh;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
margin: 1rem;
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding: 24px;
|
||||||
|
background-color: var(--semi-color-bg-0);
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui.inverted.segment {
|
|
||||||
background: #31303078 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ui.black.label,
|
|
||||||
.ui.black.labels .label {
|
|
||||||
background-color: #31303078 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:link {
|
|
||||||
color: #54a9ff;
|
|
||||||
background-color: transparent;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited {
|
|
||||||
color: #54a9ff;
|
|
||||||
background-color: transparent;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #54a9ff;
|
|
||||||
background-color: transparent;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:active {
|
|
||||||
color: #54a9ff;
|
|
||||||
background-color: transparent;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './Index.less';
|
import './Index.less';
|
||||||
|
|
||||||
|
|||||||
BIN
ui/src/assets/news/1.png
Normal file
BIN
ui/src/assets/news/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
BIN
ui/src/assets/news/2.png
Normal file
BIN
ui/src/assets/news/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
20
ui/src/assets/news/news.json
Normal file
20
ui/src/assets/news/news.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876510",
|
||||||
|
"content":
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Listings can now be filtered by location",
|
||||||
|
"text": "Thanks to https://github.com/strech345 for adding a new feature that allows you to filter listings based on their their location. When creating a job, you can now create multiple areas in which Fredy searches for new listings",
|
||||||
|
"image": "1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "More details from the Immoscout Provider",
|
||||||
|
"text": "https://github.com/MindCollaps added a new user-setting. When enabled Fredy will pull way more information for each listing from Immoscout.",
|
||||||
|
"image": "2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Listings now marked as invisible if filtered",
|
||||||
|
"text": "So far, when a listing was filtered e.g. due to being a duplicate, it was not send to you, but still shown on the map. This was now fixed."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
70
ui/src/components/ListingDeletionModal.jsx
Normal file
70
ui/src/components/ListingDeletionModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const ListingDeletionModal = ({
|
||||||
|
visible,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
title = 'Delete Listings',
|
||||||
|
showOptions = true,
|
||||||
|
message = 'How would you like to delete the selected listing(s)?',
|
||||||
|
}) => {
|
||||||
|
const [deleteType, setDeleteType] = useState('soft');
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
onConfirm(!showOptions || deleteType === 'hard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
visible={visible}
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText="Confirm"
|
||||||
|
cancelText="Cancel"
|
||||||
|
style={{ maxWidth: '500px' }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</div>
|
||||||
|
{showOptions && (
|
||||||
|
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||||
|
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||||
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||||
|
scraping session.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||||
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
<Text strong>Remove from database (Hard Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are completely removed from the database.
|
||||||
|
<br />
|
||||||
|
<Text type="warning">
|
||||||
|
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||||
|
previously found.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListingDeletionModal;
|
||||||
@@ -1,92 +1,80 @@
|
|||||||
@import "DashboardCardColors.less";
|
|
||||||
|
|
||||||
.color-variant(@bg, @border, @text) {
|
|
||||||
background-color: @bg;
|
|
||||||
border: 1px solid @border;
|
|
||||||
color: @text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card {
|
.dashboard-card {
|
||||||
box-sizing: border-box;
|
|
||||||
padding: .8rem;
|
|
||||||
border-radius: .5rem;
|
|
||||||
border-width: 1px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
|
||||||
/* Make all KPI boxes the same size regardless of content/font */
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
height: 140px;
|
||||||
height: 10rem;
|
margin-bottom: 16px;
|
||||||
display: flex;
|
transition: transform 0.2s;
|
||||||
flex-direction: column;
|
background-color: rgba(36, 36, 36, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
--pulse-color: rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
&.blue {
|
&::after {
|
||||||
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
|
content: '';
|
||||||
}
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
&.orange {
|
left: 0;
|
||||||
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
|
right: 0;
|
||||||
}
|
bottom: 0;
|
||||||
|
border-radius: inherit;
|
||||||
&.green {
|
box-shadow: 0 4px 25px -2px var(--pulse-color);
|
||||||
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
|
opacity: 0;
|
||||||
}
|
animation: pulse 5s infinite ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
&.purple {
|
z-index: -1;
|
||||||
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
|
will-change: opacity;
|
||||||
}
|
|
||||||
|
|
||||||
&.gray {
|
|
||||||
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .6rem;
|
|
||||||
/* Keep header from growing content height */
|
|
||||||
min-height: 2rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
border-radius: .6rem;
|
font-size: 20px;
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
margin-top: .4rem;
|
width: 100%;
|
||||||
font-size: .7rem;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
margin: 0;
|
font-weight: 700;
|
||||||
font-size: 1.5rem;
|
margin-bottom: 4px;
|
||||||
line-height: 1.1;
|
color: var(--semi-color-text-0);
|
||||||
color: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
&.blue {
|
||||||
opacity: .8;
|
--pulse-color: var(--semi-color-primary);
|
||||||
overflow: hidden;
|
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.orange {
|
||||||
|
--pulse-color: var(--semi-color-warning);
|
||||||
|
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.green {
|
||||||
|
--pulse-color: var(--semi-color-success);
|
||||||
|
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.purple {
|
||||||
|
--pulse-color: var(--semi-color-info);
|
||||||
|
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.gray {
|
||||||
|
--pulse-color: rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 4px 20px -5px var(--pulse-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||||
* Copyright (c) 2025 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import './DashboardCard.less';
|
import './DashboardCard.less';
|
||||||
|
|
||||||
export default function KpiCard({
|
export default function KpiCard({
|
||||||
@@ -20,21 +15,28 @@ export default function KpiCard({
|
|||||||
color = 'gray',
|
color = 'gray',
|
||||||
children,
|
children,
|
||||||
}) {
|
}) {
|
||||||
|
const { Text } = Typography;
|
||||||
return (
|
return (
|
||||||
<div className={`dashboard-card ${color}`}>
|
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
|
||||||
<div className="dashboard-card__header">
|
<Space vertical align="start" spacing="tight" style={{ width: '100%' }}>
|
||||||
<div className="dashboard-card__icon">{icon}</div>
|
<Space>
|
||||||
<div className="dashboard-card__title">
|
<div className="dashboard-card__icon">{icon}</div>
|
||||||
<span>{title}</span>
|
<Text strong className="dashboard-card__title">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<div className="dashboard-card__content">
|
||||||
|
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||||
|
{value}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<Text size="small" type="tertiary" className="dashboard-card__desc">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Space>
|
||||||
<div className="dashboard-card__content">
|
</Card>
|
||||||
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
|
||||||
{value}
|
|
||||||
{children}
|
|
||||||
</p>
|
|
||||||
{description && <span className="dashboard-card__desc">{description}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,25 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import './FredyFooter.less';
|
import './FredyFooter.less';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
export default function FredyFooter() {
|
export default function FredyFooter() {
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
const { Footer } = Layout;
|
||||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fredyFooter">
|
<Footer className="fredyFooter">
|
||||||
<div className="fredyFooter__version">
|
<Space split={<Divider layout="vertical" />}>
|
||||||
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
|
<Text type="tertiary" size="small">
|
||||||
</div>
|
Fredy V{version?.localFredyVersion || 'N/A'}
|
||||||
<div className="fredyFooter__copyRight">
|
</Text>
|
||||||
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with ❤️</Text>
|
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
|
||||||
</div>
|
Made with ❤️
|
||||||
</div>
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,12 @@
|
|||||||
.fredyFooter {
|
.fredyFooter {
|
||||||
background:rgb(53, 54, 60);
|
background-color: var(--semi-color-bg-1);
|
||||||
color: white;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 1.7rem;
|
padding: 0 1rem;
|
||||||
border-radius: .3rem;
|
height: 32px;
|
||||||
border-top: 1px solid #45464b;
|
border-top: 1px solid var(--semi-color-border);
|
||||||
|
z-index: 1000;
|
||||||
&__version {
|
position: relative;
|
||||||
padding-left: .5rem;
|
flex-shrink: 0;
|
||||||
font-size: small;
|
|
||||||
|
|
||||||
}
|
|
||||||
&__copyRight {
|
|
||||||
padding-right: 1rem;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
@@ -20,12 +20,13 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Toast,
|
Toast,
|
||||||
Empty,
|
Empty,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconDelete,
|
IconDelete,
|
||||||
IconDescend2,
|
IconDescend2,
|
||||||
IconEdit,
|
IconEdit,
|
||||||
|
IconCopy,
|
||||||
IconPlayCircle,
|
IconPlayCircle,
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconBell,
|
IconBell,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
IconPlusCircle,
|
IconPlusCircle,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import { useActions, useSelector } from '../../../services/state/store.js';
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
@@ -59,6 +61,9 @@ const JobGrid = () => {
|
|||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
|
||||||
|
|
||||||
const pendingJobIdRef = useRef(null);
|
const pendingJobIdRef = useRef(null);
|
||||||
const evtSourceRef = useRef(null);
|
const evtSourceRef = useRef(null);
|
||||||
|
|
||||||
@@ -124,24 +129,35 @@ const JobGrid = () => {
|
|||||||
};
|
};
|
||||||
}, [handleFilterChange]);
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
const onJobRemoval = (jobId) => {
|
||||||
try {
|
setPendingDeletion({ type: 'job', jobId });
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
setDeleteModalVisible(true);
|
||||||
Toast.success('Job successfully removed');
|
|
||||||
loadData();
|
|
||||||
actions.jobsData.getJobs(); // refresh select list too
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onListingRemoval = async (jobId) => {
|
const onListingRemoval = (jobId) => {
|
||||||
|
setPendingDeletion({ type: 'listings', jobId });
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeletion = async (hardDelete) => {
|
||||||
|
const { type, jobId } = pendingDeletion;
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/job', { jobId });
|
if (type === 'job') {
|
||||||
Toast.success('Listings successfully removed');
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
|
Toast.success('Job and listings successfully removed');
|
||||||
|
} else if (type === 'listings') {
|
||||||
|
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||||
|
Toast.success('Listings successfully removed');
|
||||||
|
}
|
||||||
loadData();
|
loadData();
|
||||||
|
if (type === 'job') {
|
||||||
|
actions.jobsData.getJobs(); // refresh select list too
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error);
|
Toast.error(error.message || 'Error performing deletion');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setPendingDeletion(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,29 +200,21 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jobGrid">
|
<div className="jobGrid">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
|
||||||
<Button
|
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||||
style={{ width: '7rem', margin: 0 }}
|
|
||||||
type="primary"
|
|
||||||
icon={<IconPlusCircle />}
|
|
||||||
className="jobs__newButton"
|
|
||||||
onClick={() => navigate('/jobs/new')}
|
|
||||||
>
|
|
||||||
New Job
|
New Job
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
|
||||||
<div className="jobGrid__searchbar">
|
|
||||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
<Button
|
||||||
<Button
|
icon={<IconFilter />}
|
||||||
icon={<IconFilter />}
|
style={{ marginLeft: '8px' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowFilterBar(!showFilterBar);
|
setShowFilterBar(!showFilterBar);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Space>
|
||||||
|
|
||||||
{showFilterBar && (
|
{showFilterBar && (
|
||||||
<div className="jobGrid__toolbar">
|
<div className="jobGrid__toolbar">
|
||||||
@@ -274,7 +282,6 @@ const JobGrid = () => {
|
|||||||
<Card
|
<Card
|
||||||
className="jobGrid__card"
|
className="jobGrid__card"
|
||||||
bodyStyle={{ padding: '16px' }}
|
bodyStyle={{ padding: '16px' }}
|
||||||
headerLine={true}
|
|
||||||
title={
|
title={
|
||||||
<div className="jobGrid__header">
|
<div className="jobGrid__header">
|
||||||
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||||
@@ -287,7 +294,9 @@ const JobGrid = () => {
|
|||||||
'This job has been shared with you by another user, therefor it is read-only.',
|
'This job has been shared with you by another user, therefor it is read-only.',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
<div>
|
||||||
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -343,40 +352,61 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
<div className="jobGrid__actions">
|
<div className="jobGrid__actions">
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="primary"
|
<Button
|
||||||
theme="solid"
|
type="primary"
|
||||||
icon={<IconPlayCircle />}
|
style={{ background: '#21aa21b5' }}
|
||||||
disabled={job.isOnlyShared || job.running}
|
size="small"
|
||||||
onClick={() => onJobRun(job.id)}
|
theme="solid"
|
||||||
/>
|
icon={<IconPlayCircle />}
|
||||||
|
disabled={job.isOnlyShared || job.running}
|
||||||
|
onClick={() => onJobRun(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="secondary"
|
<Button
|
||||||
theme="solid"
|
type="secondary"
|
||||||
icon={<IconEdit />}
|
size="small"
|
||||||
disabled={job.isOnlyShared}
|
icon={<IconEdit />}
|
||||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
disabled={job.isOnlyShared}
|
||||||
/>
|
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Clone Job')}>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="tertiary"
|
||||||
|
size="small"
|
||||||
|
icon={<IconCopy />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="danger"
|
<Button
|
||||||
theme="solid"
|
type="danger"
|
||||||
icon={<IconDescend2 />}
|
size="small"
|
||||||
disabled={job.isOnlyShared}
|
icon={<IconDescend2 />}
|
||||||
onClick={() => onListingRemoval(job.id)}
|
disabled={job.isOnlyShared}
|
||||||
/>
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
<Button
|
<div>
|
||||||
type="danger"
|
<Button
|
||||||
theme="solid"
|
type="danger"
|
||||||
icon={<IconDelete />}
|
size="small"
|
||||||
disabled={job.isOnlyShared}
|
icon={<IconDelete />}
|
||||||
onClick={() => onJobRemoval(job.id)}
|
disabled={job.isOnlyShared}
|
||||||
/>
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -395,6 +425,21 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||||
|
showOptions={pendingDeletion?.type !== 'job'}
|
||||||
|
message={
|
||||||
|
pendingDeletion?.type === 'job'
|
||||||
|
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||||
|
: 'How would you like to delete the selected listing(s)?'
|
||||||
|
}
|
||||||
|
onConfirm={confirmDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setPendingDeletion(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
.jobGrid {
|
.jobGrid {
|
||||||
&__card {
|
&__card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
background-color: rgba(36, 36, 36, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: var(--semi-shadow-elevated);
|
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||||
|
background-color: rgba(36, 36, 36, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,12 +24,14 @@
|
|||||||
|
|
||||||
&__toolbar {
|
&__toolbar {
|
||||||
&__card {
|
&__card {
|
||||||
border-radius: 5px;
|
border-radius: var(--semi-border-radius-medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .3rem;
|
gap: .3rem;
|
||||||
background: #232429;
|
background: rgba(36, 36, 36, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Popover,
|
Popover,
|
||||||
Empty,
|
Empty,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import {
|
import {
|
||||||
IconBriefcase,
|
IconBriefcase,
|
||||||
IconCart,
|
IconCart,
|
||||||
@@ -31,7 +31,11 @@ import {
|
|||||||
IconStarStroked,
|
IconStarStroked,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
|
IconActivity,
|
||||||
|
IconEyeOpened,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
import { xhrDelete, xhrPost } from '../../../services/xhr.js';
|
||||||
@@ -48,6 +52,7 @@ const ListingsGrid = () => {
|
|||||||
const providers = useSelector((state) => state.provider);
|
const providers = useSelector((state) => state.provider);
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
@@ -61,6 +66,9 @@ const ListingsGrid = () => {
|
|||||||
const [providerFilter, setProviderFilter] = useState(null);
|
const [providerFilter, setProviderFilter] = useState(null);
|
||||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
const loadData = () => {
|
const loadData = () => {
|
||||||
actions.listingsData.getListingsData({
|
actions.listingsData.getListingsData({
|
||||||
page,
|
page,
|
||||||
@@ -102,17 +110,36 @@ const ListingsGrid = () => {
|
|||||||
setPage(_page);
|
setPage(_page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmDeletion = async (hardDelete) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
||||||
|
Toast.success('Listing successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error.message || 'Error deleting listing');
|
||||||
|
} finally {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cap = (val) => {
|
||||||
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="listingsGrid">
|
<div className="listingsGrid">
|
||||||
<div className="listingsGrid__searchbar">
|
<div className="listingsGrid__searchbar">
|
||||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
<Button
|
<div>
|
||||||
icon={<IconFilter />}
|
<Button
|
||||||
onClick={() => {
|
icon={<IconFilter />}
|
||||||
setShowFilterBar(!showFilterBar);
|
onClick={() => {
|
||||||
}}
|
setShowFilterBar(!showFilterBar);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
{showFilterBar && (
|
{showFilterBar && (
|
||||||
@@ -216,6 +243,8 @@ const ListingsGrid = () => {
|
|||||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||||
<Card
|
<Card
|
||||||
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||||
cover={
|
cover={
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<div className="listingsGrid__imageContainer">
|
<div className="listingsGrid__imageContainer">
|
||||||
@@ -248,11 +277,9 @@ const ListingsGrid = () => {
|
|||||||
bodyStyle={{ padding: '12px' }}
|
bodyStyle={{ padding: '12px' }}
|
||||||
>
|
>
|
||||||
<div className="listingsGrid__content">
|
<div className="listingsGrid__content">
|
||||||
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
{cap(item.title)}
|
||||||
{item.title}
|
</Text>
|
||||||
</Text>
|
|
||||||
</a>
|
|
||||||
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
||||||
<Text type="secondary" icon={<IconCart />} size="small">
|
<Text type="secondary" icon={<IconCart />} size="small">
|
||||||
{item.price} €
|
{item.price} €
|
||||||
@@ -272,31 +299,40 @@ const ListingsGrid = () => {
|
|||||||
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
|
||||||
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
|
||||||
</Text>
|
</Text>
|
||||||
|
{item.distance_to_destination ? (
|
||||||
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
||||||
|
{item.distance_to_destination} m to chosen address
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text type="tertiary" size="small" icon={<IconActivity />}>
|
||||||
|
Distance cannot be calculated, provide an address
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Divider margin=".6rem" />
|
<Divider margin=".6rem" />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<IconLink />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Link to listing"
|
type="secondary"
|
||||||
type="primary"
|
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async () => {
|
title="View Details"
|
||||||
window.open(item.link);
|
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||||
}}
|
icon={<IconEyeOpened />}
|
||||||
icon={<IconLink />}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Remove"
|
title="Remove"
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async () => {
|
onClick={(e) => {
|
||||||
try {
|
e.stopPropagation();
|
||||||
await xhrDelete('/api/listings/', { ids: [item.id] });
|
setListingToDelete(item.id);
|
||||||
Toast.success('Listing(s) successfully removed');
|
setDeleteModalVisible(true);
|
||||||
loadData();
|
|
||||||
} catch (error) {
|
|
||||||
Toast.error(error);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
/>
|
/>
|
||||||
@@ -317,6 +353,14 @@ const ListingsGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ListingDeletionModal
|
||||||
|
visible={deleteModalVisible}
|
||||||
|
onConfirm={confirmDeletion}
|
||||||
|
onCancel={() => {
|
||||||
|
setDeleteModalVisible(false);
|
||||||
|
setListingToDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,11 +33,15 @@
|
|||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
background-color: rgba(36, 36, 36, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: var(--semi-shadow-elevated);
|
box-shadow: var(--semi-shadow-elevated);
|
||||||
|
background-color: rgba(36, 36, 36, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--inactive {
|
&--inactive {
|
||||||
@@ -90,17 +94,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__toolbar {
|
&__toolbar {
|
||||||
|
|
||||||
&__card {
|
&__card {
|
||||||
border-radius: 5px;
|
border-radius: var(--semi-border-radius-medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .3rem;
|
gap: .3rem;
|
||||||
background: #232429;
|
background: rgba(36, 36, 36, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--semi-color-border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__setupButton {
|
&__setupButton {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__linkButton {
|
||||||
|
background: var(--semi-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--semi-color-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import { Typography } from '@douyinfe/semi-ui-19';
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
export default function Headline({ text, size = 3 } = {}) {
|
export default function Headline({ text, size = 3 } = {}) {
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import logo from '../../assets/logo.png';
|
import logo from '../../assets/logo.png';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import { Button } from '@douyinfe/semi-ui-19';
|
||||||
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';
|
||||||
|
|
||||||
|
|||||||
213
ui/src/components/map/Map.jsx
Normal file
213
ui/src/components/map/Map.jsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
|
||||||
|
import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventListeners } from './MapDrawingExtension.js';
|
||||||
|
import './Map.less';
|
||||||
|
|
||||||
|
export const GERMANY_BOUNDS = [
|
||||||
|
[5.866, 47.27], // Southwest coordinates
|
||||||
|
[15.042, 55.059], // Northeast coordinates
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STYLES = {
|
||||||
|
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
|
||||||
|
SATELLITE: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'satellite-tiles': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution:
|
||||||
|
'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
||||||
|
},
|
||||||
|
'satellite-labels': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© Esri',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'satellite-tiles',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'satellite-tiles',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'satellite-labels',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'satellite-labels',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Map({
|
||||||
|
style = 'STANDARD',
|
||||||
|
show3dBuildings = false,
|
||||||
|
onMapReady = null,
|
||||||
|
enableDrawing = false,
|
||||||
|
initialSpatialFilter = null,
|
||||||
|
onDrawingChange = null,
|
||||||
|
}) {
|
||||||
|
const mapContainerRef = useRef(null);
|
||||||
|
const mapRef = useRef(null);
|
||||||
|
const drawRef = useRef(null);
|
||||||
|
|
||||||
|
// Initialize map - ONLY when container changes, never reinitialize
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current) return; // Map already exists, don't reinitialize
|
||||||
|
|
||||||
|
mapRef.current = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: STYLES[style],
|
||||||
|
center: [10.4515, 51.1657], // Center of Germany
|
||||||
|
zoom: 4,
|
||||||
|
maxBounds: GERMANY_BOUNDS,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.addControl(
|
||||||
|
new maplibregl.NavigationControl({
|
||||||
|
showCompass: true,
|
||||||
|
visualizePitch: true,
|
||||||
|
visualizeRoll: true,
|
||||||
|
}),
|
||||||
|
'top-right',
|
||||||
|
);
|
||||||
|
|
||||||
|
mapRef.current.addControl(
|
||||||
|
new maplibregl.GeolocateControl({
|
||||||
|
positionOptions: {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
},
|
||||||
|
trackUserLocation: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize drawing extension only if enabled
|
||||||
|
if (enableDrawing) {
|
||||||
|
fixMapboxDrawCompatibility();
|
||||||
|
drawRef.current = addDrawingControl(mapRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call onMapReady callback if provided
|
||||||
|
if (onMapReady) {
|
||||||
|
onMapReady(mapRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [mapContainerRef]); // ONLY depend on mapContainerRef - nothing else!
|
||||||
|
|
||||||
|
// Load spatial filter and setup area filter event listeners
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !drawRef.current || !enableDrawing) return;
|
||||||
|
|
||||||
|
// Load initial spatial filter if provided
|
||||||
|
if (initialSpatialFilter) {
|
||||||
|
try {
|
||||||
|
drawRef.current.set(initialSpatialFilter);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading spatial filter:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup drawing event listeners
|
||||||
|
const cleanup = setupAreaFilterEventListeners(mapRef.current, drawRef.current, onDrawingChange);
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [initialSpatialFilter, onDrawingChange, enableDrawing]);
|
||||||
|
|
||||||
|
// Handle style changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.setStyle(STYLES[style]);
|
||||||
|
}
|
||||||
|
}, [style]);
|
||||||
|
|
||||||
|
// Handle 3D buildings layer
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
const add3dLayer = () => {
|
||||||
|
if (!mapRef.current || !mapRef.current.isStyleLoaded()) return;
|
||||||
|
if (show3dBuildings) {
|
||||||
|
if (!mapRef.current.getSource('openfreemap')) {
|
||||||
|
mapRef.current.addSource('openfreemap', {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'https://tiles.openfreemap.org/planet',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!mapRef.current.getLayer('3d-buildings')) {
|
||||||
|
const layers = mapRef.current.getStyle().layers;
|
||||||
|
let labelLayerId;
|
||||||
|
for (let i = 0; i < layers.length; i++) {
|
||||||
|
if (layers[i].type === 'symbol' && layers[i].layout?.['text-field']) {
|
||||||
|
labelLayerId = layers[i].id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mapRef.current.addLayer(
|
||||||
|
{
|
||||||
|
id: '3d-buildings',
|
||||||
|
source: 'openfreemap',
|
||||||
|
'source-layer': 'building',
|
||||||
|
type: 'fill-extrusion',
|
||||||
|
minzoom: 15,
|
||||||
|
filter: ['!=', ['get', 'hide_3d'], true],
|
||||||
|
paint: {
|
||||||
|
'fill-extrusion-color': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['get', 'render_height'],
|
||||||
|
0,
|
||||||
|
'lightgray',
|
||||||
|
200,
|
||||||
|
'royalblue',
|
||||||
|
400,
|
||||||
|
'lightblue',
|
||||||
|
],
|
||||||
|
'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 15, 0, 16, ['get', 'render_height']],
|
||||||
|
'fill-extrusion-base': ['case', ['>=', ['get', 'zoom'], 16], ['get', 'render_min_height'], 0],
|
||||||
|
'fill-extrusion-opacity': 0.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labelLayerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (mapRef.current.getLayer('3d-buildings')) {
|
||||||
|
mapRef.current.removeLayer('3d-buildings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add3dLayer();
|
||||||
|
}, [show3dBuildings, style]);
|
||||||
|
|
||||||
|
// Handle pitch for 3D
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
mapRef.current.setPitch(show3dBuildings ? 45 : 0);
|
||||||
|
}, [show3dBuildings]);
|
||||||
|
|
||||||
|
return <div ref={mapContainerRef} className="map-container" />;
|
||||||
|
}
|
||||||
45
ui/src/components/map/Map.less
Normal file
45
ui/src/components/map/Map.less
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix Mapbox Draw cursors for MapLibre GL compatibility */
|
||||||
|
.maplibregl-map.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mouse-add .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mouse-move.mode-direct_select .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mode-direct_select.feature-vertex.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mode-direct_select.feature-midpoint.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mode-direct_select.feature-feature.mouse-move .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-map.mode-static.mouse-pointer .maplibregl-canvas-container.maplibregl-interactive {
|
||||||
|
cursor: grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
}
|
||||||
177
ui/src/components/map/MapDrawingExtension.js
Normal file
177
ui/src/components/map/MapDrawingExtension.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import MapboxDraw from '@mapbox/mapbox-gl-draw';
|
||||||
|
|
||||||
|
const drawStyles = [
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-fill-inactive',
|
||||||
|
type: 'fill',
|
||||||
|
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||||
|
paint: { 'fill-color': '#3bb2d0', 'fill-outline-color': '#3bb2d0', 'fill-opacity': 0.1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-fill-active',
|
||||||
|
type: 'fill',
|
||||||
|
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||||
|
paint: { 'fill-color': '#fbb03b', 'fill-outline-color': '#fbb03b', 'fill-opacity': 0.1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-midpoint',
|
||||||
|
type: 'circle',
|
||||||
|
filter: ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
|
||||||
|
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-stroke-inactive',
|
||||||
|
type: 'line',
|
||||||
|
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-stroke-active',
|
||||||
|
type: 'line',
|
||||||
|
filter: ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-line-inactive',
|
||||||
|
type: 'line',
|
||||||
|
filter: ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: { 'line-color': '#3bb2d0', 'line-width': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-line-active',
|
||||||
|
type: 'line',
|
||||||
|
filter: ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: { 'line-color': '#fbb03b', 'line-dasharray': [0.2, 2], 'line-width': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-and-line-vertex-stroke-inactive',
|
||||||
|
type: 'circle',
|
||||||
|
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||||
|
paint: { 'circle-radius': 5, 'circle-color': '#fff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-and-line-vertex-inactive',
|
||||||
|
type: 'circle',
|
||||||
|
filter: ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
|
||||||
|
paint: { 'circle-radius': 3, 'circle-color': '#fbb03b' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-point-point-stroke-inactive',
|
||||||
|
type: 'circle',
|
||||||
|
filter: [
|
||||||
|
'all',
|
||||||
|
['==', 'active', 'false'],
|
||||||
|
['==', '$type', 'Point'],
|
||||||
|
['==', 'meta', 'feature'],
|
||||||
|
['!=', 'mode', 'static'],
|
||||||
|
],
|
||||||
|
paint: { 'circle-radius': 5, 'circle-opacity': 1, 'circle-color': '#fff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-point-inactive',
|
||||||
|
type: 'circle',
|
||||||
|
filter: [
|
||||||
|
'all',
|
||||||
|
['==', 'active', 'false'],
|
||||||
|
['==', '$type', 'Point'],
|
||||||
|
['==', 'meta', 'feature'],
|
||||||
|
['!=', 'mode', 'static'],
|
||||||
|
],
|
||||||
|
paint: { 'circle-radius': 3, 'circle-color': '#3bb2d0' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-point-stroke-active',
|
||||||
|
type: 'circle',
|
||||||
|
filter: ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
|
||||||
|
paint: { 'circle-radius': 7, 'circle-color': '#fff' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-point-active',
|
||||||
|
type: 'circle',
|
||||||
|
filter: ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
|
||||||
|
paint: { 'circle-radius': 5, 'circle-color': '#fbb03b' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-fill-static',
|
||||||
|
type: 'fill',
|
||||||
|
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||||
|
paint: { 'fill-color': '#404040', 'fill-outline-color': '#404040', 'fill-opacity': 0.1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-polygon-stroke-static',
|
||||||
|
type: 'line',
|
||||||
|
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: { 'line-color': '#404040', 'line-width': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-line-static',
|
||||||
|
type: 'line',
|
||||||
|
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']],
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
paint: { 'line-color': '#404040', 'line-width': 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gl-draw-point-static',
|
||||||
|
type: 'circle',
|
||||||
|
filter: ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']],
|
||||||
|
paint: { 'circle-radius': 5, 'circle-color': '#404040' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function fixMapboxDrawCompatibility() {
|
||||||
|
MapboxDraw.constants.classes.CANVAS = 'maplibregl-canvas';
|
||||||
|
MapboxDraw.constants.classes.CONTROL_BASE = 'maplibregl-ctrl';
|
||||||
|
MapboxDraw.constants.classes.CONTROL_PREFIX = 'maplibregl-ctrl-';
|
||||||
|
MapboxDraw.constants.classes.CONTROL_GROUP = 'maplibregl-ctrl-group';
|
||||||
|
MapboxDraw.constants.classes.ATTRIBUTION = 'maplibregl-ctrl-attrib';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDrawingControl(map) {
|
||||||
|
const draw = new MapboxDraw({
|
||||||
|
displayControlsDefault: false,
|
||||||
|
controls: {
|
||||||
|
polygon: true,
|
||||||
|
trash: true,
|
||||||
|
},
|
||||||
|
styles: drawStyles,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(draw, 'top-left');
|
||||||
|
return draw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupAreaFilterEventListeners(map, draw, onDrawingChange) {
|
||||||
|
if (!map || !draw) return () => {};
|
||||||
|
|
||||||
|
const handleDrawChange = () => {
|
||||||
|
if (draw) {
|
||||||
|
const data = draw.getAll();
|
||||||
|
if (onDrawingChange) {
|
||||||
|
onDrawingChange(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('draw.create', handleDrawChange);
|
||||||
|
map.on('draw.update', handleDrawChange);
|
||||||
|
map.on('draw.delete', handleDrawChange);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
if (map) {
|
||||||
|
map.off('draw.create', handleDrawChange);
|
||||||
|
map.off('draw.update', handleDrawChange);
|
||||||
|
map.off('draw.delete', handleDrawChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,5 +6,6 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Nav } from '@douyinfe/semi-ui';
|
import { Button, Nav } from '@douyinfe/semi-ui-19';
|
||||||
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
import heart from '../../assets/heart.png';
|
import heart from '../../assets/heart.png';
|
||||||
@@ -12,7 +12,6 @@ import Logout from '../logout/Logout.jsx';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import './Navigate.less';
|
import './Navigate.less';
|
||||||
import { useFeature } from '../../hooks/featureHook.js';
|
|
||||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
|
||||||
export default function Navigation({ isAdmin }) {
|
export default function Navigation({ isAdmin }) {
|
||||||
@@ -21,7 +20,6 @@ export default function Navigation({ isAdmin }) {
|
|||||||
|
|
||||||
const width = useScreenWidth();
|
const width = useScreenWidth();
|
||||||
const [collapsed, setCollapsed] = useState(width <= 850);
|
const [collapsed, setCollapsed] = useState(width <= 850);
|
||||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (width <= 850) {
|
if (width <= 850) {
|
||||||
@@ -46,11 +44,9 @@ export default function Navigation({ isAdmin }) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ itemKey: '/users', text: 'User Management' },
|
{ itemKey: '/users', text: 'User Management' },
|
||||||
|
{ itemKey: '/userSettings', text: 'User Specific Settings' },
|
||||||
{ itemKey: '/generalSettings', text: 'General Settings' },
|
{ itemKey: '/generalSettings', text: 'General Settings' },
|
||||||
];
|
];
|
||||||
if (watchlistFeature) {
|
|
||||||
settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' });
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
@@ -58,6 +54,13 @@ export default function Navigation({ isAdmin }) {
|
|||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: settingsItems,
|
items: settingsItems,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
items.push({
|
||||||
|
itemKey: 'settings',
|
||||||
|
text: 'Settings',
|
||||||
|
icon: <IconSetting />,
|
||||||
|
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePathName(name) {
|
function parsePathName(name) {
|
||||||
@@ -67,20 +70,18 @@ export default function Navigation({ isAdmin }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Nav
|
<Nav
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
|
||||||
items={items}
|
items={items}
|
||||||
isCollapsed={collapsed}
|
isCollapsed={collapsed}
|
||||||
selectedKeys={[parsePathName(location.pathname)]}
|
selectedKeys={[parsePathName(location.pathname)]}
|
||||||
onSelect={(key) => {
|
onSelect={(key) => {
|
||||||
navigate(key.itemKey);
|
navigate(key.itemKey);
|
||||||
}}
|
}}
|
||||||
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
|
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
|
||||||
footer={
|
footer={
|
||||||
<Nav.Footer className="navigate__footer">
|
<Nav.Footer className="navigate__footer">
|
||||||
<Logout text={!collapsed} />
|
<Logout text={!collapsed} />
|
||||||
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
|
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
|
||||||
{!collapsed && 'Collapse'}
|
|
||||||
</Button>
|
|
||||||
</Nav.Footer>
|
</Nav.Footer>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
70
ui/src/components/news/NewsModal.jsx
Normal file
70
ui/src/components/news/NewsModal.jsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UserGuide } from '@douyinfe/semi-ui-19';
|
||||||
|
import { useScreenWidth } from '../../hooks/screenWidth';
|
||||||
|
import heart from '../../assets/heart.png';
|
||||||
|
import newsConfig from '../../assets/news/news.json';
|
||||||
|
import { useActions, useSelector } from '../../services/state/store';
|
||||||
|
|
||||||
|
import './NewsModal.less';
|
||||||
|
|
||||||
|
const newsImages = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||||
|
|
||||||
|
const NewsModal = () => {
|
||||||
|
const screenWidth = useScreenWidth();
|
||||||
|
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
||||||
|
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
||||||
|
const pois = useSelector((state) => state.tracking.pois);
|
||||||
|
const actions = useActions();
|
||||||
|
|
||||||
|
if (newsConfig == null || newsConfig.length === 0 || screenWidth <= 768) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = newsConfig.content.map((item) => ({
|
||||||
|
title: (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<img src={heart} width="30" alt="Fredy Logo" style={{ marginRight: '10px' }} />
|
||||||
|
<b>{item.title}</b>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div style={{ textAlign: 'left' }}>
|
||||||
|
{item.image && newsImages[`../../assets/news/${item.image}`] && (
|
||||||
|
<img
|
||||||
|
src={newsImages[`../../assets/news/${item.image}`]}
|
||||||
|
alt={item.title}
|
||||||
|
style={{ width: '100%', marginBottom: 10, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p dangerouslySetInnerHTML={{ __html: item.text }} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleClose = (poi) => {
|
||||||
|
actions.userSettings.setNewsHash(newsConfig.key);
|
||||||
|
if (poi) {
|
||||||
|
actions.tracking.trackPoi(poi);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserGuide
|
||||||
|
mode="modal"
|
||||||
|
mask={true}
|
||||||
|
steps={steps}
|
||||||
|
visible={userSettingsLoaded && newsHash !== newsConfig.key}
|
||||||
|
onFinish={() => handleClose(pois.WELCOME_FINISHED)}
|
||||||
|
onSkip={() => handleClose(pois.WELCOME_SKIPPED)}
|
||||||
|
modalProps={{
|
||||||
|
width: '10rem',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewsModal;
|
||||||
3
ui/src/components/news/NewsModal.less
Normal file
3
ui/src/components/news/NewsModal.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.semi-userGuide-modal-body-title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||||
|
|
||||||
export default function InsufficientPermission() {
|
export default function InsufficientPermission() {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
export default function PermissionAwareRoute({ currentUser, children }) {
|
export default function PermissionAwareRoute({ currentUser, children }) {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import './Placeholder.less';
|
import './Placeholder.less';
|
||||||
|
|
||||||
function getPlaceholder(rowCount, className) {
|
function getPlaceholder(rowCount, className) {
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import { Card } from '@douyinfe/semi-ui-19';
|
||||||
import { Card } from '@douyinfe/semi-ui';
|
|
||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|
||||||
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
|
export const SegmentPart = ({ name, Icon = null, children, helpText = null, className = '' }) => {
|
||||||
const { Meta } = Card;
|
const { Meta } = Card;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="segmentParts"
|
className={`segmentParts ${className}`}
|
||||||
title={
|
title={
|
||||||
(helpText || name) && (
|
(helpText || name) && (
|
||||||
<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" />} />
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user