Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
342 lines
8.1 KiB
TypeScript
342 lines
8.1 KiB
TypeScript
import type { IMessage, IUser } from '@rocket.chat/core-typings';
|
|
import { escapeRegExp } from '@rocket.chat/tools';
|
|
import type { Filter, FindOptions } from 'mongodb';
|
|
|
|
class MessageSearchQueryParser {
|
|
private query: Exclude<Filter<IMessage>, Partial<IMessage>> = {};
|
|
|
|
private options: FindOptions<IMessage> = {
|
|
projection: {},
|
|
sort: {
|
|
ts: -1,
|
|
},
|
|
skip: 0,
|
|
limit: 20,
|
|
};
|
|
|
|
private user: IUser | undefined;
|
|
|
|
private forceRegex = false;
|
|
|
|
constructor({
|
|
user,
|
|
offset = 0,
|
|
limit = 20,
|
|
forceRegex = false,
|
|
}: {
|
|
user?: IUser;
|
|
offset?: number;
|
|
limit?: number;
|
|
forceRegex?: boolean;
|
|
}) {
|
|
this.user = user;
|
|
this.options.skip = offset;
|
|
this.options.limit = limit;
|
|
this.forceRegex = forceRegex;
|
|
}
|
|
|
|
private consumeFrom(text: string) {
|
|
const from: string[] = [];
|
|
|
|
return text.replace(/from:([a-z0-9.\-_]+)/gi, (_, username) => {
|
|
if (username === 'me' && this.user?.username && !from.includes(this.user.username)) {
|
|
username = this.user.username;
|
|
}
|
|
from.push(username);
|
|
|
|
this.query['u.username'] = {
|
|
$regex: from.join('|'),
|
|
$options: 'i',
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
private consumeMention(text: string) {
|
|
const mentions: string[] = [];
|
|
|
|
return text.replace(/mention:([a-z0-9.\-_]+)/gi, (_: string, username: string) => {
|
|
mentions.push(username);
|
|
|
|
this.query['mentions.username'] = {
|
|
$regex: mentions.join('|'),
|
|
$options: 'i',
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on messages that are starred by the current user.
|
|
*/
|
|
private consumeHasStar(text: string) {
|
|
return text.replace(/has:star/g, () => {
|
|
if (this.user?._id) {
|
|
this.query['starred._id'] = this.user._id;
|
|
}
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on messages that have an url.
|
|
*/
|
|
private consumeHasUrl(text: string) {
|
|
return text.replace(/has:url|has:link/g, () => {
|
|
this.query['urls.0'] = {
|
|
$exists: true,
|
|
};
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on pinned messages.
|
|
*/
|
|
private consumeIsPinned(text: string) {
|
|
return text.replace(/is:pinned|has:pin/g, () => {
|
|
this.query.pinned = true;
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on messages which have a location attached.
|
|
*/
|
|
private consumeHasLocation(text: string) {
|
|
return text.replace(/has:location|has:map/g, () => {
|
|
this.query.location = {
|
|
$exists: true,
|
|
};
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter image tags
|
|
*/
|
|
private consumeLabel(text: string) {
|
|
return text.replace(/label:*"([^"]+)"|label:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => {
|
|
const tag = (quoted ?? unquoted)?.trim();
|
|
if (!tag || typeof tag !== 'string') return '';
|
|
|
|
this.query['attachments.0.labels'] = {
|
|
$regex: escapeRegExp(tag.trim()),
|
|
$options: 'i',
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on description of messages.
|
|
*/
|
|
private consumeFileDescription(text: string) {
|
|
return text.replace(/file-desc:"([^"]+)"|file-desc:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => {
|
|
const tag = (quoted ?? unquoted)?.trim();
|
|
if (!tag || typeof tag !== 'string') return '';
|
|
|
|
this.query['attachments.description'] = {
|
|
$regex: escapeRegExp(tag.trim()),
|
|
$options: 'i',
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on title of messages.
|
|
*/
|
|
private consumeFileTitle(text: string) {
|
|
return text.replace(/file-title:"([^"]+)"|file-title:"?([^\s"]+[^"]?)"?/gu, (_match, quoted, unquoted) => {
|
|
const tag = (quoted ?? unquoted)?.trim();
|
|
if (!tag || typeof tag !== 'string') return '';
|
|
|
|
this.query['attachments.title'] = {
|
|
$regex: escapeRegExp(tag.trim()),
|
|
$options: 'i',
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on messages that have been sent before a date.
|
|
*/
|
|
private consumeBefore(text: string) {
|
|
return text.replace(/before:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => {
|
|
const beforeDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
|
|
beforeDate.setUTCHours(beforeDate.getUTCHours() + beforeDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0));
|
|
|
|
this.query.ts = {
|
|
...this.query.ts,
|
|
$lte: beforeDate,
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on messages that have been sent after a date.
|
|
*/
|
|
private consumeAfter(text: string) {
|
|
return text.replace(/after:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => {
|
|
const afterDate = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10) + 1);
|
|
afterDate.setUTCHours(afterDate.getUTCHours() + afterDate.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0));
|
|
|
|
this.query.ts = {
|
|
...this.query.ts,
|
|
$gte: afterDate,
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter on messages that have been sent on a date.
|
|
*/
|
|
private consumeOn(text: string) {
|
|
return text.replace(/on:(\d{1,2})[\/\.-](\d{1,2})[\/\.-](\d{4})/g, (_: string, day: string, month: string, year: string) => {
|
|
const date = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
|
|
date.setUTCHours(date.getUTCHours() + date.getTimezoneOffset() / 60 + (this.user?.utcOffset ?? 0));
|
|
const dayAfter = new Date(date);
|
|
dayAfter.setDate(dayAfter.getDate() + 1);
|
|
|
|
this.query.ts = {
|
|
$gte: date,
|
|
$lt: dayAfter,
|
|
};
|
|
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sort by timestamp.
|
|
*/
|
|
consumeOrder(text: string) {
|
|
return text.replace(/(?:order|sort):(asc|ascend|ascending|desc|descend|descending)/g, (_: string, direction: string) => {
|
|
if (direction.startsWith('asc')) {
|
|
this.options.sort = {
|
|
...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}),
|
|
ts: 1,
|
|
};
|
|
} else if (direction.startsWith('desc')) {
|
|
this.options.sort = {
|
|
...(typeof this.options.sort === 'object' && !Array.isArray(this.options.sort) ? this.options.sort : {}),
|
|
ts: -1,
|
|
};
|
|
}
|
|
return '';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Query in message text
|
|
*/
|
|
private consumeMessageText(text: string) {
|
|
text = text.trim().replace(/\s\s/g, ' ');
|
|
|
|
if (text === '') {
|
|
return text;
|
|
}
|
|
|
|
if (/^\/.+\/[imxs]*$/.test(text)) {
|
|
const r = text.split('/');
|
|
|
|
// We remove the 'x' flag that JS does not support but Mongo does
|
|
new RegExp(r[1], r[2].replace(/x/g, ''));
|
|
|
|
this.query.msg = {
|
|
$regex: r[1],
|
|
$options: r[2],
|
|
};
|
|
} else if (this.forceRegex) {
|
|
new RegExp(text, 'i');
|
|
|
|
this.query.msg = {
|
|
$regex: text,
|
|
$options: 'i',
|
|
};
|
|
} else {
|
|
this.query.$text = {
|
|
$search: text,
|
|
};
|
|
this.options.projection = {
|
|
score: {
|
|
$meta: 'textScore',
|
|
},
|
|
};
|
|
}
|
|
|
|
return text;
|
|
}
|
|
|
|
parse(text: string) {
|
|
[
|
|
(input: string) => this.consumeFrom(input),
|
|
(input: string) => this.consumeMention(input),
|
|
(input: string) => this.consumeHasStar(input),
|
|
(input: string) => this.consumeHasUrl(input),
|
|
(input: string) => this.consumeIsPinned(input),
|
|
(input: string) => this.consumeHasLocation(input),
|
|
(input: string) => this.consumeLabel(input),
|
|
(input: string) => this.consumeFileDescription(input),
|
|
(input: string) => this.consumeFileTitle(input),
|
|
(input: string) => this.consumeBefore(input),
|
|
(input: string) => this.consumeAfter(input),
|
|
(input: string) => this.consumeOn(input),
|
|
(input: string) => this.consumeOrder(input),
|
|
(input: string) => this.consumeMessageText(input),
|
|
].reduce((text, fn) => fn(text), text);
|
|
|
|
return {
|
|
query: this.query,
|
|
options: this.options,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses a message search query and returns a MongoDB query and options
|
|
* @param text The query text
|
|
* @param options The options
|
|
* @param options.user The user object
|
|
* @param options.offset The offset
|
|
* @param options.limit The limit
|
|
* @param options.forceRegex Whether to force the use of regex
|
|
* @returns The MongoDB query and options
|
|
* @private
|
|
* @example
|
|
* const { query, options } = parseMessageSearchQuery('from:rocket.cat', {
|
|
* user: await Meteor.userAsync(),
|
|
* offset: 0,
|
|
* limit: 20,
|
|
* forceRegex: false,
|
|
* });
|
|
*/
|
|
export function parseMessageSearchQuery(
|
|
text: string,
|
|
{
|
|
user,
|
|
offset = 0,
|
|
limit = 20,
|
|
forceRegex = false,
|
|
}: {
|
|
user?: IUser;
|
|
offset?: number;
|
|
limit?: number;
|
|
forceRegex?: boolean;
|
|
},
|
|
) {
|
|
const parser = new MessageSearchQueryParser({ user, offset, limit, forceRegex });
|
|
return parser.parse(text);
|
|
}
|