mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Phase 2, part 4. Baseline 50 -> 15.
Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.
Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message. useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.
Two library gaps closed, both found by call sites needing them:
ActionInput is a forwardRef now. Its ref reaches the input rather than the
wrapper, because callers use it to focus or select the field -- a ref to the
surrounding div would do neither, which is why one call site had resorted to
hand-copying the component's markup.
Header takes a `color` token name. Three separate call sites had reached for
an inline style to colour a heading that carries meaning ("3 items to add"
against "2 to remove"), which is how a hex ends up in markup.
Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used. Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.
Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
458 lines
18 KiB
JavaScript
458 lines
18 KiB
JavaScript
import React, {useCallback, useEffect, useState} from 'react';
|
|
import {Button, Icon, Label, Loading, Message, Modal, Progress, Stack, Table} from '../ui';
|
|
import {APIButton} from '../Common';
|
|
import {
|
|
executeCollectionReorganization,
|
|
fetchCollectionConflicts,
|
|
getReorganizationStatus,
|
|
previewCollectionReorganization
|
|
} from '../../api';
|
|
import {useReorganizationStatus} from '../../contexts/FileWorkerStatusContext';
|
|
import {ConflictResolutionModal} from './ConflictResolutionModal';
|
|
|
|
/**
|
|
* Modal component for previewing and executing collection file reorganization.
|
|
*
|
|
* Shows a preview of files that will be moved when the file_name_format changes,
|
|
* allows user to execute the reorganization, and tracks progress.
|
|
*
|
|
* @param {boolean} open - Whether the modal is open
|
|
* @param {Function} onClose - Callback when modal is closed
|
|
* @param {number} collectionId - ID of the collection to reorganize
|
|
* @param {string} collectionName - Name of the collection for display
|
|
* @param {Function} onComplete - Optional callback when reorganization completes
|
|
* @param {boolean} needsReorganization - Whether the collection.needs_reorganization flag is true
|
|
* @param {string} collectionKind - 'channel' or 'domain' for conflict resolution modal
|
|
*/
|
|
export function CollectionReorganizeModal({
|
|
open,
|
|
onClose,
|
|
collectionId,
|
|
collectionName,
|
|
onComplete,
|
|
needsReorganization,
|
|
collectionKind,
|
|
}) {
|
|
const [loading, setLoading] = useState(false);
|
|
const [preview, setPreview] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const [jobId, setJobId] = useState(null);
|
|
const [status, setStatus] = useState(null);
|
|
const [polling, setPolling] = useState(false);
|
|
const [showConflictModal, setShowConflictModal] = useState(false);
|
|
const [loadingConflicts, setLoadingConflicts] = useState(false);
|
|
|
|
// Check if this collection is currently being reorganized
|
|
const {
|
|
isReorganizing,
|
|
taskType,
|
|
collectionId: activeCollectionId,
|
|
workerStatus,
|
|
} = useReorganizationStatus();
|
|
|
|
// Load preview when modal opens, or resume if already reorganizing
|
|
useEffect(() => {
|
|
if (open && collectionId) {
|
|
// Check if THIS collection is currently being reorganized
|
|
const isThisActive = isReorganizing &&
|
|
taskType === 'reorganize' &&
|
|
activeCollectionId === collectionId;
|
|
|
|
if (isThisActive) {
|
|
// Resume: show current progress from worker status
|
|
// Don't set a fake job ID - we'll watch context instead
|
|
setLoading(false);
|
|
setPreview(null);
|
|
setJobId(null);
|
|
setStatus({
|
|
status: workerStatus.status,
|
|
total: workerStatus.operation_total,
|
|
completed: workerStatus.operation_processed,
|
|
percent: workerStatus.operation_percent,
|
|
});
|
|
setPolling(false); // Don't poll job endpoint - watch context instead
|
|
} else if (!polling && !jobId) {
|
|
// Normal: fetch preview (only if not already polling/resuming)
|
|
setLoading(true);
|
|
setError(null);
|
|
setPreview(null);
|
|
setJobId(null);
|
|
setStatus(null);
|
|
|
|
previewCollectionReorganization(collectionId)
|
|
.then(data => {
|
|
setPreview(data);
|
|
setLoading(false);
|
|
})
|
|
.catch(err => {
|
|
setError(err.message);
|
|
setLoading(false);
|
|
});
|
|
}
|
|
}
|
|
}, [open, collectionId, isReorganizing, taskType, activeCollectionId]);
|
|
|
|
// Poll for status when we have a job ID
|
|
// Uses setTimeout instead of setInterval to wait for each request to complete
|
|
// before scheduling the next one, preventing request pile-up on slow connections
|
|
useEffect(() => {
|
|
if (!jobId || !polling) return;
|
|
|
|
let timeoutId = null;
|
|
let cancelled = false;
|
|
|
|
const pollStatus = async () => {
|
|
if (cancelled) return;
|
|
|
|
try {
|
|
const statusData = await getReorganizationStatus(collectionId, jobId);
|
|
if (cancelled) return;
|
|
|
|
setStatus(statusData);
|
|
|
|
if (statusData.status === 'complete') {
|
|
setPolling(false);
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
} else if (statusData.status === 'error' || statusData.error) {
|
|
setPolling(false);
|
|
setError(statusData.error || 'Reorganization failed');
|
|
} else {
|
|
// Schedule next poll after this one completes
|
|
timeoutId = setTimeout(pollStatus, 1000);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to get reorganization status', err);
|
|
if (!cancelled) {
|
|
// Retry after delay even on error
|
|
timeoutId = setTimeout(pollStatus, 1000);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Start polling immediately
|
|
pollStatus();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, [jobId, polling, collectionId, onComplete]);
|
|
|
|
// Watch context for status updates when resuming an active reorganization
|
|
useEffect(() => {
|
|
const isThisActive = isReorganizing &&
|
|
taskType === 'reorganize' &&
|
|
activeCollectionId === collectionId;
|
|
|
|
// Only watch when: modal is open, no real job ID, and this collection is active
|
|
if (!open || jobId || !isThisActive) return;
|
|
|
|
// Update status from context (only when values actually change)
|
|
const newStatus = workerStatus?.status || 'running';
|
|
const newTotal = workerStatus?.operation_total || 0;
|
|
const newCompleted = workerStatus?.operation_processed || 0;
|
|
const newPercent = workerStatus?.operation_percent || 0;
|
|
|
|
setStatus(prev => {
|
|
// Only update if values actually changed to prevent infinite loops
|
|
if (prev &&
|
|
prev.status === newStatus &&
|
|
prev.total === newTotal &&
|
|
prev.completed === newCompleted &&
|
|
prev.percent === newPercent) {
|
|
return prev; // Return same object to prevent re-render
|
|
}
|
|
return {
|
|
status: newStatus,
|
|
total: newTotal,
|
|
completed: newCompleted,
|
|
percent: newPercent,
|
|
};
|
|
});
|
|
}, [open, jobId, isReorganizing, taskType, activeCollectionId, workerStatus, collectionId]);
|
|
|
|
// Detect completion when context indicates reorganization stopped for this collection
|
|
useEffect(() => {
|
|
// Only relevant when we have status but no job ID (resumed from context)
|
|
if (!status || jobId) return;
|
|
|
|
const isThisActive = isReorganizing &&
|
|
taskType === 'reorganize' &&
|
|
activeCollectionId === collectionId;
|
|
|
|
if (!isThisActive && status.status !== 'complete') {
|
|
// Reorganization completed
|
|
setStatus(prev => ({...prev, status: 'complete', percent: 100}));
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
}
|
|
}, [isReorganizing, taskType, activeCollectionId, collectionId, status, jobId, onComplete]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
// Allow closing - user can reopen to resume if needed
|
|
setPreview(null);
|
|
setError(null);
|
|
setJobId(null);
|
|
setStatus(null);
|
|
setPolling(false);
|
|
setShowConflictModal(false);
|
|
onClose();
|
|
}, [onClose]);
|
|
|
|
// Reload preview (used after conflict resolution)
|
|
const reloadPreview = useCallback(() => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
previewCollectionReorganization(collectionId)
|
|
.then(data => {
|
|
setPreview(data);
|
|
setLoading(false);
|
|
})
|
|
.catch(err => {
|
|
setError(err.message);
|
|
setLoading(false);
|
|
});
|
|
}, [collectionId]);
|
|
|
|
const handleConflictResolved = useCallback(() => {
|
|
// Reload preview to get updated conflicts
|
|
// Note: The conflict modal calls onClose() after onResolved(), which handles closing
|
|
reloadPreview();
|
|
}, [reloadPreview]);
|
|
|
|
const handleResolveConflicts = useCallback(async () => {
|
|
setLoadingConflicts(true);
|
|
setError(null);
|
|
try {
|
|
const data = await fetchCollectionConflicts(collectionId);
|
|
// Update preview with fresh conflicts (including updated quality rankings)
|
|
setPreview(prev => ({
|
|
...prev,
|
|
conflicts: data.conflicts,
|
|
has_conflicts: data.conflicts.length > 0,
|
|
}));
|
|
setShowConflictModal(true);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoadingConflicts(false);
|
|
}
|
|
}, [collectionId]);
|
|
|
|
const handleReorganize = useCallback(async () => {
|
|
try {
|
|
setError(null);
|
|
const result = await executeCollectionReorganization(collectionId);
|
|
if (result.job_id) {
|
|
setJobId(result.job_id);
|
|
setPolling(true);
|
|
setStatus({status: 'pending', total: 0, completed: 0, percent: 0});
|
|
} else {
|
|
// No files needed reorganization
|
|
if (onComplete) {
|
|
onComplete();
|
|
}
|
|
handleClose();
|
|
}
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
}, [collectionId, onComplete, handleClose]);
|
|
|
|
const renderPreview = () => {
|
|
if (!preview) return null;
|
|
|
|
return (
|
|
<Stack>
|
|
{/* Warning when collection appears organized but has files to move */}
|
|
{preview.files_needing_move > 0 && !needsReorganization && (
|
|
<Message kind='warning' title='Collection Appears Organized'>
|
|
<p>
|
|
This collection's format matches the current configuration, but
|
|
{' '}<strong>{preview.files_needing_move}</strong> files don't match the expected
|
|
layout.
|
|
This can happen if a previous reorganization was interrupted.
|
|
</p>
|
|
</Message>
|
|
)}
|
|
<div>
|
|
<p>
|
|
<strong>{preview.files_needing_move}</strong> of <strong>{preview.total_files}</strong> files
|
|
will be reorganized.
|
|
</p>
|
|
{preview.current_file_format && (
|
|
<p>
|
|
<strong>Current format:</strong> <code>{preview.current_file_format}</code>
|
|
</p>
|
|
)}
|
|
<p>
|
|
<strong>New format:</strong> <Label>{preview.new_file_format}</Label>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Conflict warning */}
|
|
{preview.has_conflicts && (
|
|
<Message kind='error' icon='warning sign' title='Conflicts Detected'>
|
|
<p>
|
|
<strong>{preview.conflicts.length}</strong> destination{' '}
|
|
{preview.conflicts.length === 1 ? 'path has' : 'paths have'}{' '}
|
|
multiple files that would collide.
|
|
You must resolve these conflicts before reorganizing.
|
|
</p>
|
|
<Button
|
|
role='retry'
|
|
icon='wrench'
|
|
onClick={handleResolveConflicts}
|
|
loading={loadingConflicts}
|
|
disabled={loadingConflicts}
|
|
>
|
|
Resolve Conflicts
|
|
</Button>
|
|
</Message>
|
|
)}
|
|
|
|
{preview.sample_moves && preview.sample_moves.length > 0 && (
|
|
<div>
|
|
<strong>Sample moves:</strong>
|
|
<Table>
|
|
<Table.Header>
|
|
<Table.Row>
|
|
<Table.HeaderCell>Current Path</Table.HeaderCell>
|
|
<Table.HeaderCell/>
|
|
<Table.HeaderCell>New Path</Table.HeaderCell>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{preview.sample_moves.map((move, idx) => (
|
|
<Table.Row key={idx}>
|
|
<Table.Cell style={{wordBreak: 'break-all'}}>
|
|
{move.old_path}
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<Icon name='arrow right'/>
|
|
</Table.Cell>
|
|
<Table.Cell style={{wordBreak: 'break-all'}}>
|
|
{move.new_path}
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
))}
|
|
</Table.Body>
|
|
</Table>
|
|
{preview.files_needing_move > preview.sample_moves.length && (
|
|
<p style={{fontStyle: 'italic'}}>
|
|
...and {preview.files_needing_move - preview.sample_moves.length} more files
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{preview.files_needing_move === 0 && (
|
|
<Message kind='info' title='No Files Need Reorganization'>
|
|
<p>
|
|
All files are already organized according to the current format.
|
|
{needsReorganization && ' Click "Mark as Organized" to dismiss the warning.'}
|
|
</p>
|
|
</Message>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
const renderProgress = () => {
|
|
if (!status) return null;
|
|
|
|
const progressColor = status.status === 'complete' ? 'green' : status.status === 'error' ? 'red' : 'blue';
|
|
|
|
return (
|
|
<Stack>
|
|
<div>
|
|
<p>
|
|
<strong>Status:</strong> {status.status}
|
|
</p>
|
|
<Progress percent={status.percent || 0} color={progressColor}/>
|
|
<p>
|
|
{status.completed || 0} of {status.total || 0} files processed
|
|
</p>
|
|
</div>
|
|
|
|
{status.status === 'complete' && (
|
|
<Message kind='success' title='Reorganization Complete'>
|
|
<p>All files have been reorganized successfully.</p>
|
|
</Message>
|
|
)}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
const isInProgress = polling || (status && status.status !== 'complete' && status.status !== 'error');
|
|
const hasConflicts = preview && preview.has_conflicts;
|
|
const canReorganize = preview && preview.files_needing_move > 0 && !isInProgress && !jobId && !hasConflicts;
|
|
const canMarkAsOrganized = preview && preview.files_needing_move === 0 && needsReorganization && !isInProgress && !jobId;
|
|
|
|
return (
|
|
<>
|
|
<Modal
|
|
open={open}
|
|
onClose={handleClose}
|
|
closeIcon={true}
|
|
closeOnClickOutside={!isInProgress}
|
|
closeOnEscape={!isInProgress}
|
|
size='fullscreen'
|
|
>
|
|
<Modal.Header>
|
|
Reorganize Files: {collectionName}
|
|
</Modal.Header>
|
|
<Modal.Content>
|
|
{loading && <Loading>Analyzing files...</Loading>}
|
|
|
|
{error && (
|
|
<Message kind='error' title='Error'>
|
|
<p style={{whiteSpace: 'pre-wrap'}}>{error}</p>
|
|
</Message>
|
|
)}
|
|
|
|
{!loading && !status && renderPreview()}
|
|
{status && renderProgress()}
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' onClick={handleClose}>
|
|
{status?.status === 'complete' ? 'Close' : isInProgress ? 'Hide' : 'Cancel'}
|
|
</Button>
|
|
{canReorganize && (
|
|
<APIButton
|
|
role='primary'
|
|
onClick={handleReorganize}
|
|
obeyWROLMode={true}
|
|
>
|
|
Reorganize Files
|
|
</APIButton>
|
|
)}
|
|
{canMarkAsOrganized && (
|
|
<APIButton
|
|
role='save'
|
|
onClick={handleReorganize}
|
|
obeyWROLMode={true}
|
|
>
|
|
Mark as Organized
|
|
</APIButton>
|
|
)}
|
|
</Modal.Actions>
|
|
</Modal>
|
|
|
|
{/* Conflict Resolution Modal */}
|
|
<ConflictResolutionModal
|
|
open={showConflictModal}
|
|
onClose={() => setShowConflictModal(false)}
|
|
conflicts={preview?.conflicts || []}
|
|
collectionKind={collectionKind}
|
|
onResolved={handleConflictResolved}
|
|
/>
|
|
</>
|
|
);
|
|
}
|