mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge branch 'V2' into feature/v2/improve-sign
This commit is contained in:
commit
a25ef4a56e
@ -258,12 +258,6 @@ public class AppConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Profile("default")
|
||||
public boolean googleDriveEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Bean(name = "license")
|
||||
@Profile("default")
|
||||
public String licenseType() {
|
||||
|
||||
@ -530,7 +530,6 @@ public class ApplicationProperties {
|
||||
private boolean ssoAutoLogin;
|
||||
private boolean database;
|
||||
private CustomMetadata customMetadata = new CustomMetadata();
|
||||
private GoogleDrive googleDrive = new GoogleDrive();
|
||||
|
||||
@Data
|
||||
public static class CustomMetadata {
|
||||
@ -549,26 +548,6 @@ public class ApplicationProperties {
|
||||
: producer;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GoogleDrive {
|
||||
private boolean enabled;
|
||||
private String clientId;
|
||||
private String apiKey;
|
||||
private String appId;
|
||||
|
||||
public String getClientId() {
|
||||
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
|
||||
}
|
||||
|
||||
public String getAppId() {
|
||||
return appId == null || appId.trim().isEmpty() ? "" : appId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@ -109,22 +109,6 @@ class ApplicationPropertiesLogicTest {
|
||||
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
|
||||
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
|
||||
|
||||
assertEquals("", gd.getClientId());
|
||||
assertEquals("", gd.getApiKey());
|
||||
assertEquals("", gd.getAppId());
|
||||
|
||||
gd.setClientId(" id ");
|
||||
gd.setApiKey(" key ");
|
||||
gd.setAppId(" app ");
|
||||
assertEquals(" id ", gd.getClientId());
|
||||
assertEquals(" key ", gd.getApiKey());
|
||||
assertEquals(" app ", gd.getAppId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ui_getters_return_null_for_blank() {
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
|
||||
@ -98,11 +98,6 @@ public class ConfigController {
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("GoogleDriveEnabled")) {
|
||||
configData.put(
|
||||
"GoogleDriveEnabled",
|
||||
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
|
||||
}
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
|
||||
@ -76,11 +76,6 @@ premium:
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
googleDrive:
|
||||
enabled: false
|
||||
clientId: ''
|
||||
apiKey: ''
|
||||
appId: ''
|
||||
enterpriseFeatures:
|
||||
audit:
|
||||
enabled: true # Enable audit logging
|
||||
|
||||
@ -422,10 +422,6 @@
|
||||
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
|
||||
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
|
||||
</div>
|
||||
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
|
||||
</div>
|
||||
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
|
||||
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-files flex-wrap"></div>
|
||||
@ -443,16 +439,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
|
||||
|
||||
<div th:if="${@GoogleDriveEnabled == true}" >
|
||||
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
|
||||
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
|
||||
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
|
||||
|
||||
<script th:inline="javascript">
|
||||
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
|
||||
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
|
||||
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
|
||||
</script>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
|
||||
|
||||
@Configuration
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@ -55,19 +54,6 @@ public class EEAppConfig {
|
||||
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
|
||||
}
|
||||
|
||||
@Profile("security")
|
||||
@Bean(name = "GoogleDriveEnabled")
|
||||
@Primary
|
||||
public boolean googleDriveEnabled() {
|
||||
return runningProOrHigher()
|
||||
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "GoogleDriveConfig")
|
||||
public GoogleDrive googleDriveConfig() {
|
||||
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
@SuppressWarnings("deprecation")
|
||||
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
|
||||
|
||||
@ -50,7 +50,14 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
|
||||
- **Custom Ports**: Modify port mappings in docker-compose files
|
||||
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
|
||||
|
||||
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
|
||||
|
||||
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
|
||||
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
|
||||
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
|
||||
|
||||
## Development vs Production
|
||||
|
||||
- **Development**: Keep backend port 8080 exposed for debugging
|
||||
- **Production**: Remove backend port exposure, use only frontend proxy
|
||||
- **Production**: Remove backend port exposure, use only frontend proxy
|
||||
|
||||
|
||||
@ -47,6 +47,9 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@ -44,6 +44,9 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@ -46,6 +46,9 @@ services:
|
||||
- "3000:80"
|
||||
environment:
|
||||
BACKEND_URL: http://backend:8080
|
||||
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
|
||||
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
|
||||
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
74
frontend/package-lock.json
generated
74
frontend/package-lock.json
generated
@ -67,6 +67,10 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
"@types/google.picker": "^0.0.51",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@ -2010,6 +2014,28 @@
|
||||
"react": "^18.x || ^19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@maxim_mazurok/gapi.client.discovery-v1": {
|
||||
"version": "0.4.20200806",
|
||||
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz",
|
||||
"integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/gapi.client": "*",
|
||||
"@types/gapi.client.discovery-v1": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@maxim_mazurok/gapi.client.drive-v3": {
|
||||
"version": "0.1.20250930",
|
||||
"resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz",
|
||||
"integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/gapi.client": "*",
|
||||
"@types/gapi.client.discovery-v1": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@mui/core-downloads-tracker": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
|
||||
@ -3596,6 +3622,54 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gapi": {
|
||||
"version": "0.0.47",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz",
|
||||
"integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gapi.client": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz",
|
||||
"integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/gapi.client.discovery-v1": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz",
|
||||
"integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@maxim_mazurok/gapi.client.discovery-v1": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/gapi.client.drive-v3": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
|
||||
"integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@maxim_mazurok/gapi.client.drive-v3": "latest"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/google.accounts": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
|
||||
"integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/google.picker": {
|
||||
"version": "0.0.51",
|
||||
"resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz",
|
||||
"integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
|
||||
|
||||
@ -106,6 +106,10 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/gapi": "^0.0.47",
|
||||
"@types/gapi.client.drive-v3": "^0.0.5",
|
||||
"@types/google.accounts": "^0.0.18",
|
||||
"@types/google.picker": "^0.0.51",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
||||
@ -283,7 +283,8 @@
|
||||
"capturing": "Press keys… (Esc to cancel)",
|
||||
"change": "Change shortcut",
|
||||
"reset": "Reset",
|
||||
"shortcut": "Shortcut"
|
||||
"shortcut": "Shortcut",
|
||||
"noShortcut": "No shortcut set"
|
||||
}
|
||||
},
|
||||
"changeCreds": {
|
||||
|
||||
@ -9,6 +9,8 @@ import MobileLayout from './fileManager/MobileLayout';
|
||||
import DesktopLayout from './fileManager/DesktopLayout';
|
||||
import DragOverlay from './fileManager/DragOverlay';
|
||||
import { FileManagerProvider } from '../contexts/FileManagerContext';
|
||||
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
|
||||
import { loadScript } from '../utils/scriptLoader';
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@ -84,6 +86,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Preload Google Drive scripts if configured
|
||||
useEffect(() => {
|
||||
if (isGoogleDriveConfigured()) {
|
||||
// Load scripts in parallel without blocking
|
||||
Promise.all([
|
||||
loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
async: true,
|
||||
defer: true,
|
||||
}),
|
||||
]).catch((error) => {
|
||||
console.warn('Failed to preload Google Drive scripts:', error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Modal size constants for consistent scaling
|
||||
const modalHeight = '80vh';
|
||||
const modalWidth = isMobile ? '100%' : '80vw';
|
||||
|
||||
@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
|
||||
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
|
||||
const handleGoogleDriveClick = async () => {
|
||||
try {
|
||||
const files = await openGoogleDrivePicker({ multiple: true });
|
||||
if (files.length > 0) {
|
||||
onGoogleDriveSelect(files);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick files from Google Drive:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
|
||||
@ -67,15 +80,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={buttonProps.variant('drive')}
|
||||
variant="subtle"
|
||||
color='var(--mantine-color-gray-6)'
|
||||
leftSection={<CloudIcon />}
|
||||
justify={horizontal ? "center" : "flex-start"}
|
||||
onClick={() => onSourceChange('drive')}
|
||||
onClick={handleGoogleDriveClick}
|
||||
fullWidth={!horizontal}
|
||||
size={horizontal ? "xs" : "sm"}
|
||||
disabled
|
||||
color={activeSource === 'drive' ? 'gray' : undefined}
|
||||
styles={buttonProps.getStyles('drive')}
|
||||
disabled={!isGoogleDriveEnabled}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
'&:hover': {
|
||||
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
|
||||
>
|
||||
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
|
||||
</Button>
|
||||
|
||||
@ -55,4 +55,4 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 's
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyDisplay;
|
||||
export default HotkeyDisplay;
|
||||
|
||||
@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
|
||||
import { useHotkeys } from '../../../../contexts/HotkeyContext';
|
||||
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
|
||||
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
|
||||
import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
@ -24,10 +26,10 @@ const HotkeysSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
|
||||
const [editingTool, setEditingTool] = useState<string | null>(null);
|
||||
const [editingTool, setEditingTool] = useState<ToolId | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]);
|
||||
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
@ -64,7 +66,7 @@ const HotkeysSection: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => (
|
||||
const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => (
|
||||
toolId !== editingTool && bindingEquals(existing, binding)
|
||||
));
|
||||
|
||||
@ -85,7 +87,7 @@ const HotkeysSection: React.FC = () => {
|
||||
};
|
||||
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
|
||||
|
||||
const handleStartCapture = (toolId: string) => {
|
||||
const handleStartCapture = (toolId: ToolId) => {
|
||||
setEditingTool(toolId);
|
||||
setError(null);
|
||||
};
|
||||
@ -168,4 +170,4 @@ const HotkeysSection: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeysSection;
|
||||
export default HotkeysSection;
|
||||
|
||||
@ -51,7 +51,6 @@ const Overview: React.FC = () => {
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
|
||||
@ -7,9 +7,10 @@ import { useToolSections } from '../../hooks/useToolSections';
|
||||
import SubcategoryHeader from './shared/SubcategoryHeader';
|
||||
import NoToolsFound from './shared/NoToolsFound';
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
|
||||
onSelect: (id: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
@ -40,13 +41,13 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect,
|
||||
{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 =>
|
||||
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
);
|
||||
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
|
||||
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
|
||||
matchedText.toLowerCase().includes(synonym.toLowerCase())
|
||||
) : undefined;
|
||||
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
|
||||
@ -6,11 +6,12 @@ import "./toolPicker/ToolPicker.css";
|
||||
import { useToolSections } from "../../hooks/useToolSections";
|
||||
import NoToolsFound from "./shared/NoToolsFound";
|
||||
import { renderToolButtons } from "./shared/renderToolButtons";
|
||||
import { ToolId } from "src/types/toolId";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@ import { Suspense } from "react";
|
||||
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
|
||||
import { BaseToolProps } from "../../types/tool";
|
||||
import ToolLoadingFallback from "./ToolLoadingFallback";
|
||||
import { ToolId } from "src/types/toolId";
|
||||
|
||||
interface ToolRendererProps extends BaseToolProps {
|
||||
selectedToolKey: string;
|
||||
selectedToolKey: ToolId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { useToolSections } from '../../../hooks/useToolSections';
|
||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||
import ToolSearch from '../toolPicker/ToolSearch';
|
||||
import ToolButton from '../toolPicker/ToolButton';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
interface ToolSelectorProps {
|
||||
onSelect: (toolKey: string) => void;
|
||||
@ -30,7 +31,7 @@ export default function ToolSelector({
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
return Object.entries(toolRegistry).filter(([key, tool]) =>
|
||||
return (Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]).filter(([key, tool]) =>
|
||||
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
|
||||
);
|
||||
}, [toolRegistry, excludeTools]);
|
||||
@ -66,7 +67,7 @@ export default function ToolSelector({
|
||||
}, [filteredTools]);
|
||||
|
||||
// Use the same tool sections logic as the main ToolPicker
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
|
||||
const { sections, searchGroups } = useToolSections(transformedFilteredTools as any /* FIX ME */);
|
||||
|
||||
// Determine what to display: search results or organized sections
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
@ -156,7 +157,7 @@ export default function ToolSelector({
|
||||
// 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' tool={toolRegistry[selectedValue]} isSelected={false}
|
||||
<ToolButton id={'tool' as any /* FIX ME */} tool={toolRegistry[selectedValue]} isSelected={false}
|
||||
onSelect={()=>{}} rounded={true} disableNavigation={true}></ToolButton>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -5,13 +5,14 @@ import SubcategoryHeader from './SubcategoryHeader';
|
||||
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
|
||||
import { TFunction } from 'i18next';
|
||||
import { SubcategoryGroup } from '../../../hooks/useToolSections';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
// Helper function to render tool buttons for a subcategory
|
||||
export const renderToolButtons = (
|
||||
t: TFunction,
|
||||
subcategory: SubcategoryGroup,
|
||||
selectedToolKey: string | null,
|
||||
onSelect: (id: string) => void,
|
||||
onSelect: (id: ToolId) => void,
|
||||
showSubcategoryHeader: boolean = true,
|
||||
disableNavigation: boolean = false,
|
||||
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
|
||||
@ -32,7 +33,7 @@ export const renderToolButtons = (
|
||||
<div>
|
||||
{subcategory.tools.map(({ id, tool }) => {
|
||||
const matchedSynonym = matchedTextMap.get(id);
|
||||
|
||||
|
||||
return (
|
||||
<ToolButton
|
||||
key={id}
|
||||
|
||||
@ -9,12 +9,13 @@ import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
||||
import FitText from "../../shared/FitText";
|
||||
import { useHotkeys } from "../../../contexts/HotkeyContext";
|
||||
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
|
||||
import { ToolId } from "src/types/toolId";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: string;
|
||||
id: ToolId;
|
||||
tool: ToolRegistryEntry;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onSelect: (id: ToolId) => void;
|
||||
rounded?: boolean;
|
||||
disableNavigation?: boolean;
|
||||
matchedSynonym?: string;
|
||||
@ -28,7 +29,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
const handleClick = (id: ToolId) => {
|
||||
if (isUnavailable) return;
|
||||
if (tool.link) {
|
||||
// Open external link in new tab
|
||||
@ -47,12 +48,16 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
{binding && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
|
||||
{binding ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
|
||||
<HotkeyDisplay binding={binding} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500, fontStyle: 'italic' }}>{t('settings.hotkeys.noShortcut', 'No shortcut set')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -102,7 +107,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
@ -123,7 +128,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
styles={{
|
||||
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
|
||||
@ -39,6 +39,7 @@ interface FileManagerContextValue {
|
||||
onAddToRecents: (file: StirlingFileStub) => void;
|
||||
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
|
||||
onNewFilesSelect: (files: File[]) => void;
|
||||
onGoogleDriveSelect: (files: File[]) => void;
|
||||
|
||||
// External props
|
||||
recentFiles: StirlingFileStub[];
|
||||
@ -546,6 +547,19 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [refreshRecentFiles]);
|
||||
|
||||
const handleGoogleDriveSelect = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Process Google Drive files same as local files
|
||||
onNewFilesSelect(files);
|
||||
await refreshRecentFiles();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to process Google Drive files:', error);
|
||||
}
|
||||
}
|
||||
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
|
||||
try {
|
||||
// Load the full file from storage
|
||||
@ -623,6 +637,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onAddToRecents: handleAddToRecents,
|
||||
onUnzipFile: handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
onGoogleDriveSelect: handleGoogleDriveSelect,
|
||||
|
||||
// External props
|
||||
recentFiles,
|
||||
@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
handleAddToRecents,
|
||||
handleUnzipFile,
|
||||
onNewFilesSelect,
|
||||
handleGoogleDriveSelect,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
|
||||
@ -2,14 +2,17 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS
|
||||
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys';
|
||||
import { useToolWorkflow } from './ToolWorkflowContext';
|
||||
import { ToolId } from '../types/toolId';
|
||||
import { ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
|
||||
type Bindings = Partial<Record<ToolId, HotkeyBinding>>;
|
||||
|
||||
interface HotkeyContextValue {
|
||||
hotkeys: Record<string, HotkeyBinding>;
|
||||
defaults: Record<string, HotkeyBinding>;
|
||||
hotkeys: Bindings;
|
||||
defaults: Bindings;
|
||||
isMac: boolean;
|
||||
updateHotkey: (toolId: string, binding: HotkeyBinding) => void;
|
||||
resetHotkey: (toolId: string) => void;
|
||||
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean;
|
||||
updateHotkey: (toolId: ToolId, binding: HotkeyBinding) => void;
|
||||
resetHotkey: (toolId: ToolId) => void;
|
||||
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: ToolId) => boolean;
|
||||
pauseHotkeys: () => void;
|
||||
resumeHotkeys: () => void;
|
||||
areHotkeysPaused: boolean;
|
||||
@ -20,46 +23,29 @@ const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf.hotkeys';
|
||||
|
||||
const KEY_ORDER: string[] = [
|
||||
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
|
||||
'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP',
|
||||
'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL',
|
||||
'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM',
|
||||
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
||||
];
|
||||
const generateDefaultHotkeys = (toolEntries: [ToolId, ToolRegistryEntry][], macLike: boolean): Bindings => {
|
||||
const defaults: Bindings = {};
|
||||
|
||||
const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record<string, HotkeyBinding> => {
|
||||
const defaults: Record<string, HotkeyBinding> = {};
|
||||
let index = 0;
|
||||
let useShift = false;
|
||||
// Get Quick Access tools (RECOMMENDED_TOOLS category) from registry
|
||||
const quickAccessTools = toolEntries
|
||||
.filter(([_, tool]) => tool.categoryId === ToolCategoryId.RECOMMENDED_TOOLS)
|
||||
.map(([toolId, _]) => toolId);
|
||||
|
||||
const nextBinding = (): HotkeyBinding => {
|
||||
if (index >= KEY_ORDER.length) {
|
||||
index = 0;
|
||||
if (!useShift) {
|
||||
useShift = true;
|
||||
} else {
|
||||
// If we somehow run out of combinations, wrap back around (unlikely given tool count)
|
||||
useShift = false;
|
||||
}
|
||||
// Assign Cmd+Option+Number (Mac) or Ctrl+Alt+Number (Windows) to Quick Access tools
|
||||
quickAccessTools.forEach((toolId, index) => {
|
||||
if (index < 9) { // Limit to Digit1-9
|
||||
const digitNumber = index + 1;
|
||||
defaults[toolId] = {
|
||||
code: `Digit${digitNumber}`,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: macLike,
|
||||
ctrl: !macLike,
|
||||
};
|
||||
}
|
||||
|
||||
const code = KEY_ORDER[index];
|
||||
index += 1;
|
||||
|
||||
return {
|
||||
code,
|
||||
alt: true,
|
||||
shift: useShift,
|
||||
meta: macLike,
|
||||
ctrl: !macLike,
|
||||
};
|
||||
};
|
||||
|
||||
toolIds.forEach(toolId => {
|
||||
defaults[toolId] = nextBinding();
|
||||
});
|
||||
|
||||
// All other tools have no default (will be undefined in the record)
|
||||
return defaults;
|
||||
};
|
||||
|
||||
@ -74,7 +60,7 @@ const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { toolRegistry, handleToolSelect } = useToolWorkflow();
|
||||
const isMac = useMemo(() => isMacLike(), []);
|
||||
const [customBindings, setCustomBindings] = useState<Record<string, HotkeyBinding>>(() => {
|
||||
const [customBindings, setCustomBindings] = useState<Bindings>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
@ -82,16 +68,16 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
});
|
||||
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
|
||||
|
||||
const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]);
|
||||
const toolEntries = useMemo(() => Object.entries(toolRegistry), [toolRegistry]) as [ToolId, ToolRegistryEntry][];
|
||||
|
||||
const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]);
|
||||
const defaults = useMemo(() => generateDefaultHotkeys(toolEntries, isMac), [toolRegistry, isMac]);
|
||||
|
||||
// Remove bindings for tools that are no longer present
|
||||
useEffect(() => {
|
||||
setCustomBindings(prev => {
|
||||
const next: Record<string, HotkeyBinding> = {};
|
||||
const next: Bindings = {};
|
||||
let changed = false;
|
||||
Object.entries(prev).forEach(([toolId, binding]) => {
|
||||
(Object.entries(prev) as [ToolId, HotkeyBinding][]).forEach(([toolId, binding]) => {
|
||||
if (toolRegistry[toolId]) {
|
||||
next[toolId] = binding;
|
||||
} else {
|
||||
@ -103,13 +89,21 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
}, [toolRegistry]);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
const merged: Record<string, HotkeyBinding> = {};
|
||||
toolIds.forEach(toolId => {
|
||||
const merged: Bindings = {};
|
||||
toolEntries.forEach(([toolId, _]) => {
|
||||
const custom = customBindings[toolId];
|
||||
merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId];
|
||||
const defaultBinding = defaults[toolId];
|
||||
|
||||
// Only add to resolved if there's a custom binding or a default binding
|
||||
if (custom) {
|
||||
merged[toolId] = normalizeBinding(custom);
|
||||
} else if (defaultBinding) {
|
||||
merged[toolId] = defaultBinding;
|
||||
}
|
||||
// If neither exists, don't add to merged (tool has no hotkey)
|
||||
});
|
||||
return merged;
|
||||
}, [customBindings, defaults, toolIds]);
|
||||
}, [customBindings, defaults, toolEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@ -118,7 +112,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
|
||||
}, [customBindings]);
|
||||
|
||||
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => {
|
||||
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: ToolId) => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
return Object.entries(resolved).every(([toolId, existing]) => {
|
||||
if (toolId === excludeToolId) {
|
||||
@ -128,7 +122,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
});
|
||||
}, [resolved]);
|
||||
|
||||
const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => {
|
||||
const updateHotkey = useCallback((toolId: ToolId, binding: HotkeyBinding) => {
|
||||
setCustomBindings(prev => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
const defaultsForTool = defaults[toolId];
|
||||
@ -142,7 +136,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
});
|
||||
}, [defaults]);
|
||||
|
||||
const resetHotkey = useCallback((toolId: string) => {
|
||||
const resetHotkey = useCallback((toolId: ToolId) => {
|
||||
setCustomBindings(prev => {
|
||||
if (!(toolId in prev)) {
|
||||
return prev;
|
||||
@ -165,12 +159,12 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
if (event.repeat) return;
|
||||
if (shouldIgnoreTarget(event.target)) return;
|
||||
|
||||
const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>;
|
||||
const entries = Object.entries(resolved) as [ToolId, HotkeyBinding][];
|
||||
for (const [toolId, binding] of entries) {
|
||||
if (bindingMatchesEvent(binding, event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleToolSelect(toolId as ToolId);
|
||||
handleToolSelect(toolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -208,4 +202,4 @@ export const useHotkeys = (): HotkeyContextValue => {
|
||||
throw new Error('useHotkeys must be used within a HotkeyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@ -73,10 +73,10 @@ function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowActio
|
||||
// Context value interface
|
||||
interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
// Tool management (from hook)
|
||||
selectedToolKey: string | null;
|
||||
selectedToolKey: ToolId | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
@ -101,7 +101,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
handleReaderToggle: () => void;
|
||||
|
||||
// Computed values
|
||||
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search
|
||||
isPanelVisible: boolean;
|
||||
}
|
||||
|
||||
|
||||
@ -824,7 +824,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
name: t("home.compare.title", "Compare"),
|
||||
component: null,
|
||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
synonyms: getSynonyms(t, "compare"),
|
||||
supportsAutomate: false,
|
||||
|
||||
@ -21,7 +21,6 @@ export interface AppConfig {
|
||||
runningProOrHigher?: boolean;
|
||||
runningEE?: boolean;
|
||||
license?: string;
|
||||
GoogleDriveEnabled?: boolean;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
error?: string;
|
||||
|
||||
98
frontend/src/hooks/useGoogleDrivePicker.ts
Normal file
98
frontend/src/hooks/useGoogleDrivePicker.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* React hook for Google Drive file picker
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
getGoogleDrivePickerService,
|
||||
isGoogleDriveConfigured,
|
||||
getGoogleDriveConfig,
|
||||
} from '../services/googleDrivePickerService';
|
||||
|
||||
interface UseGoogleDrivePickerOptions {
|
||||
multiple?: boolean;
|
||||
mimeTypes?: string;
|
||||
}
|
||||
|
||||
interface UseGoogleDrivePickerReturn {
|
||||
isEnabled: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
openPicker: (options?: UseGoogleDrivePickerOptions) => Promise<File[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use Google Drive file picker
|
||||
*/
|
||||
export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn {
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Check if Google Drive is configured on mount
|
||||
useEffect(() => {
|
||||
const configured = isGoogleDriveConfigured();
|
||||
setIsEnabled(configured);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize the Google Drive service (lazy initialization)
|
||||
*/
|
||||
const initializeService = useCallback(async () => {
|
||||
if (isInitialized) return;
|
||||
|
||||
const config = getGoogleDriveConfig();
|
||||
if (!config) {
|
||||
throw new Error('Google Drive is not configured');
|
||||
}
|
||||
|
||||
const service = getGoogleDrivePickerService();
|
||||
await service.initialize(config);
|
||||
setIsInitialized(true);
|
||||
}, [isInitialized]);
|
||||
|
||||
/**
|
||||
* Open the Google Drive picker
|
||||
*/
|
||||
const openPicker = useCallback(
|
||||
async (options: UseGoogleDrivePickerOptions = {}): Promise<File[]> => {
|
||||
if (!isEnabled) {
|
||||
setError('Google Drive is not configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Initialize service if needed
|
||||
await initializeService();
|
||||
|
||||
// Open picker
|
||||
const service = getGoogleDrivePickerService();
|
||||
const files = await service.openPicker({
|
||||
multiple: options.multiple ?? true,
|
||||
mimeTypes: options.mimeTypes,
|
||||
});
|
||||
|
||||
return files;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to open Google Drive picker';
|
||||
setError(errorMessage);
|
||||
console.error('Google Drive picker error:', err);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isEnabled, initializeService]
|
||||
);
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isLoading,
|
||||
error,
|
||||
openPicker,
|
||||
};
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useToolNavigation } from './useToolNavigation';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { handleUnlessSpecialClick } from '../utils/clickHandlers';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
export interface SidebarNavigationProps {
|
||||
/** Full URL for the navigation (for href attribute) */
|
||||
@ -16,7 +17,7 @@ export interface SidebarNavigationProps {
|
||||
*/
|
||||
export function useSidebarNavigation(): {
|
||||
getHomeNavigation: () => SidebarNavigationProps;
|
||||
getToolNavigation: (toolId: string) => SidebarNavigationProps | null;
|
||||
getToolNavigation: (toolId: ToolId) => SidebarNavigationProps | null;
|
||||
} {
|
||||
const { getToolNavigation: getToolNavProps } = useToolNavigation();
|
||||
const { getSelectedTool } = useToolWorkflow();
|
||||
@ -32,14 +33,14 @@ export function useSidebarNavigation(): {
|
||||
return { href, onClick: defaultNavClick };
|
||||
}, [defaultNavClick]);
|
||||
|
||||
const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => {
|
||||
const getToolNavigation = useCallback((toolId: ToolId): SidebarNavigationProps | null => {
|
||||
// Handle special nav sections that aren't tools
|
||||
if (toolId === 'read') return { href: '/read', onClick: defaultNavClick };
|
||||
if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick };
|
||||
|
||||
const tool = getSelectedTool(toolId);
|
||||
if (!tool) return null;
|
||||
|
||||
|
||||
// Delegate to useToolNavigation for true tools
|
||||
return getToolNavProps(toolId, tool);
|
||||
}, [getToolNavProps, getSelectedTool, defaultNavClick]);
|
||||
@ -48,4 +49,4 @@ export function useSidebarNavigation(): {
|
||||
getHomeNavigation,
|
||||
getToolNavigation
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
||||
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "../data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { FileId } from '../types/file';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolSelectedFileIds: FileId[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
|
||||
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
@ -30,9 +31,9 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
const toolRegistry: Record<string, ToolRegistryEntry> = useMemo(() => {
|
||||
const availableToolRegistry: Record<string, ToolRegistryEntry> = {};
|
||||
Object.keys(baseRegistry).forEach(toolKey => {
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry];
|
||||
availableToolRegistry[toolKey] = {
|
||||
@ -45,7 +46,7 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, t, baseRegistry]);
|
||||
|
||||
const getSelectedTool = useCallback((toolKey: string | null): ToolRegistryEntry | null => {
|
||||
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
}, [toolRegistry]);
|
||||
|
||||
|
||||
@ -2,9 +2,10 @@ import { useMemo } from 'react';
|
||||
|
||||
import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
type SubcategoryIdMap = {
|
||||
[subcategoryId in SubcategoryId]: Array<{ id: string /* FIX ME: Should be ToolId */; tool: ToolRegistryEntry }>;
|
||||
[subcategoryId in SubcategoryId]: Array<{ id: ToolId; tool: ToolRegistryEntry }>;
|
||||
}
|
||||
|
||||
type GroupedTools = {
|
||||
@ -14,7 +15,7 @@ type GroupedTools = {
|
||||
export interface SubcategoryGroup {
|
||||
subcategoryId: SubcategoryId;
|
||||
tools: {
|
||||
id: string /* FIX ME: Should be ToolId */;
|
||||
id: ToolId;
|
||||
tool: ToolRegistryEntry;
|
||||
}[];
|
||||
};
|
||||
@ -28,7 +29,7 @@ export interface ToolSection {
|
||||
};
|
||||
|
||||
export function useToolSections(
|
||||
filteredTools: Array<{ item: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry]; matchedText?: string }>,
|
||||
filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>,
|
||||
searchQuery?: string
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
@ -37,7 +38,7 @@ export function useToolSections(
|
||||
if (!filteredTools || !Array.isArray(filteredTools)) {
|
||||
return {} as GroupedTools;
|
||||
}
|
||||
|
||||
|
||||
const grouped = {} as GroupedTools;
|
||||
filteredTools.forEach(({ item: [id, tool] }) => {
|
||||
const categoryId = tool.categoryId;
|
||||
@ -105,11 +106,11 @@ export function useToolSections(
|
||||
if (!filteredTools || !Array.isArray(filteredTools)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
const subMap = {} as SubcategoryIdMap;
|
||||
const seen = new Set<string /* FIX ME: Should be ToolId */>();
|
||||
const seen = new Set<ToolId>();
|
||||
filteredTools.forEach(({ item: [id, tool] }) => {
|
||||
const toolId = id as string /* FIX ME: Should be ToolId */;
|
||||
const toolId = id as ToolId;
|
||||
if (seen.has(toolId)) return;
|
||||
seen.add(toolId);
|
||||
const sub = tool.subcategoryId;
|
||||
|
||||
295
frontend/src/services/googleDrivePickerService.ts
Normal file
295
frontend/src/services/googleDrivePickerService.ts
Normal file
@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Google Drive Picker Service
|
||||
* Handles Google Drive file picker integration
|
||||
*/
|
||||
|
||||
import { loadScript } from '../utils/scriptLoader';
|
||||
|
||||
const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
|
||||
const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken';
|
||||
|
||||
interface GoogleDriveConfig {
|
||||
clientId: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
interface PickerOptions {
|
||||
multiple?: boolean;
|
||||
mimeTypes?: string | null;
|
||||
}
|
||||
|
||||
// Expandable mime types for Google Picker
|
||||
const expandableMimeTypes: Record<string, string[]> = {
|
||||
'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert file input accept attribute to Google Picker mime types
|
||||
*/
|
||||
function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
|
||||
if (!accept || accept === '' || accept.includes('*/*')) {
|
||||
// Setting null will accept all supported mimetypes
|
||||
return null;
|
||||
}
|
||||
|
||||
const mimeTypes: string[] = [];
|
||||
accept.split(',').forEach((part) => {
|
||||
const trimmedPart = part.trim();
|
||||
if (!(trimmedPart in expandableMimeTypes)) {
|
||||
mimeTypes.push(trimmedPart);
|
||||
return;
|
||||
}
|
||||
|
||||
expandableMimeTypes[trimmedPart].forEach((mimeType) => {
|
||||
mimeTypes.push(mimeType);
|
||||
});
|
||||
});
|
||||
|
||||
return mimeTypes.join(',').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
class GoogleDrivePickerService {
|
||||
private config: GoogleDriveConfig | null = null;
|
||||
private tokenClient: any = null;
|
||||
private accessToken: string | null = null;
|
||||
private gapiLoaded = false;
|
||||
private gisLoaded = false;
|
||||
|
||||
constructor() {
|
||||
this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service with credentials
|
||||
*/
|
||||
async initialize(config: GoogleDriveConfig): Promise<void> {
|
||||
this.config = config;
|
||||
|
||||
// Load Google APIs
|
||||
await Promise.all([
|
||||
this.loadGapi(),
|
||||
this.loadGis(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google API client
|
||||
*/
|
||||
private async loadGapi(): Promise<void> {
|
||||
if (this.gapiLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://apis.google.com/js/api.js',
|
||||
id: 'gapi-script',
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
window.gapi.load('client:picker', async () => {
|
||||
await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest');
|
||||
this.gapiLoaded = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Identity Services
|
||||
*/
|
||||
private async loadGis(): Promise<void> {
|
||||
if (this.gisLoaded) return;
|
||||
|
||||
await loadScript({
|
||||
src: 'https://accounts.google.com/gsi/client',
|
||||
id: 'gis-script',
|
||||
});
|
||||
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive config not initialized');
|
||||
}
|
||||
|
||||
this.tokenClient = window.google.accounts.oauth2.initTokenClient({
|
||||
client_id: this.config.clientId,
|
||||
scope: SCOPES,
|
||||
callback: () => {}, // Will be overridden during picker creation
|
||||
});
|
||||
|
||||
this.gisLoaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the Google Drive picker
|
||||
*/
|
||||
async openPicker(options: PickerOptions = {}): Promise<File[]> {
|
||||
if (!this.config) {
|
||||
throw new Error('Google Drive service not initialized');
|
||||
}
|
||||
|
||||
// Request access token
|
||||
await this.requestAccessToken();
|
||||
|
||||
// Create and show picker
|
||||
return this.createPicker(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request access token from Google
|
||||
*/
|
||||
private requestAccessToken(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.tokenClient) {
|
||||
reject(new Error('Token client not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.tokenClient.callback = (response: any) => {
|
||||
if (response.error !== undefined) {
|
||||
reject(new Error(response.error));
|
||||
return;
|
||||
}
|
||||
if(response.access_token == null){
|
||||
reject(new Error("No acces token in response"));
|
||||
}
|
||||
|
||||
this.accessToken = response.access_token;
|
||||
sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? "");
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.tokenClient.requestAccessToken({
|
||||
prompt: this.accessToken === null ? 'consent' : '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and display the Google Picker
|
||||
*/
|
||||
private createPicker(options: PickerOptions): Promise<File[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.config || !this.accessToken) {
|
||||
reject(new Error('Not initialized or no access token'));
|
||||
return;
|
||||
}
|
||||
|
||||
const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined);
|
||||
|
||||
const view1 = new window.google.picker.DocsView().setIncludeFolders(true);
|
||||
if (mimeTypes !== null) {
|
||||
view1.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const view2 = new window.google.picker.DocsView()
|
||||
.setIncludeFolders(true)
|
||||
.setEnableDrives(true);
|
||||
if (mimeTypes !== null) {
|
||||
view2.setMimeTypes(mimeTypes);
|
||||
}
|
||||
|
||||
const builder = new window.google.picker.PickerBuilder()
|
||||
.setDeveloperKey(this.config.apiKey)
|
||||
.setAppId(this.config.appId)
|
||||
.setOAuthToken(this.accessToken)
|
||||
.addView(view1)
|
||||
.addView(view2)
|
||||
.setCallback((data: any) => this.pickerCallback(data, resolve, reject));
|
||||
|
||||
if (options.multiple) {
|
||||
builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
|
||||
}
|
||||
|
||||
const picker = builder.build();
|
||||
picker.setVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle picker selection callback
|
||||
*/
|
||||
private async pickerCallback(
|
||||
data: any,
|
||||
resolve: (files: File[]) => void,
|
||||
reject: (error: Error) => void
|
||||
): Promise<void> {
|
||||
if (data.action === window.google.picker.Action.PICKED) {
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => {
|
||||
const fileId = pickedFile[window.google.picker.Document.ID];
|
||||
const res = await window.gapi.client.drive.files.get({
|
||||
fileId: fileId,
|
||||
alt: 'media',
|
||||
});
|
||||
|
||||
// Convert response body to File object
|
||||
const file = new File(
|
||||
[new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))],
|
||||
pickedFile.name,
|
||||
{
|
||||
type: pickedFile.mimeType,
|
||||
lastModified: pickedFile.lastModified,
|
||||
}
|
||||
);
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
resolve(files);
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error('Failed to download files'));
|
||||
}
|
||||
} else if (data.action === window.google.picker.Action.CANCEL) {
|
||||
resolve([]); // User cancelled, return empty array
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out and revoke access token
|
||||
*/
|
||||
signOut(): void {
|
||||
if (this.accessToken) {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_ID);
|
||||
window.google?.accounts.oauth2.revoke(this.accessToken, () => {});
|
||||
this.accessToken = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let serviceInstance: GoogleDrivePickerService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the Google Drive picker service instance
|
||||
*/
|
||||
export function getGoogleDrivePickerService(): GoogleDrivePickerService {
|
||||
if (!serviceInstance) {
|
||||
serviceInstance = new GoogleDrivePickerService();
|
||||
}
|
||||
return serviceInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Google Drive credentials are configured
|
||||
*/
|
||||
export function isGoogleDriveConfigured(): boolean {
|
||||
const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
|
||||
const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
|
||||
|
||||
return !!(clientId && apiKey && appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Drive configuration from environment variables
|
||||
*/
|
||||
export function getGoogleDriveConfig(): GoogleDriveConfig | null {
|
||||
if (!isGoogleDriveConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID,
|
||||
apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY,
|
||||
appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID,
|
||||
};
|
||||
}
|
||||
55
frontend/src/utils/scriptLoader.ts
Normal file
55
frontend/src/utils/scriptLoader.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Utility for dynamically loading external scripts
|
||||
*/
|
||||
|
||||
interface ScriptLoadOptions {
|
||||
src: string;
|
||||
id?: string;
|
||||
async?: boolean;
|
||||
defer?: boolean;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const loadedScripts = new Set<string>();
|
||||
|
||||
export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check if already loaded
|
||||
const scriptId = id || src;
|
||||
if (loadedScripts.has(scriptId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if script already exists in DOM
|
||||
const existingScript = id ? document.getElementById(id) : document.querySelector(`script[src="${src}"]`);
|
||||
if (existingScript) {
|
||||
loadedScripts.add(scriptId);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and append script
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
if (id) script.id = id;
|
||||
script.async = async;
|
||||
script.defer = defer;
|
||||
|
||||
script.onload = () => {
|
||||
loadedScripts.add(scriptId);
|
||||
if (onLoad) onLoad();
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error(`Failed to load script: ${src}`));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export function isScriptLoaded(idOrSrc: string): boolean {
|
||||
return loadedScripts.has(idOrSrc);
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { ToolId } from "src/types/toolId";
|
||||
import { ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch";
|
||||
|
||||
export interface RankedToolItem {
|
||||
item: [string, ToolRegistryEntry];
|
||||
item: [ToolId, ToolRegistryEntry];
|
||||
matchedText?: string;
|
||||
}
|
||||
|
||||
@ -10,18 +11,18 @@ export function filterToolRegistryByQuery(
|
||||
toolRegistry: Record<string, ToolRegistryEntry>,
|
||||
query: string
|
||||
): RankedToolItem[] {
|
||||
const entries = Object.entries(toolRegistry);
|
||||
const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][];
|
||||
if (!query.trim()) {
|
||||
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
|
||||
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
|
||||
}
|
||||
|
||||
const nq = normalizeForSearch(query);
|
||||
const threshold = minScoreForQuery(query);
|
||||
|
||||
const exactName: Array<{ id: string; tool: ToolRegistryEntry; pos: number }> = [];
|
||||
const exactSyn: Array<{ id: string; tool: ToolRegistryEntry; text: string; pos: number }> = [];
|
||||
const fuzzyName: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = [];
|
||||
const fuzzySyn: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = [];
|
||||
const exactName: Array<{ id: ToolId; tool: ToolRegistryEntry; pos: number }> = [];
|
||||
const exactSyn: Array<{ id: ToolId; tool: ToolRegistryEntry; text: string; pos: number }> = [];
|
||||
const fuzzyName: Array<{ id: ToolId; tool: ToolRegistryEntry; score: number; text: string }> = [];
|
||||
const fuzzySyn: Array<{ id: ToolId; tool: ToolRegistryEntry; score: number; text: string }> = [];
|
||||
|
||||
for (const [id, tool] of entries) {
|
||||
const nameNorm = normalizeForSearch(tool.name || '');
|
||||
@ -78,7 +79,7 @@ export function filterToolRegistryByQuery(
|
||||
const seen = new Set<string>();
|
||||
const ordered: RankedToolItem[] = [];
|
||||
|
||||
const push = (id: string, tool: ToolRegistryEntry, matchedText?: string) => {
|
||||
const push = (id: ToolId, tool: ToolRegistryEntry, matchedText?: string) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
ordered.push({ item: [id, tool], matchedText });
|
||||
@ -92,7 +93,7 @@ export function filterToolRegistryByQuery(
|
||||
if (ordered.length > 0) return ordered;
|
||||
|
||||
// Fallback: return everything unchanged
|
||||
return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
|
||||
return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user