From 130aa4ce960c53ab40b2a251997af42c105b17f5 Mon Sep 17 00:00:00 2001 From: domimisko Date: Thu, 18 Jun 2026 00:34:39 +0200 Subject: [PATCH 01/18] Add Homegate.ch provider with Swiss support --- .gitignore | 3 + lib/api/routes/listingsRouter.js | 3 + lib/provider/homegate.js | 125 +++++++++++++++ lib/services/extractor/puppeteerExtractor.js | 7 +- .../geocoding/client/nominatimClient.js | 4 +- lib/services/storage/listingsStorage.js | 8 +- .../sql/10.add-currency-to-listings.js | 8 + lib/types/listing.js | 1 + lib/utils/formatListing.js | 2 +- test/provider/homegate.test.js | 58 +++++++ test/provider/testProvider.json | 4 + test/testFixtures/homegate.html | 150 ++++++++++++++++++ .../components/grid/listings/ListingsGrid.jsx | 3 +- ui/src/components/map/Map.jsx | 10 +- ui/src/components/table/ListingsTable.jsx | 6 +- ui/src/services/price/priceService.js | 7 +- ui/src/views/listings/ListingDetail.jsx | 2 +- ui/src/views/listings/Map.jsx | 7 +- 18 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 lib/provider/homegate.js create mode 100644 lib/services/storage/migrations/sql/10.add-currency-to-listings.js create mode 100644 test/provider/homegate.test.js create mode 100644 test/testFixtures/homegate.html diff --git a/.gitignore b/.gitignore index 7ece1ba7..fb610998 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ ui/public/ db/*.json db/*.db* +conf/*.db +conf/*.db-shm +conf/*.db-wal npm-debug.log .DS_Store .idea diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index 8f2ae22b..b5c275c0 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -12,6 +12,7 @@ import { getJob } from '../../services/storage/jobStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js'; import { trackPoi } from '../../services/tracking/Tracker.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js'; +import { initSimilarityCache } from '../../services/similarity-check/similarityCache.js'; /** * @param {import('fastify').FastifyInstance} fastify @@ -172,6 +173,7 @@ export default async function listingsPlugin(fastify) { .send({ error: 'You are trying to remove listings for a job that is not associated to your user' }); } listingStorage.deleteListingsByJobId(jobId, hardDelete); + if (hardDelete) initSimilarityCache(); } catch (error) { logger.error(error); return reply.code(500).send({ error: error.message }); @@ -188,6 +190,7 @@ export default async function listingsPlugin(fastify) { } if (Array.isArray(ids) && ids.length > 0) { listingStorage.deleteListingsById(ids, hardDelete); + if (hardDelete) initSimilarityCache(); } } catch (error) { logger.error(error); diff --git a/lib/provider/homegate.js b/lib/provider/homegate.js new file mode 100644 index 00000000..a410ba51 --- /dev/null +++ b/lib/provider/homegate.js @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { buildHash, isOneOf } from '../utils.js'; +import { extractNumber } from '../utils/extract-number.js'; +/** @import { ParsedListing } from '../types/listing.js' */ +/** @import { ProviderConfig } from '../types/providerConfig.js' */ + +let appliedBlackList = []; + +/** + * Parses a Swiss formatted price such as "CHF 1,714.–" or "CHF 2,250.50" into a number. + * Swiss notation uses ',' as the thousands separator, '.' as the decimal separator and + * '.–' as a placeholder for ".00" - the opposite of the German notation handled by + * `extractNumber`. + * @param {string|undefined|null} str + * @returns {number|null} + */ +function parsePrice(str) { + if (str == null) return null; + const cleaned = str + .replace(/CHF/gi, '') + .replace(/[’'`]/g, '') + .replace(/,/g, '') + .replace(/[–-]\s*$/, '') + .trim(); + const num = parseFloat(cleaned); + return isNaN(num) ? null : num; +} + +/** + * @param {any} o + * @returns {ParsedListing} + */ +function normalize(o) { + // Extract the numeric listing ID which is stable across language variants + // (/rent/ vs /mieten/) and query params: "/mieten/4003179219?pos=3" → "4003179219". + const rawHref = o.id || o.link || ''; + const numericId = rawHref.match(/\/(\d{6,})/)?.[1] ?? rawHref.split('?')[0]; + const id = buildHash(numericId); + + // Normalize CDN image URL to a consistent high-res Cloudinary transform. + // Multi-image listings expose a thumbnail strip (t_listing_card_117x102); single-image + // listings load the first slide directly without any transform in the URL. + let image = null; + if (o.image) { + if (o.image.includes('t_listing_card_117x102')) { + image = o.image.replace('t_listing_card_117x102', 't_listing_card_1074x585'); + } else if (o.image.includes('/listings/v2/') && !o.image.includes('/f_auto/')) { + image = o.image.replace('/listings/v2/', '/f_auto/t_listing_card_1074x585/listings/v2/'); + } else { + image = o.image; + } + } + + return { + id, + link: o.link, + title: o.title || o.address || null, + price: parsePrice(o.price), + currency: 'CHF', + size: extractNumber(o.size), + rooms: o.rooms != null ? parseFloat(o.rooms) : null, + address: o.address, + image, + description: o.description, + }; +} + +/** + * @param {ParsedListing} o + * @returns {boolean} + */ +function applyBlacklist(o) { + const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList); + const descNotBlacklisted = !isOneOf(o.description, appliedBlackList); + return titleNotBlacklisted && descNotBlacklisted; +} + +/** @type {ProviderConfig} */ +const config = { + requiredFieldNames: ['id', 'link', 'title', 'price', 'size', 'rooms', 'address', 'description'], + url: null, + crawlContainer: 'div[data-test="result-list-item"]', + sortByDateParam: 'sortBy=dateCreated,desc', + // waitForSelector targets the listing container which only exists on the real page — + // naturally waits out the DataDome JS challenge (~4-6s) without relying on network-idle + // timing which fires too early on the challenge page. + waitForSelector: 'div[data-test="result-list"]', + puppeteerOptions: { + puppeteerTimeout: 120_000, + puppeteerSelectorTimeout: 90_000, + preNavigateUrl: 'https://www.homegate.ch/', + // DataDome returns HTTP 403 for the initial response but the client-side JS challenge + // still runs and CloakBrowser passes it, loading real content. We skip the 403 status + // check here; waitForSelector already rejects bot/captcha pages (no listing element). + ignoredStatusCodes: [403], + }, + crawlFields: { + id: 'a@href', + link: 'a@href', + price: 'span[class*="HgListingCard_price"] | trim', + rooms: 'div[class*="HgListingRoomsLivingSpace_roomsLivingSpace"] span:nth-of-type(1) strong | trim', + size: 'div[class*="HgListingRoomsLivingSpace_roomsLivingSpace"] span:nth-of-type(2) strong | trim', + title: 'p[class*="HgListingDescription_title"] span | removeNewline | trim', + address: 'div[class*="HgListingCard_address"] address | removeNewline | trim', + description: 'p[class*="HgListingDescription_extra-large"] | removeNewline | trim', + image: 'img[src*="media2.homegate.ch"]@src', + }, + normalize: normalize, + filter: applyBlacklist, +}; +export const init = (sourceConfig, blacklist) => { + config.enabled = sourceConfig.enabled; + config.url = sourceConfig.url; + appliedBlackList = blacklist || []; +}; +export const metaInformation = { + name: 'Homegate', + baseUrl: 'https://www.homegate.ch/', + id: 'homegate', +}; +export { config }; diff --git a/lib/services/extractor/puppeteerExtractor.js b/lib/services/extractor/puppeteerExtractor.js index 65c06f2b..3b5d328a 100644 --- a/lib/services/extractor/puppeteerExtractor.js +++ b/lib/services/extractor/puppeteerExtractor.js @@ -142,8 +142,13 @@ export default async function execute(url, waitForSelector, options) { } const statusCode = response?.status?.() ?? 200; + // Some sites (e.g. homegate.ch via DataDome) return a 403 for the initial HTTP response + // but still serve real content via a client-side JS challenge that CloakBrowser passes. + // Providers can list such codes in ignoredStatusCodes so the status check is skipped; + // waitForSelector already guards against real bot-detection pages (captcha has no listings). + const effectiveStatusCode = options?.ignoredStatusCodes?.includes(statusCode) ? 200 : statusCode; - if (botDetected(pageSource, statusCode)) { + if (botDetected(pageSource, effectiveStatusCode)) { logger.warn('We have been detected as a bot :-/ Tried url: => ', url); if (options != null && options.name != null) { diff --git a/lib/services/geocoding/client/nominatimClient.js b/lib/services/geocoding/client/nominatimClient.js index d2cd1e05..7d0ea4ea 100644 --- a/lib/services/geocoding/client/nominatimClient.js +++ b/lib/services/geocoding/client/nominatimClient.js @@ -62,7 +62,7 @@ async function doGeocode(address) { return null; } - const url = `${API_URL}?q=${encodeURIComponent(address)}&format=json&countrycodes=de`; + const url = `${API_URL}?q=${encodeURIComponent(address)}&format=json&countrycodes=de,ch,at`; try { const response = await fetch(url, { @@ -112,7 +112,7 @@ async function doAutocomplete(query) { return []; } - const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de`; + const url = `${API_URL}?q=${encodeURIComponent(query)}&format=json&addressdetails=1&limit=5&countrycodes=de,ch,at`; try { const response = await fetch(url, { diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index f00c7f7c..170eec4c 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -37,7 +37,8 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => { `SELECT hash FROM listings WHERE job_id = @jobId - AND provider = @providerId`, + AND provider = @providerId + AND manually_deleted = 0`, { jobId, providerId }, ).map((r) => r.hash); }; @@ -202,9 +203,9 @@ export const storeListings = (jobId, providerId, listings) => { SqliteConnection.withTransaction((db) => { const stmt = db.prepare( `INSERT INTO listings (id, hash, provider, job_id, price, size, rooms, title, image_url, description, address, - link, created_at, is_active, latitude, longitude) + link, created_at, is_active, latitude, longitude, currency) VALUES (@id, @hash, @provider, @job_id, @price, @size, @rooms, @title, @image_url, @description, @address, @link, - @created_at, 1, @latitude, @longitude) + @created_at, 1, @latitude, @longitude, @currency) ON CONFLICT(job_id, hash) DO NOTHING`, ); @@ -225,6 +226,7 @@ export const storeListings = (jobId, providerId, listings) => { created_at: Date.now(), latitude: item.latitude || null, longitude: item.longitude || null, + currency: item.currency ?? null, }; stmt.run(params); // Propagate the DB primary key back so downstream pipeline steps use the correct id diff --git a/lib/services/storage/migrations/sql/10.add-currency-to-listings.js b/lib/services/storage/migrations/sql/10.add-currency-to-listings.js new file mode 100644 index 00000000..6d2b6bce --- /dev/null +++ b/lib/services/storage/migrations/sql/10.add-currency-to-listings.js @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +export function up(db) { + db.exec(`ALTER TABLE listings ADD COLUMN currency TEXT;`); +} diff --git a/lib/types/listing.js b/lib/types/listing.js index a0c9b137..34b8c676 100644 --- a/lib/types/listing.js +++ b/lib/types/listing.js @@ -12,6 +12,7 @@ * @property {string} [description] Description of the listing. * @property {string} [address] Optional address/location text. * @property {number} [price] Optional price of the listing. + * @property {string} [currency] Optional currency symbol/code for the price (defaults to '€'). * @property {number} [size] Optional size of the listing. * @property {number} [rooms] Optional number of rooms. * @property {number} [latitude] Optional latitude. diff --git a/lib/utils/formatListing.js b/lib/utils/formatListing.js index f663f018..9e8e1bce 100644 --- a/lib/utils/formatListing.js +++ b/lib/utils/formatListing.js @@ -22,7 +22,7 @@ export const formatListing = (listing) => { return { ...listing, - price: listing.price != null ? `${listing.price} €` : null, + price: listing.price != null ? `${listing.price} ${listing.currency ?? '€'}` : null, size: listing.size != null ? `${listing.size} m²` : null, rooms: listing.rooms != null ? `${listing.rooms} Zimmer` : null, }; diff --git a/test/provider/homegate.test.js b/test/provider/homegate.test.js new file mode 100644 index 00000000..fac83433 --- /dev/null +++ b/test/provider/homegate.test.js @@ -0,0 +1,58 @@ +/* + * 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 'vitest'; +import * as provider from '../../lib/provider/homegate.js'; + +describe('#homegate testsuite()', () => { + it('should test homegate provider', async () => { + const Fredy = await mockFredy(); + const mockedJob = { + id: 'homegate', + notificationAdapter: null, + spatialFilter: null, + specFilter: null, + }; + provider.init(providerConfig.homegate, []); + + const fredy = new Fredy(provider.config, mockedJob, provider.metaInformation.id, similarityCache, undefined); + + const listing = await fredy.execute(); + + if (listing == null || listing.length === 0) { + throw new Error('Listings is empty!'); + } + + expect(listing).toBeInstanceOf(Array); + const notificationObj = get(); + expect(notificationObj).toBeTypeOf('object'); + expect(notificationObj.serviceName).toBe('homegate'); + notificationObj.payload.forEach((notify) => { + /** check the actual structure **/ + expect(notify.id).toBeTypeOf('string'); + expect(notify.title).toBeTypeOf('string'); + expect(notify.link).toBeTypeOf('string'); + expect(notify.link).toContain('https://www.homegate.ch'); + expect(notify.address).toBeTypeOf('string'); + expect(notify.address).not.toBe(''); + /** check the values if possible **/ + if (notify.price != null) { + expect(notify.price).toBeTypeOf('string'); + expect(notify.price).toContain('CHF'); + } + if (notify.size != null) { + expect(notify.size).toBeTypeOf('string'); + expect(notify.size).toContain('m²'); + } + if (notify.image != null) { + expect(notify.image).toBeTypeOf('string'); + expect(notify.image).toContain('homegate.ch'); + } + }); + }); +}); diff --git a/test/provider/testProvider.json b/test/provider/testProvider.json index 5ebcaebe..f794dd97 100644 --- a/test/provider/testProvider.json +++ b/test/provider/testProvider.json @@ -8,6 +8,10 @@ "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 }, + "homegate": { + "url": "https://www.homegate.ch/rent/apartment/city-lausanne/matching-list", + "enabled": true + }, "immowelt": { "url": "https://www.immowelt.de/classified-search?distributionTypes=Buy,Buy_Auction,Compulsory_Auction&estateTypes=House,Apartment&locations=AD08DE2350", "enabled": true diff --git a/test/testFixtures/homegate.html b/test/testFixtures/homegate.html new file mode 100644 index 00000000..7c86bae8 --- /dev/null +++ b/test/testFixtures/homegate.html @@ -0,0 +1,150 @@ + + + + 521 Apartment for rent in Lausanne + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

521 results - Apartment for rent in Lausanne

1 / 13
  • View
  • Parking space
  • Elevator
  • +2
CHF 1,714.– Premium
1.5 rooms23m² living space
Rue du Petit Rocher 6, 1003 Lausanne

City Pop - La solution flexible pour les longs et courts séjours, pour se sentir toujours chez soi !

Nous proposons des appartements d'environ 23 m², de différents types (taille S, M, L, XL, Duplex, avec ou sans balcon) à partir de 400CHF/semaine.Nos ‘’Pop’’ entièrement meublés vous garantiront une expérience de vie authentique.Détendez-vous, travaillez, dormez, cuisinez dans un espace efficacement organisé avec :-Séjour ;- Cuisine entièrement équipée (lave-vaisselle, frigo, four, plaque à induction) ;- Salle de bain privées avec douche ;-Zone de couchage.Vous aurez la sensation d’un hôtel 4 étoiles !En outre, les appartements sont équipés de ;-Ustensiles de cuisine (vaisselle, verres, casseroles, etc.), ligne de lit et serviettes ;-Frais accessoires (eau, électricité) ;-Wi-Fi ;-Smart TV (excl. Serafe, ECA)-Petit déjeuner (croissant et café). Mais ce n'est pas tout !City Pop c’est un nouveau et innovant concept qui peut être réservé pour un minimum de 4 semaines et un maximum de 52 semaines.Des services supplémentaires sont disponibles et peuvent être activé facilement à travers de notre App :- Service de ménage ;- Cave ;- Parking intérieur.C'est aussi facile que de réserver une chambre d’hôtel ! Téléchargez notre application "City Pop" et choisissez le Pop que vous préférez !En cas de doute ou question, contactez-nous ! Nous sommes heureux de vous aider !Si vous rencontrez des difficultés lors du processus de réservation ou si vous avez besoin d'informations supplémentaires, n'hésitez pas à nous contacter à l'adresse contact.ch@citypop.com. Notre équipe est à votre disposition pour vous fournir de l'assistance.***********************************City Pop - A flexible solution for long and short stays, to always feel at home.We offer centrally located apartments of about 23 m² (size S, M, L, XL or Duplex). Prices start from 400CHF/week.These furnished apartments guarantee an authentic living experience: you can relax, work, sleep, cook, all in an apartment with such an organized 4-star interior:- Complete furnished apartment with kitchen, living room, sleeping area and bathroom;- Complete inventory for kitchen (plates, glasses, pots, etc.), sleeping area and towels;- Expenses costs (electricity, water);- Fast Internet connection;- Smart TV (Serafe excluded, ECA excluded);- Breakfast (croissant and coffee).But there is more! This fantastic and innovative living concept is free of contractual restrictions regarding the minimum length of stay: City Pop can be booked for 4 weeks up to a maximum of 52 weeks.A wide range of standard and additional services is available, which can be activated e.g. via the City-Pop-App:- Cleaning service (weekly, biweekly or one-shot);- Storage;- Parking spot (internal).Create and adapt the range of services to best suit your needs and lifestyle.Book a Pop is easy as booking a hotel room!To book a Pop, download our App and choose the Pop you prefer!Get more information to our Website or through our App "Citypop"!We are looking forward to meeting you soon at City Pop!If you encounter any difficulties during the booking process or need further information, please do not hesitate to contact us at contact.ch@citypop.com. Our team is at your service to provide assistance.

  • View
  • Parking space
  • Elevator
  • +2
Travel time
1 min.Station: Lausanne-Chauderon

Find a new tenant for free

Create a free listing on Homegate to find the perfect next tenant for your apartment in no time at all.

List your property now
1 / 19
CHF 4,600.– Top
5.5 rooms143m² living space
Chemin de Craivavers, 8c, 1012 Lausanne

Magnifique appartement meublé de 5.5 pièces à Lausanne avec vue panoramique sur le lac pour 12 mois

Magnifique appartement meublé de 5.5 pièces (4 chambres à coucher) de 143 m² situé en duplex (avec entrée indépendante) d'un immeuble moderne construit en 2018 dans le quartier très prisé de Chailly. Cet espace de vie offre tout le confort nécessaire pour une installation immédiate, et est à louer pour une durée déterminée de 12 mois - du 1 août 2026 au 1 août 2027 (à discuter). L'appartement jouit aussi d'un petit jardin avec jeux pour enfants et balançoires.L'appartement est loué entièrement meublé et comprend :A l'étage: • Salon spacieux, lumineux avec de grandes fenêtres offrant une vue dégagée sur le lac. Equippé d'une climatisation • Cuisine moderne entièrement équipée et coin à manger • Salle à manger ouverte sur le salon et la cuisine • Terrasse de 81m2 avec vue sur le lac, exposée a 270° (sud-ouest) • 2 chambres à coucher (11m2 et 13m2) • 1 piece de rangement (6m2) • 1 salle de bains (avec baignoire) Au rez-de chaussée • 1 vestaire/hall d'entrée • 1 chambre d'ami / bureau avec salle de douche/WC • 1 chambre à coucher avec dressing mural • 1 salle de douche (italienne) • 1 buanderie /seconde entrée Situation privilégiée : L'appartement est situé dans une PPE de 5 appartements, bénéficie d'un environnement extrêment calme et très ensoleillé. L'arrêt de bus Craivavers se trouve à seulement 3 minutes à pieds, tandis que l'École nouvelle de la Suisse romande est accessible en 6 minutes de marche.Possibilité de louer une place de parc dans le garage pour CHF 100/mois. La terrasse est équipée d'arrosage automatique.Acomptes de charges: incluent eau, internet, chauffage, jardinier pour tondre la pelouse et entretenir les plantes extérieures. Non inclus: électricité.Caution: 3mois de loyer

Travel time
21 min.Station: Fourmi
1 / 9
CHF 3,570.– Top
4.5 rooms95m² living space
Evian 4, 1006 Lausanne

Appartement de 4.5 pièces meublé ou partiellement meublé avec terrasse et vue sur le lac à Lausanne

Découvrez ce magnifique appartement de caractère de 4.5 pièces, situé au rez-de-chaussée d'un élégant immeuble de style ancien datant de 1911, rénové en 2006, dans le quartier prisé de Lausanne 1006. Avec ses 95 m² de surface habitable, ses hauts plafonds et parquets anciens, cet appartement allie le charme de l'ancien et le confort moderne.L'appartement vous offre :Séjour lumineux : Vaste pièce de vie avec parquet, grandes fenêtres laissant entrer la lumière naturelle, accès à la terrasse.Terrasse vec vue panoramique Cuisine renovée et totalement équipée2 grande chambres à coucher avec parquets anciens1 plus petite chambre 1 salle de bain et un wc séparéPlace de stationnement en sus.Cet appartement peut'etre loué meublé ou partiellement meublé.Disponible dès le 1er juillet 2026.Situation idéale : Niché à proximité immédiate du lac et des transports en commun, cet appartement jouit d'une localisation exceptionnelle.Arrêt de bus Musée Olympique : à 1 minute à pied Gare de Lausanne : à 10minutes à piedCe bien d'exception, alliant cachet architectural, confort et emplacement privilégié face au lac Léman et aux Alpes, est une opportunité rare sur le marché lausannois. Ne manquez pas la chance de faire de cet appartement votre nouveau chez-vous.

Travel time
11 min.Station: Jordils
1 / 8
CHF 6,450.– Top
6.5 rooms180m² living space
Avenue des Mousquines 24, 1005 Lausanne

Un appartement d'exception au cœur de Lausanne

Situé dans le très recherché quartier des Mousquines à Lausanne, ce somptueux appartement de 6,5 pièces, d’une surface de plus de 180 m², prend place au 2ᵉ étage avec combles d’un immeuble historique datant de 1897, entièrement rénové en 2026. Il conjugue avec élégance le charme de l’ancien et le confort contemporain.Dès l’entrée, les volumes généreux et la distribution harmonieuse des espaces séduisent immédiatement. Le séjour principal, agrémenté d’une cheminée et de bibliothèques intégrées, s’ouvre sur un balcon orienté plein sud, tout comme le second salon attenant, offrant une luminosité exceptionnelle et une vue dégagée sur le lac Léman et un parc arboré.La cuisine, fermée et entièrement équipée, propose un espace convivial et fonctionnel. L’appartement dispose également de trois chambres, dont une avec accès à un espace bureau aménagé dans les combles, idéal pour le télétravail ou un espace privé.Les combles, soigneusement aménagés, offrent un espace bureau chaleureux avec poutres apparentes, rangements sous pente et fenêtres de toit, renforçant le caractère unique du bien.Deux salles d’eau complètent ce bien : une salle de bains et une salle de douche. Une buanderie privative (lave-linge et sèche-linge) est également intégrée à l’appartement.Les prestations sont de grande qualité : hauts plafonds de près de 3 mètres, parquets en grandes lames ou en chevrons, radiateurs en fonte, armoires encastrées et finitions soignées.Baigné de lumière, cet appartement bénéficie d’un environnement calme et verdoyant, avec une place de parking extérieure incluse. Les résidents profitent également d’un accès à un jardin commun réservé exclusivement à l’immeuble, d’une grande cave ainsi que d’un local commun pour vélos et poussettes.Idéalement situé, à proximité immédiate des écoles, des transports publics et des commerces, ce bien offre un cadre de vie privilégié à quelques minutes du centre-ville.Ce bien rare propose un art de vivre raffiné dans l’un des quartiers les plus prisés de Lausanne.Une place de parking extérieur est inclus avec l'appartement.

Travel time
10 min.Station: Ours

Suggested properties for you based on search results

Additional information

Overview

+

The number of residents in Lausanne has changed by 3.03% over the past 3 years, to 144’873 inhabitants.

+

Demographic data

+

On average, residents earn CHF 75’095 per year. 31.05% of the population have an SEK II qualification, and 6.05% have a higher vocational education qualification. 38.45% are university graduates and 24.44% of residents are currently in compulsory schooling. The unemployment rate is 3.1%.

+

Taxes

+

The area has an overall rate of taxation of 14.17%. The actual percentage of tax paid varies from person to person and is based on aspects including current income, marital status, the amount of deductions and so on. In Lausanne, the tax rate for childless, single individuals is 20.17%. A married couple on a pension (over 65) pays an average of 18.45%, a married couple with two children 9.19% and a couple with no children 13%.

+

New builds

+

Over the last 5 years, 3040 new apartments have been created in Lausanne. Of these new builds, 375 are 1-room apartments, which are perfect for singles. A total of 769 new 2-room apartments have also been constructed. For families trying to find new living space, the 1067 apartments and the 669 4-room apartments are particularly attractive. 132 apartments with 5 rooms and 28 apartments with 6 or more rooms have been added to the local housing stock.

+

Housing stock

+

The new buildings have contributed to a further expansion of the housing stock, and there are now 82’321 apartments in Lausanne. The overall number of 1-room apartments amounts to 12’642. Additionally, there are 22’376 apartments with 2 rooms, 26’416 apartments with 3 rooms and 13’489 apartments with 4 rooms. In total, there are 4818 5-room apartments and 2580 large apartments with 6 or more rooms.

+

Empty apartments

+

The 0.58% of apartments in Lausanne are vacant. Consequently, 0.67% of the 1-room apartments, 0.65% of the apartments with 2 rooms, 0.54% of all 3-room apartments, 0.55% of the apartments with 4 rooms and 0.42% of apartments with 5 rooms are presently unoccupied. Apartments with more than 5 rooms have a vacancy rate of 0.42%; of that figure, 0.43% are apartments with more than 6 rooms.

+

Housing market (letting only)

+

On average, the rent price for rental properties is CHF 1700 per month. In terms of the rents on offer, a quarter of them are equivalent to or less than a monthly rent of CHF 1351. Monthly rents are less than CHF 2130 (75th percentile), or they are equal to this average price.

+
+ + + + + \ No newline at end of file diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index c6ea39a9..ee06d5bb 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -15,6 +15,7 @@ import { IconEyeOpened, } from '@douyinfe/semi-icons'; import no_image from '../../../assets/no_image.png'; +import { formatEuroPrice } from '../../../services/price/priceService.js'; import * as timeService from '../../../services/time/timeService.js'; import StatusControl from '../../listings/StatusControl.jsx'; @@ -79,7 +80,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHi {item.price && (
- {item.price} + {formatEuroPrice(item.price, item.currency ?? '€')}
)} {item.address && ( diff --git a/ui/src/components/map/Map.jsx b/ui/src/components/map/Map.jsx index 77258566..b743191f 100644 --- a/ui/src/components/map/Map.jsx +++ b/ui/src/components/map/Map.jsx @@ -11,9 +11,9 @@ import { fixMapboxDrawCompatibility, addDrawingControl, setupAreaFilterEventList import { getBoundsFromCoords } from '../../views/listings/mapUtils.js'; import './Map.less'; -export const GERMANY_BOUNDS = [ - [5.866, 47.27], // Southwest coordinates - [15.042, 55.059], // Northeast coordinates +export const DACH_BOUNDS = [ + [5.866, 45.818], // Southwest (includes Switzerland and Austria) + [17.16, 55.059], // Northeast (includes Austria) ]; export const STYLES = { @@ -76,9 +76,9 @@ export default function Map({ mapRef.current = new maplibregl.Map({ container: mapContainerRef.current, style: STYLES[style], - center: [10.4515, 51.1657], // Center of Germany + center: [10.4515, 50.5], // Center of DACH region zoom: 4, - maxBounds: GERMANY_BOUNDS, + maxBounds: DACH_BOUNDS, antialias: true, }); diff --git a/ui/src/components/table/ListingsTable.jsx b/ui/src/components/table/ListingsTable.jsx index 06057855..945c51be 100644 --- a/ui/src/components/table/ListingsTable.jsx +++ b/ui/src/components/table/ListingsTable.jsx @@ -63,7 +63,11 @@ const ListingsTable = ({
- {item.price ? formatEuroPrice(item.price) : ---} + {item.price ? ( + formatEuroPrice(item.price, item.currency ?? '€') + ) : ( + --- + )}
diff --git a/ui/src/services/price/priceService.js b/ui/src/services/price/priceService.js index 38a9552b..74e97467 100644 --- a/ui/src/services/price/priceService.js +++ b/ui/src/services/price/priceService.js @@ -10,13 +10,14 @@ const euroPriceFormatter = new Intl.NumberFormat('de-DE', { /** * @param {number|string} price + * @param {string} [currency='€'] * @returns {string} */ -export const formatEuroPrice = (price) => { +export const formatEuroPrice = (price, currency = '€') => { const parsedPrice = Number(price); if (!Number.isFinite(parsedPrice)) { - return `${price} €`; + return `${price} ${currency}`; } - return `${euroPriceFormatter.format(parsedPrice)} €`; + return `${euroPriceFormatter.format(parsedPrice)} ${currency}`; }; diff --git a/ui/src/views/listings/ListingDetail.jsx b/ui/src/views/listings/ListingDetail.jsx index a709467a..e00a3085 100644 --- a/ui/src/views/listings/ListingDetail.jsx +++ b/ui/src/views/listings/ListingDetail.jsx @@ -341,7 +341,7 @@ export default function ListingDetail() { { key: t('listing.detail.fieldPrice'), value: listing.price ? ( - {formatEuroPrice(listing.price)} + {formatEuroPrice(listing.price, listing.currency ?? '€')} ) : ( t('common.na') ), diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index 3e7a684c..d96ed9bd 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -131,8 +131,11 @@ export default function MapView() { } }, []); + const [mapReady, setMapReady] = useState(false); + const handleMapReady = (mapInstance) => { map.current = mapInstance; + setMapReady(true); }; const handleMapStyle = (value) => { @@ -224,7 +227,7 @@ export default function MapView() { }); } } - }, [homeAddress?.address, distanceFilter, listings]); + }, [homeAddress?.address, distanceFilter, listings, mapReady]); useEffect(() => { if (!map.current) return; @@ -319,7 +322,7 @@ export default function MapView() { />

${listing.title}

- ${t('map.popupPrice')} ${listing.price ? listing.price + ' €' : t('common.na')} + ${t('map.popupPrice')} ${listing.price ? listing.price + ' ' + (listing.currency ?? '€') : t('common.na')} ${t('map.popupAddress')} ${listing.address || t('common.na')} ${t('map.popupJob')} ${listing.job_name || t('common.na')} ${t('map.popupProvider')} ${capitalizedProvider} From 475e96041cf529bdaa8f4fbc13d12bf20634004c Mon Sep 17 00:00:00 2001 From: domimisko Date: Thu, 18 Jun 2026 00:48:19 +0200 Subject: [PATCH 02/18] trigger CI From 1698f24667dfb4260ee9370dee475e896a04f19d Mon Sep 17 00:00:00 2001 From: domimisko Date: Thu, 18 Jun 2026 01:56:50 +0200 Subject: [PATCH 03/18] Add DeepL translation for listing descriptions --- lib/api/routes/generalSettingsRoute.js | 6 +- lib/api/routes/listingsRouter.js | 39 ++++++++ lib/services/storage/listingsStorage.js | 18 ++++ .../sql/22.add-translations-to-listings.js | 13 +++ lib/services/translation/deeplClient.js | 67 +++++++++++++ test/translation/deeplClient.test.js | 94 +++++++++++++++++++ ui/src/locales/de.json | 7 ++ ui/src/locales/en.json | 7 ++ .../views/generalSettings/GeneralSettings.jsx | 13 +++ ui/src/views/listings/ListingDetail.jsx | 49 +++++++++- 10 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 lib/services/storage/migrations/sql/22.add-translations-to-listings.js create mode 100644 lib/services/translation/deeplClient.js create mode 100644 test/translation/deeplClient.test.js diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js index a56827be..ad8c1bea 100644 --- a/lib/api/routes/generalSettingsRoute.js +++ b/lib/api/routes/generalSettingsRoute.js @@ -17,7 +17,11 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js'; */ export default async function generalSettingsPlugin(fastify) { fastify.get('/', async () => { - return Object.assign({}, await getSettings()); + const settings = Object.assign({}, await getSettings()); + // Never expose the raw API key to the frontend — return a boolean flag instead + settings.deepl_api_key_set = !!settings.deepl_api_key; + delete settings.deepl_api_key; + return settings; }); fastify.post('/', async (request, reply) => { diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index b5c275c0..18f18299 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -13,6 +13,7 @@ import { getSettings } from '../../services/storage/settingsStorage.js'; import { trackPoi } from '../../services/tracking/Tracker.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js'; import { initSimilarityCache } from '../../services/similarity-check/similarityCache.js'; +import { translate as deeplTranslate } from '../../services/translation/deeplClient.js'; /** * @param {import('fastify').FastifyInstance} fastify @@ -199,6 +200,44 @@ export default async function listingsPlugin(fastify) { return reply.send(); }); + fastify.post('/:listingId/translate', async (request, reply) => { + const { listingId } = request.params; + const { targetLanguage } = request.body || {}; + + if (!targetLanguage) { + return reply.code(400).send({ error: 'targetLanguage is required' }); + } + + const settings = await getSettings(); + if (!settings.deepl_api_key) { + return reply.code(400).send({ error: 'DeepL API key not configured' }); + } + + try { + const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request)); + if (!listing) { + return reply.code(404).send({ error: 'Listing not found' }); + } + + if (!listing.description) { + return reply.code(400).send({ error: 'Listing has no description to translate' }); + } + + const translations = listing.translations ? JSON.parse(listing.translations) : {}; + const lang = targetLanguage.toLowerCase(); + if (translations[lang]) { + return reply.send({ text: translations[lang], cached: true }); + } + + const translated = await deeplTranslate(listing.description, targetLanguage.toUpperCase(), settings.deepl_api_key); + listingStorage.setListingTranslation(listingId, lang, translated); + return reply.send({ text: translated, cached: false }); + } catch (error) { + logger.error(error); + return reply.code(500).send({ error: error.message }); + } + }); + fastify.post('/restore', async (request, reply) => { const { ids } = request.body || {}; const settings = await getSettings(); diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index 170eec4c..27da9075 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -744,3 +744,21 @@ export const resetGeocoordinatesAndDistanceForUser = (userId) => { { userId }, ); }; + +/** + * Persist a translated description for a listing. + * + * Reads the existing `translations` JSON object for the listing, merges the new + * language entry, and writes it back. Safe to call multiple times — subsequent + * calls for the same language overwrite the previous value. + * + * @param {string} id - The listing ID (nanoid). + * @param {string} language - Language code in lowercase, e.g. 'en' or 'de'. + * @param {string} text - The translated description text. + */ +export const setListingTranslation = (id, language, text) => { + const rows = SqliteConnection.query(`SELECT translations FROM listings WHERE id = ?`, [id]); + const existing = rows[0]?.translations ? JSON.parse(rows[0].translations) : {}; + existing[language.toLowerCase()] = text; + SqliteConnection.execute(`UPDATE listings SET translations = ? WHERE id = ?`, [JSON.stringify(existing), id]); +}; diff --git a/lib/services/storage/migrations/sql/22.add-translations-to-listings.js b/lib/services/storage/migrations/sql/22.add-translations-to-listings.js new file mode 100644 index 00000000..21cbfd87 --- /dev/null +++ b/lib/services/storage/migrations/sql/22.add-translations-to-listings.js @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Adds a `translations` JSON column to the listings table. + * Stores translated descriptions keyed by language code, e.g. { "en": "...", "de": "..." }. + * @param {import('better-sqlite3').Database} db + */ +export function up(db) { + db.exec(`ALTER TABLE listings ADD COLUMN translations TEXT;`); +} diff --git a/lib/services/translation/deeplClient.js b/lib/services/translation/deeplClient.js new file mode 100644 index 00000000..3b46759e --- /dev/null +++ b/lib/services/translation/deeplClient.js @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Thin wrapper around the DeepL translate API. + * + * Supports both the free tier (keys ending in `:fx`, uses api-free.deepl.com) + * and the pro tier (all other keys, uses api.deepl.com). + * + * @module deeplClient + */ + +/** + * Returns the correct DeepL API base URL based on the API key tier. + * Free-tier keys end with the suffix `:fx`. + * @param {string} apiKey + * @returns {string} + */ +function getApiUrl(apiKey) { + return apiKey.endsWith(':fx') + ? 'https://api-free.deepl.com/v2/translate' + : 'https://api.deepl.com/v2/translate'; +} + +/** + * Translate a single text string using the DeepL API. + * Source language is omitted so DeepL auto-detects it. + * + * @param {string} text - The text to translate. + * @param {string} targetLang - Target language code in uppercase, e.g. 'EN' or 'DE'. + * @param {string} apiKey - DeepL API authentication key. + * @returns {Promise} The translated text. + * @throws {Error} If the API returns a non-OK status or an unexpected response shape. + */ +export async function translate(text, targetLang, apiKey) { + const url = getApiUrl(apiKey); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `DeepL-Auth-Key ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: [text], + target_lang: targetLang, + }), + }); + + if (response.status === 429) { + throw new Error('DeepL rate limit exceeded. Please try again later.'); + } + + if (!response.ok) { + throw new Error(`DeepL API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + if (!data?.translations?.[0]?.text) { + throw new Error('DeepL returned an unexpected response shape.'); + } + + return data.translations[0].text; +} diff --git a/test/translation/deeplClient.test.js b/test/translation/deeplClient.test.js new file mode 100644 index 00000000..211d12e4 --- /dev/null +++ b/test/translation/deeplClient.test.js @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { translate } from '../../lib/services/translation/deeplClient.js'; + +const FREE_KEY = 'test-key:fx'; +const PRO_KEY = 'test-key-pro'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + mockFetch.mockReset(); +}); + +function makeResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : status === 429 ? 'Too Many Requests' : 'Error', + json: async () => body, + }; +} + +describe('#deeplClient translate()', () => { + it('returns translated text on successful response', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hello world' }] })); + + const result = await translate('Bonjour le monde', 'EN', FREE_KEY); + + expect(result).toBe('Hello world'); + }); + + it('calls the free-tier URL for keys ending with :fx', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hello' }] })); + + await translate('Bonjour', 'EN', FREE_KEY); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api-free.deepl.com/v2/translate', + expect.any(Object), + ); + }); + + it('calls the pro URL for keys not ending with :fx', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hello' }] })); + + await translate('Bonjour', 'EN', PRO_KEY); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.deepl.com/v2/translate', + expect.any(Object), + ); + }); + + it('sends the correct Authorization header and body', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ translations: [{ text: 'Hallo' }] })); + + await translate('Hello', 'DE', FREE_KEY); + + const [, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('POST'); + expect(options.headers['Authorization']).toBe(`DeepL-Auth-Key ${FREE_KEY}`); + const body = JSON.parse(options.body); + expect(body.text).toEqual(['Hello']); + expect(body.target_lang).toBe('DE'); + expect(body.source_lang).toBeUndefined(); + }); + + it('throws with a rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(translate('Bonjour', 'EN', FREE_KEY)).rejects.toThrow('rate limit'); + }); + + it('throws on non-OK responses', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 403)); + + await expect(translate('Bonjour', 'EN', FREE_KEY)).rejects.toThrow('DeepL API error: 403'); + }); + + it('throws when response shape is unexpected', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ unexpected: true })); + + await expect(translate('Bonjour', 'EN', FREE_KEY)).rejects.toThrow('unexpected response shape'); + }); +}); diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 67ecda34..1b08b18e 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -195,6 +195,9 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Beschreibung", "listing.detail.noDescription": "Keine Beschreibung verfügbar.", + "listing.detail.translate": "Übersetzen", + "listing.detail.showOriginal": "Original anzeigen", + "listing.detail.translateError": "Übersetzung fehlgeschlagen. Bitte DeepL-API-Schlüssel prüfen.", "listing.detail.distanceToHome": "Entfernung nach Hause:", "listing.detail.locationTitle": "Lage", "listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.", @@ -334,6 +337,10 @@ "settings.workingHoursFrom": "Von", "settings.workingHoursUntil": "Bis", "settings.proxyUrl": "Proxy-URL", + "settings.deeplApiKey": "DeepL-API-Schlüssel", + "settings.deeplApiKeyHelp": "Optional. Ermöglicht die On-Demand-Übersetzung von Beschreibungen in der Inseratdetailansicht. Kostenlosen Schlüssel unter deepl.com erhalten (1 Mio. Zeichen gratis, einmalig). Unterstützt sowohl kostenlose (:fx-Suffix) als auch Pro-Schlüssel.", + "settings.deeplApiKeyPlaceholder": "DeepL-API-Schlüssel hier einfügen", + "settings.deeplApiKeyAlreadySet": "Bereits gesetzt — neuen Schlüssel einfügen zum Ersetzen", "settings.proxyUrlHelp": "Optional. Leitet den Scraping-Browser durch einen Proxy. Server/Rechenzentrum-IPs werden von Anbietern (z. B. Immowelt) unabhängig vom Browser-Fingerprint häufig blockiert. Ein deutscher Wohnproxy lässt Anfragen wie einen normalen Haushalt erscheinen. Format: http://benutzer:passwort@host:port oder socks5://benutzer:passwort@host:port. Leer lassen zum Deaktivieren.", "settings.proxyUrlPlaceholder": "http://benutzer:passwort@host:port", "settings.homeAddress": "Heimatadresse", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index ae5dbf86..b52abd50 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -195,6 +195,9 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "No description available.", + "listing.detail.translate": "Translate", + "listing.detail.showOriginal": "Show original", + "listing.detail.translateError": "Translation failed. Please check your DeepL API key.", "listing.detail.distanceToHome": "Distance to home:", "listing.detail.locationTitle": "Location", "listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.", @@ -334,6 +337,10 @@ "settings.workingHoursFrom": "From", "settings.workingHoursUntil": "Until", "settings.proxyUrl": "Proxy URL", + "settings.deeplApiKey": "DeepL API Key", + "settings.deeplApiKeyHelp": "Optional. Enables on-demand description translation in listing detail views. Get a free key at deepl.com (1M characters free, one-time). Supports both free (:fx suffix) and pro keys.", + "settings.deeplApiKeyPlaceholder": "Paste your DeepL API key here", + "settings.deeplApiKeyAlreadySet": "Already set — paste a new key to replace it", "settings.proxyUrlHelp": "Optional. Routes the scraping browser through a proxy. Server/datacenter IPs are frequently blocked by providers (e.g. immowelt) regardless of browser fingerprint, a German residential proxy makes requests look like a normal household and is the most effective fix. Format: http://user:pass@host:port or socks5://user:pass@host:port. Leave empty to disable.", "settings.proxyUrlPlaceholder": "http://user:pass@host:port", "settings.homeAddress": "Home Address", diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index c07deca6..f4687268 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -100,6 +100,8 @@ const GeneralSettings = function GeneralSettings() { const [interval, setInterval] = React.useState(''); const [proxyUrl, setProxyUrl] = React.useState(''); + const [deeplApiKey, setDeeplApiKey] = React.useState(''); + const [deeplApiKeySet, setDeeplApiKeySet] = React.useState(false); const [port, setPort] = React.useState(''); const [workingHourFrom, setWorkingHourFrom] = React.useState(null); const [workingHourTo, setWorkingHourTo] = React.useState(null); @@ -163,6 +165,7 @@ const GeneralSettings = function GeneralSettings() { setDemoMode(settings?.demoMode || false); setSqlitePath(settings?.sqlitepath); setBaseUrl(settings?.baseUrl ?? ''); + setDeeplApiKeySet(settings?.deepl_api_key_set ?? false); } init(); @@ -252,6 +255,7 @@ const GeneralSettings = function GeneralSettings() { analyticsEnabled, sqlitepath: sqlitePath, baseUrl, + ...(deeplApiKey.trim() ? { deepl_api_key: deeplApiKey.trim() } : {}), }); } catch (exception) { console.error(exception); @@ -569,6 +573,15 @@ const GeneralSettings = function GeneralSettings() { /> + + setDeeplApiKey(value)} + /> + +
From bb2a3ac933446eacd53a5ee184a0bd9cf9fffa0f Mon Sep 17 00:00:00 2001 From: domimisko Date: Thu, 18 Jun 2026 16:30:16 +0200 Subject: [PATCH 05/18] Fix news modal not dismissing / reappearing on reload The Finish button appeared broken when the user's session had expired: the backend save failed silently, so the in-memory state never updated and the modal stayed open. On reload it would reappear because nothing was persisted. Fix: update store state optimistically before the API call so the modal closes immediately regardless of backend result. Also write the dismissed hash to localStorage as a fallback, so reloads don't re-show the modal even when the backend save failed (e.g. due to session expiry). --- docker-compose.yml | 2 +- ui/src/services/state/store.js | 21 +++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5961f3df..2dbdfc44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: build: context: . dockerfile: Dockerfile - image: ghcr.io/orangecoding/fredy + image: fredy-custom environment: - NODE_ENV=production volumes: diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index a9dc70b3..769750ef 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -289,7 +289,12 @@ export const useFredyState = create( async getUserSettings() { try { const response = await xhrGet('/api/user/settings'); - set((state) => ({ userSettings: { ...state.userSettings, settings: response.json, loaded: true } })); + const settings = response.json; + // Fall back to localStorage so dismissal survives a session expiry + if (!settings.news_hash) { + settings.news_hash = localStorage.getItem('fredy_news_hash') ?? undefined; + } + set((state) => ({ userSettings: { ...state.userSettings, settings, loaded: true } })); } catch (Exception) { console.error('Error while trying to get resource for api/user/settings. Error:', Exception); // Mark as loaded even on error to prevent blocking the UI @@ -297,17 +302,17 @@ export const useFredyState = create( } }, async setNewsHash(newsHash) { + localStorage.setItem('fredy_news_hash', newsHash); + set((state) => ({ + userSettings: { + ...state.userSettings, + settings: { ...state.userSettings.settings, news_hash: newsHash }, + }, + })); try { await xhrPost('/api/user/settings/news-hash', { news_hash: newsHash }); - set((state) => ({ - userSettings: { - ...state.userSettings, - settings: { ...state.userSettings.settings, news_hash: newsHash }, - }, - })); } catch (Exception) { console.error('Error while trying to update news hash. Error:', Exception); - throw Exception; } }, async setHomeAddress(address) { From fff48b5a1faef7dd848e505f9674de35d8fe70c1 Mon Sep 17 00:00:00 2001 From: domimisko Date: Thu, 18 Jun 2026 18:34:01 +0200 Subject: [PATCH 06/18] Replace ORS with Transitous for commute times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transitous (MOTIS v2) covers all four modes — walking, cycling, driving, and public transit — for free with no API key required, making the ORS key in admin settings unnecessary. The transit mode also shows transfer count alongside the travel time. --- lib/api/routes/generalSettingsRoute.js | 2 - lib/api/routes/listingsRouter.js | 14 +- lib/services/routing/transitousClient.js | 136 +++++++++++++ test/routing/transitousClient.test.js | 190 ++++++++++++++++++ ui/src/locales/de.json | 4 - ui/src/locales/en.json | 4 - .../views/generalSettings/GeneralSettings.jsx | 13 -- ui/src/views/listings/ListingDetail.jsx | 17 +- 8 files changed, 342 insertions(+), 38 deletions(-) create mode 100644 lib/services/routing/transitousClient.js create mode 100644 test/routing/transitousClient.test.js diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js index cebe8747..ad8c1bea 100644 --- a/lib/api/routes/generalSettingsRoute.js +++ b/lib/api/routes/generalSettingsRoute.js @@ -21,8 +21,6 @@ export default async function generalSettingsPlugin(fastify) { // Never expose the raw API key to the frontend — return a boolean flag instead settings.deepl_api_key_set = !!settings.deepl_api_key; delete settings.deepl_api_key; - settings.ors_api_key_set = !!settings.ors_api_key; - delete settings.ors_api_key; return settings; }); diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index d546600b..ff8c5b19 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -14,7 +14,7 @@ import { trackPoi } from '../../services/tracking/Tracker.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js'; import { initSimilarityCache } from '../../services/similarity-check/similarityCache.js'; import { translate as deeplTranslate } from '../../services/translation/deeplClient.js'; -import { getAllDirections as orsGetAllDirections } from '../../services/routing/orsClient.js'; +import { getAllRoutes as transitousGetAllRoutes } from '../../services/routing/transitousClient.js'; import { getUserSettings } from '../../services/storage/settingsStorage.js'; /** @@ -247,11 +247,6 @@ export default async function listingsPlugin(fastify) { fastify.post('/:listingId/commute', async (request, reply) => { const { listingId } = request.params; - const settings = await getSettings(); - if (!settings.ors_api_key) { - return reply.code(400).send({ error: 'ORS API key not configured' }); - } - try { const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request)); if (!listing) return reply.code(404).send({ error: 'Listing not found' }); @@ -265,12 +260,11 @@ export default async function listingsPlugin(fastify) { return reply.code(400).send({ error: 'No commute destination set in user settings' }); } - const result = await orsGetAllDirections( - listing.longitude, + const result = await transitousGetAllRoutes( listing.latitude, - destination.lng, + listing.longitude, destination.lat, - settings.ors_api_key, + destination.lng, ); return reply.send(result); } catch (error) { diff --git a/lib/services/routing/transitousClient.js b/lib/services/routing/transitousClient.js new file mode 100644 index 00000000..6fba8771 --- /dev/null +++ b/lib/services/routing/transitousClient.js @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * Thin wrapper around the Transitous / MOTIS v2 routing API. + * + * Endpoint: GET https://api.transitous.org/api/v1/plan + * No API key required. Coordinates are passed as latitude,longitude (MOTIS order). + * + * Modes returned: + * WALK, BIKE, CAR → resolved from `direct[0]` + * TRANSIT → resolved from `itineraries[0]` (best public-transport option) + * + * @module transitousClient + */ + +const BASE_URL = 'https://api.transitous.org/api/v1/plan'; + +/** User-Agent required by Transitous usage policy. */ +const USER_AGENT = 'fredy/1.0 (https://github.com/orangecoding/fredy)'; + +export const MODES = /** @type {const} */ ({ + WALKING: 'WALK', + CYCLING: 'BIKE', + DRIVING: 'CAR', + TRANSIT: 'TRANSIT', +}); + +/** + * Build the query URL for a direct (non-transit) mode. + * @param {number} startLat @param {number} startLng + * @param {number} endLat @param {number} endLng + * @param {string} mode One of WALK | BIKE | CAR + * @returns {string} + */ +function directUrl(startLat, startLng, endLat, endLng, mode) { + return `${BASE_URL}?fromPlace=${startLat},${startLng}&toPlace=${endLat},${endLng}&numItineraries=1&directModes=${mode}`; +} + +/** + * Build the query URL for public-transport routing. + * @param {number} startLat @param {number} startLng + * @param {number} endLat @param {number} endLng + * @returns {string} + */ +function transitUrl(startLat, startLng, endLat, endLng) { + return `${BASE_URL}?fromPlace=${startLat},${startLng}&toPlace=${endLat},${endLng}&numItineraries=1&transportModes=TRANSIT,WALK`; +} + +/** + * @param {string} url + * @returns {Promise} + */ +async function apiFetch(url) { + return fetch(url, { headers: { Accept: 'application/json', 'User-Agent': USER_AGENT } }); +} + +/** + * Fetch directions for a direct transport mode (WALK, BIKE, CAR). + * + * @param {number} startLat + * @param {number} startLng + * @param {number} endLat + * @param {number} endLng + * @param {string} mode - WALK | BIKE | CAR + * @returns {Promise<{ duration: number, distance: number }>} duration in seconds, distance in meters + * @throws {Error} On rate limit, API error, or unexpected response shape + */ +export async function getDirectRoute(startLat, startLng, endLat, endLng, mode) { + const response = await apiFetch(directUrl(startLat, startLng, endLat, endLng, mode)); + + if (response.status === 429) throw new Error('Transitous rate limit exceeded. Please try again later.'); + if (!response.ok) throw new Error(`Transitous API error: ${response.status} ${response.statusText}`); + + const data = await response.json(); + const itinerary = data?.direct?.[0]; + + if (!itinerary || itinerary.duration == null) throw new Error('Transitous returned an unexpected response shape.'); + + const leg = itinerary.legs?.[0]; + return { duration: itinerary.duration, distance: leg?.distance ?? 0 }; +} + +/** + * Fetch the best public-transport itinerary. + * + * @param {number} startLat + * @param {number} startLng + * @param {number} endLat + * @param {number} endLng + * @returns {Promise<{ duration: number, transfers: number }>} duration in seconds, number of transfers + * @throws {Error} On rate limit, API error, unexpected response shape, or no transit available + */ +export async function getTransitRoute(startLat, startLng, endLat, endLng) { + const response = await apiFetch(transitUrl(startLat, startLng, endLat, endLng)); + + if (response.status === 429) throw new Error('Transitous rate limit exceeded. Please try again later.'); + if (!response.ok) throw new Error(`Transitous API error: ${response.status} ${response.statusText}`); + + const data = await response.json(); + const itinerary = data?.itineraries?.[0]; + + if (!itinerary || itinerary.duration == null) throw new Error('No transit connection found.'); + + return { duration: itinerary.duration, transfers: itinerary.transfers ?? 0 }; +} + +/** + * Fetch all four transport modes in parallel. + * + * Uses Promise.allSettled so a failure for one mode does not block the others. + * Failed modes are returned as null. + * + * @param {number} startLat + * @param {number} startLng + * @param {number} endLat + * @param {number} endLng + * @returns {Promise>} + */ +export async function getAllRoutes(startLat, startLng, endLat, endLng) { + const [walking, cycling, driving, transit] = await Promise.allSettled([ + getDirectRoute(startLat, startLng, endLat, endLng, MODES.WALKING), + getDirectRoute(startLat, startLng, endLat, endLng, MODES.CYCLING), + getDirectRoute(startLat, startLng, endLat, endLng, MODES.DRIVING), + getTransitRoute(startLat, startLng, endLat, endLng), + ]); + + return { + [MODES.WALKING]: walking.status === 'fulfilled' ? walking.value : null, + [MODES.CYCLING]: cycling.status === 'fulfilled' ? cycling.value : null, + [MODES.DRIVING]: driving.status === 'fulfilled' ? driving.value : null, + [MODES.TRANSIT]: transit.status === 'fulfilled' ? transit.value : null, + }; +} diff --git a/test/routing/transitousClient.test.js b/test/routing/transitousClient.test.js new file mode 100644 index 00000000..3854750a --- /dev/null +++ b/test/routing/transitousClient.test.js @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getDirectRoute, getTransitRoute, getAllRoutes, MODES } from '../../lib/services/routing/transitousClient.js'; + +const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + mockFetch.mockReset(); +}); + +function makeResponse(body, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : status === 429 ? 'Too Many Requests' : 'Error', + json: async () => body, + }; +} + +const directBody = (mode, duration, distance) => ({ + direct: [ + { + duration, + legs: [{ mode, duration, distance }], + }, + ], + itineraries: [], +}); + +const transitBody = (duration, transfers) => ({ + direct: [], + itineraries: [{ duration, transfers, legs: [] }], +}); + +describe('#transitousClient getDirectRoute()', () => { + it('returns duration and distance for WALK', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))); + + const result = await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING); + + expect(result.duration).toBe(600); + expect(result.distance).toBe(450); + }); + + it('returns duration and distance for BIKE', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))); + + const result = await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.CYCLING); + + expect(result.duration).toBe(360); + expect(result.distance).toBe(1350); + }); + + it('returns duration and distance for CAR', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))); + + const result = await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.DRIVING); + + expect(result.duration).toBe(665); + expect(result.distance).toBe(1720); + }); + + it('uses latitude,longitude order in URL (MOTIS order)', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))); + + const startLat = 47.376; + const startLng = 8.541; + await getDirectRoute(startLat, startLng, 47.369, 8.539, MODES.WALKING); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain(`fromPlace=${startLat},${startLng}`); + }); + + it('passes directModes in URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))); + + await getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.CYCLING); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('directModes=BIKE'); + }); + + it('throws with rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING)).rejects.toThrow('rate limit'); + }); + + it('throws on non-OK responses', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 500)); + + await expect(getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING)).rejects.toThrow( + 'Transitous API error: 500', + ); + }); + + it('throws when direct array is empty', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })); + + await expect(getDirectRoute(47.376, 8.541, 47.369, 8.539, MODES.WALKING)).rejects.toThrow( + 'unexpected response shape', + ); + }); +}); + +describe('#transitousClient getTransitRoute()', () => { + it('returns duration and transfers', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(transitBody(900, 1))); + + const result = await getTransitRoute(47.376, 8.541, 47.369, 8.539); + + expect(result.duration).toBe(900); + expect(result.transfers).toBe(1); + }); + + it('passes transportModes=TRANSIT,WALK in URL', async () => { + mockFetch.mockResolvedValueOnce(makeResponse(transitBody(900, 0))); + + await getTransitRoute(47.376, 8.541, 47.369, 8.539); + + const [url] = mockFetch.mock.calls[0]; + expect(url).toContain('transportModes=TRANSIT,WALK'); + }); + + it('throws when no itinerary found', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })); + + await expect(getTransitRoute(47.376, 8.541, 47.369, 8.539)).rejects.toThrow('No transit connection found.'); + }); + + it('throws with rate-limit message on 429', async () => { + mockFetch.mockResolvedValueOnce(makeResponse({}, 429)); + + await expect(getTransitRoute(47.376, 8.541, 47.369, 8.539)).rejects.toThrow('rate limit'); + }); +}); + +describe('#transitousClient getAllRoutes()', () => { + it('returns results for all four modes', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))) + .mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse(transitBody(900, 1))); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.WALKING]).toMatchObject({ duration: 600, distance: 450 }); + expect(result[MODES.CYCLING]).toMatchObject({ duration: 360, distance: 1350 }); + expect(result[MODES.DRIVING]).toMatchObject({ duration: 665, distance: 1720 }); + expect(result[MODES.TRANSIT]).toMatchObject({ duration: 900, transfers: 1 }); + }); + + it('returns null for a mode that fails, keeps others', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))) + .mockResolvedValueOnce(makeResponse({}, 500)) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse(transitBody(900, 0))); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.WALKING]).not.toBeNull(); + expect(result[MODES.CYCLING]).toBeNull(); + expect(result[MODES.DRIVING]).not.toBeNull(); + expect(result[MODES.TRANSIT]).not.toBeNull(); + }); + + it('returns null for transit when no connection exists', async () => { + mockFetch + .mockResolvedValueOnce(makeResponse(directBody('WALK', 600, 450))) + .mockResolvedValueOnce(makeResponse(directBody('BIKE', 360, 1350))) + .mockResolvedValueOnce(makeResponse(directBody('CAR', 665, 1720))) + .mockResolvedValueOnce(makeResponse({ direct: [], itineraries: [] })); + + const result = await getAllRoutes(47.376, 8.541, 47.369, 8.539); + + expect(result[MODES.TRANSIT]).toBeNull(); + expect(result[MODES.WALKING]).not.toBeNull(); + }); +}); diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 4a47cdb5..949557f9 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -347,10 +347,6 @@ "settings.homeAddress": "Pendeladresse", "settings.homeAddressHelp": "Wird zur Berechnung von Pendelzeiten und Luftlinienentfernungen zu jedem Inserat verwendet. Arbeitsplatz, Universität oder ein anderes regelmäßiges Ziel eingeben. Eine Aktualisierung berechnet die Entfernungen für alle aktiven Inserate neu.", "settings.homeAddressPlaceholder": "Pendelziel eingeben", - "settings.orsApiKey": "OpenRouteService API-Schlüssel", - "settings.orsApiKeyHelp": "Erforderlich für Pendelzeitberechnungen. Kostenlosen Schlüssel unter openrouteservice.org erhalten (2000 Anfragen/Tag kostenlos). Schlüssel eingeben, um die Pendelzeiten-Funktion in den Inseratdetails zu aktivieren.", - "settings.orsApiKeyPlaceholder": "ORS API-Schlüssel hier einfügen", - "settings.orsApiKeyAlreadySet": "Bereits gesetzt — neuen Schlüssel einfügen zum Ersetzen", "settings.homeAddressGeoError": "Adresse gefunden, konnte aber nicht genau geokodiert werden.", "settings.providerDetails": "Anbieter-Details", "settings.providerDetailsHelp": "Zusätzliche Details (Beschreibung, Attribute, Maklerinfos) für Inserate abrufen. Erfordert einen zusätzlichen API-Aufruf pro Inserat.", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index aab6ac94..1786e502 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -347,10 +347,6 @@ "settings.homeAddress": "Commute Address", "settings.homeAddressHelp": "Used to calculate commute times and straight-line distances from each listing. Enter your workplace, university, or any destination you regularly commute to. Updating this recalculates distances for all active listings.", "settings.homeAddressPlaceholder": "Enter your commute destination", - "settings.orsApiKey": "OpenRouteService API Key", - "settings.orsApiKeyHelp": "Required for commute time calculations. Get a free key at openrouteservice.org (2000 requests/day free). Enter your key to enable the commute times feature in listing details.", - "settings.orsApiKeyPlaceholder": "Paste your ORS API key here", - "settings.orsApiKeyAlreadySet": "Already set — paste a new key to replace it", "settings.homeAddressGeoError": "Address found but could not be geocoded accurately.", "settings.providerDetails": "Provider Details", "settings.providerDetailsHelp": "Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing.", diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index 4244092b..9350310e 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -102,8 +102,6 @@ const GeneralSettings = function GeneralSettings() { const [proxyUrl, setProxyUrl] = React.useState(''); const [deeplApiKey, setDeeplApiKey] = React.useState(''); const [deeplApiKeySet, setDeeplApiKeySet] = React.useState(false); - const [orsApiKey, setOrsApiKey] = React.useState(''); - const [orsApiKeySet, setOrsApiKeySet] = React.useState(false); const [port, setPort] = React.useState(''); const [workingHourFrom, setWorkingHourFrom] = React.useState(null); const [workingHourTo, setWorkingHourTo] = React.useState(null); @@ -168,7 +166,6 @@ const GeneralSettings = function GeneralSettings() { setSqlitePath(settings?.sqlitepath); setBaseUrl(settings?.baseUrl ?? ''); setDeeplApiKeySet(settings?.deepl_api_key_set ?? false); - setOrsApiKeySet(settings?.ors_api_key_set ?? false); } init(); @@ -259,7 +256,6 @@ const GeneralSettings = function GeneralSettings() { sqlitepath: sqlitePath, baseUrl, ...(deeplApiKey.trim() ? { deepl_api_key: deeplApiKey.trim() } : {}), - ...(orsApiKey.trim() ? { ors_api_key: orsApiKey.trim() } : {}), }); } catch (exception) { console.error(exception); @@ -588,15 +584,6 @@ const GeneralSettings = function GeneralSettings() { /> - - setOrsApiKey(value)} - /> - -