import React, {useContext, useState} from "react"; import { CardContent, CardDescription, CardHeader, CardMeta, Checkbox, Container, Form, Image, Label, PlaceholderLine, Step, TableCell, TableHeaderCell, } from "semantic-ui-react"; import { CardGroupCentered, CardPoster, encodeMediaPath, ErrorMessage, ExternalCardLink, FileIcon, findPosterPath, HandPointMessage, humanFileSize, isoDatetimeToAgoPopup, mimetypeColor, PageContainer, Paginator, PreviewLink, SearchInput, TagIcon, Toggle, textEllipsis, useTitle } from "./Common"; import { usePages, useSearchDate, useSearchFiles, useSearchCensored, useSearchDeep, useSearchFilter, useSearchOrder, useSearchView, useStatusFlag, useWROLMode } from "../hooks/customHooks"; import {useFileWorkerStatus} from "../contexts/FileWorkerStatusContext"; import {Link, Route, Routes, useSearchParams} from "react-router"; import {CardPlaceholder} from "./Placeholder"; import {ArchiveCard, ArchiveRowCells} from "./Archive"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; import {QueryContext, ThemeContext} from "../contexts/contexts"; import { Button, Card, Divider, Header, Icon, Modal, Placeholder, Popup, Progress, Segment } from "./Theme"; import {DateRangeForm, dateRangeIsEmpty, MonthsForm} from "./DatesSelector"; import {SelectableTable} from "./Tables"; import {VideoCard, VideoRowCells} from "./Videos"; import {FileBrowser} from "./FileBrowser"; import {refreshFiles} from "../api"; import {TagsSelector} from "../Tags"; import {Headlines} from "./Headline"; import {useSearch} from "./Search"; import {FILES_MEDIA_URI} from "./Vars"; // Split a ts_headline snippet on our sentinel markers and wrap each match in . // We use sentinels instead of ts_headline's default tags so the snippet can be // safely rendered as React children without dangerouslySetInnerHTML. function renderHighlightedSnippet(snippet, s) { const parts = snippet.split(/\[\[WROLPI_HL\]\]|\[\[\/WROLPI_HL\]\]/); // Even indices are plain text, odd indices are highlighted matches. return parts.map((part, i) => i % 2 === 1 ? {part} : {part}); } function EbookCard({file, sortField}) { const {s, t} = useContext(ThemeContext); const [searchParams] = useSearchParams(); const query = searchParams.get('q') || ''; const downloadUrl = `/download/${encodeMediaPath(file.primary_path)}`; const isEpub = file['mimetype'] && file['mimetype'].startsWith('application/epub'); const viewerUrl = isEpub ? `/epub/epub.html?url=${downloadUrl}` : null; // Link to doc detail page if this is a modeled doc, augmenting with // the section hint from search so the viewer opens at the right chapter/page. const hint = file.section_hint; let detailUrl = file.model === 'doc' ? `/docs/${file.id}` : null; if (detailUrl && hint) { const params = new URLSearchParams(); if (hint.kind === 'epub_spine') params.set('loc', String(hint.ordinal)); if (hint.kind === 'pdf_page') params.set('page', String(hint.ordinal)); if (query) params.set('q', query); detailUrl = `${detailUrl}?${params.toString()}`; } const color = mimetypeColor(file.mimetype, file.primary_path); const title = file.title || file.stem || file.name; const header = detailUrl ? {title} : {title}; return {file.author ? {file.author} : null} {sortField === 'published_datetime' ?

{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}

: file.size &&

{humanFileSize(file.size)}

} {hint &&

{hint.label} {hint.snippet && {renderHighlightedSnippet(hint.snippet, s)} }

}
} function ImageCard({file}) { const {s} = useContext(ThemeContext); const url = `/media/${encodeMediaPath(file.primary_path)}`; const title = file.title || file.stem || file.primary_path; const header =

{textEllipsis(title)}

; const dt = file.published_datetime || file.published_modified_datetime || file.modified; return

{isoDatetimeToAgoPopup(dt, false)}

{humanFileSize(file.size)}

} function FileCard({file}) { const {s} = useContext(ThemeContext); const {sort} = useSearchOrder(); const sortField = sort ? sort.replace(/^-/, '') : null; const isDocType = file.model === 'doc' || (file.mimetype && ( file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook') )); // Default doc sort is published_datetime when no order query param is set. const docSortField = sortField || 'published_datetime'; if (file.model === 'video' && 'video' in file) { return ; } else if (file.model === 'archive') { return ; } else if (file.mimetype && file.mimetype.startsWith('image/')) { return ; } else if (isDocType) { return ; } const author = file.author; const color = mimetypeColor(file.mimetype, file.primary_path); const size = file.size !== null && file.size !== undefined ? humanFileSize(file.size) : null; const title = file.title || file.name || file.primary_path; const dt = file.published_datetime || file.published_modified_datetime || file.modified; return {title}} content={title}/> {author && {author}} {sortField === 'published_datetime' ?

{file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime, false) : null}

: <>

{isoDatetimeToAgoPopup(dt, false)}

{size}

}
} export function FileCards({files}) { if (files && files.length >= 1) { return {files.map(i => )} } else if (files && files.length === 0) { return No results! } else if (files === undefined) { return Could not search! } // Response is pending. return } function ImageRowCells({file}) { const url = `/media/${encodeMediaPath(file.primary_path)}`; let poster = ; if (file.size && file.size < 50000000) { // Image is less than 5mb, use it. poster = ; } // Fragment for SelectableRow return
{poster}

{textEllipsis(file.title || file.stem || file.primary_path)}

{humanFileSize(file.size)}
} export function EbookRowCells({file}) { let cover = ; const posterPath = findPosterPath(file); if (posterPath) { const coverSrc = `/media/${encodeMediaPath(posterPath)}`; cover = ; } // Fragment for SelectableRow return
{cover}
{textEllipsis(file.title || file.stem)} {humanFileSize(file.size)}
} function FileRow({file}) { const isDocType = file.model === 'doc' || (file.mimetype && ( file.mimetype.startsWith('application/epub') || file.mimetype.startsWith('application/x-mobipocket-ebook') )); if (file.model === 'video' && 'video' in file) { return ; } else if (file.model === 'archive') { return ; } else if (file.mimetype && file.mimetype.startsWith('image/')) { return ; } else if (isDocType) { return ; } // Fragment for SelectableRow return
{textEllipsis(file.title || file.name || file.stem)}
} export function FileTable({files, selectOn, onSelect, footer, selectedKeys}) { if (files && files.length > 0) { const headerContents = ['Poster', 'Title', 'Data']; const rows = files.map(i => ); return ; } else if (files) { return No results! } else if (files === undefined) { return Could not search! } return } export function FileRowTagIcon({file}) { if (file.tags && file.tags.length) { return ; } } export function SearchViewButton({headlines}) { const {view, setView} = useSearchView(); if (headlines) { // Cycle: cards -> headline -> list if (view === 'headline') { return ); const directionToggle = ; // File-type filter section (single-select radios; clicking the active one clears it). Laid out in // columns so more options fit on each line. const filterFields = fileFilterOptions && fileFilterOptions.map(i => setDraftFilter(draftFilter === i['value'] ? null : i['value'])} /> ); // Results-per-page as buttons (matches the Sort By buttons and avoids a dropdown menu being clipped by // the scrolling modal content). const limitButtons = limitOptions.map(i => ); return Search Filters {sorts &&
{directionToggle}
{sortButtons}
} {fileFilterOptions &&
{filterFields}
} {(showTags || showLimit) && {showTags && 0)} selectedTagNames={draftTags} anyTag={draftAnyTag} filterByOverlap={true} onChange={(tagNames, newAnyTag) => { if (newAnyTag) { setDraftAnyTag(true); setDraftTags([]); } else { setDraftAnyTag(false); setDraftTags(tagNames); } }} style={{marginLeft: '0.3em', marginTop: '0.3em'}} /> } {showLimit &&
{limitButtons}
}
} {showCensored && } {showDeep && } {showDates && }
} // The trigger button which opens the comprehensive SearchFilterModal. Turns violet and displays a count // badge when any of its filters are active. export function SearchFilterButton( { sorts = null, fileFilterOptions = null, showDates = false, showTags = true, showLimit = true, showCensored = false, showDeep = false, limitOptions = [12, 24, 48, 96], size = 'medium', content = 'Filter', }, ) { const [open, setOpen] = useState(false); const {filter} = useSearchFilter(); const {sort} = useSearchOrder(); const {dateRange, months} = useSearchDate(); const {activeTags, anyTag} = useSearch(); const {censored} = useSearchCensored(); const {deep} = useSearchDeep(); let count = 0; if (fileFilterOptions && filter) { count += 1; } if (sorts && sort) { count += 1; } if (showTags && ((activeTags && activeTags.length > 0) || anyTag)) { count += 1; } if (showDates && ((months && months.length > 0) || !dateRangeIsEmpty(dateRange))) { count += 1; } if (showCensored && censored) { count += 1; } if (showDeep && deep) { count += 1; } const active = count > 0; return <> setOpen(false)} sorts={sorts} fileFilterOptions={fileFilterOptions} showDates={showDates} showTags={showTags} showLimit={showLimit} showCensored={showCensored} showDeep={showDeep} limitOptions={limitOptions} /> } // The single-line search control row shared by the Videos/Archives/Docs result pages: // [ view toggle ] [ search input (grows) ] [ Filter button ] // The text input is managed locally and only submits its value on enter/clear. Filter options are // forwarded to the SearchFilterButton so each page shows only the sections relevant to it. export function SearchControlBar( { searchStr, setSearchStr, placeholder = 'Search...', inputRef, viewButton, sorts = null, fileFilterOptions = null, showDates = false, showCensored = false, showDeep = false, }, ) { const [localSearchStr, setLocalSearchStr] = useState(searchStr || ''); const searchInput = setLocalSearchStr(null)} onSubmit={setSearchStr} placeholder={placeholder} inputRef={inputRef} />; return
{viewButton}
{searchInput}
} // Reminds the user that the fast search only matches titles/authors/descriptions. Used by the module // pages (Videos/Archives/Docs), which have no deep estimate, so it shows whenever a fast search is // active; FilesSearchView has a count-aware variant. export function DeepSearchHint({searchStr, files}) { const {deep} = useSearchDeep(); const {updateQuery} = useContext(QueryContext); if (!searchStr || deep || !Array.isArray(files)) { return null; } return {files.length === 0 ? 'Not finding what you need?' : 'More results may be available in deep search.'} } export function FilesSearchView({ showView = true, emptySearch = false, model, deepEstimate = null, }) { const {searchFiles, total, searchStr, pages} = useSearchFiles(24, emptySearch, model); const {deep} = useSearchDeep(); const {updateQuery} = useContext(QueryContext); const {body, paginator, viewButton} = FilesView( { files: searchFiles, activePage: pages.activePage, totalPages: pages.totalPages, selectElem: null, selectedKeys: null, onSelect: null, setPage: pages.setPage, headlines: true, }, ); // The default search only matches titles/authors/descriptions; offer deep search when it would find // more. `deepEstimate` comes from the search estimates; without it, fall back to a generic hint on // zero results. let deepHint = null; if (Boolean(searchStr) && !deep && searchFiles) { const moreCount = deepEstimate !== null && deepEstimate !== undefined && total !== null ? deepEstimate - total : null; // Enable deep search and reset pagination in a single update (like the filter modal does), so the // user starts at page 1 of the deep results. const deepButton = ; if (searchFiles.length === 0 && (moreCount === null || moreCount > 0)) { deepHint = {moreCount > 0 ? `No results in fast search. ${moreCount} result${moreCount === 1 ? '' : 's'} available in deep search.` : 'Not finding what you need?'} {deepButton} ; } else if (searchFiles.length > 0 && moreCount > 0) { deepHint = {`${moreCount} more result${moreCount === 1 ? '' : 's'} available in deep search.`} {deepButton} ; } } return <> {showView &&
{viewButton}
} {body} {deepHint} {paginator} } function getPhaseLabel(status) { switch (status) { case 'counting': return 'Counting'; case 'comparing': case 'upserting': case 'deleting': return 'Inserting'; case 'modeling': return 'Modeling'; case 'indexing': return 'Indexing'; case 'cleanup': return 'Cleanup'; case 'planning': return 'Move: Planning'; case 'moving': return 'Move: Moving'; case 'reverting': return 'Reverting'; case 'reorganizing': return 'Reorganizing'; case 'batch_reorganizing': return 'Batch Reorganizing'; case 'error': return 'Error'; default: return null; } } // Map backend status to refresh phase number (1-5) function getRefreshPhase(status) { switch (status) { case 'counting': return 1; case 'comparing': case 'upserting': case 'deleting': return 2; case 'modeling': return 3; case 'indexing': return 4; case 'cleanup': return 5; default: return 0; } } // Check if this is a global refresh (full media directory) function isGlobalRefresh(progress) { if (!progress || progress.status === 'idle') return false; if (progress.task_type !== 'refresh') return false; // Global refresh has a single path ending with the media directory name const paths = progress.paths || []; return paths.length === 1 && (paths[0].endsWith('/wrolpi') || paths[0] === '/media/wrolpi'); } const REFRESH_PHASES = [ {key: 1, icon: 'search', title: 'Counting', description: 'Finding files'}, {key: 2, icon: 'database', title: 'Inserting', description: 'Updating database'}, {key: 3, icon: 'cogs', title: 'Modeling', description: 'Extracting metadata'}, {key: 4, icon: 'book', title: 'Indexing', description: 'Building search index'}, {key: 5, icon: 'check circle', title: 'Cleanup', description: 'Finalizing'}, ]; export function RefreshSteps({progress}) { const currentPhase = getRefreshPhase(progress?.status); const {operation_processed, operation_total, status} = progress || {}; if (currentPhase === 0) { return null; } return ( {REFRESH_PHASES.map(phase => { const isCompleted = phase.key < currentPhase; const isActive = phase.key === currentPhase; const isDisabled = phase.key > currentPhase; // Determine description for active phase let description = phase.description; if (isActive) { if (operation_total > 0) { description = `${operation_processed?.toLocaleString()} / ${operation_total?.toLocaleString()}`; } else if (status === 'comparing') { description = 'Comparing files...'; } else if (status === 'upserting') { description = 'Updating files...'; } else if (status === 'deleting') { description = 'Removing deleted...'; } } return ( {phase.title} {isActive || isCompleted ? description : ''} ); })} ); } function RefreshProgressBar({status, operation_total, operation_processed, operation_percent, error}) { const phaseLabel = getPhaseLabel(status); if (!phaseLabel) { return null; } // Show error state if (status === 'error' && error) { return {`Error: ${error}`}; } let label = `${phaseLabel}`; if (operation_total > 0) { label = `${label} (${operation_processed.toLocaleString()} / ${operation_total.toLocaleString()})`; } return {label}; } export function FilesRefreshProgress() { const {status: progress} = useFileWorkerStatus(); if (!progress || progress.status === 'idle') { return null; } const {status, operation_total, operation_processed, operation_percent, error} = progress; // Show Steps component AND progress bar for global refresh operations if (isGlobalRefresh(progress)) { return <> ; } // Show just progress bar for other operations (moves, reorganize, targeted refresh) return ; } function FilesPage() { useTitle('Files'); return <>

You can also view your media directory at {FILES_MEDIA_URI}

; } export function FilesRoute() { return }/> ; } const useRefresh = () => { const wrolModeEnabled = useWROLMode(); const globalRefreshActive = useStatusFlag('global_refresh_active'); const [loading, setLoading] = React.useState(false); const [globalRefreshLoading, setGlobalRefreshLoading] = React.useState(false); const prevGlobalRefreshActive = React.useRef(false); const localRefreshFiles = async (paths) => { setLoading(true); try { await refreshFiles(paths); // Check if this was a global refresh (no paths = refresh all) // If so, set globalRefreshLoading and schedule a fallback timeout if (!paths || paths.length === 0) { setGlobalRefreshLoading(true); setTimeout(() => { setGlobalRefreshLoading(false); }, 10000); } } finally { setLoading(false); } } // Clear globalRefreshLoading when flag transitions from true to false React.useEffect(() => { if (prevGlobalRefreshActive.current && !globalRefreshActive) { setGlobalRefreshLoading(false); } prevGlobalRefreshActive.current = globalRefreshActive; }, [globalRefreshActive]); const isGlobalRefreshing = globalRefreshLoading || globalRefreshActive; return { globalRefreshing: isGlobalRefreshing, loading, wrolModeEnabled, refreshFiles: localRefreshFiles, } } export function FilesRefreshButton({paths, showLabel}) { const {globalRefreshing, loading, wrolModeEnabled, refreshFiles} = useRefresh(); return