Refactor to fix circular imports (#4700)

# Description of Changes
Refactors code to avoid circular imports everywhere and adds linting for
circular imports to ensure it doesn't happen again. Most changes are
around the tool registry, making it a provider, and splitting into tool
types to make it easier for things like Automate to only have access to
tools excluding itself.
This commit is contained in:
James Brunton 2025-10-21 14:53:18 +01:00 committed by GitHub
parent 3e23dc59b6
commit c9eee00d66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2272 additions and 352 deletions

View File

@ -1,9 +1,10 @@
// @ts-check
import eslint from '@eslint/js';
import globals from "globals";
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
@ -14,35 +15,37 @@ const nodeGlobs = [
];
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
ignores: [
'dist',
'node_modules',
'public',
],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: [
"dist", // Contains 3rd party code
"public", // Contains 3rd party code
],
},
{
rules: {
"@typescript-eslint/no-empty-object-type": [
"error",
'@typescript-eslint/no-empty-object-type': [
'error',
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-unused-vars': [
'error',
{
"args": "all", // All function args must be used (or explicitly ignored)
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
'args': 'all', // All function args must be used (or explicitly ignored)
'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored)
'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore
'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
@ -65,4 +68,21 @@ export default defineConfig(
}
}
},
// Config for import plugin
{
...importPlugin.flatConfigs.recommended,
...importPlugin.flatConfigs.typescript,
rules: {
// ...importPlugin.flatConfigs.recommended.rules, // Temporarily disabled until codebase conformant
...importPlugin.flatConfigs.typescript.rules,
'import/no-cycle': 'error',
},
settings: {
'import/resolver': {
typescript: {
project: './tsconfig.json',
},
},
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -121,6 +121,8 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",

View File

@ -2,6 +2,7 @@ import { Suspense } from "react";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { ToolRegistryProvider } from "./contexts/ToolRegistryProvider";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
@ -48,26 +49,28 @@ export default function App() {
<ErrorBoundary>
<OnboardingProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<HomePage />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<HomePage />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</OnboardingProvider>
</ErrorBoundary>

View File

@ -25,7 +25,7 @@ interface AutomationCreationProps {
existingAutomation?: AutomationConfig;
onBack: () => void;
onComplete: (automation: AutomationConfig) => void;
toolRegistry: ToolRegistry;
toolRegistry: Partial<ToolRegistry>;
}
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {

View File

@ -7,7 +7,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolIcon } from '../../shared/ToolIcon';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface AutomationEntryProps {
@ -32,7 +32,7 @@ interface AutomationEntryProps {
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<ToolId, ToolRegistryEntry>;
toolRegistry?: Partial<ToolRegistry>;
}
export default function AutomationEntry({
@ -56,8 +56,9 @@ export default function AutomationEntry({
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation as ToolId]?.name) {
return toolRegistry[operation as ToolId].name;
const entry = toolRegistry?.[operation as ToolId];
if (entry?.name) {
return entry.name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);

View File

@ -4,7 +4,7 @@ import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckIcon from "@mui/icons-material/Check";
import { useFileSelection } from "../../../contexts/FileContext";
import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry";
import { useToolRegistry } from "../../../contexts/ToolRegistryContext";
import { AutomationConfig, ExecutionStep } from "../../../types/automation";
import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation";
import { useResourceCleanup } from "../../../utils/resourceManager";
@ -18,7 +18,8 @@ interface AutomationRunProps {
export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const toolRegistry = useFlatToolRegistry();
const { regularTools } = useToolRegistry();
const toolRegistry = regularTools;
const cleanup = useResourceCleanup();
// Progress tracking state

View File

@ -6,8 +6,7 @@ import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@ -16,7 +15,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<ToolId, ToolRegistryEntry>;
toolRegistry: Partial<ToolRegistry>;
}
export default function AutomationSelection({

View File

@ -15,6 +15,7 @@ import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import WarningIcon from '@mui/icons-material/Warning';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { ToolId } from '../../../types/toolId';
import { getAvailableToExtensions } from '../../../utils/convertUtils';
interface ToolConfigurationModalProps {
opened: boolean;
@ -26,7 +27,7 @@ interface ToolConfigurationModalProps {
};
onSave: (parameters: any) => void;
onCancel: () => void;
toolRegistry: ToolRegistry;
toolRegistry: Partial<ToolRegistry>;
}
export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) {
@ -35,7 +36,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
const [parameters, setParameters] = useState<any>({});
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
const toolInfo = toolRegistry[tool.operation as ToolId];
const SettingsComponent = toolInfo?.automationSettings;
// Initialize parameters from tool (which should contain defaults from registry)

View File

@ -5,14 +5,14 @@ import SettingsIcon from "@mui/icons-material/Settings";
import CloseIcon from "@mui/icons-material/Close";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import { AutomationTool } from "../../../types/automation";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { ToolRegistry } from "../../../data/toolsTaxonomy";
import { ToolId } from "../../../types/toolId";
import ToolSelector from "./ToolSelector";
import AutomationEntry from "./AutomationEntry";
interface ToolListProps {
tools: AutomationTool[];
toolRegistry: Record<ToolId, ToolRegistryEntry>;
toolRegistry: Partial<ToolRegistry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;

View File

@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy';
import { ToolRegistryEntry, ToolRegistry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
@ -11,7 +11,7 @@ import { ToolId } from '../../../types/toolId';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<ToolId, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
toolRegistry: Partial<ToolRegistry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
@ -54,7 +54,7 @@ export default function ToolSelector({
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<ToolId, ToolRegistryEntry> = {} as Record<ToolId, ToolRegistryEntry>;
const registry: Partial<ToolRegistry> = {};
baseFilteredTools.forEach(([key, tool]) => {
registry[key as ToolId] = tool;
});
@ -142,10 +142,10 @@ export default function ToolSelector({
};
// Get display value for selected tool
const selectedTool = selectedValue ? toolRegistry[selectedValue as ToolId] : undefined;
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue as ToolId]) {
return toolRegistry[selectedValue as ToolId].name;
}
if (selectedTool) return selectedTool.name;
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
@ -153,12 +153,18 @@ export default function ToolSelector({
<div ref={containerRef} className='rounded-xl'>
{/* Always show the target - either selected tool or search input */}
{selectedValue && toolRegistry[selectedValue as ToolId] && !opened ? (
{selectedTool && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and dropdown closed
<div onClick={handleSearchFocus} style={{ cursor: 'pointer',
borderRadius: "var(--mantine-radius-lg)" }}>
<ToolButton id={'tool' as ToolId} tool={toolRegistry[selectedValue as ToolId]} isSelected={false}
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
<ToolButton
id={'tool' as ToolId}
tool={selectedTool}
isSelected={false}
onSelect={()=>{}}
rounded={true}
disableNavigation={true}
/>
</div>
) : (
// Show search input when no tool selected OR when dropdown is opened

View File

@ -3,13 +3,7 @@ import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '../../contexts/SignatureContext';
import { uuidV4 } from '@embedpdf/models';
export interface HistoryAPI {
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
}
import type { HistoryAPI } from './viewerTypes';
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
const { provides: historyApi } = useHistoryCapability();
@ -42,7 +36,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
const currentStoredData = getImageData(annotation.id);
// Check if the annotation lacks image data but we have it stored
if (currentStoredData && (!annotation.imageSrc || annotation.imageSrc !== currentStoredData)) {
// Generate new ID to avoid React key conflicts
const newId = uuidV4();
@ -113,4 +107,4 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
return null; // This is a bridge component with no UI
});
HistoryAPIBridge.displayName = 'HistoryAPIBridge';
HistoryAPIBridge.displayName = 'HistoryAPIBridge';

View File

@ -34,8 +34,9 @@ import { SpreadAPIBridge } from './SpreadAPIBridge';
import { SearchAPIBridge } from './SearchAPIBridge';
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
import { RotateAPIBridge } from './RotateAPIBridge';
import { SignatureAPIBridge, SignatureAPI } from './SignatureAPIBridge';
import { HistoryAPIBridge, HistoryAPI } from './HistoryAPIBridge';
import { SignatureAPIBridge } from './SignatureAPIBridge';
import { HistoryAPIBridge } from './HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from './viewerTypes';
import { ExportAPIBridge } from './ExportAPIBridge';
interface LocalEmbedPDFProps {

View File

@ -2,17 +2,7 @@ import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { useSignature } from '../../contexts/SignatureContext';
export interface SignatureAPI {
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
activateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
updateDrawSettings: (color: string, size: number) => void;
deactivateTools: () => void;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
import type { SignatureAPI } from './viewerTypes';
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
@ -246,4 +236,4 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return null; // This is a bridge component with no UI
});
SignatureAPIBridge.displayName = 'SignatureAPIBridge';
SignatureAPIBridge.displayName = 'SignatureAPIBridge';

View File

@ -0,0 +1,24 @@
export interface SignatureAPI {
addImageSignature: (
signatureData: string,
x: number,
y: number,
width: number,
height: number,
pageIndex: number
) => void;
activateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
updateDrawSettings: (color: string, size: number) => void;
deactivateTools: () => void;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
export interface HistoryAPI {
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
}

View File

@ -1,10 +1,3 @@
import { useAppConfig } from '../hooks/useAppConfig';
// Get base URL from app config with fallback
export const getBaseUrl = (): string => {
const { config } = useAppConfig();
return config?.baseUrl || 'https://stirling.com';
};
// Base path from Vite config - build-time constant, normalized (no trailing slash)
// When no subpath, use empty string instead of '.' to avoid relative path issues

View File

@ -1,7 +1,7 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
import { WorkbenchType, getDefaultWorkbench } from '../types/workbench';
import { ToolId, isValidToolId } from '../types/toolId';
import { useFlatToolRegistry } from '../data/useTranslatedToolRegistry';
import { useToolRegistry } from './ToolRegistryContext';
/**
* NavigationContext - Complete navigation management system
@ -107,7 +107,7 @@ export const NavigationProvider: React.FC<{
enableUrlSync?: boolean;
}> = ({ children }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState);
const toolRegistry = useFlatToolRegistry();
const { allTools: toolRegistry } = useToolRegistry();
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
const actions: NavigationContextActions = {

View File

@ -1,7 +1,6 @@
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
import { SignParameters } from '../hooks/tools/sign/useSignParameters';
import { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
import { HistoryAPI } from '../components/viewer/HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from '../components/viewer/viewerTypes';
// Signature state interface
interface SignatureState {
@ -175,4 +174,4 @@ export const useSignatureMode = () => {
isSignatureModeActive: context?.isPlacementMode || false,
hasSignatureConfig: context?.signatureConfig !== null,
};
};
};

View File

@ -0,0 +1,30 @@
import { createContext, useContext } from 'react';
import type {
ToolRegistryEntry,
ToolRegistry,
RegularToolRegistry,
SuperToolRegistry,
LinkToolRegistry,
} from '../data/toolsTaxonomy';
import type { ToolId } from '../types/toolId';
export interface ToolRegistryCatalog {
regularTools: RegularToolRegistry;
superTools: SuperToolRegistry;
linkTools: LinkToolRegistry;
allTools: ToolRegistry;
getToolById: (toolId: ToolId | null) => ToolRegistryEntry | null;
}
const ToolRegistryContext = createContext<ToolRegistryCatalog | null>(null);
export const useToolRegistry = (): ToolRegistryCatalog => {
const context = useContext(ToolRegistryContext);
if (context === null) {
throw new Error('useToolRegistry must be used within a ToolRegistryProvider');
}
return context;
};
export default ToolRegistryContext;

View File

@ -0,0 +1,44 @@
import React, { useMemo } from 'react';
import type { ToolId } from '../types/toolId';
import type { ToolRegistry } from '../data/toolsTaxonomy';
import ToolRegistryContext, { ToolRegistryCatalog } from './ToolRegistryContext';
import { useTranslatedToolCatalog } from '../data/useTranslatedToolRegistry';
interface ToolRegistryProviderProps {
children: React.ReactNode;
}
export const ToolRegistryProvider: React.FC<ToolRegistryProviderProps> = ({ children }) => {
const catalog = useTranslatedToolCatalog();
const contextValue = useMemo<ToolRegistryCatalog>(() => {
const { regularTools, superTools, linkTools } = catalog;
const allTools: ToolRegistry = {
...regularTools,
...superTools,
...linkTools,
};
const getToolById = (toolId: ToolId | null) => {
if (!toolId) {
return null;
}
return allTools[toolId] ?? null;
};
return {
regularTools,
superTools,
linkTools,
allTools,
getToolById,
};
}, [catalog]);
return (
<ToolRegistryContext.Provider value={contextValue}>
{children}
</ToolRegistryContext.Provider>
);
};

View File

@ -20,6 +20,7 @@ import {
} from './toolWorkflow/toolWorkflowState';
import type { ToolPanelMode } from '../constants/toolPanel';
import { usePreferences } from './PreferencesContext';
import { useToolRegistry } from './ToolRegistryContext';
// State interface
// Types and reducer/state moved to './toolWorkflow/state'
@ -111,10 +112,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const navigationState = useNavigationState();
// Tool management hook
const {
toolRegistry,
getSelectedTool,
} = useToolManagement();
const { toolRegistry, getSelectedTool } = useToolManagement();
const { allTools } = useToolRegistry();
// Tool history hook
const {
@ -315,7 +314,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
const filteredTools = useMemo(() => {
if (!toolRegistry) return [];
return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery);
return filterToolRegistryByQuery(toolRegistry, state.searchQuery);
}, [toolRegistry, state.searchQuery]);
const isPanelVisible = useMemo(() =>
@ -327,7 +326,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
navigationState.selectedTool,
handleToolSelect,
handleBackToTools,
toolRegistry as ToolRegistry,
allTools,
true
);

View File

@ -3,7 +3,7 @@ import React from 'react';
import { ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
import { BaseToolProps } from '../types/tool';
import { WorkbenchType } from '../types/workbench';
import { ToolId } from '../types/toolId';
import { LinkToolId, RegularToolId, SuperToolId, ToolId, ToolKind } from '../types/toolId';
import DrawRoundedIcon from '@mui/icons-material/DrawRounded';
import SecurityRoundedIcon from '@mui/icons-material/SecurityRounded';
import VerifiedUserRoundedIcon from '@mui/icons-material/VerifiedUserRounded';
@ -47,7 +47,7 @@ export type ToolRegistryEntry = {
supportedFormats?: string[];
endpoints?: string[];
link?: string;
type?: string;
kind?: ToolKind;
// Workbench type for navigation
workbench?: WorkbenchType;
// Operation configuration for automation
@ -60,6 +60,9 @@ export type ToolRegistryEntry = {
synonyms?: string[];
}
export type RegularToolRegistry = Record<RegularToolId, ToolRegistryEntry>;
export type SuperToolRegistry = Record<SuperToolId, ToolRegistryEntry>;
export type LinkToolRegistry = Record<LinkToolId, ToolRegistryEntry>;
export type ToolRegistry = Record<ToolId, ToolRegistryEntry>;
export const SUBCATEGORY_ORDER: SubcategoryId[] = [

View File

@ -13,7 +13,15 @@ import RemovePages from "../tools/RemovePages";
import ReorganizePages from "../tools/ReorganizePages";
import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
import RemovePassword from "../tools/RemovePassword";
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
import {
SubcategoryId,
ToolCategoryId,
ToolRegistry,
RegularToolRegistry,
SuperToolRegistry,
LinkToolRegistry,
} from "./toolsTaxonomy";
import { isSuperToolId, isLinkToolId } from '../types/toolId';
import AdjustContrast from "../tools/AdjustContrast";
import AdjustContrastSingleStepSettings from "../components/tools/adjustContrast/AdjustContrastSingleStepSettings";
import { adjustContrastOperationConfig } from "../hooks/tools/adjustContrast/useAdjustContrastOperation";
@ -109,14 +117,18 @@ import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksS
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
import ValidateSignature from "../tools/ValidateSignature";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
// Convert tool supported file formats
import Automate from "../tools/Automate";
import { CONVERT_SUPPORTED_FORMATS } from "../constants/convertSupportedFornats";
export interface TranslatedToolCatalog {
allTools: ToolRegistry;
regularTools: RegularToolRegistry;
superTools: SuperToolRegistry;
linkTools: LinkToolRegistry;
}
// Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry {
export function useTranslatedToolCatalog(): TranslatedToolCatalog {
const { t } = useTranslation();
return useMemo(() => {
@ -564,7 +576,7 @@ export function useFlatToolRegistry(): ToolRegistry {
automate: {
icon: <LocalIcon icon="automation-outline" width="1.5rem" height="1.5rem" />,
name: t("home.automate.title", "Automate"),
component: React.lazy(() => import("../tools/Automate")),
component: Automate,
description: t(
"home.automate.desc",
"Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
@ -829,15 +841,26 @@ export function useFlatToolRegistry(): ToolRegistry {
},
};
if (showPlaceholderTools) {
return allTools;
}
const filteredTools = Object.keys(allTools)
.filter((key) => allTools[key as ToolId].component !== null || allTools[key as ToolId].link)
.reduce((obj, key) => {
obj[key as ToolId] = allTools[key as ToolId];
return obj;
}, {} as ToolRegistry);
return filteredTools;
const regularTools = {} as RegularToolRegistry;
const superTools = {} as SuperToolRegistry;
const linkTools = {} as LinkToolRegistry;
Object.entries(allTools).forEach(([key, entry]) => {
const toolId = key as ToolId;
if (isSuperToolId(toolId)) {
superTools[toolId] = entry;
} else if (isLinkToolId(toolId)) {
linkTools[toolId] = entry;
} else {
regularTools[toolId] = entry;
}
});
return {
allTools,
regularTools,
superTools,
linkTools,
};
}, [t]); // Only re-compute when translations change
}

View File

@ -1,11 +1,12 @@
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { useCallback } from 'react';
import { executeAutomationSequence } from '../../../utils/automationExecutor';
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
import { useToolRegistry } from '../../../contexts/ToolRegistryContext';
import { AutomateParameters } from '../../../types/automation';
export function useAutomateOperation() {
const toolRegistry = useFlatToolRegistry();
const { allTools } = useToolRegistry();
const toolRegistry = allTools;
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
console.log('🚀 Starting automation execution via customProcessor', { params, files });

View File

@ -9,7 +9,7 @@ import { ToolId } from 'src/types/toolId';
interface UseAutomationFormProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
toolRegistry: ToolRegistry;
toolRegistry: Partial<ToolRegistry>;
}
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
@ -21,12 +21,12 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = useCallback((operation: string) => {
const tool = toolRegistry?.[operation as keyof ToolRegistry] as any;
const tool = toolRegistry?.[operation as ToolId] as any;
return tool?.name || t(`tools.${operation}.name`, operation);
}, [toolRegistry, t]);
const getToolDefaultParameters = useCallback((operation: string): Record<string, any> => {
const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
const config = toolRegistry[operation as ToolId]?.operationConfig;
if (config?.defaultParameters) {
return { ...config.defaultParameters };
}

View File

@ -0,0 +1,6 @@
import { useAppConfig } from './useAppConfig';
export const useBaseUrl = (): string => {
const { config } = useAppConfig();
return config?.baseUrl || 'https://demo.stirlingpdf.com';
};

View File

@ -1,6 +1,5 @@
import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { useToolRegistry } from "../contexts/ToolRegistryContext";
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "../data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
import { FileId } from '../types/file';
@ -15,19 +14,19 @@ interface ToolManagementResult {
}
export const useToolManagement = (): ToolManagementResult => {
const { t } = useTranslation();
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Build endpoints list from registry entries with fallback to legacy mapping
const baseRegistry = useFlatToolRegistry();
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
if (endpointsLoading) return true;
const endpoints = baseRegistry[toolKey as keyof typeof baseRegistry]?.endpoints || [];
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
}, [endpointsLoading, endpointStatus, baseRegistry]);
@ -35,16 +34,18 @@ export const useToolManagement = (): ToolManagementResult => {
const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
availableToolRegistry[toolKey as ToolId] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
const baseTool = baseRegistry[toolKey];
if (baseTool) {
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
}
}
});
return availableToolRegistry;
}, [isToolAvailable, t, baseRegistry]);
}, [isToolAvailable, baseRegistry]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;

View File

@ -4,7 +4,8 @@ import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { Group, useMantineColorScheme } from "@mantine/core";
import { useSidebarContext } from "../contexts/SidebarContext";
import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { BASE_PATH, getBaseUrl } from "../constants/app";
import { BASE_PATH } from "../constants/app";
import { useBaseUrl } from "../hooks/useBaseUrl";
import { useMediaQuery } from "@mantine/hooks";
import AppsIcon from '@mui/icons-material/AppsRounded';
@ -135,7 +136,7 @@ export default function HomePage() {
}
}, [isMobile, activeMobileView, selectedTool, setLeftPanelView]);
const baseUrl = getBaseUrl();
const baseUrl = useBaseUrl();
// Update document meta when tool changes
useDocumentMeta({

View File

@ -119,5 +119,49 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
// Provide a minimal DOMMatrix implementation for pdf.js in the test environment
if (typeof globalThis.DOMMatrix === 'undefined') {
class DOMMatrixStub {
a = 1;
b = 0;
c = 0;
d = 1;
e = 0;
f = 0;
constructor(init?: string | number[]) {
if (Array.isArray(init) && init.length === 6) {
[this.a, this.b, this.c, this.d, this.e, this.f] = init as [number, number, number, number, number, number];
}
}
multiplySelf(): this {
return this;
}
translateSelf(): this {
return this;
}
scaleSelf(): this {
return this;
}
rotateSelf(): this {
return this;
}
inverse(): this {
return this;
}
}
Object.defineProperty(globalThis, 'DOMMatrix', {
value: DOMMatrixStub,
writable: false,
configurable: true,
});
}
// Set global test timeout to prevent hangs
vi.setConfig({ testTimeout: 5000, hookTimeout: 5000 });

View File

@ -12,7 +12,7 @@ import AutomationRun from "../components/tools/automate/AutomationRun";
import { useAutomateOperation } from "../hooks/tools/automate/useAutomateOperation";
import { BaseToolProps } from "../types/tool";
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { useToolRegistry } from "../contexts/ToolRegistryContext";
import { useSavedAutomations } from "../hooks/tools/automate/useSavedAutomations";
import { AutomationConfig, AutomationStepData, AutomationMode, AutomationStep } from "../types/automation";
import { AUTOMATION_STEPS } from "../constants/automation";
@ -27,7 +27,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION });
const automateOperation = useAutomateOperation();
const toolRegistry = useFlatToolRegistry();
const { regularTools: toolRegistry } = useToolRegistry();
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations();

View File

@ -1,5 +1,6 @@
// Define all possible tool IDs as source of truth
export const TOOL_IDS = [
export type ToolKind = 'regular' | 'super' | 'link';
export const REGULAR_TOOL_IDS = [
'certSign',
'sign',
'addPassword',
@ -26,7 +27,6 @@ export const TOOL_IDS = [
'adjustContrast',
'crop',
'pdfToSinglePage',
'multiTool',
'repair',
'compare',
'addPageNumbers',
@ -44,21 +44,52 @@ export const TOOL_IDS = [
'overlayPdfs',
'getPdfInfo',
'validateSignature',
'read',
'automate',
'replaceColor',
'showJS',
'bookletImposition',
] as const;
export const SUPER_TOOL_IDS = [
'multiTool',
'read',
'automate',
] as const;
const LINK_TOOL_IDS = [
'devApi',
'devFolderScanning',
'devSsoGuide',
'devAirgapped',
'bookletImposition',
] as const;
const TOOL_IDS = [
...REGULAR_TOOL_IDS,
...SUPER_TOOL_IDS,
...LINK_TOOL_IDS,
];
// Tool identity - what PDF operation we're performing (type-safe)
export type ToolId = typeof TOOL_IDS[number];
export const isValidToolId = (value: string): value is ToolId =>
TOOL_IDS.includes(value as ToolId);
export type RegularToolId = typeof REGULAR_TOOL_IDS[number];
export const isRegularToolId = (toolId: ToolId): toolId is RegularToolId =>
REGULAR_TOOL_IDS.includes(toolId as RegularToolId);
export type SuperToolId = typeof SUPER_TOOL_IDS[number];
export const isSuperToolId = (toolId: ToolId): toolId is SuperToolId =>
SUPER_TOOL_IDS.includes(toolId as SuperToolId);
export type LinkToolId = typeof LINK_TOOL_IDS[number];
export const isLinkToolId = (toolId: ToolId): toolId is LinkToolId =>
LINK_TOOL_IDS.includes(toolId as LinkToolId);
type Assert<A extends true> = A;
type Disjoint<A, B> = [A & B] extends [never] ? true : false;
type _Check1 = Assert<Disjoint<RegularToolId, SuperToolId>>;
type _Check2 = Assert<Disjoint<RegularToolId, LinkToolId>>;
type _Check3 = Assert<Disjoint<SuperToolId, LinkToolId>>;
// Type guard using the same source of truth
export const isValidToolId = (value: string): value is ToolId => {
return TOOL_IDS.includes(value as ToolId);
};

View File

@ -1,5 +1,6 @@
import axios from 'axios';
import { ToolRegistry } from '../data/toolsTaxonomy';
import { ToolId } from '../types/toolId';
import { AUTOMATION_CONSTANTS } from '../constants/automation';
import { AutomationFileProcessor } from './automationFileProcessor';
import { ToolType } from '../hooks/tools/shared/useToolOperation';
@ -149,7 +150,7 @@ export const executeToolOperationWithPrefix = async (
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => {
const config = toolRegistry[operationName as keyof ToolRegistry]?.operationConfig;
const config = toolRegistry[operationName as ToolId]?.operationConfig;
if (!config) {
throw new Error(`Tool operation not supported: ${operationName}`);
}

View File

@ -2,7 +2,7 @@ import { PDFDocument, rgb } from 'pdf-lib';
import { generateThumbnailWithMetadata } from './thumbnailUtils';
import { createProcessedFile, createChildStub } from '../contexts/file/fileActions';
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
import type { SignatureAPI } from '../components/viewer/viewerTypes';
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
@ -319,4 +319,4 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
console.error('Error flattening signatures:', error);
return null;
}
}
}

View File

@ -1,5 +1,5 @@
import { ToolId } from "src/types/toolId";
import { ToolRegistryEntry } from "../data/toolsTaxonomy";
import { ToolRegistryEntry, ToolRegistry } from "../data/toolsTaxonomy";
import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch";
export interface RankedToolItem {
@ -8,7 +8,7 @@ export interface RankedToolItem {
}
export function filterToolRegistryByQuery(
toolRegistry: Record<ToolId, ToolRegistryEntry>,
toolRegistry: Partial<ToolRegistry>,
query: string
): RankedToolItem[] {
const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
@ -96,5 +96,3 @@ export function filterToolRegistryByQuery(
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
}

View File

@ -115,4 +115,3 @@ export function getToolDisplayName(toolId: ToolId, registry: ToolRegistry): stri
const tool = registry[toolId];
return tool ? tool.name : toolId;
}