Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
436b291
fix(db): update default search engine URL template in seed migration
ajnart Jan 7, 2026
21ce992
feat(api): add DuckDuckGo bangs search endpoint
ajnart Jan 7, 2026
f380f4a
feat(spotlight): make launcher default and support in-place query upd…
ajnart Jan 7, 2026
005fe49
feat(spotlight): support !bang search with DDG fallback
ajnart Jan 7, 2026
0375a1d
feat(spotlight): remove redundant home search-engine switch action
ajnart Jan 7, 2026
8dc8987
perf(spotlight): debounce integration and bang searches
ajnart Jan 7, 2026
802f791
perf(spotlight): debounce integration search engine children results
ajnart Jan 7, 2026
7ed557e
perf(spotlight): tune debounce timings
ajnart Jan 7, 2026
1640a09
Revert "perf(spotlight): tune debounce timings"
ajnart Jan 7, 2026
7967fbc
Revert "perf(spotlight): debounce integration search engine children …
ajnart Jan 7, 2026
074a219
Revert "perf(spotlight): debounce integration and bang searches"
ajnart Jan 7, 2026
f35197b
feat(request-handler): cache DuckDuckGo bangs with schema parsing
ajnart Jan 25, 2026
7df1ea3
refactor(api): serve DuckDuckGo bangs via cached request-handler
ajnart Jan 25, 2026
90bb760
fix(spotlight): improve !bang UX and reduce query spam
ajnart Jan 25, 2026
fb15e1e
feat(user): add support for ddg bangs feature
ajnart Jan 25, 2026
4e4c02c
Merge branch 'dev' into feat/rework-search
ajnart Jan 25, 2026
742f0e0
fix(request-handler): use ResponseError instead of generic Error
ajnart Jan 28, 2026
d0569a2
docs(api): explain binary search benefit over findIndex
ajnart Jan 28, 2026
c640a05
refactor(user): move ddgBangs toggle into search preferences
ajnart Jan 28, 2026
bc296f3
chore(db): add mysql and postgresql migrations for ddgBangs column
ajnart Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDirty]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export const BoardReadyProvider = ({ children }: PropsWithChildren) => {

useEffect(() => {
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.sections.length, setReadySections]);

const markAsReady = useCallback((id: string) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
ddgBangsEnabled: variables.ddgBangsEnabled,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
Expand All @@ -41,6 +42,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
},
});

Expand All @@ -64,6 +66,10 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>

<Group justify="end">
<Button type="submit" loading={isPending}>
Expand Down
1 change: 0 additions & 1 deletion apps/nextjs/src/app/api/health/live/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ const executeHealthCheckSafelyAsync = async (
},
};
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
logger.error(new ErrorWithMetadata("Healthcheck failed", { name }, { cause: error }));
return {
status: "unhealthy",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
}

// Only run this effect when the section items change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemIds.length, columnCount]);

/**
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const appRouter = createTRPCRouter({
media: lazy(() => import("./router/medias/media-router").then((mod) => mod.mediaRouter)),
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
});

Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/router/bangs/bangs-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from "zod/v4";

import { searchDuckDuckGoBangsAsync } from "../../services/duckduckgo-bangs";
import { createTRPCRouter, publicProcedure } from "../../trpc";

export const bangsRouter = createTRPCRouter({
search: publicProcedure
.input(
z.object({
query: z.string(),
limit: z.number().int().min(1).max(50).default(20),
}),
)
.query(async ({ input }) => {
return await searchDuckDuckGoBangsAsync({ query: input.query, limit: input.limit });
}),
});
30 changes: 30 additions & 0 deletions packages/api/src/router/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
userChangePasswordApiSchema,
userChangeSearchPreferencesSchema,
userCreateSchema,
userDdgBangsSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userInitSchema,
Expand Down Expand Up @@ -231,6 +232,7 @@ export const userRouter = createTRPCRouter({
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
Expand All @@ -256,6 +258,7 @@ export const userRouter = createTRPCRouter({
pingIconsEnabled: true,
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
},
where: eq(users.id, input.userId),
});
Expand Down Expand Up @@ -499,6 +502,33 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
changeDdgBangs: protectedProcedure
.input(userDdgBangsSchema.and(byIdSchema))
.meta({
openapi: {
method: "PATCH",
path: "/api/users/ddg-bangs",
tags: ["users"],
protect: true,
deprecated: true,
},
})
.mutation(async ({ input, ctx }) => {
// Only admins can change other users DDG bang preference
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}

await ctx.db
.update(users)
.set({
ddgBangs: input.ddgBangs,
})
.where(eq(users.id, input.id));
}),
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
.output(z.void())
Expand Down
8 changes: 6 additions & 2 deletions packages/api/src/router/user/change-search-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ export const changeSearchPreferencesInputSchema = userChangeSearchPreferencesSch
export const changeSearchPreferencesAsync = async (
db: Database,
session: Session,
input: Modify<z.infer<typeof changeSearchPreferencesInputSchema>, { openInNewTab: boolean | undefined }>,
input: Modify<
z.infer<typeof changeSearchPreferencesInputSchema>,
{ openInNewTab: boolean | undefined; ddgBangsEnabled: boolean | undefined }
>,
) => {
const user = session.user;
// Only admins can change other users passwords
// Only admins can change other users search preferences
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
Expand All @@ -45,6 +48,7 @@ export const changeSearchPreferencesAsync = async (
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
openSearchInNewTab: input.openInNewTab,
ddgBangs: input.ddgBangsEnabled,
})
.where(eq(users.id, input.userId));
};
55 changes: 55 additions & 0 deletions packages/api/src/services/duckduckgo-bangs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { duckDuckGoBangsRequestHandler } from "@homarr/request-handler/duckduckgo-bangs";
import type { DuckDuckGoBang } from "@homarr/request-handler/duckduckgo-bangs";

// DuckDuckGo bang keys are intentionally short:
// - `t`: token (e.g. "yt"), `s`: display name, `u`: URL template (contains `{{{s}}}`)
// - `d`: domain, `c`: category, `sc`: subcategory, `r`: rank (optional)

const normalizeBangToken = (token: string) => token.toLowerCase().trim();

/**
* Binary search to find the first index where bang.t >= tokenPrefix.
* This is O(log n) vs O(n) for findIndex, which matters because DuckDuckGo
* has ~13,000+ bangs. Combined with the pre-sorted data, this allows
* efficient prefix matching by finding the start position and iterating
* only through consecutive matches.
*/
const lowerBound = (arr: DuckDuckGoBang[], tokenPrefix: string) => {
let low = 0;
let high = arr.length;
while (low < high) {
const mid = (low + high) >> 1;
const midBang = arr[mid];
// Must use the same ordering as the source list sort (localeCompare),
// otherwise binary search can miss tokens with symbols like "&" or "_".
if (!midBang || midBang.t.localeCompare(tokenPrefix) >= 0) {
high = mid;
continue;
}

low = mid + 1;
}
return low;
};

export const searchDuckDuckGoBangsAsync = async (input: {
query: string;
limit: number;
}): Promise<DuckDuckGoBang[]> => {
const queryTokenPrefix = normalizeBangToken(input.query);
if (!queryTokenPrefix) return [];

const { data: allBangs } = await duckDuckGoBangsRequestHandler.handler({}).getCachedOrUpdatedDataAsync({});
const startIndex = lowerBound(allBangs, queryTokenPrefix);
const matches: DuckDuckGoBang[] = [];

for (let index = startIndex; index < allBangs.length; index++) {
const bang = allBangs[index];
if (!bang) break;
if (!bang.t.startsWith(queryTokenPrefix)) break;
matches.push(bang);
if (matches.length >= input.limit) break;
}

return matches;
};
1 change: 1 addition & 0 deletions packages/db/migrations/mysql/0037_lying_electro.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `user` ADD `ddg_bangs` boolean DEFAULT true NOT NULL;
Loading
Loading