From 5656e394d3d879d9cd48c18400cd7ce4c12f416e Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 26 Jan 2026 00:50:55 +0000 Subject: feat(search): add tag: alias for # and ! alias for negation (#2425) Add `tag:` as an alternative syntax to `#` for tag search queries, and `!` as an alternative to `-` for negating qualifiers. This provides more intuitive syntax options for users who prefer text-based qualifiers over special characters. Co-authored-by: Claude --- .../04-using-karakeep/search-query-language.md | 12 +- packages/shared/searchQueryParser.test.ts | 133 +++++++++++++++++++++ packages/shared/searchQueryParser.ts | 12 +- 3 files changed, 151 insertions(+), 6 deletions(-) diff --git a/docs/docs/04-using-karakeep/search-query-language.md b/docs/docs/04-using-karakeep/search-query-language.md index a1e830e8..de11e533 100644 --- a/docs/docs/04-using-karakeep/search-query-language.md +++ b/docs/docs/04-using-karakeep/search-query-language.md @@ -11,7 +11,7 @@ Karakeep provides a search query language to filter and find bookmarks. Here are - Use spaces to separate multiple conditions (implicit AND) - Use `and`/`or` keywords for explicit boolean logic -- Prefix qualifiers with `-` to negate them +- Prefix qualifiers with `-` or `!` to negate them (e.g., `-is:archived` or `!is:archived`) - Use parentheses `()` for grouping conditions (note that groups can't be negated) ## Qualifiers @@ -29,8 +29,8 @@ Here's a comprehensive table of all supported qualifiers: | `url:` | Match bookmarks with URL substring | `url:example.com` | | `title:` | Match bookmarks with title substring | `title:example` | | | Supports quoted strings for titles with spaces | `title:"my title"` | -| `#` | Match bookmarks with specific tag | `#important` | -| | Supports quoted strings for tags with spaces | `#"work in progress"` | +| `#` or `tag:` | Match bookmarks with specific tag | `#important` or `tag:important` | +| | Supports quoted strings for tags with spaces | `#"work in progress"` or `tag:"work in progress"` | | `list:` | Match bookmarks in specific list | `list:reading` | | | Supports quoted strings for list names with spaces | `list:"to review"` | | `after:` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` | @@ -66,6 +66,12 @@ is:archived and (list:reading or #work) # Find bookmarks that are not favorited and not archived -is:fav -is:archived + +# Using ! as an alias for negation +!is:fav !is:archived + +# Using tag: as an alias for # +tag:important tag:"work in progress" ``` ## Text Search diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts index aa11433f..3954e871 100644 --- a/packages/shared/searchQueryParser.test.ts +++ b/packages/shared/searchQueryParser.test.ts @@ -333,6 +333,139 @@ describe("Search Query Parser", () => { }, }); }); + test("! negation alias for -", () => { + // ! should work exactly like - for negation + 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: false, + }, + }); + expect(parseSearchQuery("!#my-tag")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my-tag", + inverse: true, + }, + }); + expect(parseSearchQuery("!tag:my-tag")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my-tag", + inverse: true, + }, + }); + expect(parseSearchQuery("!url:example.com")).toEqual({ + result: "full", + text: "", + matcher: { + type: "url", + url: "example.com", + inverse: true, + }, + }); + expect(parseSearchQuery("!list:my-list")).toEqual({ + result: "full", + text: "", + matcher: { + type: "listName", + listName: "my-list", + inverse: true, + }, + }); + expect(parseSearchQuery("!is:link")).toEqual({ + result: "full", + text: "", + matcher: { + type: "type", + typeName: BookmarkTypes.LINK, + inverse: true, + }, + }); + // Combined with complex queries + expect(parseSearchQuery("is:fav !is:archived")).toEqual({ + result: "full", + text: "", + matcher: { + type: "and", + matchers: [ + { + type: "favourited", + favourited: true, + }, + { + type: "archived", + archived: false, + }, + ], + }, + }); + }); + + test("tag: qualifier alias for #", () => { + // tag: should work exactly like # + expect(parseSearchQuery("tag:my-tag")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my-tag", + inverse: false, + }, + }); + expect(parseSearchQuery("-tag:my-tag")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my-tag", + inverse: true, + }, + }); + expect(parseSearchQuery('tag:"my tag"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my tag", + inverse: false, + }, + }); + expect(parseSearchQuery('-tag:"my tag"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my tag", + inverse: true, + }, + }); + // Tags starting with qualifiers should be treated correctly + expect(parseSearchQuery("tag:android")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "android", + inverse: false, + }, + }); + }); + test("date queries", () => { expect(parseSearchQuery("after:2023-10-12")).toEqual({ result: "full", diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts index 7447593a..027a662f 100644 --- a/packages/shared/searchQueryParser.ts +++ b/packages/shared/searchQueryParser.ts @@ -33,6 +33,7 @@ enum TokenType { Space = "SPACE", Hash = "HASH", Minus = "MINUS", + Exclamation = "EXCLAMATION", } // Rules are in order of priority @@ -41,7 +42,7 @@ const lexerRules: [RegExp, TokenType][] = [ [/^\s+or/i, TokenType.Or], [/^#/, TokenType.Hash], - [/^(is|url|list|after|before|age|feed|title):/, TokenType.Qualifier], + [/^(is|url|list|after|before|age|feed|title|tag):/, TokenType.Qualifier], [/^"([^"]+)"/, TokenType.StringLiteral], @@ -49,6 +50,7 @@ const lexerRules: [RegExp, TokenType][] = [ [/^\)/, TokenType.RParen], [/^\s+/, TokenType.Space], [/^-/, TokenType.Minus], + [/^!/, TokenType.Exclamation], // This needs to be last as it matches a lot of stuff [/^[^ )(]+/, TokenType.Ident], @@ -116,7 +118,10 @@ const EXP = rule(); MATCHER.setPattern( alt_sc( apply( - seq(opt(str("-")), kright(str("is:"), tok(TokenType.Ident))), + seq( + opt(alt(str("-"), str("!"))), + kright(str("is:"), tok(TokenType.Ident)), + ), ([minus, ident]) => { switch (ident.text) { case "fav": @@ -182,7 +187,7 @@ MATCHER.setPattern( ), apply( seq( - opt(str("-")), + opt(alt(str("-"), str("!"))), alt(tok(TokenType.Qualifier), tok(TokenType.Hash)), alt( apply(tok(TokenType.Ident), (tok) => { @@ -206,6 +211,7 @@ MATCHER.setPattern( matcher: { type: "title", title: ident, inverse: !!minus }, }; case "#": + case "tag:": return { text: "", matcher: { type: "tagName", tagName: ident, inverse: !!minus }, -- cgit v1.2.3-70-g09d2