mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33120ebeca | ||
|
|
de2dd05c70 | ||
|
|
e4784e5960 | ||
|
|
2e537ce0be | ||
|
|
f0f1244baa | ||
|
|
b858529f06 | ||
|
|
c9bd5dc161 | ||
|
|
daa4a7b8f1 | ||
|
|
035f0e9f83 |
6
docker-test.sh
Normal file → Executable file
6
docker-test.sh
Normal file → Executable file
@@ -7,12 +7,12 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
|
|||||||
docker rm fredy || true
|
docker rm fredy || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build image from local Dockerfile
|
# Build image from local Dockerfile, forcing a fresh build without cache
|
||||||
docker build -t fredy:local .
|
docker build --no-cache -t fredy:local .
|
||||||
|
|
||||||
# Run container with volumes and port mapping
|
# Run container with volumes and port mapping
|
||||||
docker run -d --name fredy \
|
docker run -d --name fredy \
|
||||||
-v fredy_conf:/conf \
|
-v fredy_conf:/conf \
|
||||||
-v fredy_db:/db \
|
-v fredy_db:/db \
|
||||||
-p 9998:9998 \
|
-p 9998:9998 \
|
||||||
fredy:local
|
fredy:local
|
||||||
@@ -77,6 +77,7 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
|
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
||||||
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
||||||
|
|
||||||
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
||||||
@@ -95,6 +96,7 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_save(newListings) {
|
_save(newListings) {
|
||||||
|
logger.debug(`Storing ${newListings.length} new listings (Provider: '${this._providerId}')`);
|
||||||
storeListings(this._jobKey, this._providerId, newListings);
|
storeListings(this._jobKey, this._providerId, newListings);
|
||||||
return newListings;
|
return newListings;
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,9 @@ class FredyRuntime {
|
|||||||
const filteredList = listings.filter((listing) => {
|
const filteredList = listings.filter((listing) => {
|
||||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
||||||
if (similar) {
|
if (similar) {
|
||||||
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
|
logger.debug(
|
||||||
|
`Filtering similar entry for title '${listing.title}' and address '${listing.address}' (Provider: '${this._providerId}')`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return !similar;
|
return !similar;
|
||||||
});
|
});
|
||||||
@@ -112,7 +116,11 @@ class FredyRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_handleError(err) {
|
_handleError(err) {
|
||||||
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
if (err.name === 'NoNewListingsWarning') {
|
||||||
|
logger.debug(`No new listings found (Provider: '${this._providerId}').`);
|
||||||
|
} else {
|
||||||
|
logger.error(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,25 @@ function doesJobBelongsToUser(job, req) {
|
|||||||
jobRouter.get('/', async (req, res) => {
|
jobRouter.get('/', async (req, res) => {
|
||||||
const isUserAdmin = isAdmin(req);
|
const isUserAdmin = isAdmin(req);
|
||||||
//show only the jobs which belongs to the user (or all of the user is an admin)
|
//show only the jobs which belongs to the user (or all of the user is an admin)
|
||||||
res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser);
|
res.body = jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter(
|
||||||
|
(job) =>
|
||||||
|
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
)
|
||||||
|
.map((job) => {
|
||||||
|
return {
|
||||||
|
...job,
|
||||||
|
isOnlyShared:
|
||||||
|
!isUserAdmin &&
|
||||||
|
job.userId !== req.session.currentUser &&
|
||||||
|
job.shared_with_user.includes(req.session.currentUser),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/processingTimes', async (req, res) => {
|
||||||
res.body = {
|
res.body = {
|
||||||
interval: config.interval,
|
interval: config.interval,
|
||||||
@@ -41,8 +57,15 @@ jobRouter.post('/startAll', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.post('/', async (req, res) => {
|
jobRouter.post('/', async (req, res) => {
|
||||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||||
try {
|
try {
|
||||||
|
let jobFromDb = jobStorage.getJob(jobId);
|
||||||
|
|
||||||
|
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) {
|
||||||
|
res.send(new Error('You are trying to change a job that is not associated to your user.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
jobStorage.upsertJob({
|
jobStorage.upsertJob({
|
||||||
userId: req.session.currentUser,
|
userId: req.session.currentUser,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -51,6 +74,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
blacklist,
|
blacklist,
|
||||||
provider,
|
provider,
|
||||||
notificationAdapter,
|
notificationAdapter,
|
||||||
|
shareWithUsers,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.send(new Error(error));
|
res.send(new Error(error));
|
||||||
@@ -58,6 +82,7 @@ jobRouter.post('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.delete('', async (req, res) => {
|
jobRouter.delete('', async (req, res) => {
|
||||||
const { jobId } = req.body;
|
const { jobId } = req.body;
|
||||||
try {
|
try {
|
||||||
@@ -92,4 +117,16 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jobRouter.get('/shareableUserList', async (req, res) => {
|
||||||
|
const currentUser = req.session.currentUser;
|
||||||
|
const users = userStorage.getUsers(false);
|
||||||
|
res.body = users
|
||||||
|
.filter((user) => !user.isAdmin && user.id !== currentUser)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
}));
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
export { jobRouter };
|
export { jobRouter };
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
|
|||||||
return req.session.currentUser === userIdToBeRemoved;
|
return req.session.currentUser === userIdToBeRemoved;
|
||||||
}
|
}
|
||||||
const nullOrEmpty = (str) => str == null || str.length === 0;
|
const nullOrEmpty = (str) => str == null || str.length === 0;
|
||||||
|
|
||||||
userRouter.get('/', async (req, res) => {
|
userRouter.get('/', async (req, res) => {
|
||||||
res.body = userStorage.getUsers(false);
|
res.body = userStorage.getUsers(false);
|
||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
userRouter.get('/:userId', async (req, res) => {
|
userRouter.get('/:userId', async (req, res) => {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
res.body = userStorage.getUser(userId);
|
res.body = userStorage.getUser(userId);
|
||||||
|
|||||||
@@ -36,7 +36,17 @@ Link: ${newListing.link}`;
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: message,
|
body: message,
|
||||||
});
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Ntfy message could not be sent. Status code: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Ensure we reject with an Error object and prevent unhandled rejections
|
||||||
|
throw error instanceof Error ? error : new Error(String(error));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import pThrottle from 'p-throttle';
|
import pThrottle from 'p-throttle';
|
||||||
import { normalizeImageUrl } from '../../utils.js';
|
import { normalizeImageUrl } from '../../utils.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const RATE_LIMIT_INTERVAL = 1000;
|
const RATE_LIMIT_INTERVAL = 1000;
|
||||||
const chatThrottleMap = new Map();
|
const chatThrottleMap = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes stale throttled call entries to keep memory bounded.
|
||||||
|
*/
|
||||||
function cleanupOldThrottles() {
|
function cleanupOldThrottles() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
const maxAge = RATE_LIMIT_INTERVAL + 1000;
|
||||||
@@ -17,6 +21,15 @@ function cleanupOldThrottles() {
|
|||||||
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
for (const chatId of toBeDeleted) chatThrottleMap.delete(chatId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a throttled wrapper for a chatId to limit Telegram API calls.
|
||||||
|
* Uses p-throttle with 1 request per RATE_LIMIT_INTERVAL per chat.
|
||||||
|
*
|
||||||
|
* @template {Function} T
|
||||||
|
* @param {string|number} chatId
|
||||||
|
* @param {T} call - async function (endpoint: string, body: any) => Promise<Response>
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
function getThrottled(chatId, call) {
|
function getThrottled(chatId, call) {
|
||||||
cleanupOldThrottles();
|
cleanupOldThrottles();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -30,15 +43,38 @@ function getThrottled(chatId, call) {
|
|||||||
return throttled;
|
return throttled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten a string to a maximum length with an ellipsis suffix.
|
||||||
|
* @param {string} str
|
||||||
|
* @param {number} [len=90]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function shorten(str, len = 90) {
|
function shorten(str, len = 90) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
return str.length > len ? str.substring(0, len).trim() + '...' : str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape basic HTML entities for Telegram HTML parse mode.
|
||||||
|
* @param {string} [s='']
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function escapeHtml(s = '') {
|
function escapeHtml(s = '') {
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Telegram photo caption (max 1024 characters) using HTML parse mode.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @param {string} [o.title]
|
||||||
|
* @param {string} [o.address]
|
||||||
|
* @param {string|number} [o.price]
|
||||||
|
* @param {string|number} [o.size]
|
||||||
|
* @param {string} [o.link]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function buildCaption(jobName, serviceName, o) {
|
function buildCaption(jobName, serviceName, o) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
@@ -47,6 +83,13 @@ function buildCaption(jobName, serviceName, o) {
|
|||||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Telegram message text using HTML parse mode.
|
||||||
|
* @param {string} jobName
|
||||||
|
* @param {string} serviceName
|
||||||
|
* @param {Object} o - Listing object
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
function buildText(jobName, serviceName, o) {
|
function buildText(jobName, serviceName, o) {
|
||||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||||
@@ -57,8 +100,27 @@ function buildText(jobName, serviceName, o) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
/**
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
* Send new listings to Telegram.
|
||||||
|
* - Respects per-chat Telegram rate limits using a lightweight throttle cache.
|
||||||
|
* - Falls back to sendMessage when sendPhoto fails or image is missing.
|
||||||
|
*
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.serviceName - Name of the crawler/service producing the listings.
|
||||||
|
* @param {Array<Object>} params.newListings - Array of new listing objects.
|
||||||
|
* @param {Array<Object>} params.notificationConfig - Notification adapters configuration array.
|
||||||
|
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
||||||
|
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
||||||
|
*/
|
||||||
|
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
||||||
|
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||||
|
if (!adapterCfg || !adapterCfg.fields) {
|
||||||
|
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||||
|
}
|
||||||
|
const { token, chatId } = adapterCfg.fields;
|
||||||
|
if (!token || !chatId) {
|
||||||
|
throw new Error("Telegram 'token' and 'chatId' must be provided in notification config");
|
||||||
|
}
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
|
||||||
@@ -68,9 +130,16 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorBody = await res.text();
|
||||||
|
throw new Error(`API error for '${jobName}'. '${endpoint}' returned ${errorBody}`);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(newListings) || newListings.length === 0) return Promise.resolve([]);
|
||||||
|
|
||||||
const promises = newListings.map(async (o) => {
|
const promises = newListings.map(async (o) => {
|
||||||
const img = normalizeImageUrl(o.image);
|
const img = normalizeImageUrl(o.image);
|
||||||
const textPayload = {
|
const textPayload = {
|
||||||
@@ -81,28 +150,32 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!img) {
|
if (!img) {
|
||||||
return throttledCall('sendMessage', textPayload);
|
return await throttledCall('sendMessage', textPayload).catch(async (e) => {
|
||||||
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return await throttledCall('sendPhoto', {
|
||||||
return await throttledCall('sendPhoto', {
|
chat_id: chatId,
|
||||||
chat_id: chatId,
|
photo: img,
|
||||||
photo: img,
|
caption: buildCaption(jobName, serviceName, o),
|
||||||
caption: buildCaption(jobName, serviceName, o),
|
parse_mode: 'HTML',
|
||||||
parse_mode: 'HTML',
|
}).catch(async (e) => {
|
||||||
|
logger.error(`Error sending photo to Telegram and use a fallback: ${e.message}`);
|
||||||
|
return await throttledCall('sendMessage', textPayload).catch((e) => {
|
||||||
|
logger.error(`Error sending message to Telegram: ${e.message}`);
|
||||||
|
throw e;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
});
|
||||||
// If we see a timeout due to sending an image, try sending it without
|
|
||||||
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
|
|
||||||
return throttledCall('sendMessage', textPayload);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram notification adapter configuration schema.
|
||||||
|
* @type {{id:string,name:string,readme:string,description:string,fields:{token:{type:string,label:string,description:string},chatId:{type:string,label:string,description:string}}}}
|
||||||
|
*/
|
||||||
export const config = {
|
export const config = {
|
||||||
id: 'telegram',
|
id: 'telegram',
|
||||||
name: 'Telegram',
|
name: 'Telegram',
|
||||||
|
|||||||
@@ -16,7 +16,16 @@ import { toJson, fromJson } from '../../utils.js';
|
|||||||
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
* @param {string} params.userId - Owner user id for inserts; preserved on updates.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
export const upsertJob = ({
|
||||||
|
jobId,
|
||||||
|
name,
|
||||||
|
blacklist = [],
|
||||||
|
enabled = true,
|
||||||
|
provider,
|
||||||
|
notificationAdapter,
|
||||||
|
userId,
|
||||||
|
shareWithUsers = [],
|
||||||
|
}) => {
|
||||||
const id = jobId || nanoid();
|
const id = jobId || nanoid();
|
||||||
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0];
|
||||||
const ownerId = existing ? existing.user_id : userId;
|
const ownerId = existing ? existing.user_id : userId;
|
||||||
@@ -27,21 +36,23 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
|
|||||||
name = @name,
|
name = @name,
|
||||||
blacklist = @blacklist,
|
blacklist = @blacklist,
|
||||||
provider = @provider,
|
provider = @provider,
|
||||||
notification_adapter = @notification_adapter
|
notification_adapter = @notification_adapter,
|
||||||
|
shared_with_user = @shareWithUsers
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
enabled: enabled ? 1 : 0,
|
enabled: enabled ? 1 : 0,
|
||||||
name: name ?? null,
|
name: name ?? null,
|
||||||
blacklist: toJson(blacklist ?? []),
|
blacklist: toJson(blacklist ?? []),
|
||||||
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
SqliteConnection.execute(
|
SqliteConnection.execute(
|
||||||
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter)
|
`INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user)
|
||||||
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`,
|
VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
user_id: ownerId,
|
user_id: ownerId,
|
||||||
@@ -49,6 +60,7 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide
|
|||||||
name: name ?? null,
|
name: name ?? null,
|
||||||
blacklist: toJson(blacklist ?? []),
|
blacklist: toJson(blacklist ?? []),
|
||||||
provider: toJson(provider ?? []),
|
provider: toJson(provider ?? []),
|
||||||
|
shareWithUsers: toJson(shareWithUsers ?? []),
|
||||||
notification_adapter: toJson(notificationAdapter ?? []),
|
notification_adapter: toJson(notificationAdapter ?? []),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -129,6 +141,7 @@ export const getJobs = () => {
|
|||||||
j.name,
|
j.name,
|
||||||
j.blacklist,
|
j.blacklist,
|
||||||
j.provider,
|
j.provider,
|
||||||
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -139,6 +152,7 @@ export const getJobs = () => {
|
|||||||
enabled: !!row.enabled,
|
enabled: !!row.enabled,
|
||||||
blacklist: fromJson(row.blacklist, []),
|
blacklist: fromJson(row.blacklist, []),
|
||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
7
lib/services/storage/migrations/sql/5.job-sharing.js
Normal file
7
lib/services/storage/migrations/sql/5.job-sharing.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]'
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "14.1.0",
|
"version": "14.2.0",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"react-router": "7.9.3",
|
"react-router": "7.9.3",
|
||||||
"react-router-dom": "7.9.3",
|
"react-router-dom": "7.9.3",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.3",
|
||||||
"serve-static": "2.2.0",
|
"serve-static": "2.2.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.1.9",
|
"vite": "7.1.9",
|
||||||
@@ -98,13 +98,13 @@
|
|||||||
"@babel/preset-env": "7.28.3",
|
"@babel/preset-env": "7.28.3",
|
||||||
"@babel/preset-react": "7.27.1",
|
"@babel/preset-react": "7.27.1",
|
||||||
"chai": "6.2.0",
|
"chai": "6.2.0",
|
||||||
"eslint": "9.36.0",
|
"eslint": "9.37.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"esmock": "2.7.3",
|
"esmock": "2.7.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.4.1",
|
"less": "4.4.2",
|
||||||
"lint-staged": "16.2.3",
|
"lint-staged": "16.2.3",
|
||||||
"mocha": "11.7.4",
|
"mocha": "11.7.4",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function FredyApp() {
|
|||||||
await actions.provider.getProvider();
|
await actions.provider.getProvider();
|
||||||
await actions.jobs.getJobs();
|
await actions.jobs.getJobs();
|
||||||
await actions.jobs.getProcessingTimes();
|
await actions.jobs.getProcessingTimes();
|
||||||
|
await actions.jobs.getSharableUserList();
|
||||||
await actions.notificationAdapter.getAdapter();
|
await actions.notificationAdapter.getAdapter();
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
await actions.versionUpdate.getVersionUpdate();
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||||
import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './JobTable.less';
|
import './JobTable.less';
|
||||||
@@ -33,12 +33,38 @@ export default function JobTable({
|
|||||||
title: '',
|
title: '',
|
||||||
dataIndex: '',
|
dataIndex: '',
|
||||||
render: (job) => {
|
render: (job) => {
|
||||||
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
return (
|
||||||
|
<Switch
|
||||||
|
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
|
render: (name, job) => {
|
||||||
|
if (job.isOnlyShared) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
content={getPopoverContent(
|
||||||
|
'This job has been shared with you by another user, therefor it is read-only.',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
|
||||||
|
<IconAlertTriangle />
|
||||||
|
</div>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Listings',
|
title: 'Listings',
|
||||||
@@ -48,14 +74,14 @@ export default function JobTable({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Providers',
|
title: 'Provider',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Notification adapters',
|
title: 'Notification Adapter',
|
||||||
dataIndex: 'notificationAdapter',
|
dataIndex: 'notificationAdapter',
|
||||||
render: (value) => {
|
render: (value) => {
|
||||||
return value.length || 0;
|
return value.length || 0;
|
||||||
@@ -68,16 +94,36 @@ export default function JobTable({
|
|||||||
return (
|
return (
|
||||||
<div className="interactions">
|
<div className="interactions">
|
||||||
<Popover content={getPopoverContent('Job Insights')}>
|
<Popover content={getPopoverContent('Job Insights')}>
|
||||||
<Button type="primary" icon={<IconHistogram />} onClick={() => onJobInsight(job.id)} />
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconHistogram />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobInsight(job.id)}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
<Button type="secondary" icon={<IconEdit />} onClick={() => onJobEdit(job.id)} />
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobEdit(job.id)}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
<Button type="danger" icon={<IconDescend2 />} onClick={() => onListingRemoval(job.id)} />
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDescend2 />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export default function ListingsFilter({ onWatchListFilter, onActivityFilter, on
|
|||||||
{jobs != null &&
|
{jobs != null &&
|
||||||
jobs.length > 0 &&
|
jobs.length > 0 &&
|
||||||
jobs.map((job) => {
|
jobs.map((job) => {
|
||||||
return <Select.Option value={job.id}>{job.name}</Select.Option>;
|
return (
|
||||||
|
<Select.Option value={job.id} key={job.id}>
|
||||||
|
{job.name}
|
||||||
|
</Select.Option>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
@@ -35,7 +39,11 @@ export default function ListingsFilter({ onWatchListFilter, onActivityFilter, on
|
|||||||
{provider != null &&
|
{provider != null &&
|
||||||
provider.length > 0 &&
|
provider.length > 0 &&
|
||||||
provider.map((prov) => {
|
provider.map((prov) => {
|
||||||
return <Select.Option value={prov.id}>{prov.name}</Select.Option>;
|
return (
|
||||||
|
<Select.Option value={prov.id} key={prov.id}>
|
||||||
|
{prov.name}
|
||||||
|
</Select.Option>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</Select>
|
</Select>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const columns = [
|
|||||||
type="danger"
|
type="danger"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/', { ids: [id] });
|
await xhrDelete('/api/listings/', { ids: [row.id] });
|
||||||
Toast.success('Listing(s) successfully removed');
|
Toast.success('Listing(s) successfully removed');
|
||||||
row.reloadTable();
|
row.reloadTable();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ export const useFredyState = create(
|
|||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async getSharableUserList() {
|
||||||
|
try {
|
||||||
|
const response = await xhrGet('/api/jobs/shareableUserList');
|
||||||
|
set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
async getProcessingTimes() {
|
async getProcessingTimes() {
|
||||||
try {
|
try {
|
||||||
const response = await xhrGet('/api/jobs/processingTimes');
|
const response = await xhrGet('/api/jobs/processingTimes');
|
||||||
@@ -172,7 +180,7 @@ export const useFredyState = create(
|
|||||||
demoMode: { demoMode: false },
|
demoMode: { demoMode: false },
|
||||||
versionUpdate: {},
|
versionUpdate: {},
|
||||||
provider: [],
|
provider: [],
|
||||||
jobs: { jobs: [], insights: {}, processingTimes: {} },
|
jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] },
|
||||||
user: { users: [], currentUser: null },
|
user: { users: [], currentUser: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import Headline from '../../../components/headline/Headline';
|
|||||||
import { useActions, useSelector } from '../../../services/state/store';
|
import { useActions, useSelector } from '../../../services/state/store';
|
||||||
import { xhrPost } from '../../../services/xhr';
|
import { xhrPost } from '../../../services/xhr';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui';
|
||||||
import './JobMutation.less';
|
import './JobMutation.less';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
import { IconBell, IconBriefcase, IconPaperclip, IconPlayCircle, IconPlusCircle, IconUser } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function JobMutator() {
|
export default function JobMutator() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
|
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||||
@@ -32,6 +33,7 @@ export default function JobMutator() {
|
|||||||
const [name, setName] = useState(defaultName);
|
const [name, setName] = useState(defaultName);
|
||||||
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
||||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||||
|
const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []);
|
||||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
@@ -45,6 +47,7 @@ export default function JobMutator() {
|
|||||||
await xhrPost('/api/jobs', {
|
await xhrPost('/api/jobs', {
|
||||||
provider: providerData,
|
provider: providerData,
|
||||||
notificationAdapter: notificationAdapterData,
|
notificationAdapter: notificationAdapterData,
|
||||||
|
shareWithUsers,
|
||||||
name,
|
name,
|
||||||
blacklist,
|
blacklist,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -91,7 +94,7 @@ export default function JobMutator() {
|
|||||||
|
|
||||||
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
|
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
|
||||||
<form>
|
<form>
|
||||||
<SegmentPart name="Name">
|
<SegmentPart name="Name" Icon={IconPaperclip}>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
@@ -105,7 +108,7 @@ export default function JobMutator() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Providers"
|
name="Providers"
|
||||||
icon="briefcase"
|
Icon={IconBriefcase}
|
||||||
helpText={`
|
helpText={`
|
||||||
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
|
A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings.
|
||||||
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
|
Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter
|
||||||
@@ -130,7 +133,7 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="bell"
|
Icon={IconBell}
|
||||||
name="Notification Adapters"
|
name="Notification Adapters"
|
||||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||||
>
|
>
|
||||||
@@ -157,7 +160,7 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="bell"
|
Icon={IconBell}
|
||||||
name="Blacklist"
|
name="Blacklist"
|
||||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||||
>
|
>
|
||||||
@@ -169,7 +172,32 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="play circle outline"
|
Icon={IconUser}
|
||||||
|
name="Sharing with user"
|
||||||
|
helpText="You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything."
|
||||||
|
>
|
||||||
|
{shareableUserList.length === 0 ? (
|
||||||
|
<div>No users found to share this Job to. Please create additional non-admin user.</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
filter
|
||||||
|
multiple
|
||||||
|
placeholder="Search user"
|
||||||
|
autoClearSearchValue={false}
|
||||||
|
defaultValue={shareWithUsers}
|
||||||
|
onChange={(value) => setShareWithUsers(value)}
|
||||||
|
>
|
||||||
|
{shareableUserList.map((user) => (
|
||||||
|
<Select.Option value={user.id} key={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
|
<SegmentPart
|
||||||
|
Icon={IconPlayCircle}
|
||||||
name="Job activation"
|
name="Job activation"
|
||||||
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
||||||
>
|
>
|
||||||
|
|||||||
65
yarn.lock
65
yarn.lock
@@ -1176,15 +1176,17 @@
|
|||||||
debug "^4.3.1"
|
debug "^4.3.1"
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
|
|
||||||
"@eslint/config-helpers@^0.3.1":
|
"@eslint/config-helpers@^0.4.0":
|
||||||
version "0.3.1"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617"
|
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.0.tgz#e9f94ba3b5b875e32205cb83fece18e64486e9e6"
|
||||||
integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==
|
integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==
|
||||||
|
dependencies:
|
||||||
|
"@eslint/core" "^0.16.0"
|
||||||
|
|
||||||
"@eslint/core@^0.15.2":
|
"@eslint/core@^0.16.0":
|
||||||
version "0.15.2"
|
version "0.16.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f"
|
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.16.0.tgz#490254f275ba9667ddbab344f4f0a6b7a7bd7209"
|
||||||
integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==
|
integrity sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/json-schema" "^7.0.15"
|
"@types/json-schema" "^7.0.15"
|
||||||
|
|
||||||
@@ -1203,22 +1205,22 @@
|
|||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
"@eslint/js@9.36.0":
|
"@eslint/js@9.37.0":
|
||||||
version "9.36.0"
|
version "9.37.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
|
||||||
integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==
|
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
|
||||||
|
|
||||||
"@eslint/object-schema@^2.1.6":
|
"@eslint/object-schema@^2.1.6":
|
||||||
version "2.1.6"
|
version "2.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
||||||
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
|
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
|
||||||
|
|
||||||
"@eslint/plugin-kit@^0.3.5":
|
"@eslint/plugin-kit@^0.4.0":
|
||||||
version "0.3.5"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5"
|
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz#f6a245b42886abf6fc9c7ab7744a932250335ab2"
|
||||||
integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==
|
integrity sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint/core" "^0.15.2"
|
"@eslint/core" "^0.16.0"
|
||||||
levn "^0.4.1"
|
levn "^0.4.1"
|
||||||
|
|
||||||
"@humanfs/core@^0.19.1":
|
"@humanfs/core@^0.19.1":
|
||||||
@@ -3276,19 +3278,19 @@ eslint-visitor-keys@^4.2.1:
|
|||||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||||
|
|
||||||
eslint@9.36.0:
|
eslint@9.37.0:
|
||||||
version "9.36.0"
|
version "9.37.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.37.0.tgz#ac0222127f76b09c0db63036f4fe289562072d74"
|
||||||
integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==
|
integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils" "^4.8.0"
|
"@eslint-community/eslint-utils" "^4.8.0"
|
||||||
"@eslint-community/regexpp" "^4.12.1"
|
"@eslint-community/regexpp" "^4.12.1"
|
||||||
"@eslint/config-array" "^0.21.0"
|
"@eslint/config-array" "^0.21.0"
|
||||||
"@eslint/config-helpers" "^0.3.1"
|
"@eslint/config-helpers" "^0.4.0"
|
||||||
"@eslint/core" "^0.15.2"
|
"@eslint/core" "^0.16.0"
|
||||||
"@eslint/eslintrc" "^3.3.1"
|
"@eslint/eslintrc" "^3.3.1"
|
||||||
"@eslint/js" "9.36.0"
|
"@eslint/js" "9.37.0"
|
||||||
"@eslint/plugin-kit" "^0.3.5"
|
"@eslint/plugin-kit" "^0.4.0"
|
||||||
"@humanfs/node" "^0.16.6"
|
"@humanfs/node" "^0.16.6"
|
||||||
"@humanwhocodes/module-importer" "^1.0.1"
|
"@humanwhocodes/module-importer" "^1.0.1"
|
||||||
"@humanwhocodes/retry" "^0.4.2"
|
"@humanwhocodes/retry" "^0.4.2"
|
||||||
@@ -4534,10 +4536,10 @@ lazy-cache@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
|
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
|
||||||
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
|
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
|
||||||
|
|
||||||
less@4.4.1:
|
less@4.4.2:
|
||||||
version "4.4.1"
|
version "4.4.2"
|
||||||
resolved "https://registry.yarnpkg.com/less/-/less-4.4.1.tgz#2f97168bf887ca6a9957ee69e16cc34f8b007cc7"
|
resolved "https://registry.yarnpkg.com/less/-/less-4.4.2.tgz#fa4291fdb0334de91163622cc038f4bd3eb6b8d7"
|
||||||
integrity sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA==
|
integrity sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything "^2.0.1"
|
copy-anything "^2.0.1"
|
||||||
parse-node-version "^1.0.1"
|
parse-node-version "^1.0.1"
|
||||||
@@ -6538,6 +6540,11 @@ semver@^7.3.5, semver@^7.5.3, semver@^7.7.2:
|
|||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
|
||||||
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
|
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
|
||||||
|
|
||||||
|
semver@^7.7.3:
|
||||||
|
version "7.7.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946"
|
||||||
|
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
|
||||||
|
|
||||||
send@^1.2.0:
|
send@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"
|
resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"
|
||||||
|
|||||||
Reference in New Issue
Block a user