feat(search): surface settings content matches in the super search

Extract the settings modal search's translation-content technique
(section prefix map, string flattening, match snippets) into a shared
component-free module. The super search's settings source now falls
back to content matching, so terms that only appear in a section's
copy ("SMTP", a field label) find the section in both the editor and
portal bars, ranked below label matches with a context snippet. The
in-modal SettingsSearchBar builds on the same module.
This commit is contained in:
Reece
2026-07-15 17:45:46 +01:00
parent d734867ea9
commit d2a5f0cac7
3 changed files with 197 additions and 107 deletions
@@ -4,6 +4,10 @@ import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
import {
buildMatchSnippet,
getSettingsSectionContent,
} from "@app/data/settingsContentSearch";
import type {
ConfigNavSection,
ConfigNavItem,
@@ -24,102 +28,6 @@ interface SettingsSearchOption {
matchedContext?: string;
}
const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> =
{
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: [
"settings.planBilling",
"admin.settings.premium",
"settings.licensingAnalytics",
],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SETTINGS_SEARCH_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
};
const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
}
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
};
export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
configNavSections,
onNavigate,
@@ -135,19 +43,12 @@ export const SettingsSearchBar: React.FC<SettingsSearchBarProps> = ({
section.items
.filter((item: ConfigNavItem) => !item.disabled)
.map((item: ConfigNavItem) => {
const translationPrefixes = getTranslationPrefixesForNavKey(item.key);
const translationContent = translationPrefixes.flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} } as any),
),
);
const searchableContent = Array.from(
new Set([
item.label,
section.title,
`/settings/${item.key}`,
...translationContent,
...getSettingsSectionContent(item.key, t),
]),
);
@@ -0,0 +1,157 @@
import i18n from "i18next";
import type { TFunction } from "i18next";
/**
* Content-level settings search: matches a query against every translation
* string a settings section renders, so a term that appears anywhere on a
* settings page ("SMTP", "OCR", a field label…) finds that section without a
* curated keyword.
*
* This is the single source for the technique — the in-modal settings search
* bar and the global super search both build on it. It is intentionally
* component-free (translation subtrees only) so the always-mounted super
* search bar can use it without pulling the lazy settings modal into the main
* bundle.
*/
/**
* Translation subtrees whose strings appear on each settings section, for the
* sections where the nav key doesn't map 1:1 onto a toml prefix. Keys missing
* here fall back to the inferred `settings.<key>` / `admin.settings.<key>`
* prefix.
*/
const SECTION_TRANSLATION_PREFIXES: Partial<Record<string, string[]>> = {
general: ["settings.general"],
hotkeys: ["settings.hotkeys"],
account: ["account"],
people: ["settings.workspace"],
teams: ["settings.workspace", "settings.team"],
"api-keys": ["settings.developer"],
connectionMode: ["settings.connection"],
planBilling: ["settings.planBilling"],
adminGeneral: ["admin.settings.general"],
adminFeatures: ["admin.settings.features"],
adminEndpoints: ["admin.settings.endpoints"],
adminDatabase: ["admin.settings.database"],
adminAdvanced: ["admin.settings.advanced"],
adminSecurity: ["admin.settings.security"],
adminMcp: ["admin.settings.mcp"],
adminConnections: [
"admin.settings.connections",
"admin.settings.mail",
"admin.settings.security",
"admin.settings.telegram",
"admin.settings.premium",
"admin.settings.general",
"settings.securityAuth",
"settings.connection",
],
adminPlan: [
"settings.planBilling",
"admin.settings.premium",
"settings.licensingAnalytics",
],
adminAudit: ["settings.licensingAnalytics"],
adminUsage: ["settings.licensingAnalytics"],
adminLegal: ["admin.settings.legal"],
adminPrivacy: ["admin.settings.privacy"],
};
export const getTranslationPrefixesForNavKey = (key: string): string[] => {
const explicitPrefixes = SECTION_TRANSLATION_PREFIXES[key] ?? [];
const inferredPrefixes: string[] = [];
if (key.startsWith("admin")) {
const adminSuffix = key.replace(/^admin/, "");
const normalizedAdminSuffix =
adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1);
inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`);
} else {
inferredPrefixes.push(`settings.${key}`);
}
return Array.from(new Set([...explicitPrefixes, ...inferredPrefixes]));
};
export const flattenTranslationStrings = (value: unknown): string[] => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (Array.isArray(value)) {
return value.flatMap(flattenTranslationStrings);
}
if (value && typeof value === "object") {
return Object.values(value as Record<string, unknown>).flatMap(
flattenTranslationStrings,
);
}
return [];
};
/** Trims a matched string to a short snippet centred on the query hit. */
export const buildMatchSnippet = (text: string, query: string): string => {
const normalizedText = text.toLocaleLowerCase();
const normalizedQuery = query.toLocaleLowerCase();
const matchIndex = normalizedText.indexOf(normalizedQuery);
if (matchIndex === -1) {
return text;
}
const maxLength = 84;
const contextPadding = 28;
const start = Math.max(0, matchIndex - contextPadding);
const end = Math.min(text.length, matchIndex + query.length + contextPadding);
const snippet = text.slice(start, end);
if (snippet.length <= maxLength) {
return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`;
}
return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`;
};
// Flattening every subtree on each keystroke would be wasteful; sections'
// content is static per language, so cache it and drop the cache on switch.
const contentCache = new Map<string, string[]>();
let contentCacheLanguage: string | undefined;
export function getSettingsSectionContent(key: string, t: TFunction): string[] {
if (contentCacheLanguage !== i18n.language) {
contentCache.clear();
contentCacheLanguage = i18n.language;
}
const cached = contentCache.get(key);
if (cached) return cached;
const content = getTranslationPrefixesForNavKey(key).flatMap((prefix) =>
flattenTranslationStrings(
t(prefix, { returnObjects: true, defaultValue: {} }),
),
);
contentCache.set(key, content);
return content;
}
/**
* First content string of the section containing the query
* (case-insensitive), or null. Substring only — fuzzy matching across whole
* paragraphs of copy produces junk hits.
*/
export function findSettingsContentMatch(
key: string,
query: string,
t: TFunction,
): string | null {
const normalizedQuery = query.toLocaleLowerCase();
return (
getSettingsSectionContent(key, t).find((text) =>
text.toLocaleLowerCase().includes(normalizedQuery),
) ?? null
);
}
@@ -23,6 +23,10 @@ import type { ToolId } from "@app/types/toolId";
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
import {
buildMatchSnippet,
findSettingsContentMatch,
} from "@app/data/settingsContentSearch";
import {
PROCESSOR_SEARCH_INDEX,
type ProcessorSearchEntry,
@@ -195,11 +199,12 @@ export function rankSettingsResults(
if (s.adminArea && !(isAdmin || !loginEnabled)) return false;
return true;
});
const sections = rankByFuzzy(visibleSections, trimmed, [
const sectionMatches = rankByFuzzy(visibleSections, trimmed, [
(s) => t(s.labelKey, s.labelFallback),
(s) => s.labelFallback,
(s) => s.keywords?.join(" ") ?? "",
]).map(({ item, score }) => ({
]);
const sections = sectionMatches.map(({ item, score }) => ({
key: `setting-section:${item.key}`,
group: "settings",
title: t(item.labelKey, item.labelFallback),
@@ -208,7 +213,34 @@ export function rankSettingsResults(
onSelect: () => openSettings(item.key),
}));
return [...rows, ...sections]
// Content matches: sections whose rendered copy contains the query (the
// in-modal settings search technique), so terms with no curated keyword
// ("SMTP", a field label) still find their section. Ranked below every
// label/keyword match; 3+ chars so a single letter doesn't match half the
// modal.
const labelMatchedKeys = new Set(sectionMatches.map(({ item }) => item.key));
const contentMatches =
trimmed.length < 3
? []
: visibleSections
.filter((s) => !labelMatchedKeys.has(s.key))
.flatMap((s) => {
const match = findSettingsContentMatch(s.key, trimmed, t);
if (!match) return [];
return [
{
key: `setting-content:${s.key}`,
group: "settings",
title: t(s.labelKey, s.labelFallback),
subtitle: buildMatchSnippet(match, trimmed),
iconName: "settings-rounded",
score: 20, // below the fuzzy thresholds (30/40)
onSelect: () => openSettings(s.key),
},
];
});
return [...rows, ...sections, ...contentMatches]
.sort((a, b) => b.score - a.score)
.slice(0, GROUP_LIMIT);
}