wrolpi/app/src/components/collections/CollectionReorganizeModal.js
Roland 929534e455
ui: thirty-five files off Semantic, leaving fifteen
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.
2026-08-05 18:34:58 -06:00

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}
/>
</>
);
}