feat: add is:broken search qualifier for broken links

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
This commit is contained in:
Claude
2025-12-04 22:04:13 +00:00
parent de98873a06
commit f739c35008
7 changed files with 60 additions and 0 deletions

View File

@@ -63,6 +63,12 @@ const QUALIFIER_DEFINITIONS = [
value: "is:media",
appendSpace: true,
},
{
value: "is:broken",
descriptionKey: "search.is_broken_link",
negatedDescriptionKey: "search.is_not_broken_link",
appendSpace: true,
},
{
value: "url:",
descriptionKey: "search.url_contains",

View File

@@ -662,6 +662,8 @@
"type_is_not": "Type is not",
"is_from_feed": "Is from RSS Feed",
"is_not_from_feed": "Is not from RSS Feed",
"is_broken_link": "Has Broken Link",
"is_not_broken_link": "Has Working Link",
"and": "And",
"or": "Or",
"history": "Recent Searches",

View File

@@ -20,6 +20,7 @@ Here's a comprehensive table of all supported qualifiers:
| `is:tagged` | Bookmarks that has one or more tags | `is:tagged` |
| `is:inlist` | Bookmarks that are in one or more lists | `is:inlist` |
| `is:link`, `is:text`, `is:media` | Bookmarks that are of type link, text or media | `is:link` |
| `is:broken` | Bookmarks with broken/failed links (crawl failures or non-2xx status codes) | `is:broken` |
| `url:<value>` | Match bookmarks with URL substring | `url:example.com` |
| `title:<value>` | Match bookmarks with title substring | `title:example` |
| | Supports quoted strings for titles with spaces | `title:"my title"` |

View File

@@ -123,6 +123,22 @@ describe("Search Query Parser", () => {
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", () => {

View File

@@ -166,6 +166,11 @@ MATCHER.setPattern(
inverse: !!minus,
},
};
case "broken":
return {
text: "",
matcher: { type: "brokenLinks", brokenLinks: !minus },
};
default:
// If the token is not known, emit it as pure text
return {

View File

@@ -83,6 +83,11 @@ const zTypeMatcher = z.object({
inverse: z.boolean(),
});
const zBrokenLinksMatcher = z.object({
type: z.literal("brokenLinks"),
brokenLinks: z.boolean(),
});
const zNonRecursiveMatcher = z.union([
zTagNameMatcher,
zListNameMatcher,
@@ -97,6 +102,7 @@ const zNonRecursiveMatcher = z.union([
zIsInListMatcher,
zTypeMatcher,
zRssFeedNameMatcher,
zBrokenLinksMatcher,
]);
type NonRecursiveMatcher = z.infer<typeof zNonRecursiveMatcher>;
@@ -120,6 +126,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => {
zIsInListMatcher,
zTypeMatcher,
zRssFeedNameMatcher,
zBrokenLinksMatcher,
z.object({
type: z.literal("and"),
matchers: z.array(zMatcherSchema),

View File

@@ -350,6 +350,29 @@ async function getIds(
),
);
}
case "brokenLinks": {
// Only applies to bookmarks of type LINK
return db
.select({ id: bookmarkLinks.id })
.from(bookmarkLinks)
.leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
.where(
and(
eq(bookmarks.userId, userId),
matcher.brokenLinks
? or(
eq(bookmarkLinks.crawlStatus, "failure"),
lt(bookmarkLinks.crawlStatusCode, 200),
gt(bookmarkLinks.crawlStatusCode, 299),
)
: and(
eq(bookmarkLinks.crawlStatus, "success"),
gte(bookmarkLinks.crawlStatusCode, 200),
lte(bookmarkLinks.crawlStatusCode, 299),
),
),
);
}
case "and": {
const vals = await Promise.all(
matcher.matchers.map((m) => getIds(db, userId, m)),