Files
linkwarden/apps/web/lib/api/searchQueryBuilder.ts
2025-04-10 18:38:59 -04:00

212 lines
5.2 KiB
TypeScript

interface Token {
field: string;
value: string;
isNegative: boolean;
}
const SEARCH_CONDITIONS = [
"url",
"name",
"description",
"type",
"collection",
"pinned",
"public",
"before",
"after",
"tag",
];
export function parseSearchTokens(searchQueryString: string): Token[] {
// Split on whitespace
const rawTokens =
searchQueryString.match(/\\?.|^$/g)?.reduce(
(p, c) => {
if (c === '"' || c === "'") {
p.quote ^= 1;
} else if (!p.quote && c === " ") {
p.str.push("");
} else {
p.str[p.str.length - 1] += c.replace(/\\(.)/, "$1");
}
return p;
},
{ str: [""], quote: 0 }
).str || [];
const tokens: Token[] = [];
for (let token of rawTokens) {
let isNegative = false;
if (token.startsWith("!") && token.length > 1) {
const valueAfterNegation = token.substring(1);
if (
SEARCH_CONDITIONS.some((field) =>
valueAfterNegation.startsWith(`${field}:`)
)
) {
isNegative = true;
token = valueAfterNegation;
}
}
let match = false;
for (const field of SEARCH_CONDITIONS) {
if (token.startsWith(`${field}:`) && token.length > `${field}:`.length) {
tokens.push({
field,
value: token.substring(`${field}:`.length),
isNegative,
});
match = true;
break;
}
}
if (!match) {
// everything else -> 'general' text
tokens.push({ field: "general", value: token, isNegative });
}
}
const SEARCH_FILTER_LIMIT = Number(process.env.SEARCH_FILTER_LIMIT);
if (SEARCH_FILTER_LIMIT) {
const generalTokens = tokens.filter((t) => t.field === "general");
const otherTokens = tokens.filter((t) => t.field !== "general");
return [...generalTokens, ...otherTokens.slice(0, SEARCH_FILTER_LIMIT)];
}
return tokens;
}
export function buildMeiliQuery(tokens: Token[]): string {
const generalValues = tokens
.filter((t) => t.field === "general")
.map((t) => t.value);
return generalValues.join(" ");
}
export function buildMeiliFilters({
tokens,
userId,
publicOnly,
}: {
tokens: Token[];
userId?: number;
publicOnly?: boolean;
}): string[] {
const filters: string[] = publicOnly
? ["collectionIsPublic = true"]
: [`(collectionOwnerId = ${userId}) OR (collectionMemberIds = ${userId})`];
for (const { field, value, isNegative } of tokens) {
switch (field) {
case "url":
filters.push(
isNegative
? `NOT url = "${escapeForMeilisearch(value)}"`
: `url = "${escapeForMeilisearch(value)}"`
);
break;
case "name":
filters.push(
isNegative
? `NOT name = "${escapeForMeilisearch(value)}"`
: `name = "${escapeForMeilisearch(value)}"`
);
break;
case "description":
filters.push(
isNegative
? `NOT description = "${escapeForMeilisearch(value)}"`
: `description = "${escapeForMeilisearch(value)}"`
);
break;
case "type":
filters.push(
isNegative
? `NOT type = "${escapeForMeilisearch(value)}"`
: `type = "${escapeForMeilisearch(value)}"`
);
break;
case "collection":
filters.push(
isNegative
? `NOT collectionName = "${escapeForMeilisearch(value)}"`
: `collectionName = "${escapeForMeilisearch(value)}"`
);
break;
case "pinned":
if (value === "true") {
filters.push(
isNegative ? `NOT pinnedBy = ${userId}` : `pinnedBy = ${userId}`
);
} else if (value === "false") {
filters.push(
isNegative ? `pinnedBy = ${userId}` : `NOT pinnedBy = ${userId}`
);
}
break;
case "public":
if (value === "true") {
filters.push(
isNegative
? `NOT collectionIsPublic = true`
: `collectionIsPublic = true`
);
}
break;
case "before":
if (!isNaN(Date.parse(value))) {
const creationTimestamp = Date.parse(value) / 1000;
filters.push(
isNegative
? `creationTimestamp >= ${creationTimestamp}`
: `creationTimestamp < ${creationTimestamp}`
);
}
break;
case "after":
if (!isNaN(Date.parse(value))) {
const creationTimestamp = Date.parse(value) / 1000;
filters.push(
isNegative
? `creationTimestamp <= ${creationTimestamp}`
: `creationTimestamp > ${creationTimestamp}`
);
}
break;
case "tag":
filters.push(
isNegative
? `NOT tags = "${escapeForMeilisearch(value)}"`
: `tags = "${escapeForMeilisearch(value)}"`
);
break;
// "general" text is handled by the main query, not by filters
case "general":
default:
break;
}
}
return filters;
}
export function escapeForMeilisearch(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}