import { SettingsPages } from '../pages'; /** * [Gitea #463] Search across all settings. The index is built from the * settings source itself (each and each * section header), so options added later are found without a hand-kept list. * Pure: path + source text in, entries out. */ export type SettingsEntry = { title: string; description: string; page: SettingsPages; /** Section header the setting sits under, when known. */ section?: string; /** Desktop-only (hidden in a browser). */ desktopOnly?: boolean; }; // General.tsx: which page each section (or helper used by a section) is on. const GENERAL_SECTIONS: Record = { Appearance: SettingsPages.AppearancePage, SystemThemePreferences: SettingsPages.AppearancePage, SelectTheme: SettingsPages.AppearancePage, PageZoomInput: SettingsPages.AppearancePage, CollapsiblePicker: SettingsPages.AppearancePage, SeasonalBgGrid: SettingsPages.AppearancePage, ChatBgGrid: SettingsPages.AppearancePage, Messages: SettingsPages.MessagesPage, SelectMessageLayout: SettingsPages.MessagesPage, SelectMessageSpacing: SettingsPages.MessagesPage, Editor: SettingsPages.MessagesPage, ComposerToolbarReorder: SettingsPages.MessagesPage, ComposerToolbarButtonRow: SettingsPages.MessagesPage, DateAndTime: SettingsPages.MessagesPage, SelectDateFormat: SettingsPages.MessagesPage, DateHint: SettingsPages.MessagesPage, CustomDateFormat: SettingsPages.MessagesPage, PresetDateFormat: SettingsPages.MessagesPage, Calls: SettingsPages.CallsPage, Privacy: SettingsPages.DevicesPage, SettingsSyncSection: SettingsPages.DataStoragePage, AdvancedDisplayOptions: SettingsPages.DeveloperToolsPage, DesktopChromeSetting: SettingsPages.DesktopPage, AutostartSetting: SettingsPages.DesktopPage, KeychainMirrorSetting: SettingsPages.DesktopPage, DesktopSettings: SettingsPages.DesktopPage, PackageUpdate: SettingsPages.DesktopPage, AppUpdates: SettingsPages.DesktopPage, }; const FOLDER_PAGES: [RegExp, SettingsPages][] = [ [/\/account\//, SettingsPages.AccountPage], [/\/notifications\//, SettingsPages.NotificationPage], [/\/devices\//, SettingsPages.DevicesPage], [/\/developer(-tools)?\//, SettingsPages.DeveloperToolsPage], [/\/emojis-stickers\//, SettingsPages.EmojisStickersPage], [/\/about\//, SettingsPages.AboutPage], [/\/general\/(StorageUsage|YourData)\.tsx$/, SettingsPages.DataStoragePage], ]; const decode = (s: string): string => s .replace(/&/g, '&') .replace(/'|'/g, "'") .replace(/"/g, '"') .replace(/\\'/g, "'") .replace(/\s+/g, ' ') .trim(); /** A JSX attribute's static text: "…", {'…'}, {"…"} or {`…`} without ${}. */ const attr = (tag: string, name: string): string | undefined => { const m = new RegExp( `\\b${name}=(?:"([^"]*)"|\\{\\s*'((?:[^'\\\\]|\\\\.)*)'\\s*\\}|\\{\\s*"([^"]*)"\\s*\\}|\\{\\s*\`([^\`$]*)\`\\s*\\})`, ).exec(tag); const v = m && (m[1] ?? m[2] ?? m[3] ?? m[4]); return v ? decode(v) : undefined; }; /** The opening tag starting at `start` (up to the matching '>' outside braces/strings). */ const openingTag = (src: string, start: number): string => { let depth = 0; let quote: string | null = null; for (let i = start; i < src.length; i += 1) { const c = src[i]; if (quote) { if (c === quote && src[i - 1] !== '\\') quote = null; } else if (c === '"' || c === "'" || c === '`') quote = c; else if (c === '{') depth += 1; else if (c === '}') depth -= 1; else if (c === '>' && depth === 0) return src.slice(start, i + 1); } return src.slice(start); }; export const parseSettingsSource = (path: string, src: string): SettingsEntry[] => { const isGeneral = /\/general\/General\.tsx$/.test(path); const folderPage = FOLDER_PAGES.find(([re]) => re.test(path))?.[1]; if (!isGeneral && folderPage === undefined) return []; // For General.tsx: positions of each top-level function, to know the section. const fns = isGeneral ? [...src.matchAll(/\n(?:export )?function (\w+)\(/g)].map((m) => ({ at: m.index ?? 0, name: m[1], })) : []; const pageAt = (pos: number): SettingsPages | undefined => { if (!isGeneral) return folderPage; let name: string | undefined; fns.forEach((f) => { if (f.at < pos) name = f.name; }); return name ? GENERAL_SECTIONS[name] : undefined; }; const out: SettingsEntry[] = []; const headers = [...src.matchAll(/([^<{]+)<\/Text>/g)].map((m) => ({ at: m.index ?? 0, text: decode(m[1]), })); const sectionAt = (pos: number): string | undefined => { let s: string | undefined; headers.forEach((h) => { if (h.at < pos) s = h.text; }); return s; }; for (const m of src.matchAll(/ { const page = pageAt(h.at); if (page !== undefined && !out.some((e) => e.title === h.text && e.page === page)) { out.push({ title: h.text, description: '', page, desktopOnly: page === SettingsPages.DesktopPage || undefined, }); } }); return out; }; const norm = (s: string): string => s.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, ''); /** * Pure: rank entries for a query. Every word must appear in the title, * description or section; title matches rank first. */ export const searchSettings = ( entries: SettingsEntry[], query: string, { desktop = false, limit = 30 }: { desktop?: boolean; limit?: number } = {}, ): SettingsEntry[] => { const words = norm(query).split(/\s+/).filter(Boolean); if (words.length === 0) return []; const scored: { e: SettingsEntry; score: number }[] = []; entries.forEach((e) => { if (e.desktopOnly && !desktop) return; const title = norm(e.title); const rest = norm(`${e.description} ${e.section ?? ''}`); let score = 0; const all = words.every((w) => { if (title.startsWith(w)) score += 3; else if (title.includes(w)) score += 2; else if (rest.includes(w)) score += 1; else return false; return true; }); if (all) scored.push({ e, score }); }); return scored .sort((a, b) => b.score - a.score || a.e.title.localeCompare(b.e.title)) .slice(0, limit) .map((s) => s.e); };