ui-nyla/src/pages/views/neutralization/NeutralizationView.tsx

750 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NeutralizationView
*
* Combined view for the Neutralization feature with two tabs:
* - Configuration: Configure names to parse.
* - Playground: Upload file, paste text, or neutralize SharePoint files. Resolve placeholders.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
import { useFileContext } from '../../../contexts/FileContext';
import { useConnections, type Connection } from '../../../hooks/useConnections';
import {
getNeutralizationConfig,
saveNeutralizationConfig,
processSharepointFiles,
neutralizeText,
neutralizeFile,
resolveText,
type NeutralizationConfig,
} from '../../../api/neutralizationApi';
import { Tabs } from '../../../components/UiComponents/Tabs';
import api from '../../../api';
import styles from './NeutralizationViews.module.css';
interface SiteOption {
value: string;
label: string;
siteId: string;
siteName: string;
webUrl: string;
path: string;
}
interface FolderOption {
value: string;
label: string;
siteId: string;
folderName: string;
path: string;
}
// =============================================================================
// CONFIGURATION TAB
// =============================================================================
const ConfigTab: React.FC = () => {
const { instance } = useCurrentInstance();
const { showSuccess, showError } = useToast();
const [config, setConfig] = useState<NeutralizationConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [enabled, setEnabled] = useState(true);
const [namesToParse, setNamesToParse] = useState('');
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getNeutralizationConfig();
setConfig(data);
setEnabled(data.enabled);
setNamesToParse(data.namesToParse || '');
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string | { msg?: string }[] } }; message?: string };
const detail = errObj.response?.data?.detail;
const message =
(typeof detail === 'string' ? detail : null) ||
(Array.isArray(detail) ? detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ') : null) ||
errObj.message ||
'Error loading configuration';
setError(message);
showError('Error', message);
} finally {
setLoading(false);
}
}, [showError]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const mandateId = instance?.mandateId;
const featureInstanceId = instance?.id;
if (!mandateId || !featureInstanceId) {
throw new Error('Missing mandate or instance context');
}
await saveNeutralizationConfig({
mandateId,
featureInstanceId,
userId: config?.userId || '',
enabled,
namesToParse,
sharepointSourcePath: config?.sharepointSourcePath || '',
sharepointTargetPath: config?.sharepointTargetPath || '',
});
showSuccess('Saved', 'Configuration saved successfully.');
await loadConfig();
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const message =
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
errObj.message ||
'Failed to save configuration';
setError(message);
showError('Error', message);
} finally {
setSaving(false);
}
};
const dismissError = () => setError(null);
if (loading) {
return <div className={styles.loading}>Loading configuration...</div>;
}
return (
<div className={styles.configSection}>
<h3 className={styles.sectionTitle}>Neutralization Configuration</h3>
<p className={styles.sectionDescription}>
Configure data neutralization settings for this instance.
</p>
<div className={styles.configCard}>
{error && (
<div className={styles.errorMessage}>
<span>{error}</span>
<button type="button" onClick={dismissError} aria-label="Dismiss">
×
</button>
</div>
)}
<div className={styles.formRow}>
<label htmlFor="names-to-parse">Names to Parse (one per line):</label>
<textarea
id="names-to-parse"
className={styles.textareaField}
value={namesToParse}
onChange={(e) => setNamesToParse(e.target.value)}
placeholder="Enter names to parse, one per line..."
rows={6}
/>
</div>
<div className={styles.buttonRow}>
<button
type="button"
className={styles.primaryButton}
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
</div>
</div>
);
};
// =============================================================================
// PLAYGROUND TAB
// =============================================================================
const PlaygroundTab: React.FC = () => {
const { showSuccess, showError } = useToast();
const { refetch: refetchFiles, handleFileDownload } = useFileContext();
const { connections } = useConnections();
const msftConnections = connections.filter(
(c: Connection) => (c.authority === 'msft' || c.type === 'msft') && c.status === 'active'
);
const hasMsftConnection = msftConnections.length > 0;
const msftConnection = msftConnections[0] ?? null;
const [inputText, setInputText] = useState('');
const [neutralizedText, setNeutralizedText] = useState('');
const [neutralizing, setNeutralizing] = useState(false);
const [resolving, setResolving] = useState(false);
const [sharepointSourcePath, setSharepointSourcePath] = useState('');
const [sharepointTargetPath, setSharepointTargetPath] = useState('');
const [processingSharepoint, setProcessingSharepoint] = useState(false);
const [browseTarget, setBrowseTarget] = useState<'source' | 'target' | null>(null);
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [folderOptions, setFolderOptions] = useState<FolderOption[]>([]);
const [selectedSite, setSelectedSite] = useState<SiteOption | null>(null);
const [browseCurrentPath, setBrowseCurrentPath] = useState('');
const [isLoadingSites, setIsLoadingSites] = useState(false);
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
const [browseError, setBrowseError] = useState<string | null>(null);
const getConnectionReference = useCallback((conn: Connection): string => {
return conn.connectionReference || `connection:${conn.authority || conn.type}:${conn.externalUsername}`;
}, []);
const loadSiteOptions = useCallback(async () => {
if (!msftConnection) return;
setIsLoadingSites(true);
setBrowseError(null);
try {
const ref = getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: ref });
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
setSiteOptions(response.data || []);
} catch (err: unknown) {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
setBrowseError(typeof detail === 'string' ? detail : 'Failed to load SharePoint sites');
setSiteOptions([]);
} finally {
setIsLoadingSites(false);
}
}, [msftConnection, getConnectionReference]);
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
if (!msftConnection || !siteId) return;
setIsLoadingFolders(true);
setBrowseError(null);
try {
const ref = getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: ref, siteId });
if (path) params.append('path', path);
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
setFolderOptions(response.data || []);
} catch (err: unknown) {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
setBrowseError(typeof detail === 'string' ? detail : 'Failed to load folders');
setFolderOptions([]);
} finally {
setIsLoadingFolders(false);
}
}, [msftConnection, getConnectionReference]);
const handleOpenBrowse = useCallback((target: 'source' | 'target') => {
setBrowseTarget(target);
setSelectedSite(null);
setBrowseCurrentPath('');
setFolderOptions([]);
setBrowseError(null);
loadSiteOptions();
}, [loadSiteOptions]);
const handleCloseBrowse = useCallback(() => {
setBrowseTarget(null);
setSelectedSite(null);
setBrowseCurrentPath('');
}, []);
const handleSiteChange = useCallback((siteId: string) => {
const site = siteOptions.find((s) => s.siteId === siteId) || null;
setSelectedSite(site);
setBrowseCurrentPath('');
if (site) {
loadFolderOptions(site.siteId, '');
} else {
setFolderOptions([]);
}
}, [siteOptions, loadFolderOptions]);
const handleFolderNavigate = useCallback((folder: FolderOption) => {
setBrowseCurrentPath(folder.path);
loadFolderOptions(selectedSite!.siteId, folder.path);
}, [selectedSite, loadFolderOptions]);
const handleFolderSelect = useCallback((folder: FolderOption) => {
if (!selectedSite) return;
const fullPath = selectedSite.webUrl + (folder.path ? '/' + folder.path : '');
if (browseTarget === 'source') setSharepointSourcePath(fullPath);
else setSharepointTargetPath(fullPath);
handleCloseBrowse();
}, [selectedSite, browseTarget, handleCloseBrowse]);
const handleSelectCurrentFolder = useCallback(() => {
if (!selectedSite) return;
const fullPath = selectedSite.webUrl + (browseCurrentPath ? '/' + browseCurrentPath : '');
if (browseTarget === 'source') setSharepointSourcePath(fullPath);
else setSharepointTargetPath(fullPath);
handleCloseBrowse();
}, [selectedSite, browseCurrentPath, browseTarget, handleCloseBrowse]);
const handleGoUp = useCallback(() => {
if (!browseCurrentPath || !selectedSite) return;
const parts = browseCurrentPath.split('/');
parts.pop();
const parentPath = parts.join('/');
setBrowseCurrentPath(parentPath);
loadFolderOptions(selectedSite.siteId, parentPath);
}, [browseCurrentPath, selectedSite, loadFolderOptions]);
useEffect(() => {
let mounted = true;
getNeutralizationConfig()
.then((data) => {
if (mounted) {
setSharepointSourcePath(data.sharepointSourcePath || '');
setSharepointTargetPath(data.sharepointTargetPath || '');
}
})
.catch(() => {});
return () => { mounted = false; };
}, []);
const [fileResult, setFileResult] = useState<{
base64?: string;
fileName?: string;
mimeType?: string;
fileId?: string;
} | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleNeutralize = async () => {
if (!inputText.trim()) {
showError('Error', 'Please enter text to neutralize.');
return;
}
setNeutralizing(true);
setFileResult(null);
try {
const result = await neutralizeText(inputText);
setNeutralizedText(result.neutralized_text || '');
showSuccess('Done', 'Text neutralized successfully.');
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const message =
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
errObj.message ||
'Failed to neutralize text';
showError('Error', message);
} finally {
setNeutralizing(false);
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setNeutralizing(true);
setFileResult(null);
setNeutralizedText('');
try {
const result = await neutralizeFile(file);
const base64Data = result.neutralized_file_base64 || (typeof result.neutralized_bytes === 'string' ? result.neutralized_bytes : null);
const fileName = result.neutralized_file_name || `neutralized_${file.name}`;
if (base64Data) {
setFileResult({
base64: base64Data,
fileName,
mimeType: result.mime_type || 'application/octet-stream',
fileId: result.neutralized_file_id,
});
setNeutralizedText('');
showSuccess('Done', 'File neutralized. Download below or find it in your Files.');
} else if (result.neutralized_text !== undefined) {
setNeutralizedText(result.neutralized_text || '');
setFileResult(result.neutralized_file_id ? { fileName, fileId: result.neutralized_file_id } : { fileName });
showSuccess('Done', result.neutralized_file_id ? 'File neutralized. Download or find in Files.' : 'File neutralized.');
} else {
const err = (result.processed_info as { error?: string })?.error || 'No result returned';
console.warn('[Neutralization] Unexpected result:', result);
showError('Error', err);
}
if (result.original_file_id || result.neutralized_file_id) {
refetchFiles();
}
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const message =
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
errObj.message ||
'Failed to neutralize file';
showError('Error', message);
} finally {
setNeutralizing(false);
e.target.value = '';
}
};
const handleDownload = async () => {
if (!fileResult?.fileName) return;
if (fileResult.fileId) {
try {
await handleFileDownload(fileResult.fileId, fileResult.fileName);
} catch {
showError('Error', 'Failed to download file');
}
return;
}
if (fileResult.base64) {
try {
const bin = atob(fileResult.base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const blob = new Blob([bytes], { type: fileResult.mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileResult.fileName;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
showError('Error', 'Failed to download file');
}
return;
}
if (neutralizedText) {
const blob = new Blob([neutralizedText], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileResult.fileName;
a.click();
URL.revokeObjectURL(url);
return;
}
showError('Error', 'No file data available to download. The neutralized file may not have been generated.');
};
const handleResolve = async () => {
if (!neutralizedText.trim()) {
showError('Error', 'No neutralized text to resolve.');
return;
}
setResolving(true);
try {
const result = await resolveText(neutralizedText);
setNeutralizedText(result.resolved_text || '');
showSuccess('Done', 'Text resolved successfully.');
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const message =
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
errObj.message ||
'Failed to resolve text';
showError('Error', message);
} finally {
setResolving(false);
}
};
const handleClear = () => {
setNeutralizedText('');
setFileResult(null);
};
const handleProcessSharepoint = async () => {
if (!sharepointSourcePath.trim() || !sharepointTargetPath.trim()) {
showError('Error', 'Both SharePoint source and target paths are required.');
return;
}
setProcessingSharepoint(true);
try {
const result = await processSharepointFiles(sharepointSourcePath, sharepointTargetPath);
if (result.success) {
showSuccess('Done', result.message || 'SharePoint files processed successfully.');
} else {
showError('Error', result.message || 'Processing failed');
}
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const message =
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
errObj.message ||
'Failed to process SharePoint files';
showError('Error', message);
} finally {
setProcessingSharepoint(false);
}
};
return (
<>
<div className={styles.playgroundSection}>
<h3 className={styles.sectionTitle}>Manual Text &amp; File Neutralization</h3>
<p className={styles.sectionDescription}>
Upload a file, paste text, or neutralize SharePoint files. Use &quot;Resolve Text&quot; to convert placeholders back to the original text.
</p>
<div className={styles.playgroundCard}>
<div className={styles.formRow}>
<label>1. Upload file to neutralize:</label>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.docx,.xlsx,.xlsm,.pptx,.txt,.csv,.json"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<div className={styles.buttonRow}>
<button
type="button"
className={styles.primaryButton}
onClick={() => fileInputRef.current?.click()}
disabled={neutralizing}
>
{neutralizing ? 'Processing...' : 'Upload & Neutralize File'}
</button>
{fileResult && (
<button
type="button"
className={styles.primaryButton}
onClick={handleDownload}
>
Download {fileResult.fileName || 'Neutralized File'}
</button>
)}
</div>
{fileResult?.fileId && (
<p className={styles.sectionDescription} style={{ marginTop: 4, marginBottom: 0 }}>
File saved to your Files. You can also download it from Basisdaten Files.
</p>
)}
</div>
<div className={styles.formRow}>
<label htmlFor="input-text">2. Or paste text:</label>
<textarea
id="input-text"
className={styles.textareaField}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Enter text to neutralize..."
rows={6}
/>
</div>
<div className={styles.buttonRow}>
<button
type="button"
className={styles.primaryButton}
onClick={handleNeutralize}
disabled={neutralizing}
>
{neutralizing ? 'Neutralizing...' : 'Neutralize Text'}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={handleResolve}
disabled={resolving || !neutralizedText.trim()}
>
{resolving ? 'Resolving...' : 'Resolve Text'}
</button>
</div>
<div className={styles.formRow}>
<label>3. Or neutralize SharePoint files:</label>
<div className={styles.formRow} style={{ gap: '0.75rem', marginTop: 0 }}>
<div>
<label htmlFor="sharepoint-source" style={{ fontSize: '0.8125rem', fontWeight: 400 }}>Source:</label>
<div className={styles.inputWithButton}>
<input
id="sharepoint-source"
type="text"
value={sharepointSourcePath}
onChange={(e) => setSharepointSourcePath(e.target.value)}
placeholder="SharePoint source path..."
/>
<button
type="button"
className={styles.secondaryButton}
onClick={() => handleOpenBrowse('source')}
disabled={!hasMsftConnection}
title={hasMsftConnection ? 'Browse SharePoint' : 'Add a Microsoft connection first (Basisdaten → Connections)'}
>
Browse
</button>
</div>
</div>
<div>
<label htmlFor="sharepoint-target" style={{ fontSize: '0.8125rem', fontWeight: 400 }}>Target:</label>
<div className={styles.inputWithButton}>
<input
id="sharepoint-target"
type="text"
value={sharepointTargetPath}
onChange={(e) => setSharepointTargetPath(e.target.value)}
placeholder="SharePoint target path..."
/>
<button
type="button"
className={styles.secondaryButton}
onClick={() => handleOpenBrowse('target')}
disabled={!hasMsftConnection}
title={hasMsftConnection ? 'Browse SharePoint' : 'Add a Microsoft connection first (Basisdaten → Connections)'}
>
Browse
</button>
</div>
</div>
</div>
<div className={styles.buttonRow}>
<button
type="button"
className={styles.secondaryButton}
onClick={handleProcessSharepoint}
disabled={processingSharepoint || !sharepointSourcePath.trim() || !sharepointTargetPath.trim()}
title={
!sharepointSourcePath.trim() || !sharepointTargetPath.trim()
? 'Enter source and target SharePoint paths to enable'
: undefined
}
>
{processingSharepoint ? 'Processing...' : 'Neutralize SharePoint Files'}
</button>
</div>
</div>
{neutralizedText && !fileResult && (
<div className={styles.formRow}>
<label htmlFor="neutralized-text">Neutralized Text:</label>
<div
id="neutralized-text"
className={styles.outputBlock}
role="region"
aria-live="polite"
>
{neutralizedText}
</div>
</div>
)}
{(neutralizedText || fileResult) && (
<button
type="button"
className={styles.clearLink}
onClick={handleClear}
>
Clear Result
</button>
)}
</div>
</div>
{browseTarget && (
<div className={styles.modalOverlay} onClick={handleCloseBrowse}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<h3 className={styles.modalTitle}>
Browse SharePoint {browseTarget === 'source' ? 'Source' : 'Target'} Folder
</h3>
{browseError && (
<div className={styles.errorMessage} style={{ marginBottom: '0.75rem' }}>
{browseError}
</div>
)}
{isLoadingSites ? (
<div className={styles.loading}>Loading sites...</div>
) : (
<>
<label htmlFor="browse-site" style={{ fontSize: '0.875rem', fontWeight: 500 }}>Site:</label>
<select
id="browse-site"
className={styles.folderSelect}
value={selectedSite?.siteId || ''}
onChange={(e) => handleSiteChange(e.target.value)}
>
<option value="">Select a site...</option>
{siteOptions.map((site) => (
<option key={site.siteId} value={site.siteId}>
{site.label}
</option>
))}
</select>
</>
)}
{selectedSite && (
<>
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', margin: '0 0 0.5rem 0' }}>
Current path: <strong>{selectedSite.path}{browseCurrentPath ? '/' + browseCurrentPath : ''}</strong>
</p>
{isLoadingFolders ? (
<div className={styles.loading}>Loading folders...</div>
) : (
<div>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
{browseCurrentPath && (
<button type="button" className={styles.secondaryButton} onClick={handleGoUp}>
Go Up
</button>
)}
<button
type="button"
className={styles.primaryButton}
onClick={handleSelectCurrentFolder}
>
Select This Folder
</button>
</div>
<div className={styles.folderList}>
{folderOptions.map((folder) => (
<div key={folder.value} className={styles.folderItem}>
<span
className={styles.folderName}
onClick={() => handleFolderNavigate(folder)}
>
📁 {folder.label}
</span>
<button
type="button"
className={styles.selectButton}
onClick={(e) => { e.stopPropagation(); handleFolderSelect(folder); }}
>
Select
</button>
</div>
))}
{folderOptions.length === 0 && (
<div style={{ padding: '0.75rem', color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
No subfolders in this location
</div>
)}
</div>
</div>
)}
</>
)}
<div style={{ marginTop: '1rem' }}>
<button type="button" className={styles.secondaryButton} onClick={handleCloseBrowse}>
Cancel
</button>
</div>
</div>
</div>
)}
</>
);
};
// =============================================================================
// MAIN VIEW
// =============================================================================
const TABS = [
{ id: 'config', label: 'Configuration', content: <ConfigTab /> },
{ id: 'playground', label: 'Playground', content: <PlaygroundTab /> },
];
export const NeutralizationView: React.FC = () => {
return (
<Tabs tabs={TABS} defaultTabId="playground" />
);
};
export default NeutralizationView;