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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user