diff --git a/app/src/api.js b/app/src/api.js index b25dfc24..884e9b38 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -247,7 +247,7 @@ export async function getVideoCaptions(fileGroupId) { } } -export async function deleteVideos(fileGroupIds) { +export async function deleteVideos(fileGroupIds, force = false) { if (!fileGroupIds || fileGroupIds.length === 0) { toast({ type: 'error', @@ -258,17 +258,27 @@ export async function deleteVideos(fileGroupIds) { } console.info(`Deleting Videos: ${fileGroupIds}`); const i = fileGroupIds.join(','); - const response = await apiDelete(`${VIDEOS_API}/${i}`); - if (response.status !== 204) { - const message = await getErrorMessage(response, 'Failed to delete videos.'); - toast({ - type: 'error', - title: 'Deleting Videos failed', - description: message, - time: 5000, - }); - throw Error(message); + const url = force ? `${VIDEOS_API}/${i}?force=true` : `${VIDEOS_API}/${i}`; + const response = await apiDelete(url); + if (response.ok) { + return response; } + try { + const json = await response.clone().json(); + if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) { + return {tagged: true, file_groups: json.file_groups}; + } + } catch (e) { + // Not JSON, fall through to generic error. + } + const message = await getErrorMessage(response, 'Failed to delete videos.'); + toast({ + type: 'error', + title: 'Deleting Videos failed', + description: message, + time: 5000, + }); + throw Error(message); } export async function downloadVideoMetadata(videoUrl) { @@ -741,7 +751,7 @@ export async function deleteItems(itemIds) { return response } -export async function deleteArchives(archiveIds) { +export async function deleteArchives(archiveIds, force = false) { if (!archiveIds || archiveIds.length === 0) { toast({ type: 'error', @@ -752,15 +762,24 @@ export async function deleteArchives(archiveIds) { } console.log(`Deleting Archives: ${archiveIds}`); let i = archiveIds.join(','); - const response = await apiDelete(`${ARCHIVES_API}/${i}`); - if (!response.ok) { - const message = await getErrorMessage(response, 'Failed to delete archives'); - toast({ - type: 'error', title: 'Unexpected server response', description: message, time: 5000, - }); - throw Error('Failed to delete archives'); + const url = force ? `${ARCHIVES_API}/${i}?force=true` : `${ARCHIVES_API}/${i}`; + const response = await apiDelete(url); + if (response.ok) { + return response; } - return response + try { + const json = await response.clone().json(); + if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) { + return {tagged: true, file_groups: json.file_groups}; + } + } catch (e) { + // Not JSON, fall through to generic error. + } + const message = await getErrorMessage(response, 'Failed to delete archives'); + toast({ + type: 'error', title: 'Unexpected server response', description: message, time: 5000, + }); + throw Error('Failed to delete archives'); } export async function searchArchives(offset, limit, domain, searchStr, order, tagNames, headline) { @@ -2234,9 +2253,29 @@ export async function getDoc(fileGroupId) { return await apiGet(`${DOCS_API}/${fileGroupId}`); } -export async function deleteDocs(docIds) { +export async function deleteDocs(docIds, force = false) { const ids = Array.isArray(docIds) ? docIds.join(',') : docIds; - return await apiDelete(`${DOCS_API}/${ids}`); + const url = force ? `${DOCS_API}/${ids}?force=true` : `${DOCS_API}/${ids}`; + const response = await apiDelete(url); + if (response.ok) { + return response; + } + try { + const json = await response.clone().json(); + if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) { + return {tagged: true, file_groups: json.file_groups}; + } + } catch (e) { + // Not JSON, fall through to generic error. + } + const message = await getErrorMessage(response, 'Failed to delete documents.'); + toast({ + type: 'error', + title: 'Deleting Documents failed', + description: message, + time: 5000, + }); + throw Error(message); } export async function searchDocs(offset, limit, searchStr, order, tagNames, author, subject, mimetype) { diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js index f726ddd4..90d450c7 100644 --- a/app/src/components/Archive.js +++ b/app/src/components/Archive.js @@ -60,6 +60,7 @@ import { updateArchiveDownloaderConfig, } from "../api"; import {InputForm, useForm} from "../hooks/useForm"; +import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; import {CollectionTagModal} from "./collections/CollectionTagModal"; import {CollectionReorganizeModal} from "./collections/CollectionReorganizeModal"; import {BatchReorganizeModal} from "./collections/BatchReorganizeModal"; @@ -96,6 +97,7 @@ function ArchivePage() { const {fileGroupId} = useParams(); const {archiveFile, history, fetchArchive} = useArchive(fileGroupId); const {theme} = useContext(ThemeContext); + const [taggedFileGroups, setTaggedFileGroups] = useState(null); let title = archiveFile ? archiveFile.title ? archiveFile.title : archiveFile.name : null; useTitle(title); @@ -132,8 +134,12 @@ function ArchivePage() { : null; - const localDeleteArchive = async () => { - await deleteArchives([archiveFile.id]); + const localDeleteArchive = async (force = false) => { + const result = await deleteArchives([archiveFile.id], force); + if (result && result.tagged) { + setTaggedFileGroups(result.file_groups); + return; + } toast({ type: 'success', title: 'Archive Deleted', @@ -180,7 +186,7 @@ function ArchivePage() { color='red' confirmContent='Are you sure you want to delete this archive? All files will be deleted' confirmButton='Delete' - onClick={localDeleteArchive} + onClick={() => localDeleteArchive(false)} obeyWROLMode={true} > Delete @@ -356,6 +362,16 @@ function ArchivePage() { /> {historyList} + + setTaggedFileGroups(null)} + onConfirm={async () => { + setTaggedFileGroups(null); + await localDeleteArchive(true); + }} + /> } @@ -1082,6 +1098,7 @@ function ArchiveSettingsPage() { function ArchivesPage() { const [selectedArchives, setSelectedArchives] = useState([]); const [bulkTagOpen, setBulkTagOpen] = useState(false); + const [taggedFileGroups, setTaggedFileGroups] = useState(null); const searchInputRef = React.useRef(); useHotkeys('f', (e) => { @@ -1132,10 +1149,17 @@ function ArchivesPage() { } } - const onDelete = async () => { - const archiveIds = archives.filter(i => selectedArchives.indexOf(i['primary_path']) >= 0) - .map(i => i['id']); - await deleteArchives(archiveIds); + const archiveIdsForSelection = () => archives + .filter(i => selectedArchives.indexOf(i['primary_path']) >= 0) + .map(i => i['id']); + + const onDelete = async (force = false) => { + const archiveIds = archiveIdsForSelection(); + const result = await deleteArchives(archiveIds, force); + if (result && result.tagged) { + setTaggedFileGroups(result.file_groups); + return; + } await fetchArchives(); setSelectedArchives([]); } @@ -1166,7 +1190,7 @@ function ArchivesPage() { disabled={_.isEmpty(selectedArchives)} confirmButton='Delete' confirmContent='Are you sure you want to delete these archives files? This cannot be undone.' - onClick={onDelete} + onClick={() => onDelete(false)} >Delete @@ -162,6 +168,15 @@ function DocsPage() { {body} {paginator} + setTaggedFileGroups(null)} + onConfirm={async () => { + setTaggedFileGroups(null); + await onDelete(true); + }} + /> ; } @@ -171,6 +186,7 @@ function DocPage() { const {docFile, doc, fetchDoc} = useDoc(parseInt(fileGroupId)); const {theme} = useContext(ThemeContext); const [searchParams] = useSearchParams(); + const [taggedFileGroups, setTaggedFileGroups] = useState(null); // Deep-link params: `loc` = EPUB spine index, `page` = PDF page, `q` = search term. const locParam = searchParams.get('loc'); const pageParam = searchParams.get('page'); @@ -244,9 +260,13 @@ function DocPage() { : null; const embedUrl = isEpub ? viewerUrl : pdfSrc; - const handleDelete = async () => { + const handleDelete = async (force = false) => { if (doc && doc.id) { - await deleteDocs([docFile.id]); + const result = await deleteDocs([docFile.id], force); + if (result && result.tagged) { + setTaggedFileGroups(result.file_groups); + return; + } toast({ type: 'success', title: 'Document Deleted', @@ -415,7 +435,7 @@ function DocPage() { color='red' confirmContent='Are you sure you want to delete this document? All files will be deleted.' confirmButton='Delete' - onClick={handleDelete} + onClick={() => handleDelete(false)} obeyWROLMode={true} >Delete ; @@ -442,6 +462,16 @@ function DocPage() { + + setTaggedFileGroups(null)} + onConfirm={async () => { + setTaggedFileGroups(null); + await handleDelete(true); + }} + /> ; } diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index f77aa791..a35158f6 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -33,6 +33,7 @@ import {FilePreviewContext} from "./FilePreview"; import {DragSelectionContext, DragSelectionProvider, SettingsContext, ThemeContext} from "../contexts/contexts"; import {BulkTagModal} from "./BulkTagModal"; import {InlineErrorBoundary} from "./ErrorBoundary"; +import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; function depthIndentation(path) { // Repeated spaces for every folder a path is in. @@ -431,38 +432,12 @@ export function FileBrowser() { paths={selectedPaths} onComplete={reset} /> - setTaggedFileGroups(null)} - closeIcon - > - Tagged Files Will Be Deleted - -

The following tagged files will be deleted:

- - - - File - Tags - - - - {taggedFileGroups && taggedFileGroups.map((fg, idx) => ( - - {fg.primary_path || fg.name} - {(fg.tags || []).join(', ')} - - ))} - -
-
- - - - -
+ taggedFileGroups={taggedFileGroups} + onCancel={() => setTaggedFileGroups(null)} + onConfirm={onForceDelete} + /> ; const onFolderClick = async (folder) => { diff --git a/app/src/components/TaggedDeleteConfirmModal.js b/app/src/components/TaggedDeleteConfirmModal.js new file mode 100644 index 00000000..0c161e3d --- /dev/null +++ b/app/src/components/TaggedDeleteConfirmModal.js @@ -0,0 +1,34 @@ +import React from "react"; +import {Button, Icon, Modal, Table} from "./Theme"; +import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react"; + +export function TaggedDeleteConfirmModal({open, taggedFileGroups, onConfirm, onCancel}) { + return + Tagged Files Will Be Deleted + +

The following tagged files will be deleted:

+ + + + File + Tags + + + + {(taggedFileGroups || []).map((fg, idx) => ( + + {fg.primary_path || fg.name} + {(fg.tags || []).join(', ')} + + ))} + +
+
+ + + + +
; +} diff --git a/app/src/components/TaggedDeleteConfirmModal.test.js b/app/src/components/TaggedDeleteConfirmModal.test.js new file mode 100644 index 00000000..c4854c85 --- /dev/null +++ b/app/src/components/TaggedDeleteConfirmModal.test.js @@ -0,0 +1,93 @@ +import React from 'react'; +import {screen, fireEvent} from '@testing-library/react'; +import {renderWithProviders} from '../test-utils'; +import {TaggedDeleteConfirmModal} from './TaggedDeleteConfirmModal'; + +describe('TaggedDeleteConfirmModal', () => { + const sampleGroups = [ + {id: 1, primary_path: 'videos/foo.mp4', tags: ['favorite', 'review']}, + {id: 2, primary_path: 'archives/bar.html', tags: ['important']}, + ]; + + it('renders nothing when closed', () => { + renderWithProviders( + + ); + expect(screen.queryByText(/Tagged Files Will Be Deleted/i)).not.toBeInTheDocument(); + }); + + it('renders rows for each tagged file group', () => { + renderWithProviders( + + ); + expect(screen.getAllByText(/Tagged Files Will Be Deleted/i).length).toBeGreaterThan(0); + expect(screen.getByText('videos/foo.mp4')).toBeInTheDocument(); + expect(screen.getByText('archives/bar.html')).toBeInTheDocument(); + expect(screen.getByText('favorite, review')).toBeInTheDocument(); + expect(screen.getByText('important')).toBeInTheDocument(); + }); + + it('calls onCancel when Cancel button clicked', () => { + const onCancel = jest.fn(); + renderWithProviders( + + ); + fireEvent.click(screen.getByRole('button', {name: /Cancel/i})); + expect(onCancel).toHaveBeenCalled(); + }); + + it('calls onConfirm when Delete button clicked', () => { + const onConfirm = jest.fn(); + renderWithProviders( + + ); + const deleteButton = screen.getAllByRole('button', {name: /Delete/i}) + .find(b => b.textContent.includes('Delete')); + fireEvent.click(deleteButton); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('handles empty/null taggedFileGroups gracefully', () => { + renderWithProviders( + + ); + expect(screen.getAllByText(/Tagged Files Will Be Deleted/i).length).toBeGreaterThan(0); + }); + + it('falls back to name when primary_path is missing', () => { + renderWithProviders( + + ); + expect(screen.getByText('fallback-name')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/VideoPlayer.js b/app/src/components/VideoPlayer.js index 24616867..cd98591f 100644 --- a/app/src/components/VideoPlayer.js +++ b/app/src/components/VideoPlayer.js @@ -26,6 +26,7 @@ import {Button, darkTheme, Header, Icon, Loader, Segment, Tab} from "./Theme"; import {VideoCard} from "./Videos"; import {TagsSelector} from "../Tags"; import {Comment, CommentGroup, Input, Label, Transition} from "semantic-ui-react"; +import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; const MEDIA_PATH = '/media'; @@ -171,6 +172,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { const navigate = useNavigate(); const videoRef = React.useRef(); + const [taggedFileGroups, setTaggedFileGroups] = useState(null); useTitle(videoFile ? videoFile.title ? videoFile.title : videoFile.name : null); let video; @@ -276,8 +278,12 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { return
Could not find video
; } - const handleDeleteVideo = async (video_id) => { - await deleteVideos([video_id]) + const handleDeleteVideo = async (video_id, force = false) => { + const result = await deleteVideos([video_id], force); + if (result && result.tagged) { + setTaggedFileGroups(result.file_groups); + return; + } navigate(-1); } @@ -575,6 +581,15 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) { {prevNextVideosSegment} + setTaggedFileGroups(null)} + onConfirm={async () => { + setTaggedFileGroups(null); + await handleDeleteVideo(videoFile.id, true); + }} + /> } diff --git a/app/src/components/Videos.js b/app/src/components/Videos.js index d05ea6d8..8283dc44 100644 --- a/app/src/components/Videos.js +++ b/app/src/components/Videos.js @@ -52,6 +52,7 @@ import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import Icon from "semantic-ui-react/dist/commonjs/elements/Icon"; import {Button, Card, Header, Loader, Modal, Placeholder, Popup, Segment, Statistic} from "./Theme"; import {BulkTagModal} from "./BulkTagModal"; +import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; import { deleteVideos, deleteCookies, @@ -88,6 +89,7 @@ export function VideosPage() { const {searchParams} = React.useContext(QueryContext); const [selectedVideos, setSelectedVideos] = useState([]); const [bulkTagOpen, setBulkTagOpen] = useState(false); + const [taggedFileGroups, setTaggedFileGroups] = useState(null); const searchInputRef = React.useRef(); useHotkeys('f', (e) => { @@ -156,9 +158,13 @@ export function VideosPage() { setSelectedVideos(newSelectedVideos); } - const onDelete = async () => { + const onDelete = async (force = false) => { const fileGroupIds = videos.filter(i => selectedVideos.indexOf(i['primary_path']) >= 0).map(i => i['id']); - await deleteVideos(fileGroupIds); + const result = await deleteVideos(fileGroupIds, force); + if (result && result.tagged) { + setTaggedFileGroups(result.file_groups); + return; + } await fetchVideos(); setSelectedVideos([]); } @@ -189,7 +195,7 @@ export function VideosPage() { disabled={_.isEmpty(selectedVideos)} confirmButton='Delete' confirmContent='Are you sure you want to delete these video files? This cannot be undone.' - onClick={onDelete} + onClick={() => onDelete(false)} >Delete