diff --git a/alembic/versions/36cae041ad31_files.py b/alembic/versions/36cae041ad31_files.py index dc8f8a2e..df8a7dfd 100644 --- a/alembic/versions/36cae041ad31_files.py +++ b/alembic/versions/36cae041ad31_files.py @@ -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)') diff --git a/app/src/api.js b/app/src/api.js index bcb80594..e410a8e2 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -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); diff --git a/app/src/components/Files.js b/app/src/components/Files.js index 149d8329..ceb8c02f 100644 --- a/app/src/components/Files.js +++ b/app/src/components/Files.js @@ -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: , @@ -13,107 +15,143 @@ const icons = { 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 && - - File Preview - -
{this.state.selectedFile.name}
-

{humanFileSize(this.state.selectedFile.size)}

-
- - - - - -
} - <>} // Hide the preview that the 3rd party provided. - /> - + let selectedModal = <>; + if (selectedFile) { + selectedModal = ( + + File Preview + +
{selectedFile.name}
+

{humanFileSize(selectedFile.size)}

+
+ + + + + +
); } + + let browser; + let pagination = null; + if (searchStr) { + browser = ( <>} // Hide the preview that the 3rd party provided. + />); + pagination = ( +
+ +
); + } else { + browser = ( <>} // Hide the preview that the 3rd party provided. + />); + } + + console.log('Files', limit); + return <> + + {selectedModal} + {browser} + {pagination} + +} + +function ManageFiles(props) { + return (<> + + + ) } export function FilesRoute(props) { + const links = [ + {text: 'Files', to: '/files', exact: true, key: 'files'}, + {text: 'Manage', to: '/files/manage', exact: true, key: 'manage'}, + ]; + return ( - + + + ); } diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 95def379..b19341b1 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -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}; +} diff --git a/modules/files/lib.py b/modules/files/lib.py index b93f5fcf..ba318ebc 100644 --- a/modules/files/lib.py +++ b/modules/files/lib.py @@ -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 diff --git a/modules/files/models.py b/modules/files/models.py index 979a5391..e116fc7b 100644 --- a/modules/files/models.py +++ b/modules/files/models.py @@ -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'' 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 diff --git a/modules/files/test/test_api.py b/modules/files/test/test_api.py index 1b09e281..3931282b 100644 --- a/modules/files/test/test_api.py +++ b/modules/files/test/test_api.py @@ -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',