mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eee4be977 | ||
|
|
bb0f7b860b | ||
|
|
17d67bed51 |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user