mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Files can be searched.
This commit is contained in:
parent
a5e8ef16b6
commit
643c86e9a4
7 changed files with 187 additions and 87 deletions
|
|
@ -31,7 +31,7 @@ def upgrade():
|
|||
size INTEGER,
|
||||
modification_datetime TIMESTAMPTZ,
|
||||
textsearch tsvector GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english'::regconfig, COALESCE(title, ''::text)), 'A'::"char")
|
||||
setweight(to_tsvector('english'::regconfig, title), 'A'::"char")
|
||||
) stored
|
||||
)''')
|
||||
session.execute('CREATE INDEX file_path_idx ON file(path)')
|
||||
|
|
|
|||
|
|
@ -352,19 +352,16 @@ export async function postDownload(urls, downloader) {
|
|||
return response;
|
||||
}
|
||||
|
||||
|
||||
export async function killDownload(download_id) {
|
||||
let response = await apiPost(`${API_URI}/download/${download_id}/kill`);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
export async function killDownloads() {
|
||||
let response = await apiPost(`${API_URI}/download/kill`);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
export async function startDownloads() {
|
||||
let response = await apiPost(`${API_URI}/download/enable`);
|
||||
return response;
|
||||
|
|
@ -375,6 +372,16 @@ export async function getDownloaders() {
|
|||
return await response.json();
|
||||
}
|
||||
|
||||
export async function filesSearch(offset, limit, searchStr) {
|
||||
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit)};
|
||||
const response = await apiPost(`${API_URI}/files/search`, body);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function refreshFiles() {
|
||||
return await apiPost(`${API_URI}/files/refresh`);
|
||||
}
|
||||
|
||||
export async function getFiles(directories) {
|
||||
let body = {directories: directories || []};
|
||||
let response = await apiPost(`${API_URI}/files`, body);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import React from "react";
|
|||
import FileBrowser from 'react-keyed-file-browser';
|
||||
import {Button, Confirm, Header, Icon, Modal} from "semantic-ui-react";
|
||||
import 'react-keyed-file-browser/dist/react-keyed-file-browser.css';
|
||||
import {deleteFile, getFiles} from "../api";
|
||||
import {humanFileSize, PageContainer, secondsToDateTime} from "./Common";
|
||||
import {deleteFile, refreshFiles} from "../api";
|
||||
import Paginator, {humanFileSize, PageContainer, SearchInput, TabLinks} from "./Common";
|
||||
import {useBrowseFiles, useSearchFiles} from "../hooks/customHooks";
|
||||
import {Route} from "react-router-dom";
|
||||
|
||||
const icons = {
|
||||
File: <Icon name='file'/>,
|
||||
|
|
@ -13,107 +15,143 @@ const icons = {
|
|||
PDF: <Icon name='file pdf'/>,
|
||||
};
|
||||
|
||||
class Files extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
deleteOpen: false,
|
||||
files: [],
|
||||
openFolders: [],
|
||||
previewOpen: false,
|
||||
selectedFile: null,
|
||||
};
|
||||
}
|
||||
export function Files(props) {
|
||||
const {searchFiles, setPage, limit, totalPages, activePage, searchStr, setSearchStr} = useSearchFiles(50);
|
||||
const {browseFiles, setOpenFolders, fetchFiles} = useBrowseFiles();
|
||||
|
||||
async componentDidMount() {
|
||||
await this.fetchFiles();
|
||||
}
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
const [selectedFile, setSelectedFile] = React.useState(null);
|
||||
|
||||
fetchFiles = async () => {
|
||||
let {files} = await getFiles(this.state.openFolders);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
files[i]['modified'] = secondsToDateTime(files[i]['modified']);
|
||||
}
|
||||
this.setState({files: files});
|
||||
}
|
||||
|
||||
handleFolderChange = async (file, browserProps) => {
|
||||
const handleFolderChange = async (file, browserProps) => {
|
||||
let openFolders = Object.keys(browserProps.openFolders);
|
||||
this.setState({openFolders}, this.fetchFiles);
|
||||
setOpenFolders(openFolders);
|
||||
}
|
||||
|
||||
onDownloadFile = async () => {
|
||||
const onDownloadFile = async () => {
|
||||
// Open file in new tab/window.
|
||||
window.open(`/media/${this.state.selectedFile.key}`);
|
||||
window.open(`/media/${selectedFile.key}`);
|
||||
}
|
||||
|
||||
onSelectFile = async (file) => {
|
||||
this.setState({selectedFile: file}, this.openPreview);
|
||||
const onSelectFile = async (file) => {
|
||||
setSelectedFile(file);
|
||||
openPreview();
|
||||
}
|
||||
|
||||
onDeleteFile = async () => {
|
||||
await deleteFile(this.state.selectedFile.key);
|
||||
await this.fetchFiles();
|
||||
this.closePreview();
|
||||
const onDeleteFile = async () => {
|
||||
await deleteFile(selectedFile.key);
|
||||
await fetchFiles();
|
||||
closePreview();
|
||||
}
|
||||
|
||||
openPreview = () => {
|
||||
this.setState({previewOpen: true});
|
||||
const openPreview = () => {
|
||||
setPreviewOpen(true);
|
||||
}
|
||||
|
||||
closePreview = () => {
|
||||
this.setState({previewOpen: false, selectedFile: null, deleteOpen: false});
|
||||
const closePreview = () => {
|
||||
setPreviewOpen(false);
|
||||
setSelectedFile(null);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
|
||||
openDelete = () => {
|
||||
this.setState({deleteOpen: true});
|
||||
const openDelete = () => {
|
||||
setDeleteOpen(true);
|
||||
}
|
||||
|
||||
closeDelete = () => {
|
||||
this.setState({deleteOpen: false});
|
||||
const closeDelete = () => {
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <>
|
||||
{this.state.selectedFile &&
|
||||
<Modal closeIcon
|
||||
open={this.state.previewOpen}
|
||||
onClose={this.closePreview}
|
||||
>
|
||||
<Modal.Header><Icon name='file'/>File Preview</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Header>{this.state.selectedFile.name}</Header>
|
||||
<p>{humanFileSize(this.state.selectedFile.size)}</p>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button color='red' onClick={this.openDelete} floated='left'>Delete</Button>
|
||||
<Confirm
|
||||
open={this.state.deleteOpen}
|
||||
content={`Are you sure you want to delete ${this.state.selectedFile.key}`}
|
||||
onCancel={this.closeDelete}
|
||||
onConfirm={this.onDeleteFile}
|
||||
/>
|
||||
<Button color='green' onClick={this.onDownloadFile}>Download</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>}
|
||||
<FileBrowser
|
||||
showActionBar={false}
|
||||
canFilter={false}
|
||||
files={this.state.files}
|
||||
icons={icons}
|
||||
onFolderOpen={this.handleFolderChange}
|
||||
onFolderClose={this.handleFolderChange}
|
||||
onSelectFile={this.onSelectFile}
|
||||
detailRenderer={() => <></>} // Hide the preview that the 3rd party provided.
|
||||
/>
|
||||
</>
|
||||
let selectedModal = <></>;
|
||||
if (selectedFile) {
|
||||
selectedModal = (
|
||||
<Modal closeIcon
|
||||
open={previewOpen}
|
||||
onClose={closePreview}
|
||||
>
|
||||
<Modal.Header><Icon name='file'/>File Preview</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Header>{selectedFile.name}</Header>
|
||||
<p>{humanFileSize(selectedFile.size)}</p>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button color='red' onClick={openDelete} floated='left'>Delete</Button>
|
||||
<Confirm
|
||||
open={deleteOpen}
|
||||
content={`Are you sure you want to delete ${selectedFile.key}`}
|
||||
onCancel={closeDelete}
|
||||
onConfirm={onDeleteFile}
|
||||
/>
|
||||
<Button color='green' onClick={onDownloadFile}>Download</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>);
|
||||
}
|
||||
|
||||
let browser;
|
||||
let pagination = null;
|
||||
if (searchStr) {
|
||||
browser = (<FileBrowser
|
||||
showActionBar={false}
|
||||
canFilter={false}
|
||||
files={searchFiles}
|
||||
icons={icons}
|
||||
onSelectFile={onSelectFile}
|
||||
detailRenderer={() => <></>} // Hide the preview that the 3rd party provided.
|
||||
/>);
|
||||
pagination = (
|
||||
<div style={{marginTop: '3em', textAlign: 'center'}}>
|
||||
<Paginator
|
||||
activePage={activePage}
|
||||
changePage={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>);
|
||||
} else {
|
||||
browser = (<FileBrowser
|
||||
showActionBar={false}
|
||||
canFilter={false}
|
||||
files={browseFiles}
|
||||
icons={icons}
|
||||
onFolderOpen={handleFolderChange}
|
||||
onFolderClose={handleFolderChange}
|
||||
onSelectFile={onSelectFile}
|
||||
detailRenderer={() => <></>} // Hide the preview that the 3rd party provided.
|
||||
/>);
|
||||
}
|
||||
|
||||
console.log('Files', limit);
|
||||
return <>
|
||||
<SearchInput initValue={searchStr} onSubmit={setSearchStr}/>
|
||||
{selectedModal}
|
||||
{browser}
|
||||
{pagination}
|
||||
</>
|
||||
}
|
||||
|
||||
function ManageFiles(props) {
|
||||
return (<>
|
||||
<Button secondary
|
||||
id='refresh_files'
|
||||
onClick={refreshFiles}>
|
||||
Refresh Files
|
||||
</Button>
|
||||
<label htmlFor='refresh_files'>
|
||||
Find and index any new files.
|
||||
</label>
|
||||
</>)
|
||||
}
|
||||
|
||||
export function FilesRoute(props) {
|
||||
const links = [
|
||||
{text: 'Files', to: '/files', exact: true, key: 'files'},
|
||||
{text: 'Manage', to: '/files/manage', exact: true, key: 'manage'},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Files/>
|
||||
<TabLinks links={links}/>
|
||||
<Route path='/files' exact component={Files}/>
|
||||
<Route path='/files/manage' exact component={ManageFiles}/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import {fetchDomains, getVersion, searchArchives, searchVideos} from "../api";
|
||||
import {fetchDomains, filesSearch, getFiles, getVersion, searchArchives, searchVideos} from "../api";
|
||||
import {useHistory} from "react-router-dom";
|
||||
|
||||
export const useSearchParam = (key, defaultValue = null) => {
|
||||
|
|
@ -130,3 +130,51 @@ export const useVersion = () => {
|
|||
|
||||
return version;
|
||||
}
|
||||
|
||||
export const useSearchFiles = ({defaultLimit = 50}) => {
|
||||
const [searchFiles, setSearchFiles] = useState([]);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [offset, setOffset] = useSearchParam('o');
|
||||
const [limit, setLimit] = useSearchParam('l', defaultLimit);
|
||||
const [searchStr, setSearchStr] = useSearchParam('q');
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
|
||||
const setPage = (i) => {
|
||||
i = parseInt(i);
|
||||
let l = parseInt(limit);
|
||||
setOffset((l * i) - l);
|
||||
setActivePage(i);
|
||||
}
|
||||
|
||||
const localSearchFiles = async () => {
|
||||
setSearchFiles([]);
|
||||
setTotalPages(0);
|
||||
if (searchStr) {
|
||||
let {files, totals} = await filesSearch(offset, limit, searchStr);
|
||||
setSearchFiles(files);
|
||||
setTotalPages(Math.floor(totals['files'] / limit) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
localSearchFiles();
|
||||
}, [searchStr, limit, offset, activePage]);
|
||||
|
||||
return {searchFiles, totalPages, limit, setLimit, setOffset, searchStr, setSearchStr, activePage, setPage};
|
||||
}
|
||||
|
||||
export const useBrowseFiles = () => {
|
||||
const [browseFiles, setBrowseFiles] = useState([]);
|
||||
const [openFolders, setOpenFolders] = useState([]);
|
||||
|
||||
const fetchFiles = async () => {
|
||||
let {files} = await getFiles(openFolders);
|
||||
setBrowseFiles(files);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, [openFolders])
|
||||
|
||||
return {browseFiles, openFolders, setOpenFolders, fetchFiles};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,9 @@ def refresh_files():
|
|||
|
||||
|
||||
def search(search_str: str, limit: int, offset: int):
|
||||
"""
|
||||
Search the "title" of each file. The title is the words in the file's stem.
|
||||
"""
|
||||
with get_db_curs() as curs:
|
||||
stmt = '''
|
||||
SELECT id, ts_rank_cd(textsearch, websearch_to_tsquery(%(search_str)s)), COUNT(*) OVER() AS total
|
||||
|
|
|
|||
|
|
@ -9,26 +9,27 @@ from wrolpi.media_path import MediaPathType
|
|||
class File(ModelHelper, Base):
|
||||
__tablename__ = 'file'
|
||||
id = Column(Integer, primary_key=True)
|
||||
path = Column(MediaPathType)
|
||||
|
||||
mimetype = Column(String)
|
||||
modification_datetime = Column(TZDateTime)
|
||||
path = Column(MediaPathType)
|
||||
size = Column(Integer)
|
||||
title = Column(String)
|
||||
title = deferred(Column(String)) # used by textsearch column
|
||||
|
||||
textsearch = deferred(
|
||||
Column(tsvector, Computed('''to_tsvector('english'::regconfig, (COALESCE(title, ''::text) || ' '::text))''')))
|
||||
Column(tsvector, Computed('''to_tsvector('english'::regconfig, title)''')))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<File id={self.id} path={self.path.relative_to(get_media_directory())} mime={self.mimetype}>'
|
||||
|
||||
def __json__(self):
|
||||
path = self.path.path.relative_to(get_media_directory())
|
||||
d = dict(
|
||||
id=self.id,
|
||||
mimetype=self.mimetype,
|
||||
modification_datetime=self.modification_datetime,
|
||||
path=self.path.path.relative_to(get_media_directory()),
|
||||
path=path,
|
||||
size=self.size,
|
||||
title=self.title,
|
||||
key=path,
|
||||
)
|
||||
return d
|
||||
|
|
|
|||
|
|
@ -124,12 +124,15 @@ def do_search(test_client, search_str, total, expected):
|
|||
assert response.json['totals']['files'] == total
|
||||
for file, expected in zip_longest(response.json['files'], expected):
|
||||
assert_dict_contains(file, expected)
|
||||
# FileBrowser in React requires a key. We use the path.
|
||||
assert file['path'] == file['key']
|
||||
|
||||
|
||||
def test_files_search(test_session, test_client, make_files_structure):
|
||||
# You can search an empty directory.
|
||||
do_search(test_client, 'nothing', 0, [])
|
||||
|
||||
# Create files in the temporary directory. Add some contents so the mimetype can be tested.
|
||||
files = [
|
||||
'foo_is_the_name.txt',
|
||||
'archives/bar.txt',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue