From be1e770282116b14c4efae18bab4c3d8a076a563 Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Tue, 23 Jun 2026 10:32:22 -0700 Subject: [PATCH 1/2] BF-011-BF-014: improve tool discovery search and local favorites --- src/app/[lang]/all-tools/page.tsx | 8 + src/components/layout/command-palette.tsx | 41 ++- src/components/layout/route-page-chrome.tsx | 22 +- .../layout/tool-favorite-control.tsx | 51 +++ src/core/i18n/translations/de.json | 9 +- src/core/i18n/translations/en.json | 9 +- src/core/i18n/translations/fr.json | 9 +- src/core/i18n/translations/ja.json | 9 +- src/core/i18n/translations/ko.json | 9 +- src/core/i18n/translations/zh-CN.json | 9 +- src/core/i18n/translations/zh-TW.json | 9 +- src/core/search/command-search.ts | 66 +++- src/core/search/tool-search-metadata.ts | 306 ++++++++++++++++++ src/core/storage/tool-discovery-state.ts | 59 +++- .../tool-discovery/all-tools-discovery.tsx | 298 ++++++++++++++--- tests/component/all-tools-discovery.test.tsx | 80 +++++ tests/component/route-page-chrome.test.tsx | 6 + tests/guards/sensitive-storage-audit.test.ts | 15 + tests/unit/command-search.test.ts | 137 ++++++++ tests/unit/tool-discovery-state.test.ts | 34 ++ 20 files changed, 1083 insertions(+), 103 deletions(-) create mode 100644 src/components/layout/tool-favorite-control.tsx create mode 100644 src/core/search/tool-search-metadata.ts diff --git a/src/app/[lang]/all-tools/page.tsx b/src/app/[lang]/all-tools/page.tsx index 800cf16b..a53be667 100644 --- a/src/app/[lang]/all-tools/page.tsx +++ b/src/app/[lang]/all-tools/page.tsx @@ -113,7 +113,9 @@ export default async function AllToolsPage({ params }: { params: Promise<{ lang: activeFilters: requireTranslationValue(t.common.active_filters, "common.active_filters"), allFamilies: requireTranslationValue(t.common.all_families, "common.all_families"), clearFilters: requireTranslationValue(t.common.clear_filters, "common.clear_filters"), + clearFavorites: requireTranslationValue(t.common.clear_favorites, "common.clear_favorites"), commonWorkflows: requireTranslationValue(t.common.common_workflows, "common.common_workflows"), + favorites: requireTranslationValue(t.common.favorites, "common.favorites"), filterByCategory: requireTranslationValue(t.common.filter_by_category, "common.filter_by_category"), filterByExecution: requireTranslationValue(t.common.filter_by_execution, "common.filter_by_execution"), filterByInputType: requireTranslationValue(t.common.filter_by_input_type, "common.filter_by_input_type"), @@ -126,13 +128,19 @@ export default async function AllToolsPage({ params }: { params: Promise<{ lang: inputUrlDomain: requireTranslationValue(t.common.input_url_domain, "common.input_url_domain"), noResults: requireTranslationValue(t.common.no_results, "common.no_results"), noResultsSuggestion: requireTranslationValue(t.common.no_results_suggestion, "common.no_results_suggestion"), + noFavorites: requireTranslationValue(t.common.no_favorites, "common.no_favorites"), + noRecentTools: requireTranslationValue(t.common.no_recent_tools, "common.no_recent_tools"), open: requireTranslationValue(t.common.open, "common.open"), popularTags: requireTranslationValue(t.common.popular_tags, "common.popular_tags"), removeFilter: requireTranslationValue(t.common.remove_filter, "common.remove_filter"), showFilters: requireTranslationValue(t.common.show_filters, "common.show_filters"), + closeFilters: requireTranslationValue(t.common.close_filters, "common.close_filters"), clearRecentTools: requireTranslationValue(t.common.clear_recent_tools, "common.clear_recent_tools"), recentTools: requireTranslationValue(t.common.recent_tools, "common.recent_tools"), recentToolsPrivacy: requireTranslationValue(t.common.recent_tools_local_only, "common.recent_tools_local_only"), + addFavorite: requireTranslationValue(t.common.add_favorite, "common.add_favorite"), + removeFavorite: requireTranslationValue(t.common.remove_favorite, "common.remove_favorite"), + favoritesPrivacy: requireTranslationValue(t.common.favorites_local_only, "common.favorites_local_only"), searchPlaceholder: requireTranslationValue(t.nav.search, "nav.search"), toolsLabel: requireTranslationValue(t.common.tools, "common.tools"), useCaseEncode: requireTranslationValue(t.common.use_case_encode, "common.use_case_encode"), diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index c593358d..540d8557 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -21,7 +21,11 @@ import { useLang } from "@/core/i18n/lang-provider" import { getCommandSearchToolByKey } from "@/generated/command-search-index" import { readFavoriteToolKeys, readRecentToolKeys, TOOL_DISCOVERY_UPDATED_EVENT } from "@/core/storage/tool-discovery-state" import { useSystemCommands } from "@/core/commands/registry" -import { scoreCommandSearch } from "@/core/search/command-search" +import { applyToolSearchScoreBonuses, scoreCommandSearch } from "@/core/search/command-search" +import { + getToolSearchMetadata, + getToolSearchMetadataTerms, +} from "@/core/search/tool-search-metadata" import { trackSearchPerformed } from "@/core/analytics/analytics" import { getAllToolsHref } from "@/core/routing/all-tools-route" import { cn } from "@/core/utils/utils" @@ -55,6 +59,7 @@ const WORKFLOW_ENTRIES = [ ] as const const RECOMMENDED_TOOL_KEYS = ["json_formatter", "jwt_decoder", "base64_encode_decode", "regex_tester"] as const +const TOOL_KEY_SCORE_MARKER = "tool-key:" const CAPABILITY_LABEL_KEYS: Record = { "browser-local": "capability_browser_local", @@ -79,6 +84,7 @@ type ToolCommandItem = { searchValue: string searchKeywords: string[] capabilities: readonly string[] + popularity: number } type CommandPaletteProps = { @@ -100,8 +106,9 @@ function isEditableShortcutTarget(target: EventTarget | null): boolean { return Boolean(target.closest("input, textarea, select, [role='textbox'], .monaco-editor, .monaco-diff-editor")) } -function buildToolSearchKeywords(toolKey: string, title: string, searchValue: string): string[] { +function buildToolSearchKeywords(toolKey: string, title: string, searchValue: string, locale?: string): string[] { const tool = getCommandSearchToolByKey(toolKey) + const metadata = getToolSearchMetadata(toolKey) return [ title, searchValue, @@ -110,6 +117,10 @@ function buildToolSearchKeywords(toolKey: string, title: string, searchValue: st ...(tool?.keywords ?? []), ...(tool?.aliases ?? []), ...(tool?.searchKeywords ?? []), + ...(metadata.aliases ?? []), + ...(metadata.taskSynonyms ?? []), + ...(metadata.categoryTerms ?? []), + ...getToolSearchMetadataTerms(toolKey, locale), tool?.family, ...(tool?.tags ?? []), ...(tool?.capabilities ?? []), @@ -280,6 +291,7 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = ...(tool?.keywords ?? []), ...(tool?.aliases ?? []), ...(tool?.searchKeywords ?? []), + ...getToolSearchMetadataTerms(item.key, lang), tool?.family, ...(tool?.tags ?? []), ...(tool?.capabilities ?? []), @@ -288,7 +300,7 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = } } return values - }, [englishToolSearchAliases, t.tools]) + }, [englishToolSearchAliases, lang, t.tools]) const buildToolCommand = React.useCallback((toolKey: string): ToolCommandItem | null => { const tool = getCommandSearchToolByKey(toolKey) @@ -300,8 +312,9 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = title, href: `/${lang}/${tool.slug}`, searchValue, - searchKeywords: buildToolSearchKeywords(tool.key, title, searchValue), + searchKeywords: buildToolSearchKeywords(tool.key, title, searchValue, lang), capabilities: tool.capabilities, + popularity: getToolSearchMetadata(tool.key).popularity ?? 0, } }, [lang, t.tools, toolSearchValues]) @@ -368,7 +381,16 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = onOpenChange={setOpen} title={navigationLabel} description={searchLabel} - filter={scoreCommandSearch} + filter={(value, search, keywords) => { + const safeKeywords = keywords?.filter((keyword) => !keyword.startsWith(TOOL_KEY_SCORE_MARKER)) + const score = scoreCommandSearch(value, search, safeKeywords) + const toolKey = keywords?.find((keyword) => keyword.startsWith(TOOL_KEY_SCORE_MARKER))?.slice(TOOL_KEY_SCORE_MARKER.length) + if (!toolKey) return score + return applyToolSearchScoreBonuses(toolKey, score, { + favorite: favoriteToolKeys.includes(toolKey), + recentRank: recentToolKeys.indexOf(toolKey), + }) + }} > {navigationLabel} runCommand(() => router.push(item.href))} > } icon={Star} /> @@ -456,7 +478,7 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = runCommand(() => router.push(item.href))} > } icon={History} /> @@ -547,7 +569,7 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = runCommand(() => router.push(`/${lang}${tool.href}`))} className={cn("items-start")} > @@ -557,8 +579,9 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = title, href: `/${lang}${tool.href}`, searchValue: toolSearchValues.get(tool.key) || title, - searchKeywords: buildToolSearchKeywords(tool.key, title, toolSearchValues.get(tool.key) || title), + searchKeywords: buildToolSearchKeywords(tool.key, title, toolSearchValues.get(tool.key) || title, lang), capabilities: getCommandSearchToolByKey(tool.key)?.capabilities ?? [], + popularity: getToolSearchMetadata(tool.key).popularity ?? 0, }} labels={commonLabels as unknown as Record} icon={Sparkles} diff --git a/src/components/layout/route-page-chrome.tsx b/src/components/layout/route-page-chrome.tsx index c8a1c84e..496fbcfb 100644 --- a/src/components/layout/route-page-chrome.tsx +++ b/src/components/layout/route-page-chrome.tsx @@ -13,6 +13,7 @@ import { getRouteContext } from "@/core/routing/route-context" import { recordRecentToolKey } from "@/core/storage/tool-discovery-state" import { getRouteToolBySlug } from "@/generated/route-tool-lookup" import { ToolTrustHeader } from "@/features/tool-shell/tool-trust-header" +import { ToolFavoriteControl } from "./tool-favorite-control" const EXCLUDED_CONTENT_INTRO_SLUGS = new Set(["about", "pricing", "contact", "privacy", "terms", "install-app"]) @@ -75,15 +76,18 @@ function RoutePageChromeContent({ children, pathname }: RoutePageChromeProps) { ) : null} {activeTool ? ( - + <> + + + ) : null} {children} {routeContext.routeType === "tool" ? ( diff --git a/src/components/layout/tool-favorite-control.tsx b/src/components/layout/tool-favorite-control.tsx new file mode 100644 index 00000000..e2bac980 --- /dev/null +++ b/src/components/layout/tool-favorite-control.tsx @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" +import { Heart } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useLang } from "@/core/i18n/lang-provider" +import { requireTranslationValue } from "@/core/i18n/i18n" +import { + readFavoriteToolKeys, + toggleFavoriteToolKey, + TOOL_DISCOVERY_UPDATED_EVENT, +} from "@/core/storage/tool-discovery-state" +import { cn } from "@/core/utils/utils" + +type ToolFavoriteControlProps = { + toolKey: string +} + +export function ToolFavoriteControl({ toolKey }: ToolFavoriteControlProps) { + const { t } = useLang() + const [favoriteToolKeys, setFavoriteToolKeys] = React.useState([]) + const isFavorite = favoriteToolKeys.includes(toolKey) + const addLabel = requireTranslationValue(t.common.add_favorite, "common.add_favorite") + const removeLabel = requireTranslationValue(t.common.remove_favorite, "common.remove_favorite") + const privacyLabel = requireTranslationValue(t.common.favorites_local_only, "common.favorites_local_only") + + React.useEffect(() => { + const sync = () => setFavoriteToolKeys(readFavoriteToolKeys()) + sync() + window.addEventListener(TOOL_DISCOVERY_UPDATED_EVENT, sync) + return () => window.removeEventListener(TOOL_DISCOVERY_UPDATED_EVENT, sync) + }, []) + + return ( +
+ + {privacyLabel} +
+ ) +} + diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index ccbf8999..b53417a9 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "Datenschutzrichtlinie", "sensitive_warning_verify_devtools": "In DevTools prüfen", "reveal_secret": "Geheimnis anzeigen", - "hide_secret": "Geheimnis ausblenden" + "hide_secret": "Geheimnis ausblenden", + "close_filters": "Fertig", + "clear_favorites": "Favoriten löschen", + "favorites_local_only": "Favorisierte Tool-IDs werden nur in diesem Browser gespeichert." }, "tools": { "json_formatter": { @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "Umgang mit Tool-Eingaben", "privacy_no_collection_desc": "Die meisten Workflows von byteflow.tools verarbeiten Eingaben und Ausgaben im Browser. Wir erfassen, speichern, analysieren oder laden Payloads browser-lokaler Tools nicht hoch. Tools mit externer Anfrage kontaktieren das angegebene Netzwerkziel nur, wenn Sie diese Aktion ausdrücklich ausführen.", "privacy_cookies_title": "Cookies & Speicherung", - "privacy_cookies_desc": "localStorage wird nur für sichere UI-Einstellungen wie Theme, Sprache, Sidebar-Status und lokale Tool-Optionen verwendet. Sensible Payloads, Tokens, Logs, Dateiinhalte und Tool-Ausgaben sind nicht zur Speicherung vorgesehen.", + "privacy_cookies_desc": "localStorage wird nur für sichere UI-Einstellungen wie Theme, Sprache, Sidebar-Status, Favoriten-Tool-IDs, zuletzt genutzte Tool-IDs, Zeitstempel und lokale Tool-Optionen verwendet. Sensible Payloads, Tokens, Logs, Dateiinhalte, vollständige URLs und Tool-Ausgaben sind nicht zur Speicherung vorgesehen.", "privacy_analytics_title": "Analyse", "privacy_analytics_desc": "Analytics sind auf eine Cookie-freie Ereignistaxonomie mit Positivliste für aggregierte Produktsignale begrenzt. Erlaubte Ereignisse sind Tool geladen, Tool-Aktion, Kopieren, Download, Suche ausgeführt, Klick auf verwandtes Tool und PWA installiert. Suchereignisse erfassen nur Längen-Buckets und Ergebniszahlen. Tool-Eingaben, Ausgaben, JWTs, Geheimnisse, Logs, Dateinamen, Dateiinhalte, Bildinhalte, Hashwerte, vollständige URLs, Nutzer-IDs und Sitzungs-IDs sind niemals Analytics-Felder.", "privacy_third_party_title": "Drittanbieter-Dienste", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "Zweck", "trust_center_external_tool_col_data": "Gesendete Daten", "trust_center_storage_title": "Strategie für lokalen Speicher", - "trust_center_storage_desc": "Lokaler Speicher ist für sichere Einstellungen wie Theme, Sprache, Sidebar-Status, zuletzt genutzte Tool-IDs und lokale Presets reserviert. Tool-Payloads, Token, Logs, Dateiinhalte und erzeugte Ausgaben dürfen standardmäßig nicht persistiert werden.", + "trust_center_storage_desc": "Lokaler Speicher ist für sichere Einstellungen wie Theme, Sprache, Sidebar-Status, Favoriten-Tool-IDs, zuletzt genutzte Tool-IDs mit Zeitstempeln und lokale Presets reserviert. Tool-Payloads, Token, Logs, Dateiinhalte, vollständige URLs und erzeugte Ausgaben dürfen standardmäßig nicht persistiert werden.", "trust_center_analytics_title": "Analytics-Strategie", "trust_center_analytics_desc": "Analytics verwenden eine Ereignistaxonomie mit Positivliste und sind im Laufzeitcode standardmäßig deaktiviert. Wenn ein Cookie-freier Aggregationsanbieter aktiviert wird, darf er nur sichere Ereignisnamen und grobe Parameter wie Tool-ID, Aktionstyp, Sprache, Größen-Bucket, Ergebniszahl und Quellseite erhalten. Tool-Eingaben, Ausgaben, JWTs, Geheimnisse, Logtexte, Dateinamen, Dateiinhalte, Bildinhalte, Hashwerte, Suchtexte, vollständige URLs, Nutzer-IDs und Sitzungs-IDs sind verboten.", "trust_center_pwa_title": "PWA-Cache-Strategie", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 6ad71b7f..cf3a2580 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "Privacy policy", "sensitive_warning_verify_devtools": "Verify in DevTools", "reveal_secret": "Reveal secret", - "hide_secret": "Hide secret" + "hide_secret": "Hide secret", + "close_filters": "Done", + "clear_favorites": "Clear favorites", + "favorites_local_only": "Favorite tool IDs are stored only in this browser." }, "categories": { "formatters_desc": "Format, beautify, and transform code and data files including JSON, XML, SQL, HTML, CSS, and YAML.", @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "Tool Input Handling", "privacy_no_collection_desc": "Most byteflow.tools workflows process input and output in your browser. We do not collect, store, analyze, or upload browser-local tool payloads. Tools marked External request may contact the listed network target only when you explicitly run that action.", "privacy_cookies_title": "Cookies & Storage", - "privacy_cookies_desc": "We use localStorage only for safe UI preferences such as theme, language, sidebar state, and local tool settings. Sensitive payloads, tokens, logs, file contents, and tool outputs are not meant to be stored there.", + "privacy_cookies_desc": "We use localStorage only for safe UI preferences such as theme, language, sidebar state, favorite tool IDs, recent tool IDs, timestamps, and local tool settings. Sensitive payloads, tokens, logs, file contents, full URLs, and tool outputs are not meant to be stored there.", "privacy_analytics_title": "Analytics", "privacy_analytics_desc": "Analytics are limited to an allowlisted, cookie-free taxonomy for aggregate product signals. Allowed events include tool loaded, tool action, copy, download, search performed, related-tool click, and PWA installed. Search events record only query-length buckets and result counts; tool inputs, outputs, JWTs, secrets, logs, file names, file contents, image contents, hash values, full URLs, user IDs, and session IDs are never analytics fields.", "privacy_third_party_title": "Third-Party Services", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "Purpose", "trust_center_external_tool_col_data": "Data sent", "trust_center_storage_title": "Local storage strategy", - "trust_center_storage_desc": "Local storage is reserved for safe preferences such as theme, language, sidebar state, recent tool IDs, and local presets. Tool payloads, tokens, logs, file contents, and generated outputs must not be persisted by default.", + "trust_center_storage_desc": "Local storage is reserved for safe preferences such as theme, language, sidebar state, favorite tool IDs, recent tool IDs with timestamps, and local presets. Tool payloads, tokens, logs, file contents, full URLs, and generated outputs must not be persisted by default.", "trust_center_analytics_title": "Analytics strategy", "trust_center_analytics_desc": "Analytics use an allowlisted event taxonomy and are disabled by default in runtime code. If a cookie-free aggregate provider is enabled, it may receive only safe event names and coarse parameters such as tool ID, action type, language, size bucket, result count, and source page. Tool input, output, JWTs, secrets, log bodies, file names, file contents, image contents, hash values, search query text, full URLs, user IDs, and session IDs are forbidden.", "trust_center_pwa_title": "PWA cache strategy", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 85c23621..54faa6e3 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "Politique de confidentialité", "sensitive_warning_verify_devtools": "Vérifier dans DevTools", "reveal_secret": "Afficher le secret", - "hide_secret": "Masquer le secret" + "hide_secret": "Masquer le secret", + "close_filters": "Terminé", + "clear_favorites": "Effacer les favoris", + "favorites_local_only": "Les IDs d’outils favoris sont stockés uniquement dans ce navigateur." }, "tools": { "json_formatter": { @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "Traitement des saisies d’outils", "privacy_no_collection_desc": "La plupart des flux byteflow.tools traitent les saisies et sorties dans votre navigateur. Nous ne collectons, stockons, analysons ni téléversons les payloads des outils locaux au navigateur. Les outils marqués Requête externe ne contactent la cible réseau listée que lorsque vous lancez explicitement cette action.", "privacy_cookies_title": "Cookies et stockage", - "privacy_cookies_desc": "Nous utilisons localStorage uniquement pour des préférences d’interface sûres comme le thème, la langue, l’état de la barre latérale et les réglages locaux d’outils. Les payloads sensibles, tokens, logs, contenus de fichiers et sorties d’outils ne sont pas destinés à y être stockés.", + "privacy_cookies_desc": "Nous utilisons localStorage uniquement pour des préférences d’interface sûres comme le thème, la langue, l’état de la barre latérale, les IDs d’outils favoris, les IDs d’outils récents, les horodatages et les réglages locaux d’outils. Les payloads sensibles, tokens, logs, contenus de fichiers, URL complètes et sorties d’outils ne sont pas destinés à y être stockés.", "privacy_analytics_title": "Analytique", "privacy_analytics_desc": "L’analytics est limitée à une taxonomie d’événements sur liste d’autorisation, sans cookie, pour des signaux produit agrégés. Les événements autorisés sont outil chargé, action d’outil, copie, téléchargement, recherche effectuée, clic sur outil connexe et PWA installée. Les événements de recherche enregistrent seulement des tranches de longueur et le nombre de résultats. Entrées, sorties, JWT, secrets, journaux, noms de fichiers, contenus de fichiers, contenus d’images, valeurs de hachage, URL complètes, identifiants utilisateur et identifiants de session ne sont jamais des champs analytics.", "privacy_third_party_title": "Services tiers", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "But", "trust_center_external_tool_col_data": "Données envoyées", "trust_center_storage_title": "Stratégie de stockage local", - "trust_center_storage_desc": "Le stockage local est réservé aux préférences sûres comme le thème, la langue, l’état de la barre latérale, les IDs d’outils récents et les presets locaux. Les payloads d’outil, tokens, journaux, contenus de fichiers et sorties générées ne doivent pas être persistés par défaut.", + "trust_center_storage_desc": "Le stockage local est réservé aux préférences sûres comme le thème, la langue, l’état de la barre latérale, les IDs d’outils favoris, les IDs d’outils récents avec horodatages et les presets locaux. Les payloads d’outil, tokens, journaux, contenus de fichiers, URL complètes et sorties générées ne doivent pas être persistés par défaut.", "trust_center_analytics_title": "Stratégie analytics", "trust_center_analytics_desc": "L’analytics utilise une taxonomie d’événements sur liste d’autorisation et le code d’exécution désactive l’envoi par défaut. Si un fournisseur d’agrégation sans cookie est activé, il ne peut recevoir que des noms d’événements sûrs et des paramètres grossiers comme l’ID d’outil, le type d’action, la langue, la tranche de taille, le nombre de résultats et la page source. Entrées, sorties, JWT, secrets, corps de journaux, noms de fichiers, contenus de fichiers, contenus d’images, valeurs de hachage, texte de recherche, URL complètes, identifiants utilisateur et identifiants de session sont interdits.", "trust_center_pwa_title": "Stratégie de cache PWA", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index cdafdecc..51ae68cc 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "プライバシーポリシー", "sensitive_warning_verify_devtools": "DevTools で確認", "reveal_secret": "シークレットを表示", - "hide_secret": "シークレットを隠す" + "hide_secret": "シークレットを隠す", + "close_filters": "完了", + "clear_favorites": "お気に入りをクリア", + "favorites_local_only": "お気に入りのツール ID はこのブラウザ内にのみ保存されます。" }, "tools": { "json_formatter": { @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "ツール入力の扱い", "privacy_no_collection_desc": "byteflow.tools の多くのワークフローは、入力と出力をブラウザ内で処理します。ブラウザローカルツールの payload を収集、保存、分析、アップロードしません。「外部リクエスト」表示のあるツールは、明示的に実行した場合のみ表示されたネットワーク先へ接続します。", "privacy_cookies_title": "Cookie とストレージ", - "privacy_cookies_desc": "localStorage はテーマ、言語、サイドバー状態、ローカルツール設定など安全な UI 設定にのみ使用します。機密 payload、token、ログ、ファイル内容、ツール出力は保存対象ではありません。", + "privacy_cookies_desc": "localStorage はテーマ、言語、サイドバー状態、お気に入りツール ID、最近使ったツール ID、タイムスタンプ、ローカルツール設定など安全な UI 設定にのみ使用します。機密 payload、token、ログ、ファイル内容、完全な URL、ツール出力は保存対象ではありません。", "privacy_analytics_title": "解析", "privacy_analytics_desc": "分析は Cookie を使わない許可リスト形式のイベント分類に基づく集計プロダクトシグナルに限定します。許可されるイベントは、ツール読み込み、ツール操作、コピー、ダウンロード、検索実行、関連ツールクリック、PWA インストールです。検索イベントはクエリ長のバケットと結果数だけを記録します。ツール入力、出力、JWT、秘密情報、ログ、ファイル名、ファイル内容、画像内容、ハッシュ値、完全な URL、ユーザー ID、セッション ID は分析項目にしません。", "privacy_third_party_title": "外部サービス", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "目的", "trust_center_external_tool_col_data": "送信データ", "trust_center_storage_title": "ローカル保存方針", - "trust_center_storage_desc": "ローカル保存はテーマ、言語、サイドバー状態、最近使ったツール ID、ローカルプリセットなど安全な設定に限定します。ツール payload、token、ログ、ファイル内容、生成出力は既定で永続化してはいけません。", + "trust_center_storage_desc": "ローカル保存はテーマ、言語、サイドバー状態、お気に入りツール ID、タイムスタンプ付きの最近使ったツール ID、ローカルプリセットなど安全な設定に限定します。ツール payload、token、ログ、ファイル内容、完全な URL、生成出力は既定で永続化してはいけません。", "trust_center_analytics_title": "分析方針", "trust_center_analytics_desc": "分析は許可リスト形式のイベント分類を使い、実行時コードでは既定で送信を無効にしています。Cookie を使わない集計サービスを有効にする場合でも、安全なイベント名と、ツール ID、操作種別、言語、サイズバケット、結果数、参照元ページなどの粗いパラメータだけを送れます。ツール入力、出力、JWT、秘密情報、ログ本文、ファイル名、ファイル内容、画像内容、ハッシュ値、検索語、完全な URL、ユーザー ID、セッション ID は禁止です。", "trust_center_pwa_title": "PWA キャッシュ方針", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index a5cd1a5d..bf589d40 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "개인정보 처리방침", "sensitive_warning_verify_devtools": "DevTools에서 확인", "reveal_secret": "비밀값 표시", - "hide_secret": "비밀값 숨기기" + "hide_secret": "비밀값 숨기기", + "close_filters": "완료", + "clear_favorites": "즐겨찾기 지우기", + "favorites_local_only": "즐겨찾기 도구 ID는 이 브라우저에만 저장됩니다." }, "tools": { "json_formatter": { @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "도구 입력 처리", "privacy_no_collection_desc": "byteflow.tools의 대부분 워크플로는 입력과 출력을 브라우저에서 처리합니다. 브라우저 로컬 도구 payload를 수집, 저장, 분석 또는 업로드하지 않습니다. “외부 요청” 도구는 사용자가 명시적으로 실행할 때만 표시된 네트워크 대상으로 연결합니다.", "privacy_cookies_title": "쿠키 및 저장소", - "privacy_cookies_desc": "localStorage는 테마, 언어, 사이드바 상태, 로컬 도구 설정 같은 안전한 UI 환경설정에만 사용합니다. 민감한 payload, token, 로그, 파일 내용, 도구 출력은 저장 대상이 아닙니다.", + "privacy_cookies_desc": "localStorage는 테마, 언어, 사이드바 상태, 즐겨찾기 도구 ID, 최근 도구 ID, 타임스탬프, 로컬 도구 설정 같은 안전한 UI 환경설정에만 사용합니다. 민감한 payload, token, 로그, 파일 내용, 전체 URL, 도구 출력은 저장 대상이 아닙니다.", "privacy_analytics_title": "분석", "privacy_analytics_desc": "분석은 쿠키 없는 허용 목록 이벤트 분류의 집계 제품 신호로 제한됩니다. 허용 이벤트는 도구 로드, 도구 작업, 복사, 다운로드, 검색 실행, 관련 도구 클릭, PWA 설치입니다. 검색 이벤트는 쿼리 길이 버킷과 결과 수만 기록합니다. 도구 입력, 출력, JWT, 비밀값, 로그, 파일 이름, 파일 내용, 이미지 내용, 해시값, 전체 URL, 사용자 ID, 세션 ID는 분석 필드가 아닙니다.", "privacy_third_party_title": "제3자 서비스", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "목적", "trust_center_external_tool_col_data": "전송 데이터", "trust_center_storage_title": "로컬 저장소 전략", - "trust_center_storage_desc": "로컬 저장소는 테마, 언어, 사이드바 상태, 최근 도구 ID, 로컬 프리셋 같은 안전한 설정에 한정됩니다. 도구 payload, token, 로그, 파일 내용, 생성 출력은 기본적으로 지속 저장하면 안 됩니다.", + "trust_center_storage_desc": "로컬 저장소는 테마, 언어, 사이드바 상태, 즐겨찾기 도구 ID, 타임스탬프가 있는 최근 도구 ID, 로컬 프리셋 같은 안전한 설정에 한정됩니다. 도구 payload, token, 로그, 파일 내용, 전체 URL, 생성 출력은 기본적으로 지속 저장하면 안 됩니다.", "trust_center_analytics_title": "분석 전략", "trust_center_analytics_desc": "분석은 허용 목록 이벤트 분류를 사용하며 런타임 코드는 기본적으로 전송을 비활성화합니다. 쿠키 없는 집계 제공자가 활성화되더라도 안전한 이벤트 이름과 도구 ID, 작업 유형, 언어, 크기 버킷, 결과 수, 출처 페이지 같은 거친 매개변수만 받을 수 있습니다. 도구 입력, 출력, JWT, 비밀값, 로그 본문, 파일 이름, 파일 내용, 이미지 내용, 해시값, 검색 원문, 전체 URL, 사용자 ID, 세션 ID는 금지됩니다.", "trust_center_pwa_title": "PWA 캐시 전략", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index cb587368..c1d2ef9f 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "隐私政策", "sensitive_warning_verify_devtools": "在 DevTools 中验证", "reveal_secret": "显示密钥", - "hide_secret": "隐藏密钥" + "hide_secret": "隐藏密钥", + "close_filters": "完成", + "clear_favorites": "清除收藏", + "favorites_local_only": "收藏的工具 ID 仅保存在此浏览器中。" }, "tools": { "json_formatter": { @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "工具输入处理", "privacy_no_collection_desc": "byteflow.tools 的多数工作流会在浏览器中处理输入和输出。我们不会收集、存储、分析或上传浏览器本地工具 payload。标记为“外部请求”的工具只会在你明确运行该操作时访问列出的网络目标。", "privacy_cookies_title": "Cookie 与本地存储", - "privacy_cookies_desc": "我们仅使用 localStorage 保存主题、语言、侧边栏状态和本地工具设置等安全界面偏好。敏感 payload、token、日志、文件内容和工具输出不应保存在这里。", + "privacy_cookies_desc": "我们仅使用 localStorage 保存主题、语言、侧边栏状态、收藏工具 ID、最近工具 ID、时间戳和本地工具设置等安全界面偏好。敏感 payload、token、日志、文件内容、完整 URL 和工具输出不应保存在这里。", "privacy_analytics_title": "分析", "privacy_analytics_desc": "分析仅限无 Cookie、白名单化事件分类中的聚合产品信号。允许事件包括工具加载、工具操作、复制、下载、执行搜索、相关工具点击和 PWA 安装。搜索事件只记录查询长度分桶和结果数量;工具输入、输出、JWT、密钥、日志、文件名、文件内容、图片内容、哈希值、完整 URL、用户 ID 和会话 ID 永远不是分析字段。", "privacy_third_party_title": "第三方服务", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "用途", "trust_center_external_tool_col_data": "发送的数据", "trust_center_storage_title": "本地存储策略", - "trust_center_storage_desc": "本地存储仅用于主题、语言、侧边栏状态、最近工具 ID 和本地预设等安全偏好。工具 payload、token、日志、文件内容和生成输出默认不得持久化。", + "trust_center_storage_desc": "本地存储仅用于主题、语言、侧边栏状态、收藏工具 ID、带时间戳的最近工具 ID 和本地预设等安全偏好。工具 payload、token、日志、文件内容、完整 URL 和生成输出默认不得持久化。", "trust_center_analytics_title": "分析策略", "trust_center_analytics_desc": "分析使用白名单事件分类,并且运行时代码默认禁用上报。若启用无 Cookie 的聚合提供方,也只能接收安全事件名和粗粒度参数,例如工具 ID、操作类型、语言、大小分桶、结果数量和来源页面。工具输入、输出、JWT、密钥、日志正文、文件名、文件内容、图片内容、哈希值、搜索原文、完整 URL、用户 ID 和会话 ID 均被禁止。", "trust_center_pwa_title": "PWA 缓存策略", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index ee004939..7b148333 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -430,7 +430,10 @@ "sensitive_warning_privacy_policy": "隱私權政策", "sensitive_warning_verify_devtools": "在 DevTools 中驗證", "reveal_secret": "顯示密鑰", - "hide_secret": "隱藏密鑰" + "hide_secret": "隱藏密鑰", + "close_filters": "完成", + "clear_favorites": "清除收藏", + "favorites_local_only": "收藏的工具 ID 僅儲存在此瀏覽器中。" }, "tools": { "json_formatter": { @@ -2833,7 +2836,7 @@ "privacy_no_collection_title": "工具輸入處理", "privacy_no_collection_desc": "byteflow.tools 的多數工作流程會在瀏覽器中處理輸入與輸出。我們不會蒐集、儲存、分析或上傳瀏覽器本地工具 payload。標記為「外部請求」的工具只會在你明確執行該操作時連線至列出的網路目標。", "privacy_cookies_title": "Cookie 與本地儲存", - "privacy_cookies_desc": "我們僅使用 localStorage 儲存主題、語言、側邊欄狀態和本地工具設定等安全介面偏好。敏感 payload、token、日誌、檔案內容和工具輸出不應儲存在這裡。", + "privacy_cookies_desc": "我們僅使用 localStorage 儲存主題、語言、側邊欄狀態、收藏工具 ID、最近工具 ID、時間戳和本地工具設定等安全介面偏好。敏感 payload、token、日誌、檔案內容、完整 URL 和工具輸出不應儲存在這裡。", "privacy_analytics_title": "分析", "privacy_analytics_desc": "分析僅限無 Cookie、白名單化事件分類中的彙總產品訊號。允許事件包括工具載入、工具操作、複製、下載、執行搜尋、相關工具點擊和 PWA 安裝。搜尋事件只記錄查詢長度分桶和結果數量;工具輸入、輸出、JWT、金鑰、日誌、檔名、檔案內容、圖片內容、雜湊值、完整 URL、使用者 ID 和工作階段 ID 永遠不是分析欄位。", "privacy_third_party_title": "第三方服務", @@ -2887,7 +2890,7 @@ "trust_center_external_tool_col_purpose": "用途", "trust_center_external_tool_col_data": "送出的資料", "trust_center_storage_title": "本地儲存策略", - "trust_center_storage_desc": "本地儲存僅用於主題、語言、側邊欄狀態、最近工具 ID 和本地預設等安全偏好。工具 payload、token、日誌、檔案內容和產生輸出預設不得持久化。", + "trust_center_storage_desc": "本地儲存僅用於主題、語言、側邊欄狀態、收藏工具 ID、帶時間戳的最近工具 ID 和本地預設等安全偏好。工具 payload、token、日誌、檔案內容、完整 URL 和產生輸出預設不得持久化。", "trust_center_analytics_title": "分析策略", "trust_center_analytics_desc": "分析使用白名單事件分類,且執行階段程式碼預設停用上報。若啟用無 Cookie 的彙總提供方,也只能接收安全事件名稱和粗粒度參數,例如工具 ID、操作類型、語言、大小分桶、結果數量和來源頁面。工具輸入、輸出、JWT、金鑰、日誌本文、檔名、檔案內容、圖片內容、雜湊值、搜尋原文、完整 URL、使用者 ID 和工作階段 ID 均被禁止。", "trust_center_pwa_title": "PWA 快取策略", diff --git a/src/core/search/command-search.ts b/src/core/search/command-search.ts index 006c86d5..d3bc4bf7 100644 --- a/src/core/search/command-search.ts +++ b/src/core/search/command-search.ts @@ -1,5 +1,8 @@ +import { getToolSearchMetadataTerms, getToolSearchPopularity } from "./tool-search-metadata" + const DIACRITIC_RE = /[\u0300-\u036f]/g const WORD_SPLIT_RE = /[\s/_+.#"@[\]({&:;|,<>?!=`~\\-]+/g +const MIXED_SCRIPT_TOKEN_RE = /[a-z0-9]+|[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu function normalizeSearchText(value: string): string { return value @@ -16,10 +19,23 @@ function compactSearchText(value: string): string { } function tokenizeSearchText(value: string): string[] { - return normalizeSearchText(value) + const tokens = normalizeSearchText(value) .split(" ") .map((token) => token.trim()) .filter(Boolean) + + const expanded: string[] = [] + const seen = new Set() + for (const token of tokens) { + const parts = token.match(MIXED_SCRIPT_TOKEN_RE) ?? [] + for (const value of [token, ...parts]) { + if (!value || seen.has(value)) continue + seen.add(value) + expanded.push(value) + } + } + + return expanded } function editDistanceWithin(left: string, right: string, maxDistance: number): number { @@ -167,8 +183,13 @@ export type ToolSearchDocument = { familyLabel?: string keywords?: readonly string[] aliases?: readonly string[] + searchKeywords?: readonly string[] + localizedAliases?: readonly string[] + categoryTerms?: readonly string[] tags?: readonly string[] capabilities?: readonly string[] + popularity?: number + locale?: string } export function buildToolSearchKeywords(tool: ToolSearchDocument): string[] { @@ -180,11 +201,50 @@ export function buildToolSearchKeywords(tool: ToolSearchDocument): string[] { tool.familyLabel, ...(tool.keywords ?? []), ...(tool.aliases ?? []), + ...(tool.searchKeywords ?? []), + ...(tool.localizedAliases ?? []), + ...(tool.categoryTerms ?? []), + ...getToolSearchMetadataTerms(tool.key, tool.locale), ...(tool.tags ?? []), ...(tool.capabilities ?? []), ].filter((value): value is string => typeof value === "string" && value.trim().length > 0) } -export function scoreToolSearch(tool: ToolSearchDocument, search: string): number { - return scoreCommandSearch(tool.title, search, buildToolSearchKeywords(tool)) +export type ToolSearchScoreOptions = { + locale?: string + favorite?: boolean + recentRank?: number +} + +export function applyToolSearchScoreBonuses( + toolKey: string | undefined, + score: number, + options: ToolSearchScoreOptions & { popularity?: number } = {}, +): number { + if (score <= 0) return 0 + + const popularity = options.popularity ?? getToolSearchPopularity(toolKey) + const popularityBonus = Math.min(10, Math.max(0, popularity) / 12) + const favoriteBonus = options.favorite ? 6 : 0 + const recentBonus = typeof options.recentRank === "number" && options.recentRank >= 0 + ? Math.max(0, 4 - options.recentRank * 0.5) + : 0 + + return score + popularityBonus + favoriteBonus + recentBonus +} + +export function scoreToolSearch(tool: ToolSearchDocument, search: string, options: ToolSearchScoreOptions = {}): number { + const score = scoreCommandSearch( + tool.title, + search, + buildToolSearchKeywords({ + ...tool, + locale: options.locale ?? tool.locale, + }), + ) + + return applyToolSearchScoreBonuses(tool.key, score, { + ...options, + popularity: tool.popularity, + }) } diff --git a/src/core/search/tool-search-metadata.ts b/src/core/search/tool-search-metadata.ts new file mode 100644 index 00000000..a6e2f01e --- /dev/null +++ b/src/core/search/tool-search-metadata.ts @@ -0,0 +1,306 @@ +import type { Locale } from "@/core/i18n/i18n" + +export type ToolSearchMetadata = { + aliases?: readonly string[] + taskSynonyms?: readonly string[] + categoryTerms?: readonly string[] + localizedAliases?: Partial> + popularity?: number +} + +const CORE_LOCALIZED_ALIASES: Record> = { + en: {}, + "zh-CN": { + json_formatter: ["JSON 格式化", "JSON格式化", "JSON 美化", "格式化 JSON", "验证 JSON", "整理 JSON"], + jwt_decoder: ["JWT 解码", "JWT解码", "令牌解码", "解析 JWT", "解析令牌"], + base64_encode_decode: ["Base64 解码", "Base64 编码", "base64解码", "base64编码"], + regex_tester: ["正则 测试", "正则表达式测试", "匹配测试", "测试正则"], + image_resizer: ["图片 调整大小", "图片缩放", "调整图片尺寸", "图像尺寸"], + log_scrubber: ["日志 脱敏", "日志清理", "日志敏感信息", "清理日志"], + qr_code_generator: ["二维码 生成", "QR 码生成", "生成二维码", "制作 QR"], + password_generator: ["密码 生成", "生成密码", "随机密码", "安全密码"], + }, + "zh-TW": { + json_formatter: ["JSON 格式化", "JSON格式化", "JSON 美化", "格式化 JSON", "驗證 JSON", "整理 JSON"], + jwt_decoder: ["JWT 解碼", "JWT解碼", "權杖解碼", "解析 JWT", "解析權杖"], + base64_encode_decode: ["Base64 解碼", "Base64 編碼", "base64解碼", "base64編碼"], + regex_tester: ["正則 測試", "正則表達式測試", "匹配測試", "測試正則"], + image_resizer: ["圖片 調整大小", "圖片縮放", "調整圖片尺寸", "影像尺寸"], + log_scrubber: ["日誌 去識別", "日誌脫敏", "日誌清理", "清理日誌"], + qr_code_generator: ["QR 碼 產生", "QR碼產生", "產生 QR", "產生二維碼"], + password_generator: ["密碼 產生", "產生密碼", "隨機密碼", "安全密碼"], + }, + ja: { + json_formatter: ["JSON フォーマット", "JSON 整形", "JSON 整理", "JSON 検証"], + jwt_decoder: ["JWT デコード", "JWT 解析", "トークン デコード", "トークン解析"], + base64_encode_decode: ["Base64 デコード", "Base64 エンコード", "Base64 変換"], + regex_tester: ["正規表現 テスト", "正規表現チェッカー", "regex テスト", "パターン確認"], + image_resizer: ["画像 リサイズ", "画像サイズ変更", "画像 縮小", "画像寸法"], + log_scrubber: ["ログ マスク", "ログ サニタイズ", "ログ 秘密情報", "ログ削除"], + qr_code_generator: ["QRコード 作成", "QR コード生成", "QR 生成", "QRコード生成"], + password_generator: ["パスワード 生成", "ランダム パスワード", "安全なパスワード"], + }, + ko: { + json_formatter: ["JSON 포맷", "JSON 정리", "JSON 검증", "JSON 예쁘게"], + jwt_decoder: ["JWT 디코딩", "JWT 파서", "토큰 디코딩", "토큰 파싱"], + base64_encode_decode: ["Base64 디코딩", "Base64 인코딩", "Base64 변환"], + regex_tester: ["정규식 테스트", "정규표현식 테스트", "regex 테스트", "패턴 테스트"], + image_resizer: ["이미지 크기 조정", "이미지 리사이즈", "사진 크기", "이미지 사이즈"], + log_scrubber: ["로그 마스킹", "로그 정리", "로그 민감정보", "로그 삭제"], + qr_code_generator: ["QR 코드 생성", "QR 생성", "큐알 코드", "QR 만들기"], + password_generator: ["비밀번호 생성", "암호 생성", "랜덤 비밀번호", "안전한 비밀번호"], + }, + de: { + json_formatter: ["JSON formatieren", "JSON validieren", "JSON verschönern", "JSON strukturieren"], + jwt_decoder: ["JWT dekodieren", "JWT analysieren", "Token dekodieren", "Token prüfen"], + base64_encode_decode: ["Base64 dekodieren", "Base64 kodieren", "Base64 umwandeln"], + regex_tester: ["Regex testen", "RegEx prüfen", "regulären Ausdruck testen", "Muster testen"], + image_resizer: ["Bildgröße ändern", "Bild skalieren", "Bild verkleinern", "Foto Größe"], + log_scrubber: ["Logs bereinigen", "Logs schwärzen", "Logdaten maskieren", "Secrets aus Logs"], + qr_code_generator: ["QR Code erstellen", "QR-Code generieren", "QR erzeugen", "QR machen"], + password_generator: ["Passwort generieren", "Passwort erstellen", "zufälliges Passwort", "sicheres Passwort"], + }, + fr: { + json_formatter: ["formater JSON", "valider JSON", "embellir JSON", "mettre en forme JSON"], + jwt_decoder: ["décoder JWT", "analyser JWT", "décoder token", "inspecter token"], + base64_encode_decode: ["décoder Base64", "encoder Base64", "convertir Base64"], + regex_tester: ["tester regex", "tester expression régulière", "vérifier regex", "tester motif"], + image_resizer: ["redimensionner image", "changer taille image", "réduire image", "taille photo"], + log_scrubber: ["nettoyer logs", "masquer logs", "supprimer secrets logs", "assainir logs"], + qr_code_generator: ["générer QR code", "créer QR code", "générer code QR", "faire QR"], + password_generator: ["générer mot de passe", "créer mot de passe", "mot de passe aléatoire", "mot de passe sûr"], + }, +} + +export const TOOL_SEARCH_METADATA: Record = { + json_formatter: { + popularity: 100, + aliases: ["json beautifier", "json prettifier", "json validator", "pretty json", "format json", "format payload"], + taskSynonyms: ["pretty print json", "format api payload", "validate json payload", "clean json"], + categoryTerms: ["data", "api", "payload", "formatter", "validator"], + }, + jwt_decoder: { + popularity: 96, + aliases: ["jwt parser", "jwt inspector", "token decoder", "decode jwt", "decode token"], + taskSynonyms: ["inspect token", "parse token claims", "decode bearer token", "read jwt claims"], + categoryTerms: ["security", "token", "claims", "header", "payload"], + }, + base64_encode_decode: { + popularity: 94, + aliases: ["base 64", "base64 decoder", "base64 encoder", "b64", "decode base64", "encode base64"], + taskSynonyms: ["decode text", "encode text", "decode payload", "convert base64"], + categoryTerms: ["encoder", "decoder", "conversion"], + }, + regex_tester: { + popularity: 92, + aliases: ["regexp tester", "regular expression tester", "regex checker", "test regex"], + taskSynonyms: ["match pattern", "debug regex", "test pattern", "validate regex"], + categoryTerms: ["text", "pattern", "match"], + }, + url_encode_decode: { + popularity: 90, + aliases: ["url decoder", "url encoder", "percent decode", "percent encode", "decode url", "encode url"], + taskSynonyms: ["decode query string", "encode query", "url safe text"], + categoryTerms: ["url", "http", "encoder", "decoder"], + }, + url_parser: { + popularity: 88, + aliases: ["parse url", "url query editor", "query string parser", "inspect url"], + taskSynonyms: ["split url", "edit query params", "parse query string"], + categoryTerms: ["url", "http", "network", "query"], + }, + curl_to_code: { + popularity: 86, + aliases: ["curl converter", "curl code generator", "convert curl", "curl to fetch", "curl to python"], + taskSynonyms: ["turn curl into code", "generate request code", "convert curl command"], + categoryTerms: ["http", "api", "request", "code"], + }, + openapi_viewer: { + popularity: 84, + aliases: ["swagger viewer", "openapi explorer", "api schema viewer", "openapi docs"], + taskSynonyms: ["inspect openapi", "view swagger", "read api contract"], + categoryTerms: ["api", "schema", "contract", "http"], + }, + log_scrubber: { + popularity: 82, + aliases: ["log redactor", "log sanitizer", "scrub logs", "sanitize logs", "remove secrets from logs"], + taskSynonyms: ["mask log secrets", "clean logs", "redact log tokens"], + categoryTerms: ["logs", "security", "redaction", "privacy"], + }, + local_log_parser: { + popularity: 72, + aliases: ["log parser", "parse logs", "analyze logs", "log analyzer"], + taskSynonyms: ["inspect log levels", "filter logs", "read logs"], + categoryTerms: ["logs", "parser", "devops"], + }, + har_viewer_sanitizer: { + popularity: 80, + aliases: ["har sanitizer", "har redactor", "sanitize har", "har viewer", "clean har"], + taskSynonyms: ["remove secrets from har", "redact har headers", "sanitize network log"], + categoryTerms: ["har", "logs", "network", "privacy", "redaction"], + }, + youtube_thumbnail_grabber: { + popularity: 78, + aliases: ["thumbnail grabber", "youtube thumbnail", "video thumbnail", "yt thumbnail"], + taskSynonyms: ["get youtube thumbnail", "preview thumbnail", "download thumbnail"], + categoryTerms: ["image", "social", "metadata"], + }, + open_graph_meta_generator: { + popularity: 76, + aliases: ["seo meta generator", "meta tags", "open graph", "og tags", "twitter card"], + taskSynonyms: ["generate seo tags", "preview social share", "make og metadata"], + categoryTerms: ["seo", "metadata", "social", "html"], + }, + qr_code_generator: { + popularity: 74, + aliases: ["qr generator", "qr code maker", "make qr", "create qr", "generate qr"], + taskSynonyms: ["make qr code", "create qr code", "turn link into qr"], + categoryTerms: ["generator", "barcode", "url"], + }, + password_generator: { + popularity: 72, + aliases: ["random password", "secure password", "password maker", "generate password"], + taskSynonyms: ["make password", "create password", "generate passphrase"], + categoryTerms: ["security", "generator", "random"], + }, + image_resizer: { + popularity: 70, + aliases: ["resize image", "image resize", "photo resizer", "change image size", "scale image"], + taskSynonyms: ["make image smaller", "resize photo", "change image dimensions"], + categoryTerms: ["image", "photo", "media"], + }, + certificate_decoder: { + popularity: 68, + aliases: ["cert decoder", "decode certificate", "x509 decoder", "pem decoder"], + taskSynonyms: ["inspect certificate", "read certificate", "parse cert"], + categoryTerms: ["security", "certificate", "tls", "x509"], + }, + hash_generator: { + popularity: 66, + aliases: ["hmac", "hash text", "checksum", "digest", "sha256", "md5"], + taskSynonyms: ["generate hash", "make checksum", "calculate hmac", "hash payload"], + categoryTerms: ["security", "crypto", "hash"], + }, + env_parser: { + popularity: 64, + aliases: ["dotenv parser", "env parser", "environment variables", ".env parser"], + taskSynonyms: ["parse env", "convert dotenv", "inspect env vars"], + categoryTerms: ["devops", "config", "environment"], + }, + unix_timestamp: { + popularity: 62, + aliases: ["timestamp converter", "unix time", "epoch time", "epoch converter"], + taskSynonyms: ["convert timestamp", "read epoch", "date to unix"], + categoryTerms: ["time", "date", "converter"], + }, + uuid_generator: { + popularity: 60, + aliases: ["guid generator", "uuid maker", "generate uuid", "unique id"], + taskSynonyms: ["make uuid", "create guid", "random uuid"], + categoryTerms: ["id", "generator", "random"], + }, + id_generator: { + popularity: 58, + aliases: ["random id", "id maker", "unique id generator"], + taskSynonyms: ["make random id", "generate id"], + categoryTerms: ["id", "generator", "random"], + }, + yaml_json_converter: { + popularity: 58, + aliases: ["yaml to json", "json to yaml", "toml to json", "config converter"], + taskSynonyms: ["convert yaml", "convert config", "convert toml"], + categoryTerms: ["yaml", "json", "toml", "data"], + }, + csv_json_converter: { + popularity: 56, + aliases: ["csv to json", "json to csv", "csv converter"], + taskSynonyms: ["convert csv", "table to json", "json table"], + categoryTerms: ["csv", "json", "data"], + }, + json_diff_viewer: { + popularity: 54, + aliases: ["json diff", "diff json", "compare json", "json compare"], + taskSynonyms: ["compare payloads", "find json changes"], + categoryTerms: ["json", "diff", "compare"], + }, + crontab_generator: { + popularity: 52, + aliases: ["cron generator", "crontab generator", "cron builder"], + taskSynonyms: ["build cron", "make cron expression"], + categoryTerms: ["cron", "schedule", "generator"], + }, + cron_visualizer: { + popularity: 52, + aliases: ["cron explain", "cron visualizer", "crontab explainer"], + taskSynonyms: ["explain cron", "read cron schedule"], + categoryTerms: ["cron", "schedule", "time"], + }, + chmod_calculator: { + popularity: 50, + aliases: ["chmod", "permissions calculator", "unix permissions"], + taskSynonyms: ["calculate chmod", "file mode", "permission bits"], + categoryTerms: ["unix", "permissions", "calculator"], + }, + csp_parser: { + popularity: 50, + aliases: ["csp", "content security policy", "csp analyzer"], + taskSynonyms: ["parse csp", "inspect csp", "analyze security policy"], + categoryTerms: ["security", "headers", "policy"], + }, + saml_decoder: { + popularity: 50, + aliases: ["saml", "saml decoder", "saml response decoder", "decode saml"], + taskSynonyms: ["parse saml", "inspect saml assertion"], + categoryTerms: ["security", "sso", "xml"], + }, +} + +for (const [locale, aliasesByTool] of Object.entries(CORE_LOCALIZED_ALIASES) as Array<[Locale, Record]>) { + for (const [toolKey, aliases] of Object.entries(aliasesByTool)) { + const metadata = TOOL_SEARCH_METADATA[toolKey] ?? {} + TOOL_SEARCH_METADATA[toolKey] = { + ...metadata, + localizedAliases: { + ...(metadata.localizedAliases ?? {}), + [locale]: aliases, + }, + } + } +} + +function unique(values: readonly (string | undefined)[]): string[] { + const seen = new Set() + const next: string[] = [] + for (const value of values) { + const normalized = value?.trim() + if (!normalized || seen.has(normalized)) continue + seen.add(normalized) + next.push(normalized) + } + return next +} + +export function getToolSearchMetadata(toolKey?: string): ToolSearchMetadata { + if (!toolKey) return {} + return TOOL_SEARCH_METADATA[toolKey] ?? {} +} + +export function getToolSearchPopularity(toolKey?: string): number { + return getToolSearchMetadata(toolKey).popularity ?? 0 +} + +export function getToolLocalizedSearchAliases(toolKey: string | undefined, locale?: string): string[] { + if (!toolKey || !locale) return [] + return [...(getToolSearchMetadata(toolKey).localizedAliases?.[locale as Locale] ?? [])] +} + +export function getToolSearchMetadataTerms(toolKey: string | undefined, locale?: string): string[] { + const metadata = getToolSearchMetadata(toolKey) + return unique([ + ...(metadata.aliases ?? []), + ...(metadata.taskSynonyms ?? []), + ...(metadata.categoryTerms ?? []), + ...getToolLocalizedSearchAliases(toolKey, locale), + ]) +} + diff --git a/src/core/storage/tool-discovery-state.ts b/src/core/storage/tool-discovery-state.ts index 49c53fdf..30a9112e 100644 --- a/src/core/storage/tool-discovery-state.ts +++ b/src/core/storage/tool-discovery-state.ts @@ -6,21 +6,41 @@ const MAX_RECENT_TOOLS = 10 export const TOOL_DISCOVERY_UPDATED_EVENT = "byteflow:tool-discovery-updated" -function normalizeToolKeys(value: unknown): string[] { +export type StoredToolDiscoveryRecord = { + toolKey: string + updatedAt: string +} + +function isoNow(): string { + return new Date().toISOString() +} + +function normalizeStoredRecords(value: unknown): StoredToolDiscoveryRecord[] { if (!Array.isArray(value)) return [] - const normalized: string[] = [] + const normalized: StoredToolDiscoveryRecord[] = [] const seen = new Set() for (const item of value) { - if (typeof item !== "string") continue - const key = item.trim() + const key = typeof item === "string" + ? item.trim() + : typeof item === "object" && item !== null && "toolKey" in item && typeof item.toolKey === "string" + ? item.toolKey.trim() + : "" if (!key || seen.has(key)) continue + + const updatedAt = typeof item === "object" && item !== null && "updatedAt" in item && typeof item.updatedAt === "string" + ? item.updatedAt + : isoNow() seen.add(key) - normalized.push(key) + normalized.push({ toolKey: key, updatedAt }) } return normalized } +function normalizeToolKeys(value: unknown): string[] { + return normalizeStoredRecords(value).map((record) => record.toolKey) +} + function emitUpdateEvent() { if (typeof window === "undefined") return window.dispatchEvent(new CustomEvent(TOOL_DISCOVERY_UPDATED_EVENT)) @@ -34,11 +54,23 @@ export function readRecentToolKeys(): string[] { return normalizeToolKeys(readStorageJson(RECENT_TOOL_KEYS_STORAGE_KEY, [])) } +export function readFavoriteToolRecords(): StoredToolDiscoveryRecord[] { + return normalizeStoredRecords(readStorageJson(FAVORITE_TOOL_KEYS_STORAGE_KEY, [])) +} + +export function readRecentToolRecords(): StoredToolDiscoveryRecord[] { + return normalizeStoredRecords(readStorageJson(RECENT_TOOL_KEYS_STORAGE_KEY, [])) +} + export function setFavoriteToolKeys(toolKeys: string[]): string[] { - const next = normalizeToolKeys(toolKeys) + const currentByKey = new Map(readFavoriteToolRecords().map((record) => [record.toolKey, record.updatedAt])) + const next = normalizeStoredRecords(toolKeys).map((record) => ({ + toolKey: record.toolKey, + updatedAt: currentByKey.get(record.toolKey) ?? record.updatedAt, + })) writeStorageJson(FAVORITE_TOOL_KEYS_STORAGE_KEY, next) emitUpdateEvent() - return next + return next.map((record) => record.toolKey) } export function toggleFavoriteToolKey(toolKey: string): string[] { @@ -58,15 +90,20 @@ export function recordRecentToolKey(toolKey: string): string[] { if (!key) return readRecentToolKeys() const current = readRecentToolKeys() - const next = [key, ...current.filter((item) => item !== key)].slice(0, MAX_RECENT_TOOLS) - - if (next.length === current.length && next.every((item, index) => item === current[index])) { + const currentByKey = new Map(readRecentToolRecords().map((record) => [record.toolKey, record.updatedAt])) + const nextKeys = [key, ...current.filter((item) => item !== key)].slice(0, MAX_RECENT_TOOLS) + const next = nextKeys.map((toolKey) => ({ + toolKey, + updatedAt: toolKey === key ? isoNow() : currentByKey.get(toolKey) ?? isoNow(), + })) + + if (nextKeys.length === current.length && nextKeys.every((item, index) => item === current[index])) { return current } writeStorageJson(RECENT_TOOL_KEYS_STORAGE_KEY, next) emitUpdateEvent() - return next + return nextKeys } export function clearRecentToolKeys(): string[] { diff --git a/src/features/tool-discovery/all-tools-discovery.tsx b/src/features/tool-discovery/all-tools-discovery.tsx index bb4f43fd..00824b36 100644 --- a/src/features/tool-discovery/all-tools-discovery.tsx +++ b/src/features/tool-discovery/all-tools-discovery.tsx @@ -6,6 +6,7 @@ import { FileText, Filter, History, + Heart, ImageIcon, Link2, Network, @@ -21,7 +22,14 @@ import { import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { scoreToolSearch } from "@/core/search/command-search" -import { clearRecentToolKeys, readRecentToolKeys, TOOL_DISCOVERY_UPDATED_EVENT } from "@/core/storage/tool-discovery-state" +import { + clearFavoriteToolKeys, + clearRecentToolKeys, + readFavoriteToolKeys, + readRecentToolKeys, + toggleFavoriteToolKey, + TOOL_DISCOVERY_UPDATED_EVENT, +} from "@/core/storage/tool-discovery-state" import { cn } from "@/core/utils/utils" type DiscoveryTool = { @@ -31,6 +39,12 @@ type DiscoveryTool = { description: string family: string familyLabel: string + keywords?: readonly string[] + searchKeywords?: readonly string[] + aliases?: readonly string[] + localizedAliases?: readonly string[] + categoryTerms?: readonly string[] + popularity?: number tags: string[] capabilities: string[] } @@ -54,7 +68,9 @@ type AllToolsDiscoveryLabels = { activeFilters: string allFamilies: string clearFilters: string + clearFavorites: string commonWorkflows: string + favorites: string filterByCategory: string filterByExecution: string filterByInputType: string @@ -67,13 +83,19 @@ type AllToolsDiscoveryLabels = { inputUrlDomain: string noResults: string noResultsSuggestion: string + noFavorites: string + noRecentTools: string open: string popularTags: string removeFilter: string showFilters: string + closeFilters: string clearRecentTools: string recentTools: string recentToolsPrivacy: string + addFavorite: string + removeFavorite: string + favoritesPrivacy: string searchPlaceholder: string toolsLabel: string useCaseEncode: string @@ -322,6 +344,39 @@ function ToolCardBadges({ ) } +function FavoriteButton({ + active, + addLabel, + removeLabel, + onToggle, +}: { + active: boolean + addLabel: string + removeLabel: string + onToggle: () => void +}) { + return ( + + ) +} + export function AllToolsDiscovery({ capabilityLabels, groups, @@ -338,6 +393,8 @@ export function AllToolsDiscovery({ const [selectedUseCases, setSelectedUseCases] = React.useState([]) const [selectedTags, setSelectedTags] = React.useState([]) const [recentToolKeys, setRecentToolKeys] = React.useState([]) + const [favoriteToolKeys, setFavoriteToolKeys] = React.useState([]) + const [personalizationReady, setPersonalizationReady] = React.useState(false) const [urlFiltersLoaded, setUrlFiltersLoaded] = React.useState(false) const [showMobileFilters, setShowMobileFilters] = React.useState(false) @@ -353,10 +410,14 @@ export function AllToolsDiscovery({ const useCaseOptions = React.useMemo(() => buildUseCaseOptions(labels), [labels]) React.useEffect(() => { - const syncRecentTools = () => setRecentToolKeys(readRecentToolKeys()) - syncRecentTools() - window.addEventListener(TOOL_DISCOVERY_UPDATED_EVENT, syncRecentTools) - return () => window.removeEventListener(TOOL_DISCOVERY_UPDATED_EVENT, syncRecentTools) + const syncDiscoveryState = () => { + setRecentToolKeys(readRecentToolKeys()) + setFavoriteToolKeys(readFavoriteToolKeys()) + setPersonalizationReady(true) + } + syncDiscoveryState() + window.addEventListener(TOOL_DISCOVERY_UPDATED_EVENT, syncDiscoveryState) + return () => window.removeEventListener(TOOL_DISCOVERY_UPDATED_EVENT, syncDiscoveryState) }, []) React.useEffect(() => { @@ -396,6 +457,18 @@ export function AllToolsDiscovery({ () => recentToolKeys.map((key) => toolByKey.get(key)).filter((tool): tool is DiscoveryTool => Boolean(tool)).slice(0, 6), [recentToolKeys, toolByKey], ) + const favoriteTools = React.useMemo( + () => favoriteToolKeys.map((key) => toolByKey.get(key)).filter((tool): tool is DiscoveryTool => Boolean(tool)).slice(0, 8), + [favoriteToolKeys, toolByKey], + ) + const favoriteToolKeySet = React.useMemo(() => new Set(favoriteToolKeys), [favoriteToolKeys]) + const recentRankByToolKey = React.useMemo(() => { + const map = new Map() + recentToolKeys.forEach((key, index) => { + if (!map.has(key)) map.set(key, index) + }) + return map + }, [recentToolKeys]) const filteredGroups = React.useMemo(() => { const normalizedQuery = query.trim() @@ -406,7 +479,13 @@ export function AllToolsDiscovery({ tools: group.tools .map((tool, index) => ({ index, - score: normalizedQuery ? scoreToolSearch(tool, normalizedQuery) : 1, + score: normalizedQuery + ? scoreToolSearch(tool, normalizedQuery, { + favorite: favoriteToolKeySet.has(tool.key), + locale, + recentRank: recentRankByToolKey.get(tool.key), + }) + : 1, tool, })) .filter(({ score, tool }) => { @@ -422,9 +501,12 @@ export function AllToolsDiscovery({ .filter((group) => group.tools.length > 0) }, [ executionOptions, + favoriteToolKeySet, groups, inputOptions, + locale, query, + recentRankByToolKey, selectedCategories, selectedExecutionModes, selectedInputTypes, @@ -458,6 +540,14 @@ export function AllToolsDiscovery({ setRecentToolKeys(clearRecentToolKeys()) }, []) + const handleClearFavorites = React.useCallback(() => { + setFavoriteToolKeys(clearFavoriteToolKeys()) + }, []) + + const handleToggleFavorite = React.useCallback((toolKey: string) => { + setFavoriteToolKeys(toggleFavoriteToolKey(toolKey)) + }, []) + const removeSelectedValue = React.useCallback((setter: React.Dispatch>, value: string) => { setter((current) => current.filter((item) => item !== value)) }, []) @@ -530,6 +620,26 @@ export function AllToolsDiscovery({ useCaseOptions, ]) + const tagFilterPanel = ( +
+ + + {labels.popularTags} + + {tags.map((tag) => { + const active = selectedTags.includes(tag) + return ( + setSelectedTags((current) => toggleValue(current, tag))} + /> + ) + })} +
+ ) + const filterPanel = (
@@ -575,6 +685,72 @@ export function AllToolsDiscovery({
) + const localToolsPanel = personalizationReady ? ( +
+
+
+
+

+ + {labels.favorites} +

+

{labels.favoritesPrivacy}

+
+ +
+ {favoriteTools.length > 0 ? ( +
+ {favoriteTools.map((tool) => ( + + {tool.title} + + ))} +
+ ) : ( +

{labels.noFavorites}

+ )} +
+ +
+
+
+

+ + {labels.recentTools} +

+

{labels.recentToolsPrivacy}

+
+ +
+ {recentTools.length > 0 ? ( +
+ {recentTools.map((tool) => ( + + {tool.title} + + ))} +
+ ) : ( +

{labels.noRecentTools}

+ )} +
+
+ ) : null + return (
@@ -598,8 +774,9 @@ export function AllToolsDiscovery({ +
+
+ {filterPanel} +
{tagFilterPanel}
+
+ + +
+
+ + + ) : null} + +
+ {tagFilterPanel}
@@ -641,30 +847,6 @@ export function AllToolsDiscovery({ {resultCount} {labels.toolsLabel} - {recentTools.length > 0 ? ( -
- - - {labels.recentTools} - - {labels.recentToolsPrivacy} -
- {recentTools.map((tool) => ( - - {tool.title} - - ))} - -
-
- ) : null}
{activeFilters.length > 0 ? ( @@ -687,6 +869,8 @@ export function AllToolsDiscovery({ + {localToolsPanel} + {workflows.length > 0 ? (

{labels.commonWorkflows}

@@ -735,21 +919,35 @@ export function AllToolsDiscovery({
- {group.tools.map((tool) => ( + {group.tools.map((tool) => { + const isFavorite = favoriteToolKeySet.has(tool.key) + return ( +
+

{tool.title}

+
+ {personalizationReady ? ( + handleToggleFavorite(tool.key)} + /> + ) : null} +

{tool.description}

- ))} + )})}
))} diff --git a/tests/component/all-tools-discovery.test.tsx b/tests/component/all-tools-discovery.test.tsx index de92b2a0..ca899e9f 100644 --- a/tests/component/all-tools-discovery.test.tsx +++ b/tests/component/all-tools-discovery.test.tsx @@ -14,7 +14,9 @@ const labels = { activeFilters: "Active filters", allFamilies: "All families", clearFilters: "Clear filters", + clearFavorites: "Clear favorites", commonWorkflows: "Common workflows", + favorites: "Favorites", filterByCategory: "Category", filterByExecution: "Execution", filterByInputType: "Input type", @@ -27,13 +29,19 @@ const labels = { inputUrlDomain: "URL/domain", noResults: "No results", noResultsSuggestion: "Try another keyword.", + noFavorites: "No favorites yet.", + noRecentTools: "Open a tool to see recents.", open: "Open", popularTags: "Popular tags", removeFilter: "Remove filter", showFilters: "Show filters", + closeFilters: "Done", clearRecentTools: "Clear recent tools", recentTools: "Recent tools", recentToolsPrivacy: "Stored locally.", + addFavorite: "Add to favorites", + removeFavorite: "Remove from favorites", + favoritesPrivacy: "Stored locally.", searchPlaceholder: "Search tools...", toolsLabel: "tools", useCaseEncode: "Encode/decode", @@ -67,6 +75,8 @@ const groups = [ description: "Format and validate JSON.", family: "data-formats", familyLabel: "Data formats", + searchKeywords: ["pretty json", "format payload"], + localizedAliases: ["JSON 格式化"], tags: ["json", "data-formats"], capabilities: ["browser-local", "file-input", "offline-capable", "pipeline-ready"], }, @@ -77,6 +87,7 @@ const groups = [ description: "Encode and decode Base64.", family: "encoders-decoders", familyLabel: "Encoders", + searchKeywords: ["base 64", "decode base64"], tags: ["base64"], capabilities: ["browser-local", "offline-capable", "pipeline-ready"], }, @@ -134,8 +145,29 @@ function renderDiscovery() { ) } +function installMemoryStorage() { + const store = new Map() + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size + }, + }, + }) +} + describe("AllToolsDiscovery", () => { beforeEach(() => { + installMemoryStorage() window.history.replaceState(null, "", "/en/all-tools") }) @@ -186,4 +218,52 @@ describe("AllToolsDiscovery", () => { const badges = within(card).getAllByText(/Data formats|Browser-local|File input|Offline capable|Pipeline ready/) expect(badges.length).toBeLessThanOrEqual(3) }) + + it("uses a mobile filter drawer with counts, close, and clear controls", () => { + renderDiscovery() + + fireEvent.click(screen.getByRole("button", { name: /Show filters/ })) + + const drawer = screen.getByRole("dialog", { name: "Show filters" }) + expect(within(drawer).getByText(/4 tools/)).toBeInTheDocument() + fireEvent.click(within(drawer).getAllByRole("button", { name: "File" })[0]) + expect(within(drawer).getByText(/1 tools/)).toBeInTheDocument() + + fireEvent.click(within(drawer).getByRole("button", { name: "Clear filters" })) + expect(within(drawer).getByText(/4 tools/)).toBeInTheDocument() + + const doneButtons = within(drawer).getAllByRole("button", { name: "Done" }) + fireEvent.click(doneButtons[doneButtons.length - 1]) + expect(screen.queryByRole("dialog", { name: "Show filters" })).not.toBeInTheDocument() + }) + + it("favorites and clears tools using only local tool IDs", () => { + renderDiscovery() + + fireEvent.click(screen.getByRole("button", { name: "Add to favorites: JSON Formatter" })) + + expect(screen.getByRole("button", { name: "Remove from favorites: JSON Formatter" })).toBeInTheDocument() + expect(screen.getByRole("link", { name: "JSON Formatter" })).toHaveAttribute("href", "/en/json-formatter") + + const raw = window.localStorage.getItem("byteflow:tools:favorites") ?? "" + expect(JSON.parse(raw)).toEqual([ + expect.objectContaining({ toolKey: "json_formatter", updatedAt: expect.any(String) }), + ]) + expect(raw).not.toMatch(/input|output|payload|secret|token|file|log|url/i) + + fireEvent.click(screen.getByRole("button", { name: "Clear favorites" })) + expect(JSON.parse(window.localStorage.getItem("byteflow:tools:favorites") ?? "[]")).toEqual([]) + }) + + it("matches localized and task search aliases without persisting search text", () => { + renderDiscovery() + + fireEvent.change(screen.getByRole("textbox", { name: "Search tools" }), { target: { value: "JSON 格式化" } }) + expect(screen.getByText("JSON Formatter")).toBeInTheDocument() + expect(screen.queryByText("Base64 Encode/Decode")).not.toBeInTheDocument() + expect(window.location.search).toBe("") + + fireEvent.change(screen.getByRole("textbox", { name: "Search tools" }), { target: { value: "format payload" } }) + expect(screen.getByText("JSON Formatter")).toBeInTheDocument() + }) }) diff --git a/tests/component/route-page-chrome.test.tsx b/tests/component/route-page-chrome.test.tsx index 902266ab..fc26e9fc 100644 --- a/tests/component/route-page-chrome.test.tsx +++ b/tests/component/route-page-chrome.test.tsx @@ -51,6 +51,9 @@ const mocks = vi.hoisted(() => { install_guide: "Install guide", install_inline_description: "Install description", install_inline_title: "Install Byteflow", + add_favorite: "Add to favorites", + remove_favorite: "Remove from favorites", + favorites_local_only: "Favorite IDs stay local.", }, } @@ -77,6 +80,9 @@ vi.mock("@/core/i18n/lang-provider", () => ({ vi.mock("@/core/storage/tool-discovery-state", () => ({ recordRecentToolKey: vi.fn(), + readFavoriteToolKeys: vi.fn(() => []), + toggleFavoriteToolKey: vi.fn(() => ["json_formatter"]), + TOOL_DISCOVERY_UPDATED_EVENT: "byteflow:tool-discovery-updated", })) vi.mock("@/core/seo/components/related-tools", () => ({ diff --git a/tests/guards/sensitive-storage-audit.test.ts b/tests/guards/sensitive-storage-audit.test.ts index 32203500..59d92188 100644 --- a/tests/guards/sensitive-storage-audit.test.ts +++ b/tests/guards/sensitive-storage-audit.test.ts @@ -100,6 +100,21 @@ describe("sensitive storage audit", () => { expect(handleSavePreset).not.toContain("safeClipboardWrite") }) + it("limits favorites and recents persistence to tool IDs and timestamps", () => { + const discoveryState = read("src/core/storage/tool-discovery-state.ts") + + expect(discoveryState).toContain('toolKey: string') + expect(discoveryState).toContain('updatedAt: string') + expect(discoveryState).toContain('const FAVORITE_TOOL_KEYS_STORAGE_KEY = "byteflow:tools:favorites"') + expect(discoveryState).toContain('const RECENT_TOOL_KEYS_STORAGE_KEY = "byteflow:tools:recent"') + expect(discoveryState).not.toMatch(/\b(input|output|payload|token|secret|fileContent|logBody|fullUrl)\b/) + + const commandPalette = read("src/components/layout/command-palette.tsx") + expect(commandPalette).toContain("queryLength: trimmed.length") + expect(commandPalette).not.toContain("search_query") + expect(commandPalette).not.toContain("query: trimmed") + }) + it("keeps CacheStorage away from non-GET, third-party, and sensitive query requests", () => { const serviceWorker = read("public/sw.js") diff --git a/tests/unit/command-search.test.ts b/tests/unit/command-search.test.ts index 9c3a6cb0..187d1444 100644 --- a/tests/unit/command-search.test.ts +++ b/tests/unit/command-search.test.ts @@ -1,5 +1,68 @@ import { describe, expect, it } from "vitest" import { scoreCommandSearch, scoreToolSearch, type ToolSearchDocument } from "@/core/search/command-search" +import { getCommandSearchToolByKey } from "@/generated/command-search-index" +import { TRANSLATIONS } from "@/core/i18n/translations/catalog" +import { LOCALES, type Locale } from "@/core/i18n/i18n" + +function buildRealToolDocument(toolKey: string, locale: Locale = "en"): ToolSearchDocument { + const tool = getCommandSearchToolByKey(toolKey) + if (!tool) throw new Error(`Missing generated command search tool: ${toolKey}`) + const localizedTools = TRANSLATIONS[locale].tools as Record + const englishTools = TRANSLATIONS.en.tools as Record + const translation = localizedTools[toolKey] + const english = englishTools[toolKey] + + return { + title: translation?.title ?? english?.title ?? toolKey, + description: [translation?.description, english?.description].filter(Boolean).join(" "), + slug: tool.slug, + key: tool.key, + family: tool.family, + keywords: tool.keywords, + aliases: tool.aliases, + searchKeywords: tool.searchKeywords, + tags: tool.tags, + capabilities: tool.capabilities, + locale, + } +} + +function rankRealTools(query: string, locale: Locale = "en"): string[] { + const keys = [ + "json_formatter", + "jwt_decoder", + "base64_encode_decode", + "regex_tester", + "curl_to_code", + "openapi_viewer", + "log_scrubber", + "har_viewer_sanitizer", + "youtube_thumbnail_grabber", + "open_graph_meta_generator", + "qr_code_generator", + "password_generator", + "image_resizer", + "url_parser", + "url_encode_decode", + "hash_generator", + "certificate_decoder", + "uuid_generator", + ] + + return keys + .map((toolKey, index) => ({ + index, + score: scoreToolSearch(buildRealToolDocument(toolKey, locale), query, { locale }), + toolKey, + })) + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score || left.index - right.index) + .map((item) => item.toolKey) +} + +function expectTop3(query: string, expectedToolKey: string, locale: Locale = "en") { + expect(rankRealTools(query, locale).slice(0, 3), `${locale} query: ${query}`).toContain(expectedToolKey) +} describe("scoreCommandSearch", () => { it("prioritizes exact and prefix matches over loose token matches", () => { @@ -85,4 +148,78 @@ describe("scoreCommandSearch", () => { expect(scoreToolSearch(tools.securityHeaders, "安全头")).toBeGreaterThan(0) expect(scoreToolSearch(tools.cron, "cron explain")).toBeGreaterThan(0) }) + + it("returns intended tools in the top 3 for common English task queries", () => { + const cases: Array<[string, string]> = [ + ["json", "json_formatter"], + ["format payload", "json_formatter"], + ["jwt", "jwt_decoder"], + ["decode token", "jwt_decoder"], + ["base64", "base64_encode_decode"], + ["base 64", "base64_encode_decode"], + ["regex", "regex_tester"], + ["curl", "curl_to_code"], + ["openapi", "openapi_viewer"], + ["log", "log_scrubber"], + ["sanitize HAR", "har_viewer_sanitizer"], + ["thumbnail", "youtube_thumbnail_grabber"], + ["seo", "open_graph_meta_generator"], + ["make QR", "qr_code_generator"], + ["password", "password_generator"], + ["resize image", "image_resizer"], + ] + + for (const [query, expectedToolKey] of cases) { + expectTop3(query, expectedToolKey) + } + }) + + it("supports localized and mixed-language queries across supported locales", () => { + const cases: Partial>> = { + "zh-CN": [ + ["JSON 格式化", "json_formatter"], + ["JWT 解码", "jwt_decoder"], + ["Base64 解码", "base64_encode_decode"], + ["正则表达式测试", "regex_tester"], + ["日志 脱敏", "log_scrubber"], + ["二维码 生成", "qr_code_generator"], + ], + "zh-TW": [ + ["JSON 格式化", "json_formatter"], + ["JWT 解碼", "jwt_decoder"], + ["Base64 解碼", "base64_encode_decode"], + ["正則表達式測試", "regex_tester"], + ], + ja: [ + ["JSON フォーマット", "json_formatter"], + ["JWT デコード", "jwt_decoder"], + ["Base64 デコード", "base64_encode_decode"], + ["正規表現 テスト", "regex_tester"], + ], + ko: [ + ["JSON 포맷", "json_formatter"], + ["JWT 디코딩", "jwt_decoder"], + ["Base64 디코딩", "base64_encode_decode"], + ["정규식 테스트", "regex_tester"], + ], + de: [ + ["JSON formatieren", "json_formatter"], + ["JWT dekodieren", "jwt_decoder"], + ["Base64 dekodieren", "base64_encode_decode"], + ["Regex testen", "regex_tester"], + ], + fr: [ + ["formater JSON", "json_formatter"], + ["décoder JWT", "jwt_decoder"], + ["décoder Base64", "base64_encode_decode"], + ["tester regex", "regex_tester"], + ], + } + + for (const locale of LOCALES) { + for (const [query, expectedToolKey] of cases[locale] ?? []) { + expectTop3(query, expectedToolKey, locale) + } + } + }) }) diff --git a/tests/unit/tool-discovery-state.test.ts b/tests/unit/tool-discovery-state.test.ts index ef174eeb..43d75f1e 100644 --- a/tests/unit/tool-discovery-state.test.ts +++ b/tests/unit/tool-discovery-state.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it } from "vitest" import { clearHistory, clearRecentToolKeys, + readFavoriteToolRecords, readFavoriteToolKeys, + readRecentToolRecords, readRecentToolKeys, recordRecentToolKey, toggleFavoriteToolKey, @@ -21,8 +23,13 @@ function installMemoryStorage() { removeItem: (key: string) => { store.delete(key) }, + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size + }, }, }) + return store } describe("tool-discovery-state", () => { @@ -35,6 +42,8 @@ describe("tool-discovery-state", () => { toggleFavoriteToolKey("json_formatter") expect(readFavoriteToolKeys()).toEqual(["json_formatter"]) + expect(readFavoriteToolRecords()[0]).toMatchObject({ toolKey: "json_formatter" }) + expect(Date.parse(readFavoriteToolRecords()[0].updatedAt)).not.toBeNaN() toggleFavoriteToolKey("list_randomizer") expect(readFavoriteToolKeys()).toEqual(["list_randomizer", "json_formatter"]) @@ -56,6 +65,31 @@ describe("tool-discovery-state", () => { expect(readRecentToolKeys()).toHaveLength(10) expect(readRecentToolKeys()[0]).toBe("tool_11") + expect(readRecentToolRecords()[0]).toMatchObject({ toolKey: "tool_11" }) + expect(Date.parse(readRecentToolRecords()[0].updatedAt)).not.toBeNaN() + }) + + it("migrates legacy key arrays without storing payload-shaped fields", () => { + window.localStorage.setItem("byteflow:tools:favorites", JSON.stringify(["json_formatter", "jwt_decoder"])) + window.localStorage.setItem("byteflow:tools:recent", JSON.stringify(["base64_encode_decode"])) + + expect(readFavoriteToolKeys()).toEqual(["json_formatter", "jwt_decoder"]) + expect(readRecentToolKeys()).toEqual(["base64_encode_decode"]) + + recordRecentToolKey("json_formatter") + toggleFavoriteToolKey("base64_encode_decode") + + const favoriteRaw = window.localStorage.getItem("byteflow:tools:favorites") ?? "" + const recentRaw = window.localStorage.getItem("byteflow:tools:recent") ?? "" + const combined = `${favoriteRaw}\n${recentRaw}` + + expect(JSON.parse(favoriteRaw)).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolKey: "base64_encode_decode", updatedAt: expect.any(String) }), + ])) + expect(JSON.parse(recentRaw)).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolKey: "json_formatter", updatedAt: expect.any(String) }), + ])) + expect(combined).not.toMatch(/input|output|payload|token|secret|url|file|log/i) }) it("clears recent tools without clearing favorites", () => { From 650ef8f2202255db963aa36867e6e0d460f6b42c Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Tue, 23 Jun 2026 22:14:39 -0700 Subject: [PATCH 2/2] Fix mobile All Tools drawer layering --- .../tool-discovery/all-tools-discovery.tsx | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/src/features/tool-discovery/all-tools-discovery.tsx b/src/features/tool-discovery/all-tools-discovery.tsx index 00824b36..e4c5e6f6 100644 --- a/src/features/tool-discovery/all-tools-discovery.tsx +++ b/src/features/tool-discovery/all-tools-discovery.tsx @@ -765,7 +765,7 @@ export function AllToolsDiscovery({ aria-label={labels.filterSearch} /> - @@ -795,49 +795,6 @@ export function AllToolsDiscovery({ {filterPanel} - {showMobileFilters ? ( -
- -
-
- {filterPanel} -
{tagFilterPanel}
-
- - -
-
- - - ) : null} -
{tagFilterPanel}
@@ -869,6 +826,49 @@ export function AllToolsDiscovery({ + {showMobileFilters ? ( +
+ +
+
+ {filterPanel} +
{tagFilterPanel}
+
+ + +
+
+ + + ) : null} + {localToolsPanel} {workflows.length > 0 ? (