mirror of
https://github.com/karakeep-app/karakeep.git
synced 2026-02-28 18:25:55 +01:00
Add a new search qualifier `is:broken` that allows users to filter bookmarks with broken or failed links. This matches the functionality on the broken links settings page, where a link is considered broken if: - crawlStatus is "failure" - crawlStatusCode is less than 200 - crawlStatusCode is greater than 299 The qualifier supports negation with `-is:broken` to find working links. Changes: - Add brokenLinks matcher type definition - Update search query parser to handle is:broken qualifier - Implement query execution logic for broken links filtering - Add autocomplete support with translations - Add parser tests - Update search query language documentation Co-authored-by: Claude <noreply@anthropic.com>
571 lines
13 KiB
TypeScript
571 lines
13 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
|
|
import { parseSearchQuery } from "./searchQueryParser";
|
|
import { BookmarkTypes } from "./types/bookmarks";
|
|
|
|
describe("Search Query Parser", () => {
|
|
test("simple is queries", () => {
|
|
expect(parseSearchQuery("is:archived")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "archived",
|
|
archived: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:archived")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "archived",
|
|
archived: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:fav")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:fav")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "favourited",
|
|
favourited: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:tagged")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagged",
|
|
tagged: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:tagged")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagged",
|
|
tagged: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:inlist")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "inlist",
|
|
inList: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:inlist")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "inlist",
|
|
inList: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:link")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "type",
|
|
typeName: BookmarkTypes.LINK,
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:link")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "type",
|
|
typeName: BookmarkTypes.LINK,
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:text")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "type",
|
|
typeName: BookmarkTypes.TEXT,
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:text")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "type",
|
|
typeName: BookmarkTypes.TEXT,
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:media")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "type",
|
|
typeName: BookmarkTypes.ASSET,
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:media")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "type",
|
|
typeName: BookmarkTypes.ASSET,
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:broken")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "brokenLinks",
|
|
brokenLinks: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-is:broken")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "brokenLinks",
|
|
brokenLinks: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("simple string queries", () => {
|
|
expect(parseSearchQuery("url:https://example.com")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "url",
|
|
url: "https://example.com",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-url:https://example.com")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "url",
|
|
url: "https://example.com",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('url:"https://example.com"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "url",
|
|
url: "https://example.com",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('-url:"https://example.com"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "url",
|
|
url: "https://example.com",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("title:example")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "title",
|
|
title: "example",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-title:example")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "title",
|
|
title: "example",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('title:"my title"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "title",
|
|
title: "my title",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('-title:"my title"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "title",
|
|
title: "my title",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("#my-tag")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagName",
|
|
tagName: "my-tag",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-#my-tag")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagName",
|
|
tagName: "my-tag",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('#"my tag"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagName",
|
|
tagName: "my tag",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('-#"my tag"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagName",
|
|
tagName: "my tag",
|
|
inverse: true,
|
|
},
|
|
});
|
|
// Tags starting with qualifiers should be treated correctly
|
|
expect(parseSearchQuery("#android")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "tagName",
|
|
tagName: "android",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("list:my-list")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "listName",
|
|
listName: "my-list",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-list:my-list")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "listName",
|
|
listName: "my-list",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('list:"my list"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "listName",
|
|
listName: "my list",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('-list:"my list"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "listName",
|
|
listName: "my list",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("feed:my-feed")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "rssFeedName",
|
|
feedName: "my-feed",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-feed:my-feed")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "rssFeedName",
|
|
feedName: "my-feed",
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('feed:"my feed"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "rssFeedName",
|
|
feedName: "my feed",
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery('-feed:"my feed"')).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "rssFeedName",
|
|
feedName: "my feed",
|
|
inverse: true,
|
|
},
|
|
});
|
|
});
|
|
test("date queries", () => {
|
|
expect(parseSearchQuery("after:2023-10-12")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "dateAfter",
|
|
dateAfter: new Date("2023-10-12"),
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-after:2023-10-12")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "dateAfter",
|
|
dateAfter: new Date("2023-10-12"),
|
|
inverse: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("before:2023-10-12")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "dateBefore",
|
|
dateBefore: new Date("2023-10-12"),
|
|
inverse: false,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("-before:2023-10-12")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "dateBefore",
|
|
dateBefore: new Date("2023-10-12"),
|
|
inverse: true,
|
|
},
|
|
});
|
|
});
|
|
test("age queries", () => {
|
|
expect(parseSearchQuery("age:<3d")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "age",
|
|
relativeDate: {
|
|
direction: "newer",
|
|
amount: 3,
|
|
unit: "day",
|
|
},
|
|
},
|
|
});
|
|
expect(parseSearchQuery("age:>2y")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "age",
|
|
relativeDate: {
|
|
direction: "older",
|
|
amount: 2,
|
|
unit: "year",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("complex queries", () => {
|
|
expect(parseSearchQuery("is:fav -is:archived")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "and",
|
|
matchers: [
|
|
{
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
{
|
|
type: "archived",
|
|
archived: false,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(parseSearchQuery("(is:fav is:archived) #my-tag")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "and",
|
|
matchers: [
|
|
{
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
{
|
|
type: "archived",
|
|
archived: true,
|
|
},
|
|
{
|
|
type: "tagName",
|
|
tagName: "my-tag",
|
|
inverse: false,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(parseSearchQuery("(is:fav is:archived) or (#my-tag)")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "or",
|
|
matchers: [
|
|
{
|
|
type: "and",
|
|
matchers: [
|
|
{
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
{
|
|
type: "archived",
|
|
archived: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tagName",
|
|
tagName: "my-tag",
|
|
inverse: false,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(parseSearchQuery("(is:fav or is:archived) and #my-tag")).toEqual({
|
|
result: "full",
|
|
text: "",
|
|
matcher: {
|
|
type: "and",
|
|
matchers: [
|
|
{
|
|
type: "or",
|
|
matchers: [
|
|
{
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
{
|
|
type: "archived",
|
|
archived: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tagName",
|
|
tagName: "my-tag",
|
|
inverse: false,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
test("pure text", () => {
|
|
expect(parseSearchQuery("hello")).toEqual({
|
|
result: "full",
|
|
text: "hello",
|
|
matcher: undefined,
|
|
});
|
|
expect(parseSearchQuery("hello world")).toEqual({
|
|
result: "full",
|
|
text: "hello world",
|
|
matcher: undefined,
|
|
});
|
|
});
|
|
|
|
test("text interlived with matchers", () => {
|
|
expect(
|
|
parseSearchQuery(
|
|
"hello is:fav world is:archived mixed world #my-tag test",
|
|
),
|
|
).toEqual({
|
|
result: "full",
|
|
text: "hello world mixed world test",
|
|
matcher: {
|
|
type: "and",
|
|
matchers: [
|
|
{
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
{
|
|
type: "archived",
|
|
archived: true,
|
|
},
|
|
{
|
|
type: "tagName",
|
|
tagName: "my-tag",
|
|
inverse: false,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
test("unknown qualifiers are emitted as pure text", () => {
|
|
expect(parseSearchQuery("is:fav is:helloworld")).toEqual({
|
|
result: "full",
|
|
text: "is:helloworld",
|
|
matcher: {
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("partial results", () => {
|
|
expect(parseSearchQuery("(is:archived) or ")).toEqual({
|
|
result: "partial",
|
|
text: "or",
|
|
matcher: {
|
|
type: "archived",
|
|
archived: true,
|
|
},
|
|
});
|
|
expect(parseSearchQuery("is:fav is: ( random")).toEqual({
|
|
result: "partial",
|
|
text: "is: ( random",
|
|
matcher: {
|
|
type: "favourited",
|
|
favourited: true,
|
|
},
|
|
});
|
|
});
|
|
});
|