diff options
| author | Mohamed Bassem <me@mbassem.com> | 2026-01-26 00:50:55 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-26 00:50:55 +0000 |
| commit | 5656e394d3d879d9cd48c18400cd7ce4c12f416e (patch) | |
| tree | a3744e6cd640c09151b3bb1f32d23cbc50e6d084 /packages/shared | |
| parent | af3a587acaf641007c410e61579eeefe4836e8d7 (diff) | |
| download | karakeep-5656e394d3d879d9cd48c18400cd7ce4c12f416e.tar.zst | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/shared')
| -rw-r--r-- | packages/shared/searchQueryParser.test.ts | 133 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.ts | 12 |
2 files changed, 142 insertions, 3 deletions
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<TokenType, TextAndMatcher>(); 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 }, |
