import { Brackets } from "typeorm"; import { sqlLikeEscape } from "../../../misc/sql-like-escape.js"; import { sqlRegexEscape } from "../../../misc/sql-regex-escape.js"; import { Followings, NoteFavorites, NoteReactions, Users } from "../../../models/index.js"; const filters = { "from": fromFilter, "-from": fromFilterInverse, "mention": mentionFilter, "-mention": mentionFilterInverse, "reply": replyFilter, "-reply": replyFilterInverse, "to": replyFilter, "-to": replyFilterInverse, "before": beforeFilter, "until": beforeFilter, "after": afterFilter, "since": afterFilter, "instance": instanceFilter, "-instance": instanceFilterInverse, "domain": instanceFilter, "-domain": instanceFilterInverse, "host": instanceFilter, "-host": instanceFilterInverse, "filter": miscFilter, "-filter": miscFilterInverse, "in": inFilter, "-in": inFilterInverse, "has": attachmentFilter }; export function generateFtsQuery(query, q) { const components = q.trim().split(" "); const terms = []; let finalTerms = []; let counter = 0; let caseSensitive = false; let matchWords = false; for (const component of components){ const split = component.split(":"); if (split.length > 1 && filters[split[0]] !== undefined) filters[split[0]](query, split.slice(1).join(":"), counter++); else if (split.length > 1 && (split[0] === "search" || split[0] === "match")) matchWords = split[1] === 'word' || split[1] === 'words'; else if (split.length > 1 && split[0] === "case") caseSensitive = split[1] === 'sensitive'; else terms.push(component); } let idx = 0; let state = 'idle'; for(let i = 0; i < terms.length; i++){ if (state === 'idle') { if (terms[i].startsWith('"') && terms[i].endsWith('"') || terms[i].startsWith('(') && terms[i].endsWith(')')) { finalTerms.push(trimStartAndEnd(terms[i])); } else if (terms[i].startsWith('"')) { idx = i; state = 'quote'; } else if (terms[i].startsWith('(')) { idx = i; state = 'parenthesis'; } else { finalTerms.push(terms[i]); } } else if (state === 'quote' && terms[i].endsWith('"')) { finalTerms.push(extractToken(terms, idx, i)); state = 'idle'; } else if (state === 'parenthesis' && terms[i].endsWith(')')) { query.andWhere(new Brackets((qb)=>{ for (const term of extractToken(terms, idx, i).split(' OR ')){ const id = counter++; appendSearchQuery(term, 'or', query, qb, id, term.startsWith('-'), matchWords, caseSensitive); } })); state = 'idle'; } } if (state != "idle") { finalTerms.push(...extractToken(terms, idx, terms.length - 1, false).substring(1).split(' ')); } for (const term of finalTerms){ const id = counter++; appendSearchQuery(term, 'and', query, query, id, term.startsWith('-'), matchWords, caseSensitive); } } function fromFilter(query, filter, id) { const userQuery = generateUserSubquery(filter, id); query.andWhere(`note.userId = (${userQuery.getQuery()})`); query.setParameters(userQuery.getParameters()); } function fromFilterInverse(query, filter, id) { const userQuery = generateUserSubquery(filter, id); query.andWhere(`note.userId <> (${userQuery.getQuery()})`); query.setParameters(userQuery.getParameters()); } function mentionFilter(query, filter, id) { const userQuery = generateUserSubquery(filter, id); query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, { materialized: true }); query.andWhere(`note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[]`); query.setParameters(userQuery.getParameters()); } function mentionFilterInverse(query, filter, id) { const userQuery = generateUserSubquery(filter, id); query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, { materialized: true }); query.andWhere(`NOT (note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[])`); query.setParameters(userQuery.getParameters()); } function replyFilter(query, filter, id) { const userQuery = generateUserSubquery(filter, id); query.andWhere(`note.replyUserId = (${userQuery.getQuery()})`); query.setParameters(userQuery.getParameters()); } function replyFilterInverse(query, filter, id) { const userQuery = generateUserSubquery(filter, id); query.andWhere(`note.replyUserId <> (${userQuery.getQuery()})`); query.setParameters(userQuery.getParameters()); } function beforeFilter(query, filter) { query.andWhere('note.createdAt < :before', { before: filter }); } function afterFilter(query, filter) { query.andWhere('note.createdAt > :after', { after: filter }); } function instanceFilter(query, filter, id) { if (filter === 'local') { query.andWhere(`note.userHost IS NULL`); } else { query.andWhere(`note.userHost = :instance_${id}`); query.setParameter(`instance_${id}`, filter); } } function instanceFilterInverse(query, filter, id) { if (filter === 'local') { query.andWhere(`note.userHost IS NOT NULL`); } else { query.andWhere(`note.userHost <> :instance_${id}`); query.setParameter(`instance_${id}`, filter); } } function miscFilter(query, filter) { let subQuery = null; if (filter === 'followers') { subQuery = Followings.createQueryBuilder('following').select('following.followerId').where('following.followeeId = :meId'); } else if (filter === 'following') { subQuery = Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId'); } else if (filter === 'replies' || filter === 'reply') { query.andWhere('note.replyId IS NOT NULL'); } else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') { query.andWhere('note.renoteId IS NOT NULL'); } if (subQuery !== null) query.andWhere(`note.userId IN (${subQuery.getQuery()})`); } function miscFilterInverse(query, filter) { let subQuery = null; if (filter === 'followers') { subQuery = Followings.createQueryBuilder('following').select('following.followerId').where('following.followeeId = :meId'); } else if (filter === 'following') { subQuery = Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId'); } else if (filter === 'replies' || filter === 'reply') { query.andWhere('note.replyId IS NULL'); } else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') { query.andWhere('note.renoteId IS NULL'); } if (subQuery !== null) query.andWhere(`note.userId NOT IN (${subQuery.getQuery()})`); } function inFilter(query, filter) { let subQuery = null; if (filter === 'bookmarks') { subQuery = NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId'); } else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') { subQuery = NoteReactions.createQueryBuilder('react').select('react.noteId').where('react.userId = :meId'); } if (subQuery !== null) query.andWhere(`note.id IN (${subQuery.getQuery()})`); } function inFilterInverse(query, filter) { let subQuery = null; if (filter === 'bookmarks') { subQuery = NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId'); } else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') { subQuery = NoteReactions.createQueryBuilder('react').select('react.noteId').where('react.userId = :meId'); } if (subQuery !== null) query.andWhere(`note.id NOT IN (${subQuery.getQuery()})`); } function attachmentFilter(query, filter) { switch(filter){ case 'image': query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%image/%'`); break; case 'video': query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%video/%'`); break; case 'audio': query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%audio/%'`); break; case 'file': query.andWhere(`note."attachedFileTypes" <> '{}'`); query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%image/%')`); query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%video/%')`); query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%audio/%')`); break; default: break; } } function generateUserSubquery(filter, id) { if (filter.startsWith('@')) filter = filter.substring(1); const split = filter.split('@'); const query = Users.createQueryBuilder('user').select('user.id').where(`user.usernameLower = :user_${id}`).andWhere(`user.host ${split[1] !== undefined ? `= :host_${id}` : 'IS NULL'}`); query.setParameter(`user_${id}`, split[0].toLowerCase()); if (split[1] !== undefined) query.setParameter(`host_${id}`, split[1].toLowerCase()); return query; } function extractToken(array, start, end, trim = true) { const slice = array.slice(start, end + 1).join(" "); return trim ? trimStartAndEnd(slice) : slice; } function trimStartAndEnd(str) { return str.substring(1, str.length - 1); } function appendSearchQuery(term, mode, query, qb, id, negate, matchWords, caseSensitive) { const sql = `note.text ${getSearchMatchOperator(negate, matchWords, caseSensitive)} :q_${id}`; if (mode === 'and') qb.andWhere(sql); else if (mode === 'or') qb.orWhere(sql); query.setParameter(`q_${id}`, escapeSqlSearchParam(term.substring(negate ? 1 : 0), matchWords)); } function getSearchMatchOperator(negate, matchWords, caseSensitive) { const negatePrefix = matchWords ? '!' : 'NOT '; return `${negate ? negatePrefix : ''}${matchWords ? caseSensitive ? '~' : '~*' : caseSensitive ? 'LIKE' : 'ILIKE'}`; } function escapeSqlSearchParam(param, matchWords) { return matchWords ? `\\y${sqlRegexEscape(param)}\\y` : `%${sqlLikeEscape(param)}%`; }