Compare commits

...
Author SHA1 Message Date
Reece 2eee4be977 tsc 2026-01-05 17:55:38 +00:00
Reece bb0f7b860b Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into chore/v2/improve-search 2026-01-02 03:02:33 +00:00
Reece 17d67bed51 Subtool search for convert 2026-01-02 03:01:14 +00:00
11 changed files with 1022 additions and 89 deletions
@@ -1279,6 +1279,8 @@ cbzOptions = "CBZ to PDF Options"
optimizeForEbook = "Optimize PDF for ebook readers (uses Ghostscript)"
cbzOutputOptions = "PDF to CBZ Options"
cbzDpi = "DPI for image rendering"
subtoolName = "Convert from {{from}} to {{to}}"
subtoolDescription = "Convert {{from}} files to {{to}} format"
cbrOptions = "CBR Options"
cbrOutputOptions = "PDF to CBR Options"
cbrDpi = "DPI for image rendering"
@@ -9,13 +9,14 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import ThumbUpRoundedIcon from '@mui/icons-material/ThumbUpRounded';
import Badge from '@app/components/shared/Badge';
import { RankedSearchItem } from '@app/utils/toolSearch';
import '@app/components/tools/ToolPanel.css';
import DetailedToolItem from '@app/components/tools/fullscreen/DetailedToolItem';
import CompactToolItem from '@app/components/tools/fullscreen/CompactToolItem';
import { useFavoriteToolItems } from '@app/hooks/tools/useFavoriteToolItems';
interface FullscreenToolListProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: RankedSearchItem[];
searchQuery: string;
showDescriptions: boolean;
selectedToolKey: string | null;
@@ -34,7 +35,14 @@ const FullscreenToolList = ({
const { t } = useTranslation();
const { toolRegistry, favoriteTools } = useToolWorkflow();
const { sections, searchGroups } = useToolSections(filteredTools, searchQuery);
// Filter to only parent tools for category grouping (sub-tools not shown in fullscreen mode)
const parentToolsOnly = useMemo(() =>
filteredTools
.filter(item => item.type === 'parent')
.map(item => ({ item: item.item as [ToolId, ToolRegistryEntry], matchedText: item.matchedText }))
, [filteredTools]);
const { sections, searchGroups } = useToolSections(parentToolsOnly, searchQuery);
const tooltipPortalTarget = typeof document !== 'undefined' ? document.body : undefined;
@@ -10,6 +10,7 @@ import { useFocusTrap } from '@app/hooks/useFocusTrap';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { Tooltip } from '@app/components/shared/Tooltip';
import { RankedSearchItem } from '@app/utils/toolSearch';
import '@app/components/tools/ToolPanel.css';
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
@@ -17,7 +18,7 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface FullscreenToolSurfaceProps {
searchQuery: string;
toolRegistry: Partial<Record<ToolId, ToolRegistryEntry>>;
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: RankedSearchItem[];
selectedToolKey: string | null;
showDescriptions: boolean;
matchedTextMap: Map<string, string>;
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import React, { useState, useMemo } from 'react';
import { Box, Stack, Button, Collapse, ActionIcon } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import ToolButton from '@app/components/tools/toolPicker/ToolButton';
@@ -7,61 +7,280 @@ import { useTranslation } from 'react-i18next';
import { useToolSections } from '@app/hooks/useToolSections';
import SubcategoryHeader from '@app/components/tools/shared/SubcategoryHeader';
import NoToolsFound from '@app/components/tools/shared/NoToolsFound';
import LocalIcon from '@app/components/shared/LocalIcon';
import FitText from '@app/components/shared/FitText';
import { ToolIcon } from '@app/components/shared/ToolIcon';
import { RankedSearchItem } from '@app/utils/toolSearch';
import { parseSubToolId, SubToolId } from '@app/types/subtool';
import { Tooltip } from '@app/components/shared/Tooltip';
import "@app/components/tools/toolPicker/ToolPicker.css";
interface SearchResultsProps {
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
filteredTools: RankedSearchItem[];
onSelect: (id: string) => void;
searchQuery?: string;
}
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect, searchQuery }) => {
const { t } = useTranslation();
const { searchGroups } = useToolSections(filteredTools, searchQuery);
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set());
// Create a map of matched text for quick lookup
const matchedTextMap = new Map<string, string>();
if (filteredTools && Array.isArray(filteredTools)) {
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) matchedTextMap.set(id, matchedText);
// Check if there are any sub-tools in results
const hasSubTools = filteredTools.some(item => item.type === 'subtool');
// Separate parent tools for category grouping
const parentToolsOnly = filteredTools
.filter(item => item.type === 'parent')
.map(item => ({ item: item.item as [ToolId, ToolRegistryEntry], matchedText: item.matchedText }));
const { searchGroups } = useToolSections(parentToolsOnly, searchQuery);
// Count parent tools
const parentCount = parentToolsOnly.length;
const shouldStartCollapsed = (parentId: string, subToolCount: number) => {
// With 1-5 parents, always expand
if (parentCount <= 5) return false;
// With >5 parents, collapse Convert tool or tools with >10 sub-tools
return parentId === 'convert' || subToolCount > 10;
};
// Group results by parent
const groupedResults = useMemo(() => {
const groups = new Map<string, { parent: RankedSearchItem; subTools: RankedSearchItem[] }>();
for (const result of filteredTools) {
if (result.type === 'parent') {
const [id] = result.item;
if (!groups.has(id as string)) {
groups.set(id as string, { parent: result, subTools: [] });
}
} else {
// Sub-tool - find its parent
const [subToolId] = result.item;
const { parentId } = parseSubToolId(subToolId as SubToolId);
if (!groups.has(parentId)) {
// Parent not in results yet, create placeholder
groups.set(parentId, { parent: null as any, subTools: [] });
}
groups.get(parentId)!.subTools.push(result);
}
}
return Array.from(groups.values()).filter(g => g.parent); // Remove groups without parent
}, [filteredTools]);
// Helper to handle sub-tool selection
const handleSubToolSelect = (subToolId: string) => {
const { parentId, params } = parseSubToolId(subToolId as SubToolId);
const [from, to] = params.split('-to-');
// Navigate to parent tool
onSelect(parentId);
// Set URL params for pre-selection
const searchParams = new URLSearchParams({ from, to });
window.history.replaceState(
{},
'',
`${window.location.pathname}?${searchParams.toString()}`
);
};
const toggleParentExpanded = (parentId: string) => {
setExpandedParents(prev => {
const next = new Set(prev);
if (next.has(parentId)) {
next.delete(parentId);
} else {
next.add(parentId);
}
return next;
});
};
if (filteredTools.length === 0) {
return <NoToolsFound />;
}
// If there are sub-tools, render with grouping
if (hasSubTools) {
return (
<Stack p="sm" gap="xs" className="tool-picker-scrollable">
{groupedResults.map((group) => {
const [id, entry] = group.parent.item;
const tool = entry as ToolRegistryEntry;
const matchedText = group.parent.matchedText;
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
);
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
) : undefined;
const hasSubTools = group.subTools.length > 0;
const startCollapsed = shouldStartCollapsed(id as string, group.subTools.length);
// If should start collapsed: expanded only if user clicked to expand
// If should start expanded: collapsed only if user clicked to collapse
const isExpanded = startCollapsed
? expandedParents.has(id as string)
: !expandedParents.has(id as string);
return (
<Box key={id as string}>
{/* Parent tool */}
<Box style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Box style={{ flex: 1 }}>
<ToolButton
id={id as ToolId}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
</Box>
{hasSubTools && (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => toggleParentExpanded(id as string)}
style={{ color: 'var(--tools-text-and-icon-color)' }}
>
<LocalIcon
icon={isExpanded ? 'expand-less' : 'expand-more'}
width="1.2rem"
height="1.2rem"
/>
</ActionIcon>
)}
</Box>
{/* Sub-tools */}
{hasSubTools && (
<Collapse in={isExpanded}>
<Stack gap="xs" ml="md" mt="xs">
{group.subTools.map((subResult) => {
const [subId, subEntry] = subResult.item;
const displayEntry = subEntry as any;
const available = displayEntry?.available !== false;
const disabledMessage = t('toolPanel.fullscreen.unavailable', 'Disabled by server administrator:');
const disabledTooltipContent = (
<span>
<strong>{disabledMessage}</strong>{' '}
{displayEntry?.description || ''}
</span>
);
const button = (
<Button
key={subId as string}
variant="subtle"
size="sm"
radius="md"
aria-disabled={!available}
onClick={() => {
if (!available) return;
handleSubToolSelect(subId as string);
}}
className="tool-button"
leftSection={
<div
style={{
display: 'flex',
alignItems: 'center',
paddingLeft: '8px',
color: "var(--tools-text-and-icon-color)"
}}
>
<LocalIcon
icon="subdirectory-arrow-right"
width="1rem"
height="1rem"
style={{ marginRight: '4px', opacity: 0.6 }}
/>
<ToolIcon icon={displayEntry.icon} marginRight="0" opacity={available ? 1 : 0.4} />
</div>
}
fullWidth
justify="flex-start"
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible',
cursor: available ? undefined : 'not-allowed'
},
label: { overflow: 'visible' }
}}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<FitText
text={displayEntry.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: available ? 1 : 0.4 }}
/>
</div>
</Button>
);
return available ? button : (
<Tooltip content={disabledTooltipContent}>
<div style={{ opacity: 1 }}>{button}</div>
</Tooltip>
);
})}
</Stack>
</Collapse>
)}
</Box>
);
})}
{/* Global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 200 }} />
</Stack>
);
}
// No sub-tools - use traditional category grouping
if (searchGroups.length === 0) {
return <NoToolsFound />;
}
return (
<Stack p="sm" gap="xs"
className="tool-picker-scrollable">
{searchGroups.map(group => (
<Box key={group.subcategoryId} w="100%">
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => {
const matchedText = matchedTextMap.get(id);
// Check if the match was from synonyms and show the actual synonym that matched
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
);
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
) : undefined;
<Stack p="sm" gap="xs" className="tool-picker-scrollable">
{searchGroups
.filter(group => group.subcategoryId !== undefined)
.map(group => (
<Box key={group.subcategoryId} w="100%">
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => {
const matched = parentToolsOnly.find(item => item.item[0] === id);
const matchedText = matched?.matchedText;
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
);
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
) : undefined;
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
);
})}
</Stack>
</Box>
))}
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
);
})}
</Stack>
</Box>
))}
{/* Global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 200 }} />
</Stack>
@@ -10,6 +10,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from '@app/styles/rainbow.module.css';
import { ActionIcon, ScrollArea } from '@mantine/core';
import { ToolId } from '@app/types/toolId';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { useIsMobile } from '@app/hooks/useIsMobile';
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
@@ -90,15 +91,26 @@ export default function ToolPanel() {
return '18.5rem';
};
const parentFilteredTools = useMemo(
() => filteredTools
.filter(item => item.type === 'parent')
.map(item => ({
item: item.item as [ToolId, ToolRegistryEntry],
matchedText: item.matchedText
})),
[filteredTools]
);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
parentFilteredTools.forEach(({ item, matchedText }) => {
const [id] = item;
if (matchedText) {
map.set(id, matchedText);
map.set(id as string, matchedText);
}
});
return map;
}, [filteredTools]);
}, [parentFilteredTools]);
return (
<div
@@ -173,7 +185,7 @@ export default function ToolPanel() {
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
filteredTools={parentFilteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
</div>
@@ -5,8 +5,11 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { TextInput } from "@app/components/shared/TextInput";
import "@app/components/tools/toolPicker/ToolPicker.css";
import { rankByFuzzy, idToWords } from "@app/utils/fuzzySearch";
import { ToolId } from "@app/types/toolId";
import { parseSubToolId, SubToolId } from "@app/types/subtool";
import { filterToolRegistryWithSubTools } from "@app/utils/toolSearch";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { Tooltip } from "@app/components/shared/Tooltip";
interface ToolSearchProps {
value: string;
@@ -19,6 +22,8 @@ interface ToolSearchProps {
hideIcon?: boolean;
onFocus?: () => void;
autoFocus?: boolean;
conversionEndpointStatus?: Record<string, boolean>;
conversionEndpointsLoading?: boolean;
}
const ToolSearch = ({
@@ -32,23 +37,40 @@ const ToolSearch = ({
hideIcon = false,
onFocus,
autoFocus = false,
conversionEndpointStatus,
conversionEndpointsLoading,
}: ToolSearchProps) => {
const { t } = useTranslation();
const {
conversionEndpointStatus: workflowConversionStatus,
conversionEndpointsLoading: workflowConversionLoading
} = useToolWorkflow();
const effectiveEndpointStatus = conversionEndpointStatus ?? workflowConversionStatus;
const effectiveEndpointsLoading = conversionEndpointsLoading ?? workflowConversionLoading;
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredTools = useMemo(() => {
if (!value.trim()) return [];
const entries = Object.entries(toolRegistry).filter(([id]) => !(mode === "dropdown" && id === selectedToolKey));
const ranked = rankByFuzzy(entries, value, [
([key]) => idToWords(key),
([, v]) => v.name,
([, v]) => v.description,
([, v]) => v.synonyms?.join(' ') || '',
]).slice(0, 6);
return ranked.map(({ item: [id, tool] }) => ({ id, tool }));
}, [value, toolRegistry, mode, selectedToolKey]);
// Filter out selected tool if in dropdown mode
const filteredRegistry = mode === "dropdown"
? Object.fromEntries(Object.entries(toolRegistry).filter(([id]) => id !== selectedToolKey))
: toolRegistry;
// Use enhanced search with sub-tools
const ranked = filterToolRegistryWithSubTools(
filteredRegistry,
value,
t,
effectiveEndpointStatus,
effectiveEndpointsLoading
);
// Limit total results to avoid overwhelming UI
return ranked.slice(0, 12);
}, [value, toolRegistry, mode, selectedToolKey, t, effectiveEndpointStatus, effectiveEndpointsLoading]);
const handleSearchChange = (searchValue: string) => {
onChange(searchValue);
@@ -57,6 +79,24 @@ const ToolSearch = ({
}
};
const handleSubToolSelect = (subToolId: string) => {
const { parentId, params } = parseSubToolId(subToolId as SubToolId);
const [from, to] = params.split('-to-');
// Navigate to parent tool
onToolSelect?.(parentId);
// Set URL params for pre-selection
const searchParams = new URLSearchParams({ from, to });
window.history.replaceState(
{},
'',
`${window.location.pathname}?${searchParams.toString()}`
);
setDropdownOpen(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -124,31 +164,81 @@ const ToolSearch = ({
}}
>
<Stack gap="xs" style={{ padding: "8px" }}>
{filteredTools.map(({ id, tool }) => (
<Button
key={id}
variant="subtle"
onClick={() => {
onToolSelect?.(id as ToolId);
setDropdownOpen(false);
}}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
fullWidth
justify="flex-start"
style={{
borderRadius: "6px",
color: "var(--tools-text-and-icon-color)",
padding: "8px 12px",
}}
>
<div style={{ textAlign: "left" }}>
<div style={{ fontWeight: 500 }}>{tool.name}</div>
<Text size="xs" c="dimmed" style={{ marginTop: "2px" }}>
{tool.description}
</Text>
</div>
</Button>
))}
{filteredTools.map(({ type, item: [id, entry] }) => {
const isSubTool = type === 'subtool';
const displayEntry = entry as any;
const available = displayEntry?.available !== false;
const disabledMessage = t('toolPanel.fullscreen.unavailable', 'Disabled by server administrator:');
const disabledTooltipContent = (
<span>
<strong>{disabledMessage}</strong>{' '}
{displayEntry?.description || ''}
</span>
);
const button = (
<Button
key={id}
variant="subtle"
aria-disabled={!available}
onClick={() => {
if (!available) return;
if (isSubTool) {
handleSubToolSelect(id as string);
} else {
onToolSelect?.(id as ToolId);
setDropdownOpen(false);
}
}}
leftSection={
<div
style={{
display: 'flex',
alignItems: 'center',
paddingLeft: isSubTool ? '8px' : '0',
color: "var(--tools-text-and-icon-color)"
}}
>
{isSubTool && (
<LocalIcon
icon="subdirectory-arrow-right"
width="1rem"
height="1rem"
style={{ marginRight: '4px', opacity: 0.6 }}
/>
)}
{displayEntry.icon}
</div>
}
fullWidth
justify="flex-start"
style={{
borderRadius: "6px",
color: "var(--tools-text-and-icon-color)",
padding: "8px 12px",
paddingLeft: isSubTool ? '4px' : '12px',
cursor: available ? undefined : 'not-allowed',
}}
>
<div style={{ textAlign: "left" }}>
<div style={{ fontWeight: isSubTool ? 400 : 500, opacity: available ? 1 : 0.4 }}>
{displayEntry.name}
</div>
{!isSubTool && displayEntry.description && (
<Text size="xs" c="dimmed" style={{ marginTop: "2px", opacity: available ? 1 : 0.4 }}>
{displayEntry.description}
</Text>
)}
</div>
</Button>
);
return available ? button : (
<Tooltip content={disabledTooltipContent}>
<div style={{ opacity: 1 }}>{button}</div>
</Tooltip>
);
})}
</Stack>
</div>
)}
@@ -4,6 +4,7 @@
*/
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
import { PageEditorFunctions } from '@app/types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
@@ -11,7 +12,7 @@ import { useNavigationActions, useNavigationState } from '@app/contexts/Navigati
import { ToolId, isValidToolId } from '@app/types/toolId';
import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '@app/types/workbench';
import { useNavigationUrlSync } from '@app/hooks/useUrlSync';
import { filterToolRegistryByQuery } from '@app/utils/toolSearch';
import { filterToolRegistryWithSubTools, RankedSearchItem } from '@app/utils/toolSearch';
import { useToolHistory } from '@app/hooks/tools/useUserToolActivity';
import {
ToolWorkflowState,
@@ -21,6 +22,8 @@ import {
import type { ToolPanelMode } from '@app/constants/toolPanel';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useToolRegistry } from '@app/contexts/ToolRegistryContext';
import { useMultipleEndpointsEnabled } from '@app/hooks/useEndpointConfig';
import { getAllConversionEndpointNames } from '@app/utils/subToolExpansion';
// State interface
// Types and reducer/state moved to './toolWorkflow/state'
@@ -70,7 +73,9 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
handleReaderToggle: () => void;
// Computed values
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
filteredTools: RankedSearchItem[]; // Filtered by search (includes sub-tools)
conversionEndpointStatus?: Record<string, boolean>;
conversionEndpointsLoading?: boolean;
isPanelVisible: boolean;
// Tool History
@@ -99,9 +104,14 @@ interface ToolWorkflowProviderProps {
}
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const { t } = useTranslation();
const [state, dispatch] = useReducer(toolWorkflowReducer, undefined, createInitialState);
const { preferences, updatePreference } = usePreferences();
// Fetch endpoint availability for all conversion sub-tools
const conversionEndpoints = useMemo(() => getAllConversionEndpointNames(), []);
const { endpointStatus, loading: conversionEndpointsLoading } = useMultipleEndpointsEnabled(conversionEndpoints);
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
@@ -323,10 +333,18 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
}, [setReaderMode]);
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
// Includes sub-tools for supported parent tools (e.g., Convert PDF to PNG)
// Only shows sub-tools for available endpoints
const filteredTools = useMemo(() => {
if (!toolRegistry) return [];
return filterToolRegistryByQuery(toolRegistry, state.searchQuery);
}, [toolRegistry, state.searchQuery]);
return filterToolRegistryWithSubTools(
toolRegistry,
state.searchQuery,
t,
endpointStatus,
conversionEndpointsLoading
);
}, [toolRegistry, state.searchQuery, t, endpointStatus, conversionEndpointsLoading]);
const isPanelVisible = useMemo(() =>
state.sidebarsVisible && !state.readerMode && state.leftPanelView !== 'hidden',
@@ -374,6 +392,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Computed
filteredTools,
conversionEndpointStatus: endpointStatus,
conversionEndpointsLoading,
isPanelVisible,
// Tool History
@@ -408,6 +428,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
handleBackToTools,
handleReaderToggle,
filteredTools,
endpointStatus,
conversionEndpointsLoading,
isPanelVisible,
favoriteTools,
toggleFavorite,
@@ -434,4 +456,4 @@ export function useToolWorkflow(): ToolWorkflowContextValue {
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
return context;
}
}
@@ -11,7 +11,7 @@ import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat,
import { detectFileExtension as detectFileExtensionUtil } from '@app/utils/fileUtils';
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useEffect } from 'react';
export interface ConvertParameters extends BaseParameters {
fromExtension: string;
@@ -382,6 +382,30 @@ export const useConvertParameters = (): ConvertParametersHook => {
}
}, [baseHook.setParameters]);
// Read URL params on mount for sub-tool pre-selection
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const fromParam = urlParams.get('from');
const toParam = urlParams.get('to');
if (fromParam && toParam) {
// Validate that this is a valid conversion
const availableTargets = CONVERSION_MATRIX[fromParam] || [];
if (availableTargets.includes(toParam)) {
baseHook.setParameters(prev => ({
...prev,
fromExtension: fromParam,
toExtension: toParam,
isSmartDetection: false,
smartDetectionType: 'none'
}));
// Clear URL params after reading
window.history.replaceState({}, '', window.location.pathname);
}
}
}, []); // Only run on mount
return {
...baseHook,
getEndpoint,
+83
View File
@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { ToolId } from '@app/types/toolId';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
/**
* Represents a sub-tool entry that extends a parent tool with specific pre-configured options.
* Example: "Convert from PDF to PNG" is a sub-tool of the main "Convert" tool.
*/
export interface SubToolEntry {
/** Unique identifier in format "parentId:params", e.g., "convert:pdf-to-png" */
id: string;
/** The parent tool this sub-tool belongs to */
parentId: ToolId;
/** Display name, e.g., "Convert PDF to PNG" */
name: string;
/** Description of what this sub-tool does */
description: string;
/** Search terms for matching queries (extensions, labels, synonyms) */
searchTerms: string[];
/** Navigation parameters to pre-select options in parent tool */
navigationParams: Record<string, string>;
/** Icon to display (typically inherited from parent) */
icon: ReactNode;
/** Availability status (true/undefined = available, false = disabled) */
available?: boolean;
}
/**
* Type for sub-tool identifiers in format "parentId:params"
* Example: "convert:pdf-to-png"
*/
export type SubToolId = `${ToolId}:${string}`;
/**
* Union type for items that can appear in search results
*/
export type SearchableToolId = ToolId | SubToolId;
/**
* Represents an expanded tool entry that can be either a parent tool or sub-tool
*/
export interface ExpandedToolEntry {
/** Whether this is a parent tool or sub-tool */
type: 'parent' | 'subtool';
/** The tool or sub-tool identifier */
id: ToolId | string;
/** The actual tool or sub-tool data */
entry: ToolRegistryEntry | SubToolEntry;
}
/**
* Type guard to check if an ID is a sub-tool ID
*/
export function isSubToolId(id: string): id is SubToolId {
return id.includes(':');
}
/**
* Parse a sub-tool ID into its parent ID and parameters
* @param id Sub-tool ID in format "parentId:params"
* @returns Object with parentId and params string
* @example parseSubToolId("convert:pdf-to-png") // { parentId: "convert", params: "pdf-to-png" }
*/
export function parseSubToolId(id: SubToolId): { parentId: ToolId; params: string } {
const [parentId, params] = id.split(':', 2);
return { parentId: parentId as ToolId, params };
}
/**
* Type guard to check if an entry is a SubToolEntry
*/
export function isSubToolEntry(entry: ToolRegistryEntry | SubToolEntry): entry is SubToolEntry {
return 'parentId' in entry && 'navigationParams' in entry;
}
+246
View File
@@ -0,0 +1,246 @@
import { TFunction } from 'i18next';
import { ToolId } from '@app/types/toolId';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { SubToolEntry } from '@app/types/subtool';
import {
CONVERSION_MATRIX,
FROM_FORMAT_OPTIONS,
TO_FORMAT_OPTIONS,
EXTENSION_TO_ENDPOINT
} from '@app/constants/convertConstants';
/**
* Generate sub-tools for the Convert tool based on the conversion matrix.
* Creates one sub-tool for each valid FROM → TO conversion combination.
*
* @param t Translation function
* @param parentTool The parent Convert tool entry
* @param endpointAvailability Optional map of endpoint name -> enabled status
* @returns Array of sub-tool entries
*/
export function generateConvertSubTools(
t: TFunction,
parentTool: ToolRegistryEntry,
endpointAvailability?: Record<string, boolean>,
endpointAvailabilityLoading?: boolean
): SubToolEntry[] {
const subTools: SubToolEntry[] = [];
// Iterate through all source formats in the conversion matrix
for (const [fromExt, toExtensions] of Object.entries(CONVERSION_MATRIX)) {
// Skip special cases that are too generic for sub-tools
if (fromExt === 'any' || fromExt === 'image') {
continue;
}
// Get the label for the source format
const fromLabel = getFormatLabel(fromExt);
if (!fromLabel) continue;
// Create a sub-tool for each target format
for (const toExt of toExtensions) {
const toLabel = getFormatLabel(toExt);
if (!toLabel) continue;
// Check if endpoint is available (if availability map provided)
let available = true;
if (endpointAvailability) {
const endpointName = getConversionEndpointName(fromExt, toExt);
if (endpointName) {
const status = endpointAvailability[endpointName];
// While loading, stay optimistic; once loaded, mark unavailable when false
available = endpointAvailabilityLoading ? true : status !== false;
}
}
// Generate unique ID for this conversion
const subToolId = `convert:${fromExt}-to-${toExt}`;
// Create sub-tool entry
subTools.push({
id: subToolId,
parentId: 'convert',
name: t('convert.subtoolName', {
defaultValue: 'Convert from {{from}} to {{to}}',
from: fromLabel,
to: toLabel
}),
description: t('convert.subtoolDescription', {
defaultValue: 'Convert {{from}} files to {{to}} format',
from: fromLabel,
to: toLabel
}),
searchTerms: generateSearchTerms(t, fromExt, toExt, fromLabel, toLabel),
navigationParams: { from: fromExt, to: toExt },
icon: parentTool.icon,
available,
});
}
}
return subTools;
}
/**
* Get the display label for a file format extension.
* Looks up in FROM_FORMAT_OPTIONS and TO_FORMAT_OPTIONS.
*
* @param extension File extension (e.g., 'pdf', 'png', 'docx')
* @returns Display label (e.g., 'PDF', 'PNG', 'DOCX') or null if not found
*/
function getFormatLabel(extension: string): string | null {
// Check FROM options first
const fromOption = FROM_FORMAT_OPTIONS.find(opt => opt.value === extension);
if (fromOption) return fromOption.label;
// Check TO options
const toOption = TO_FORMAT_OPTIONS.find(opt => opt.value === extension);
if (toOption) return toOption.label;
// Fallback: capitalize extension
return extension ? extension.toUpperCase() : null;
}
/**
* Generate search terms for a conversion sub-tool.
* Includes: extensions, labels, and synonyms from translations.
*
* @param t Translation function
* @param fromExt Source extension
* @param toExt Target extension
* @param fromLabel Source label
* @param toLabel Target label
* @returns Array of search terms (lowercased)
*/
function generateSearchTerms(
t: TFunction,
fromExt: string,
toExt: string,
fromLabel: string,
toLabel: string
): string[] {
const terms = new Set<string>();
// Add extensions (lowercased)
terms.add(fromExt.toLowerCase());
terms.add(toExt.toLowerCase());
// Add labels (lowercased)
terms.add(fromLabel.toLowerCase());
terms.add(toLabel.toLowerCase());
// Add synonyms from translations
const fromSynonyms = getFormatSynonyms(t, fromExt);
const toSynonyms = getFormatSynonyms(t, toExt);
fromSynonyms.forEach(syn => terms.add(syn));
toSynonyms.forEach(syn => terms.add(syn));
// Add common variations
if (fromExt === 'jpeg') terms.add('jpg');
if (fromExt === 'jpg') terms.add('jpeg');
if (toExt === 'jpeg') terms.add('jpg');
if (toExt === 'jpg') terms.add('jpeg');
return Array.from(terms);
}
/**
* Get format synonyms from translation files.
* Looks up key like "convert.formats.png.synonyms" in translations.
*
* @param t Translation function
* @param extension File extension
* @returns Array of synonyms (lowercased)
*/
function getFormatSynonyms(t: TFunction, extension: string): string[] {
const key = `convert.formats.${extension}.synonyms`;
const synonymsString = t(key, { defaultValue: '' });
if (!synonymsString || synonymsString === key) {
return [];
}
return synonymsString
.split(',')
.map(s => s.trim().toLowerCase())
.filter(s => s.length > 0);
}
/**
* Get the endpoint name for a specific conversion.
* @param fromExt Source format extension
* @param toExt Target format extension
* @returns Endpoint name or null if not found
*/
export function getConversionEndpointName(fromExt: string, toExt: string): string | null {
const endpointMap = EXTENSION_TO_ENDPOINT[fromExt];
return endpointMap ? (endpointMap[toExt] || null) : null;
}
/**
* Get all unique endpoint names used by conversion sub-tools.
* Used to batch-fetch endpoint availability.
* @returns Array of unique endpoint names
*/
export function getAllConversionEndpointNames(): string[] {
const endpointNames = new Set<string>();
for (const [fromExt, toExtensions] of Object.entries(CONVERSION_MATRIX)) {
// Skip special cases
if (fromExt === 'any' || fromExt === 'image') {
continue;
}
for (const toExt of toExtensions) {
const endpointName = getConversionEndpointName(fromExt, toExt);
if (endpointName) {
endpointNames.add(endpointName);
}
}
}
return Array.from(endpointNames);
}
/**
* Check if a tool supports sub-tools.
* Currently only Convert tool is supported, but designed for extensibility.
*
* @param toolId The tool identifier
* @returns True if tool supports sub-tools
*/
export function toolSupportsSubTools(toolId: ToolId): boolean {
return toolId === 'convert';
// Future: Add more tools here (e.g., 'split', 'merge')
}
/**
* Generate sub-tools for a specific parent tool.
* Routes to the appropriate generator based on tool type.
*
* @param toolId Parent tool identifier
* @param tool Parent tool entry
* @param t Translation function
* @param endpointAvailability Optional map of endpoint name -> enabled status
* @returns Array of sub-tool entries
*/
export function generateSubToolsForTool(
toolId: ToolId,
tool: ToolRegistryEntry,
t: TFunction,
endpointAvailability?: Record<string, boolean>,
endpointAvailabilityLoading?: boolean
): SubToolEntry[] {
switch (toolId) {
case 'convert':
return generateConvertSubTools(t, tool, endpointAvailability, endpointAvailabilityLoading);
// Future: Add more generators here
// case 'split':
// return generateSplitSubTools(t, tool, endpointAvailability);
// case 'merge':
// return generateMergeSubTools(t, tool, endpointAvailability);
default:
return [];
}
}
+229 -3
View File
@@ -1,12 +1,21 @@
import { TFunction } from 'i18next';
import { ToolId } from "@app/types/toolId";
import { ToolRegistryEntry, ToolRegistry } from "@app/data/toolsTaxonomy";
import { SubToolEntry } from "@app/types/subtool";
import { scoreMatch, minScoreForQuery, normalizeForSearch } from "@app/utils/fuzzySearch";
import { toolSupportsSubTools, generateSubToolsForTool } from "@app/utils/subToolExpansion";
export interface RankedToolItem {
item: [ToolId, ToolRegistryEntry];
matchedText?: string;
}
export interface RankedSearchItem {
type: 'parent' | 'subtool';
item: [ToolId | string, ToolRegistryEntry | SubToolEntry];
matchedText?: string;
}
export function filterToolRegistryByQuery(
toolRegistry: Partial<ToolRegistry>,
query: string
@@ -90,9 +99,226 @@ export function filterToolRegistryByQuery(
for (const { id, tool, text } of fuzzyName) push(id as ToolId, tool, text);
for (const { id, tool, text } of fuzzySyn) push(id as ToolId, tool, text);
// No matches for a non-empty query -> return empty to avoid noisy fallbacks
if (ordered.length > 0) return ordered;
// Fallback: return everything unchanged
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
return [];
}
/**
* Enhanced search function that includes sub-tools in results.
* Sub-tools are dynamically generated and filtered based on query specificity.
*
* @param toolRegistry Registry of tools to search
* @param query Search query string
* @param t Translation function for generating sub-tool names
* @param endpointAvailability Optional map of endpoint name -> enabled status
* @returns Array of ranked search items (parent tools and sub-tools)
*/
export function filterToolRegistryWithSubTools(
toolRegistry: Partial<ToolRegistry>,
query: string,
t: TFunction,
endpointAvailability?: Record<string, boolean>,
endpointAvailabilityLoading?: boolean
): RankedSearchItem[] {
const trimmedQuery = query.trim();
const nq = normalizeForSearch(trimmedQuery);
const threshold = minScoreForQuery(trimmedQuery);
// For empty queries, return parent tools only (no sub-tools)
if (!trimmedQuery) {
return filterToolRegistryByQuery(toolRegistry, query).map(result => ({
type: 'parent' as const,
item: result.item,
matchedText: result.matchedText
}));
}
// Step 1: Perform normal tool search
const normalResults = filterToolRegistryByQuery(toolRegistry, query);
// Check if this is an exact substring match in the query
const isExactMatch = (tool: ToolRegistryEntry): boolean => {
const nameNorm = normalizeForSearch(tool.name || '');
if (nameNorm.includes(nq)) return true;
const syns = Array.isArray(tool.synonyms) ? tool.synonyms : [];
return syns.some(s => normalizeForSearch(s).includes(nq));
};
// Step 2: Expand sub-tools for tools that matched parent search
const expandedResults: RankedSearchItem[] = [];
const processedToolIds = new Set<string>();
const scoreSubTool = (subTool: SubToolEntry): number => {
const scores: number[] = [
scoreMatch(query, subTool.name || ''),
scoreMatch(query, subTool.description || '')
];
for (const term of subTool.searchTerms || []) {
scores.push(scoreMatch(query, term));
const termNorm = normalizeForSearch(term);
if (termNorm.includes(nq) || nq.includes(termNorm)) {
scores.push(100);
}
}
return Math.max(...scores.filter(s => Number.isFinite(s)));
};
const findMatchingSubTools = (subTools: SubToolEntry[]) => {
// Check if query specifies a specific conversion (e.g., "pdf to png", "from docx to pdf")
const conversionPattern = query.match(/(?:from\s+)?(\w+)\s+to\s+(\w+)/i);
const fromFormat = conversionPattern ? conversionPattern[1].toLowerCase() : null;
const toFormat = conversionPattern ? conversionPattern[2].toLowerCase() : null;
const fromOnlyMatch = query.match(/\bfrom\s+(\w+)/i);
const fromOnlyFormat = fromOnlyMatch ? fromOnlyMatch[1].toLowerCase() : null;
let scored: Array<{ subTool: SubToolEntry; score: number }> = [];
if (fromFormat && toFormat) {
scored = subTools
.filter(subTool => {
const subToolId = subTool.id.toLowerCase();
const match = subToolId.match(/convert:(\w+)-to-(\w+)/);
if (!match) return false;
const subFrom = match[1];
const subTo = match[2];
return subFrom.startsWith(fromFormat) && subTo.startsWith(toFormat);
})
.map(subTool => ({ subTool, score: scoreSubTool(subTool) }))
.filter(({ score }) => score >= threshold);
} else {
scored = subTools
.map(subTool => {
const baseScore = scoreSubTool(subTool);
let bonus = 0;
if (fromOnlyFormat) {
const subToolId = subTool.id.toLowerCase();
const match = subToolId.match(/convert:(\w+)-to-(\w+)/);
if (match) {
const subFrom = match[1];
if (subFrom.startsWith(fromOnlyFormat)) {
bonus = 15; // Nudge matching "from" format higher when no "to" specified
}
}
}
return { subTool, score: baseScore + bonus };
})
.filter(({ score }) => score >= threshold);
const toOnlyMatch = query.match(/\bto\s+(\w+)/i);
const toOnlyFormat = toOnlyMatch ? toOnlyMatch[1].toLowerCase() : null;
if (toOnlyFormat) {
scored = scored.sort((a, b) => {
const aIsToFormat = a.subTool.id.toLowerCase().endsWith(`-to-${toOnlyFormat}`);
const bIsToFormat = b.subTool.id.toLowerCase().endsWith(`-to-${toOnlyFormat}`);
if (aIsToFormat && !bIsToFormat) return -1;
if (!aIsToFormat && bIsToFormat) return 1;
return b.score - a.score;
});
}
}
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
// Limit results to avoid overwhelming UI
const limited = scored.slice(0, 15);
return limited;
};
for (const result of normalResults) {
const [toolId, tool] = result.item;
processedToolIds.add(toolId);
const hasExactMatch = isExactMatch(tool);
// Check if this tool supports sub-tools
if (!toolSupportsSubTools(toolId as ToolId)) {
// No sub-tools - keep the fuzzy ordering from normalResults
expandedResults.push({
type: 'parent',
item: result.item,
matchedText: result.matchedText
});
continue;
}
// Generate sub-tools dynamically
const subTools = generateSubToolsForTool(toolId as ToolId, tool, t, endpointAvailability, endpointAvailabilityLoading);
const matchingSubTools = findMatchingSubTools(subTools);
// If no sub-tools match and no exact match, skip this tool
// UNLESS this tool matched in the initial search (was in normalResults)
if (matchingSubTools.length === 0 && !hasExactMatch) {
// Tool matched parent search, so show it even without matching sub-tools
expandedResults.push({
type: 'parent',
item: result.item,
matchedText: result.matchedText
});
continue;
}
// Add parent tool
expandedResults.push({
type: 'parent',
item: result.item,
matchedText: result.matchedText
});
// Add matching sub-tools if any
if (matchingSubTools.length > 0) {
for (const { subTool } of matchingSubTools) {
expandedResults.push({
type: 'subtool',
item: [subTool.id, subTool],
matchedText: subTool.name
});
}
}
}
// Step 3: Check tools that didn't match parent search but might have matching sub-tools
const allEntries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
for (const [toolId, tool] of allEntries) {
// Skip if already processed or doesn't support sub-tools
if (processedToolIds.has(toolId) || !toolSupportsSubTools(toolId)) {
continue;
}
// Generate sub-tools dynamically
const subTools = generateSubToolsForTool(toolId, tool, t, endpointAvailability, endpointAvailabilityLoading);
const matchingSubTools = findMatchingSubTools(subTools);
// Only include if sub-tools match
if (matchingSubTools.length > 0) {
// Add parent first
expandedResults.push({
type: 'parent',
item: [toolId, tool],
matchedText: undefined
});
// Limit sub-tools to avoid overwhelming results
// Add matching sub-tools
for (const { subTool } of matchingSubTools) {
expandedResults.push({
type: 'subtool',
item: [subTool.id, subTool],
matchedText: subTool.name
});
}
}
}
return expandedResults;
}