From 0445df268f5b6fac9d2d963000120b7901542ed9 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Tue, 27 Oct 2026 14:44:55 +0000 Subject: [PATCH 1/7] feat(listings): add ignore list context menu to listing description --- lib/api/routes/jobRouter.js | 44 +++++ lib/services/storage/jobStorage.js | 39 ++++ test/api/jobBlacklist.test.js | 171 ++++++++++++++++++ test/storage/jobStorage.test.js | 40 ++++ ui/src/locales/de.json | 4 + ui/src/locales/en.json | 4 + ui/src/locales/es.json | 4 + ui/src/locales/fr.json | 4 + ui/src/locales/it.json | 4 + ui/src/locales/tr.json | 4 + ui/src/services/state/store.js | 17 ++ .../components/ListingDescriptionCard.jsx | 106 ++++++++++- .../components/ListingDescriptionCard.less | 13 +- 13 files changed, 450 insertions(+), 4 deletions(-) create mode 100644 test/api/jobBlacklist.test.js diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index eb33ced63..57e467c3c 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -349,6 +349,50 @@ export default async function jobPlugin(fastify) { return reply.send(); }); + fastify.post('/:jobId/blacklist', async (request, reply) => { + const { jobId } = request.params; + const { word, words } = request.body || {}; + + const candidateWords = []; + if (typeof word === 'string') candidateWords.push(word); + if (Array.isArray(words)) candidateWords.push(...words); + + const cleanedWords = candidateWords + .filter((w) => typeof w === 'string') + .map((w) => w.trim()) + .filter((w) => w.length > 0); + + if (cleanedWords.length === 0) { + return reply.code(400).send({ error: 'No valid blacklist word provided' }); + } + + try { + const job = jobStorage.getJob(jobId); + if (!job) { + return reply.code(404).send({ error: 'Job not found' }); + } + + const settings = await getSettings(); + if (settings.demoMode && !isAdmin(request) && isDemoJob(job.id)) { + return reply.code(403).send({ error: 'Sorry, but you cannot modify the Demo Job ;)' }); + } + + if (!doesJobBelongsToUser(job, request)) { + return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user' }); + } + + const result = jobStorage.appendJobBlacklist(jobId, cleanedWords); + return reply.send({ + success: true, + blacklist: result.blacklist, + added: result.added, + }); + } catch (error) { + logger.error(error); + return reply.code(500).send({ error: error.message }); + } + }); + fastify.get('/shareableUserList', async (request) => { const currentUser = request.session.currentUser; const users = userStorage.getUsers(); diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js index 6bf1de121..17aefaf89 100644 --- a/lib/services/storage/jobStorage.js +++ b/lib/services/storage/jobStorage.js @@ -213,6 +213,45 @@ export const setJobStatus = ({ jobId, status }) => { }); }; +/** + * Append words to a job's blacklist, deduplicating case-insensitively while preserving input. + * + * @param {string} jobId - Job id. + * @param {string|string[]} wordsToAppend - Single word or list of words to add. + * @returns {{ blacklist: string[], added: string[] }} + */ +export const appendJobBlacklist = (jobId, wordsToAppend) => { + const rawWords = Array.isArray(wordsToAppend) ? wordsToAppend : [wordsToAppend]; + const cleaned = rawWords.map((w) => (typeof w === 'string' ? w.trim() : '')).filter((w) => w.length > 0); + + return SqliteConnection.withTransaction(() => { + const row = SqliteConnection.query(`SELECT blacklist FROM jobs WHERE id = @id LIMIT 1`, { id: jobId })[0]; + if (!row) return null; + const currentBlacklist = fromJson(row.blacklist, []); + const existingLower = new Set(currentBlacklist.map((item) => String(item).toLowerCase())); + + const added = []; + for (const word of cleaned) { + const lower = word.toLowerCase(); + if (!existingLower.has(lower)) { + existingLower.add(lower); + added.push(word); + } + } + + const updatedBlacklist = [...currentBlacklist, ...added]; + SqliteConnection.execute(`UPDATE jobs SET blacklist = @blacklist WHERE id = @id`, { + id: jobId, + blacklist: toJson(updatedBlacklist), + }); + + return { + blacklist: updatedBlacklist, + added, + }; + }); +}; + /** * Remove a job by id. Listings are deleted automatically due to FK ON DELETE CASCADE. * @param {string} jobId - Job id. diff --git a/test/api/jobBlacklist.test.js b/test/api/jobBlacklist.test.js new file mode 100644 index 000000000..bf7b50751 --- /dev/null +++ b/test/api/jobBlacklist.test.js @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import Fastify from 'fastify'; + +let db; + +vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ + default: { + query: (sql, params = {}) => db.prepare(sql).all(params), + execute: (sql, params = {}) => db.prepare(sql).run(params), + withTransaction: (cb) => db.transaction(() => cb(db))(), + }, +})); + +vi.mock('../../lib/services/storage/settingsStorage.js', () => ({ + getSettings: async () => ({ demoMode: false }), + upsertSettings: () => {}, +})); + +const ALICE = { id: 'u1', username: 'alice', isAdmin: false }; +const BOB = { id: 'u2', username: 'bob', isAdmin: false }; +const ADMIN = { id: 'a1', username: 'root', isAdmin: true }; + +describe('POST /api/jobs/:jobId/blacklist', () => { + let app; + let currentUser; + + const build = async () => { + const plugin = (await import('../../lib/api/routes/jobRouter.js')).default; + const instance = Fastify(); + instance.addHook('preHandler', async (request) => { + request.currentUser = currentUser; + request.session = { currentUser: currentUser?.id }; + }); + await instance.register(plugin, { prefix: '/api/jobs' }); + return instance; + }; + + beforeEach(async () => { + currentUser = ALICE; + db = new Database(':memory:'); + db.exec(` + CREATE TABLE jobs ( + id TEXT PRIMARY KEY, + user_id TEXT, + enabled INTEGER DEFAULT 1, + name TEXT, + blacklist TEXT, + provider TEXT, + notification_adapter TEXT, + shared_with_user TEXT, + spatial_filter TEXT, + spec_filter TEXT, + commute_filter TEXT, + deal_type TEXT, + last_run_at INTEGER + ); + CREATE TABLE configured_adapter ( + id TEXT PRIMARY KEY, + user_id TEXT, + name TEXT, + adapter_id TEXT, + visibility TEXT, + fields TEXT + ); + CREATE TABLE listings ( + id TEXT PRIMARY KEY, + job_id TEXT, + is_active INTEGER DEFAULT 1, + manually_deleted INTEGER DEFAULT 0 + ); + `); + + db.prepare( + ` + INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user, deal_type) + VALUES (?, ?, 1, ?, ?, '[]', '[]', '[]', 'rent') + `, + ).run('job-alice', 'u1', 'Alice Job', JSON.stringify(['OldWord'])); + + app = await build(); + }); + + it('rejects empty or whitespace-only word input with 400', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: ' ' }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.payload).error).toBe('No valid blacklist word provided'); + }); + + it('returns 404 if job does not exist', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/nonexistent-job/blacklist', + payload: { word: 'Tauschwohnung' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('returns 403 if another user tries to modify the job', async () => { + currentUser = BOB; + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: 'Tauschwohnung' }, + }); + expect(res.statusCode).toBe(403); + }); + + it('successfully appends word and trims whitespace', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: ' Tauschwohnung ' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.success).toBe(true); + expect(body.added).toEqual(['Tauschwohnung']); + expect(body.blacklist).toEqual(['OldWord', 'Tauschwohnung']); + + const row = db.prepare('SELECT blacklist FROM jobs WHERE id = ?').get('job-alice'); + expect(JSON.parse(row.blacklist)).toEqual(['OldWord', 'Tauschwohnung']); + }); + + it('deduplicates case-insensitively when word already exists', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: 'oldword' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.success).toBe(true); + expect(body.added).toEqual([]); + expect(body.blacklist).toEqual(['OldWord']); + }); + + it('supports words array in body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { words: ['WBS', 'Zwangsversteigerung', 'oldword'] }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.success).toBe(true); + expect(body.added).toEqual(['WBS', 'Zwangsversteigerung']); + expect(body.blacklist).toEqual(['OldWord', 'WBS', 'Zwangsversteigerung']); + }); + + it('allows admin to modify any job', async () => { + currentUser = ADMIN; + const res = await app.inject({ + method: 'POST', + url: '/api/jobs/job-alice/blacklist', + payload: { word: 'AdminAdded' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.payload); + expect(body.added).toEqual(['AdminAdded']); + }); +}); diff --git a/test/storage/jobStorage.test.js b/test/storage/jobStorage.test.js index 8e7459f21..edacf1ba2 100644 --- a/test/storage/jobStorage.test.js +++ b/test/storage/jobStorage.test.js @@ -24,6 +24,7 @@ const sqliteMock = { return []; }, __queryHandler: null, + withTransaction: (cb) => cb(), }; vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({ @@ -165,3 +166,42 @@ describe('jobStorage.upsertJob binds nothing it does not write', () => { expect(JSON.parse(insert.params.commuteFilter)).toEqual(aJob.commuteFilter); }); }); + +describe('jobStorage.appendJobBlacklist', () => { + let jobStorage; + + beforeEach(async () => { + calls.execute.length = 0; + calls.query.length = 0; + sqliteMock.__queryHandler = null; + jobStorage = await import('../../lib/services/storage/jobStorage.js'); + }); + + it('returns null if job does not exist', () => { + sqliteMock.__queryHandler = () => []; + const result = jobStorage.appendJobBlacklist('missing-job', ['word']); + expect(result).toBeNull(); + expect(calls.execute).toHaveLength(0); + }); + + it('appends and deduplicates words case-insensitively while trimming whitespace', () => { + sqliteMock.__queryHandler = () => [{ blacklist: JSON.stringify(['ExistingWord', 'wbs']) }]; + const result = jobStorage.appendJobBlacklist('job-1', [' existingword ', 'WBS', 'Tauschwohnung', ' ']); + expect(result).toEqual({ + blacklist: ['ExistingWord', 'wbs', 'Tauschwohnung'], + added: ['Tauschwohnung'], + }); + const updateCall = calls.execute.find((c) => c.sql.includes('UPDATE jobs SET blacklist')); + expect(updateCall).toBeDefined(); + expect(JSON.parse(updateCall.params.blacklist)).toEqual(['ExistingWord', 'wbs', 'Tauschwohnung']); + }); + + it('handles a single string word parameter', () => { + sqliteMock.__queryHandler = () => [{ blacklist: '[]' }]; + const result = jobStorage.appendJobBlacklist('job-1', 'Indexmiete'); + expect(result).toEqual({ + blacklist: ['Indexmiete'], + added: ['Indexmiete'], + }); + }); +}); diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index a235db154..db972caac 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Beschreibung", "listing.detail.noDescription": "Keine Beschreibung verfügbar.", + "listing.detail.addToBlacklist": "\"{{term}}\" zur Blacklist hinzufügen", + "listing.detail.addToBlacklistHelp": "Zukünftige Suchen für {{job}} filtern Inserate mit diesem Begriff heraus.", + "listing.detail.blacklistAdded": "\"{{term}}\" zur Blacklist von {{job}} hinzugefügt.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ist bereits in der Blacklist vorhanden.", "listing.detail.distanceToHome": "Entfernungen:", "listing.detail.locationTitle": "Lage", "listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index 2c077d2a1..eeb0b0c24 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Details", "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "No description available.", + "listing.detail.addToBlacklist": "Add \"{{term}}\" to ignore list", + "listing.detail.addToBlacklistHelp": "Future searches for {{job}} will filter out listings containing this term.", + "listing.detail.blacklistAdded": "Added \"{{term}}\" to the ignore list of {{job}}.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" is already in the ignore list.", "listing.detail.distanceToHome": "Distances:", "listing.detail.locationTitle": "Location", "listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index d78966350..bf71bdf5d 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Datos", "listing.detail.descriptionTitle": "Descripción", "listing.detail.noDescription": "No hay descripción.", + "listing.detail.addToBlacklist": "Añadir \"{{term}}\" a la lista de exclusión", + "listing.detail.addToBlacklistHelp": "Las búsquedas futuras de {{job}} filtrarán los anuncios que contengan este término.", + "listing.detail.blacklistAdded": "Añadido \"{{term}}\" a la lista de exclusión de {{job}}.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ya está en la lista de exclusión.", "listing.detail.distanceToHome": "Distancias:", "listing.detail.locationTitle": "Ubicación", "listing.detail.noGeoWarning": "Este anuncio no tiene coordenadas válidas, así que no podemos mostrarlo en el mapa.", diff --git a/ui/src/locales/fr.json b/ui/src/locales/fr.json index 700154365..b72d7f75d 100644 --- a/ui/src/locales/fr.json +++ b/ui/src/locales/fr.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Détails", "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "Aucune description disponible.", + "listing.detail.addToBlacklist": "Ajouter « {{term}} » à la liste noire", + "listing.detail.addToBlacklistHelp": "Les futures recherches pour {{job}} ignoreront les annonces contenant ce terme.", + "listing.detail.blacklistAdded": "« {{term}} » ajouté à la liste noire de {{job}}.", + "listing.detail.blacklistAlreadyPresent": "« {{term}} » est déjà présent dans la liste noire.", "listing.detail.distanceToHome": "Distances :", "listing.detail.locationTitle": "Emplacement", "listing.detail.noGeoWarning": "Cette annonce n'a pas de coordonnées géographiques valides, nous ne pouvons donc pas l'afficher sur la carte.", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 4d7fa015d..8c16dc978 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -369,6 +369,10 @@ "listing.detail.detailsTitle": "Dati", "listing.detail.descriptionTitle": "Descrizione", "listing.detail.noDescription": "Nessuna descrizione disponibile.", + "listing.detail.addToBlacklist": "Aggiungi \"{{term}}\" alla lista di esclusione", + "listing.detail.addToBlacklistHelp": "Le ricerche future per {{job}} filtreranno gli annunci contenenti questo termine.", + "listing.detail.blacklistAdded": "Aggiunto \"{{term}}\" alla lista di esclusione di {{job}}.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" è già presente nella lista di esclusione.", "listing.detail.distanceToHome": "Distanze:", "listing.detail.locationTitle": "Posizione", "listing.detail.noGeoWarning": "Questo annuncio non ha coordinate valide, quindi non possiamo mostrarlo sulla mappa.", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index 0463fd41f..bf6efe7ef 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -341,6 +341,10 @@ "listing.detail.detailsTitle": "Detaylar", "listing.detail.descriptionTitle": "Açıklama", "listing.detail.noDescription": "Açıklama bulunmuyor.", + "listing.detail.addToBlacklist": "\"{{term}}\" ifadesini kara listeye ekle", + "listing.detail.addToBlacklistHelp": "{{job}} için gelecekteki aramalar bu ifadeyi içeren ilanları filtreleyecektir.", + "listing.detail.blacklistAdded": "\"{{term}}\" ifadesi {{job}} kara listesine eklendi.", + "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" zaten kara listede mevcut.", "listing.detail.distanceToHome": "Uzaklıklar:", "listing.detail.locationTitle": "Konum", "listing.detail.noGeoWarning": "Bu ilanın geçerli coğrafi koordinatları yok, bu nedenle haritada gösterilemiyor.", diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 7f6a6951a..d75cc7b81 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -322,6 +322,23 @@ export const useFredyState = create( return { jobsData: { ...state.jobsData, jobs: Object.freeze(updated), result: Object.freeze(result) } }; }); }, + async addJobBlacklistWord(jobId, word) { + const response = await xhrPost(`/api/jobs/${jobId}/blacklist`, { word }); + if (response.status === 200) { + set((state) => { + const updateList = (list) => + (list || []).map((j) => (j.id === jobId ? { ...j, blacklist: response.json.blacklist } : j)); + return { + jobsData: { + ...state.jobsData, + jobs: Object.freeze(updateList(state.jobsData.jobs)), + result: Object.freeze(updateList(state.jobsData.result)), + }, + }; + }); + } + return response.json; + }, }, user: { async getUsers() { diff --git a/ui/src/views/listings/components/ListingDescriptionCard.jsx b/ui/src/views/listings/components/ListingDescriptionCard.jsx index 364836609..b8ed396f2 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.jsx +++ b/ui/src/views/listings/components/ListingDescriptionCard.jsx @@ -3,10 +3,12 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import { Button, Spin, Typography } from '@douyinfe/semi-ui-19'; -import { IconArticle, IconRefresh } from '@douyinfe/semi-icons'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Button, Dropdown, Spin, Toast, Typography } from '@douyinfe/semi-ui-19'; +import { IconArticle, IconFilter, IconRefresh } from '@douyinfe/semi-icons'; import { useTranslation } from '../../../services/i18n/i18n.jsx'; +import { useActions } from '../../../services/state/store.js'; import './ListingDescriptionCard.less'; const { Text } = Typography; @@ -55,18 +57,95 @@ const OUTCOME = { */ export default function ListingDescriptionCard({ listing, onRefetch, refetching, outcome }) { const t = useTranslation(); + const actions = useActions(); const description = listing?.description; + const jobId = listing?.job_id; + const jobName = listing?.job_name; const note = outcome ? OUTCOME[outcome] : null; // A portal with no detail page will not grow one on a second press. const exhausted = outcome === 'unsupported'; + const [selectedText, setSelectedText] = useState(''); + const [menuVisible, setMenuVisible] = useState(false); + const [menuCoords, setMenuCoords] = useState({ x: 0, y: 0 }); + + const anchorRef = useRef(null); + + const closeMenu = useCallback(() => { + setMenuVisible(false); + }, []); + + useEffect(() => { + if (!menuVisible) return; + const handleDocumentClick = (e) => { + if (anchorRef.current && anchorRef.current.contains(e.target)) return; + closeMenu(); + }; + const handleScroll = () => closeMenu(); + + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('click', handleDocumentClick); + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('click', handleDocumentClick); + }; + }, [menuVisible, closeMenu]); + + const handleContextMenu = (e) => { + const selection = window.getSelection()?.toString().trim(); + if (!selection || !jobId) { + return; + } + + e.preventDefault(); + setSelectedText(selection); + setMenuCoords({ + x: e.clientX, + y: e.clientY, + }); + setMenuVisible(true); + }; + + const handleAddToBlacklist = async () => { + if (!jobId || !selectedText) return; + closeMenu(); + const term = selectedText; + + try { + const result = await actions.jobsData.addJobBlacklistWord(jobId, term); + if (result && Array.isArray(result.added) && result.added.length > 0) { + Toast.success( + t('listing.detail.blacklistAdded', { + term, + job: jobName || jobId, + }), + ); + } else { + Toast.info( + t('listing.detail.blacklistAlreadyPresent', { + term, + }), + ); + } + } catch (err) { + Toast.error(err?.message || 'Error adding to ignore list'); + } + }; + + const menu = ( + + } onClick={handleAddToBlacklist}> + {t('listing.detail.addToBlacklist', { term: selectedText })} + + + ); // Read without variables, so the placeholder survives to be split on. The two halves are whatever // the locale wrote around it, which is the only way a German verb at the end and an English one // in the middle can share a single string. const [before, after] = t('listing.detail.refetchHint').split(ACTION_SLOT); return ( -
+

{t('listing.detail.descriptionTitle')}

{/* Only when there is already text: the empty state acts through its own sentence, and a @@ -120,6 +199,27 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching, {note && (

{t(note.key)}

)} + + +
); } diff --git a/ui/src/views/listings/components/ListingDescriptionCard.less b/ui/src/views/listings/components/ListingDescriptionCard.less index d55dd4e2f..051c7df92 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.less +++ b/ui/src/views/listings/components/ListingDescriptionCard.less @@ -1,4 +1,4 @@ -@import '../../../tokens.less'; +@import "../../../tokens.less"; .listing-description { &__head { @@ -121,3 +121,14 @@ } } } + +.listing-description-menu { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .semi-dropdown-item { + font-size: 13px; + } +} From a7c8333424d29bc93872c7d3f5272a05d6569527 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 30 Oct 2026 16:14:33 +0000 Subject: [PATCH 2/7] style(listings): use token font size and single quotes in less --- ui/src/views/listings/components/ListingDescriptionCard.less | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/views/listings/components/ListingDescriptionCard.less b/ui/src/views/listings/components/ListingDescriptionCard.less index 051c7df92..577c8ddf1 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.less +++ b/ui/src/views/listings/components/ListingDescriptionCard.less @@ -1,4 +1,4 @@ -@import "../../../tokens.less"; +@import '../../../tokens.less'; .listing-description { &__head { @@ -129,6 +129,6 @@ white-space: nowrap; .semi-dropdown-item { - font-size: 13px; + font-size: @text-sm; } } From f9523da97f3a6b8f36183e299afebff45d982dff Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 30 Oct 2026 16:15:08 +0000 Subject: [PATCH 3/7] fix(storage): accept string[] in appendJobBlacklist and return early when no words added --- lib/services/storage/jobStorage.js | 16 ++++++++++------ test/storage/jobStorage.test.js | 16 +++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js index 17aefaf89..b3f61d62f 100644 --- a/lib/services/storage/jobStorage.js +++ b/lib/services/storage/jobStorage.js @@ -217,13 +217,10 @@ export const setJobStatus = ({ jobId, status }) => { * Append words to a job's blacklist, deduplicating case-insensitively while preserving input. * * @param {string} jobId - Job id. - * @param {string|string[]} wordsToAppend - Single word or list of words to add. - * @returns {{ blacklist: string[], added: string[] }} + * @param {string[]} wordsToAppend - List of words to add. + * @returns {{ blacklist: string[], added: string[] }|null} */ export const appendJobBlacklist = (jobId, wordsToAppend) => { - const rawWords = Array.isArray(wordsToAppend) ? wordsToAppend : [wordsToAppend]; - const cleaned = rawWords.map((w) => (typeof w === 'string' ? w.trim() : '')).filter((w) => w.length > 0); - return SqliteConnection.withTransaction(() => { const row = SqliteConnection.query(`SELECT blacklist FROM jobs WHERE id = @id LIMIT 1`, { id: jobId })[0]; if (!row) return null; @@ -231,7 +228,7 @@ export const appendJobBlacklist = (jobId, wordsToAppend) => { const existingLower = new Set(currentBlacklist.map((item) => String(item).toLowerCase())); const added = []; - for (const word of cleaned) { + for (const word of wordsToAppend) { const lower = word.toLowerCase(); if (!existingLower.has(lower)) { existingLower.add(lower); @@ -239,6 +236,13 @@ export const appendJobBlacklist = (jobId, wordsToAppend) => { } } + if (added.length === 0) { + return { + blacklist: currentBlacklist, + added: [], + }; + } + const updatedBlacklist = [...currentBlacklist, ...added]; SqliteConnection.execute(`UPDATE jobs SET blacklist = @blacklist WHERE id = @id`, { id: jobId, diff --git a/test/storage/jobStorage.test.js b/test/storage/jobStorage.test.js index edacf1ba2..f7c47d8d2 100644 --- a/test/storage/jobStorage.test.js +++ b/test/storage/jobStorage.test.js @@ -184,9 +184,9 @@ describe('jobStorage.appendJobBlacklist', () => { expect(calls.execute).toHaveLength(0); }); - it('appends and deduplicates words case-insensitively while trimming whitespace', () => { + it('appends and deduplicates words case-insensitively', () => { sqliteMock.__queryHandler = () => [{ blacklist: JSON.stringify(['ExistingWord', 'wbs']) }]; - const result = jobStorage.appendJobBlacklist('job-1', [' existingword ', 'WBS', 'Tauschwohnung', ' ']); + const result = jobStorage.appendJobBlacklist('job-1', ['existingword', 'WBS', 'Tauschwohnung']); expect(result).toEqual({ blacklist: ['ExistingWord', 'wbs', 'Tauschwohnung'], added: ['Tauschwohnung'], @@ -196,12 +196,14 @@ describe('jobStorage.appendJobBlacklist', () => { expect(JSON.parse(updateCall.params.blacklist)).toEqual(['ExistingWord', 'wbs', 'Tauschwohnung']); }); - it('handles a single string word parameter', () => { - sqliteMock.__queryHandler = () => [{ blacklist: '[]' }]; - const result = jobStorage.appendJobBlacklist('job-1', 'Indexmiete'); + it('returns early without updating the row when no words are added', () => { + sqliteMock.__queryHandler = () => [{ blacklist: JSON.stringify(['ExistingWord', 'wbs']) }]; + const result = jobStorage.appendJobBlacklist('job-1', ['existingword', 'WBS']); expect(result).toEqual({ - blacklist: ['Indexmiete'], - added: ['Indexmiete'], + blacklist: ['ExistingWord', 'wbs'], + added: [], }); + const updateCall = calls.execute.find((c) => c.sql.includes('UPDATE jobs SET blacklist')); + expect(updateCall).toBeUndefined(); }); }); From 2d13cd4c48c2d49605b022e3575fc51fb2407d92 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 30 Oct 2026 16:16:24 +0000 Subject: [PATCH 4/7] chore(locales): drop unused addToBlacklistHelp key --- ui/src/locales/de.json | 1 - ui/src/locales/en.json | 1 - ui/src/locales/es.json | 1 - ui/src/locales/fr.json | 1 - ui/src/locales/it.json | 1 - ui/src/locales/tr.json | 1 - 6 files changed, 6 deletions(-) diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index db972caac..59b8da86c 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -370,7 +370,6 @@ "listing.detail.descriptionTitle": "Beschreibung", "listing.detail.noDescription": "Keine Beschreibung verfügbar.", "listing.detail.addToBlacklist": "\"{{term}}\" zur Blacklist hinzufügen", - "listing.detail.addToBlacklistHelp": "Zukünftige Suchen für {{job}} filtern Inserate mit diesem Begriff heraus.", "listing.detail.blacklistAdded": "\"{{term}}\" zur Blacklist von {{job}} hinzugefügt.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ist bereits in der Blacklist vorhanden.", "listing.detail.distanceToHome": "Entfernungen:", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index eeb0b0c24..e7017f72a 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -370,7 +370,6 @@ "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "No description available.", "listing.detail.addToBlacklist": "Add \"{{term}}\" to ignore list", - "listing.detail.addToBlacklistHelp": "Future searches for {{job}} will filter out listings containing this term.", "listing.detail.blacklistAdded": "Added \"{{term}}\" to the ignore list of {{job}}.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" is already in the ignore list.", "listing.detail.distanceToHome": "Distances:", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index bf71bdf5d..f9b388fd1 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -370,7 +370,6 @@ "listing.detail.descriptionTitle": "Descripción", "listing.detail.noDescription": "No hay descripción.", "listing.detail.addToBlacklist": "Añadir \"{{term}}\" a la lista de exclusión", - "listing.detail.addToBlacklistHelp": "Las búsquedas futuras de {{job}} filtrarán los anuncios que contengan este término.", "listing.detail.blacklistAdded": "Añadido \"{{term}}\" a la lista de exclusión de {{job}}.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ya está en la lista de exclusión.", "listing.detail.distanceToHome": "Distancias:", diff --git a/ui/src/locales/fr.json b/ui/src/locales/fr.json index b72d7f75d..ff9c725bd 100644 --- a/ui/src/locales/fr.json +++ b/ui/src/locales/fr.json @@ -370,7 +370,6 @@ "listing.detail.descriptionTitle": "Description", "listing.detail.noDescription": "Aucune description disponible.", "listing.detail.addToBlacklist": "Ajouter « {{term}} » à la liste noire", - "listing.detail.addToBlacklistHelp": "Les futures recherches pour {{job}} ignoreront les annonces contenant ce terme.", "listing.detail.blacklistAdded": "« {{term}} » ajouté à la liste noire de {{job}}.", "listing.detail.blacklistAlreadyPresent": "« {{term}} » est déjà présent dans la liste noire.", "listing.detail.distanceToHome": "Distances :", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 8c16dc978..7450c4813 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -370,7 +370,6 @@ "listing.detail.descriptionTitle": "Descrizione", "listing.detail.noDescription": "Nessuna descrizione disponibile.", "listing.detail.addToBlacklist": "Aggiungi \"{{term}}\" alla lista di esclusione", - "listing.detail.addToBlacklistHelp": "Le ricerche future per {{job}} filtreranno gli annunci contenenti questo termine.", "listing.detail.blacklistAdded": "Aggiunto \"{{term}}\" alla lista di esclusione di {{job}}.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" è già presente nella lista di esclusione.", "listing.detail.distanceToHome": "Distanze:", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index bf6efe7ef..5bbd5c2a3 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -342,7 +342,6 @@ "listing.detail.descriptionTitle": "Açıklama", "listing.detail.noDescription": "Açıklama bulunmuyor.", "listing.detail.addToBlacklist": "\"{{term}}\" ifadesini kara listeye ekle", - "listing.detail.addToBlacklistHelp": "{{job}} için gelecekteki aramalar bu ifadeyi içeren ilanları filtreleyecektir.", "listing.detail.blacklistAdded": "\"{{term}}\" ifadesi {{job}} kara listesine eklendi.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" zaten kara listede mevcut.", "listing.detail.distanceToHome": "Uzaklıklar:", From a690e36bd5f988cdb90f47622bc36074fc17bf10 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 30 Oct 2026 16:18:10 +0000 Subject: [PATCH 5/7] fix(listings): use errorMessage and localized fallback for blacklist errors --- ui/src/locales/de.json | 1 + ui/src/locales/en.json | 1 + ui/src/locales/es.json | 1 + ui/src/locales/fr.json | 1 + ui/src/locales/it.json | 1 + ui/src/locales/tr.json | 1 + ui/src/views/listings/components/ListingDescriptionCard.jsx | 3 ++- 7 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 59b8da86c..695f82d21 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -372,6 +372,7 @@ "listing.detail.addToBlacklist": "\"{{term}}\" zur Blacklist hinzufügen", "listing.detail.blacklistAdded": "\"{{term}}\" zur Blacklist von {{job}} hinzugefügt.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ist bereits in der Blacklist vorhanden.", + "listing.detail.blacklistError": "Fehler beim Hinzufügen zur Blacklist.", "listing.detail.distanceToHome": "Entfernungen:", "listing.detail.locationTitle": "Lage", "listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.", diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index e7017f72a..dfd812c23 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -372,6 +372,7 @@ "listing.detail.addToBlacklist": "Add \"{{term}}\" to ignore list", "listing.detail.blacklistAdded": "Added \"{{term}}\" to the ignore list of {{job}}.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" is already in the ignore list.", + "listing.detail.blacklistError": "Error adding to ignore list.", "listing.detail.distanceToHome": "Distances:", "listing.detail.locationTitle": "Location", "listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.", diff --git a/ui/src/locales/es.json b/ui/src/locales/es.json index f9b388fd1..9feb2907f 100644 --- a/ui/src/locales/es.json +++ b/ui/src/locales/es.json @@ -372,6 +372,7 @@ "listing.detail.addToBlacklist": "Añadir \"{{term}}\" a la lista de exclusión", "listing.detail.blacklistAdded": "Añadido \"{{term}}\" a la lista de exclusión de {{job}}.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" ya está en la lista de exclusión.", + "listing.detail.blacklistError": "Error al añadir a la lista de exclusión.", "listing.detail.distanceToHome": "Distancias:", "listing.detail.locationTitle": "Ubicación", "listing.detail.noGeoWarning": "Este anuncio no tiene coordenadas válidas, así que no podemos mostrarlo en el mapa.", diff --git a/ui/src/locales/fr.json b/ui/src/locales/fr.json index ff9c725bd..e6ec8e665 100644 --- a/ui/src/locales/fr.json +++ b/ui/src/locales/fr.json @@ -372,6 +372,7 @@ "listing.detail.addToBlacklist": "Ajouter « {{term}} » à la liste noire", "listing.detail.blacklistAdded": "« {{term}} » ajouté à la liste noire de {{job}}.", "listing.detail.blacklistAlreadyPresent": "« {{term}} » est déjà présent dans la liste noire.", + "listing.detail.blacklistError": "Erreur lors de l'ajout à la liste noire.", "listing.detail.distanceToHome": "Distances :", "listing.detail.locationTitle": "Emplacement", "listing.detail.noGeoWarning": "Cette annonce n'a pas de coordonnées géographiques valides, nous ne pouvons donc pas l'afficher sur la carte.", diff --git a/ui/src/locales/it.json b/ui/src/locales/it.json index 7450c4813..6fc088e96 100644 --- a/ui/src/locales/it.json +++ b/ui/src/locales/it.json @@ -372,6 +372,7 @@ "listing.detail.addToBlacklist": "Aggiungi \"{{term}}\" alla lista di esclusione", "listing.detail.blacklistAdded": "Aggiunto \"{{term}}\" alla lista di esclusione di {{job}}.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" è già presente nella lista di esclusione.", + "listing.detail.blacklistError": "Errore durante l'aggiunta alla lista di esclusione.", "listing.detail.distanceToHome": "Distanze:", "listing.detail.locationTitle": "Posizione", "listing.detail.noGeoWarning": "Questo annuncio non ha coordinate valide, quindi non possiamo mostrarlo sulla mappa.", diff --git a/ui/src/locales/tr.json b/ui/src/locales/tr.json index 5bbd5c2a3..2b793c384 100644 --- a/ui/src/locales/tr.json +++ b/ui/src/locales/tr.json @@ -344,6 +344,7 @@ "listing.detail.addToBlacklist": "\"{{term}}\" ifadesini kara listeye ekle", "listing.detail.blacklistAdded": "\"{{term}}\" ifadesi {{job}} kara listesine eklendi.", "listing.detail.blacklistAlreadyPresent": "\"{{term}}\" zaten kara listede mevcut.", + "listing.detail.blacklistError": "Kara listeye eklenirken hata oluştu.", "listing.detail.distanceToHome": "Uzaklıklar:", "listing.detail.locationTitle": "Konum", "listing.detail.noGeoWarning": "Bu ilanın geçerli coğrafi koordinatları yok, bu nedenle haritada gösterilemiyor.", diff --git a/ui/src/views/listings/components/ListingDescriptionCard.jsx b/ui/src/views/listings/components/ListingDescriptionCard.jsx index b8ed396f2..b3fbe6622 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.jsx +++ b/ui/src/views/listings/components/ListingDescriptionCard.jsx @@ -9,6 +9,7 @@ import { IconArticle, IconFilter, IconRefresh } from '@douyinfe/semi-icons'; import { useTranslation } from '../../../services/i18n/i18n.jsx'; import { useActions } from '../../../services/state/store.js'; +import { errorMessage } from '../../../services/xhr.js'; import './ListingDescriptionCard.less'; const { Text } = Typography; @@ -128,7 +129,7 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching, ); } } catch (err) { - Toast.error(err?.message || 'Error adding to ignore list'); + Toast.error(errorMessage(err, t('listing.detail.blacklistError'))); } }; From 1d6af4328ac27c72a84c5c8dc723654cb9da7bb0 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 30 Oct 2026 16:18:58 +0000 Subject: [PATCH 6/7] fix(listings): gate ignore list context menu on job.isOnlyShared --- ui/src/views/listings/components/ListingDescriptionCard.jsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/views/listings/components/ListingDescriptionCard.jsx b/ui/src/views/listings/components/ListingDescriptionCard.jsx index b3fbe6622..6222fa603 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.jsx +++ b/ui/src/views/listings/components/ListingDescriptionCard.jsx @@ -8,7 +8,7 @@ import { Button, Dropdown, Spin, Toast, Typography } from '@douyinfe/semi-ui-19' import { IconArticle, IconFilter, IconRefresh } from '@douyinfe/semi-icons'; import { useTranslation } from '../../../services/i18n/i18n.jsx'; -import { useActions } from '../../../services/state/store.js'; +import { useActions, useSelector } from '../../../services/state/store.js'; import { errorMessage } from '../../../services/xhr.js'; import './ListingDescriptionCard.less'; @@ -59,8 +59,10 @@ const OUTCOME = { export default function ListingDescriptionCard({ listing, onRefetch, refetching, outcome }) { const t = useTranslation(); const actions = useActions(); + const jobs = useSelector((state) => state.jobsData.jobs); const description = listing?.description; const jobId = listing?.job_id; + const job = (jobs ?? []).find((j) => j?.id === jobId); const jobName = listing?.job_name; const note = outcome ? OUTCOME[outcome] : null; // A portal with no detail page will not grow one on a second press. @@ -94,7 +96,7 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching, const handleContextMenu = (e) => { const selection = window.getSelection()?.toString().trim(); - if (!selection || !jobId) { + if (!selection || !jobId || job?.isOnlyShared) { return; } From d6c00c27407e49f7ec67e58d05d4d8ee13b48709 Mon Sep 17 00:00:00 2001 From: chbndrhnns Date: Fri, 30 Oct 2026 16:19:59 +0000 Subject: [PATCH 7/7] fix(listings): restrict ignore list selection to description body and cap length --- .../components/ListingDescriptionCard.jsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/ui/src/views/listings/components/ListingDescriptionCard.jsx b/ui/src/views/listings/components/ListingDescriptionCard.jsx index 6222fa603..f8973d576 100644 --- a/ui/src/views/listings/components/ListingDescriptionCard.jsx +++ b/ui/src/views/listings/components/ListingDescriptionCard.jsx @@ -73,7 +73,7 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching, const [menuCoords, setMenuCoords] = useState({ x: 0, y: 0 }); const anchorRef = useRef(null); - + const bodyRef = useRef(null); const closeMenu = useCallback(() => { setMenuVisible(false); }, []); @@ -95,20 +95,33 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching, }, [menuVisible, closeMenu]); const handleContextMenu = (e) => { - const selection = window.getSelection()?.toString().trim(); - if (!selection || !jobId || job?.isOnlyShared) { + if (!jobId || job?.isOnlyShared) { + return; + } + + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || sel.rangeCount === 0) { + return; + } + + const bodyEl = bodyRef.current; + if (!bodyEl || !bodyEl.contains(sel.anchorNode) || !bodyEl.contains(sel.focusNode)) { + return; + } + + const text = sel.toString().trim(); + if (!text || text.includes('\n') || text.includes('\r') || text.length > 100) { return; } e.preventDefault(); - setSelectedText(selection); + setSelectedText(text); setMenuCoords({ x: e.clientX, y: e.clientY, }); setMenuVisible(true); }; - const handleAddToBlacklist = async () => { if (!jobId || !selectedText) return; closeMenu(); @@ -169,7 +182,9 @@ export default function ListingDescriptionCard({ listing, onRefetch, refetching, {description ? ( -

{description}

+

+ {description} +

) : (