From 1f2c8ed3cc0443882276f92a037507be8bc1c13d Mon Sep 17 00:00:00 2001 From: kaichen999 <113651927+kaichen999@users.noreply.github.com> Date: Tue, 22 Jul 2025 13:16:20 +0800 Subject: [PATCH] add-search-trash (#8045) * add-search-trash * update * Update settings.py * Update __init__.py * change search trash style * update * Update models.py * change search trash style and text * change error text * Update models.py --------- Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com> Co-authored-by: Michael An <1822852997@qq.com> --- .../components/dialog/trash-dialog/index.js | 73 +++++- .../dialog/trash-dialog/table/index.js | 2 +- .../search-filters/filter-by-creator.js | 150 +++++++++++ .../search-filters/filter-by-date.js | 196 +++++++++++++++ .../search-filters/filter-by-suffix.js | 84 +++++++ .../trash-search/search-filters/index.css | 233 ++++++++++++++++++ .../trash-search/search-filters/index.js | 24 ++ .../trash-search/search-filters/user-item.js | 22 ++ .../trash-search/search-trash.css | 57 +++++ .../trash-dialog/trash-search/search-trash.js | 161 ++++++++++++ frontend/src/pages/wiki2/wiki-trash-dialog.js | 4 +- frontend/src/repo-folder-trash.js | 2 +- frontend/src/utils/repo-trash-api.js | 28 +++ seahub/api2/endpoints/repo_trash.py | 164 +++++++++++- seahub/base/models.py | 53 ++++ seahub/urls.py | 3 +- 16 files changed, 1236 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-creator.js create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-date.js create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-suffix.js create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.css create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.js create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-filters/user-item.js create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-trash.css create mode 100644 frontend/src/components/dialog/trash-dialog/trash-search/search-trash.js diff --git a/frontend/src/components/dialog/trash-dialog/index.js b/frontend/src/components/dialog/trash-dialog/index.js index e68fae6bfb..1b5452675d 100644 --- a/frontend/src/components/dialog/trash-dialog/index.js +++ b/frontend/src/components/dialog/trash-dialog/index.js @@ -13,6 +13,7 @@ import Paginator from '../../paginator'; import Loading from '../../loading'; import BackIcon from '../../back-icon'; import EmptyTip from '../../empty-tip'; +import SearchTrash from './trash-search/search-trash'; import '../../../css/toolbar.css'; import '../../../css/search.css'; @@ -33,7 +34,10 @@ class TrashDialog extends React.Component { isOldTrashDialogOpen: false, currentPage: 1, perPage: 100, - hasNextPage: false + hasNextPage: false, + searchKeyword: '', + filteredItems: [], + canSearch: true, }; } @@ -41,9 +45,23 @@ class TrashDialog extends React.Component { this.getFolderTrash(); } + handleSearchResults = (result) => { + if (result?.reset) { + this.getFolderTrash(1); + return; + } + + const items = result?.items || []; + + this.setState({ + items, + hasNextPage: result?.hasMore || false + }); + }; + getFolderTrash = (page) => { repoTrashAPI.getRepoFolderTrash(this.props.repoID, page, this.state.perPage).then((res) => { - const { items, total_count } = res.data; + const { items, total_count, can_search } = res.data; if (!page) { page = 1; } @@ -52,7 +70,8 @@ class TrashDialog extends React.Component { hasNextPage: total_count - page * this.state.perPage > 0, isLoading: false, items: items, - more: false + more: false, + canSearch: can_search }); }); }; @@ -68,6 +87,26 @@ class TrashDialog extends React.Component { } }; + searchTrash = (query) => { + this.setState({ isSearching: true }); + + repoTrashAPI.searchRepoFolderTrash(this.props.repoID, query) + .then((res) => { + this.setState({ + isSearching: false, + items: res.data, + currentPage: 1, + hasNextPage: false + }); + }) + .catch((error) => { + this.setState({ + isSearching: false, + errorMsg: gettext('Search failed') + }); + }); + }; + getPreviousPage = () => { this.getFolderTrash(this.state.currentPage - 1); }; @@ -119,7 +158,8 @@ class TrashDialog extends React.Component { seafileAPI.listCommitDir(this.props.repoID, commitID, `${baseDir.substr(0, baseDir.length - 1)}${folderPath}`).then((res) => { this.setState({ isLoading: false, - folderItems: res.data.dirent_list + folderItems: res.data.dirent_list, + canSearch: false, }); }).catch((error) => { if (error.response) { @@ -182,7 +222,7 @@ class TrashDialog extends React.Component { render() { const { showTrashDialog, toggleTrashDialog, repoID } = this.props; - const { isCleanTrashDialogOpen, showFolder, isLoading, items, perPage, currentPage, hasNextPage } = this.state; + const { isCleanTrashDialogOpen, showFolder, isLoading, items, perPage, currentPage, hasNextPage, canSearch } = this.state; const isRepoAdmin = this.props.currentRepoInfo.owner_email === username || this.props.currentRepoInfo.is_admin; const repoFolderName = this.props.currentRepoInfo.repo_name; const oldTrashUrl = siteRoot + 'repo/' + this.props.repoID + '/trash/'; @@ -217,19 +257,38 @@ class TrashDialog extends React.Component { {isLoading && } + {!isLoading && canSearch && + + } {!isLoading && items.length === 0 && } {!isLoading && items.length > 0 && <> -
+
{gettext('Current path: ')} {showFolder ? this.renderFolderPath() : {repoFolderName} }
- +
{ { isFixed: true, width: 40, className: 'pl-2 pr-2' }, { isFixed: false, width: 0.25, children: gettext('Name') }, { isFixed: false, width: 0.4, children: gettext('Original path') }, - { isFixed: false, width: 0.12, children: gettext('Delete Time') }, + { isFixed: false, width: 0.12, children: gettext('Deleted time') }, { isFixed: false, width: 0.13, children: gettext('Size') }, { isFixed: false, width: 0.1, children: '' }, ], []); diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-creator.js b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-creator.js new file mode 100644 index 0000000000..c3e513706f --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-creator.js @@ -0,0 +1,150 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import isHotkey from 'is-hotkey'; +import { gettext } from '../../../../../utils/constants'; +import { Utils } from '../../../../../utils/utils'; +import UserItem from './user-item'; +import { seafileAPI } from '../../../../../utils/seafile-api'; +import ModalPortal from '../../../../modal-portal'; +import toaster from '../../../../toast'; +import { SEARCH_FILTERS_KEY } from '../../../../../constants'; + +const FilterByCreator = ({ creatorList, onChange }) => { + const [isOpen, setIsOpen] = useState(false); + const [options, setOptions] = useState([]); + const [selectedOptions, setSelectedOptions] = useState(creatorList || []); + const [searchValue, setSearchValue] = useState(''); + const [inputFocus, setInputFocus] = useState(false); + + const toggle = useCallback((e) => { + setIsOpen(!isOpen); + }, [isOpen]); + + const displayOptions = useMemo(() => { + if (!searchValue) return null; + return options.filter((option) => { + return option.name.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [options, searchValue]); + + const onChangeOption = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + const name = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle'); + let updated = [...selectedOptions]; + if (!updated.some((item) => item.name === name)) { + const newOption = options.find((option) => option.name === name); + updated = [...updated, newOption]; + } else { + updated = updated.filter((option) => option.name !== name); + } + setSelectedOptions(updated); + onChange(SEARCH_FILTERS_KEY.CREATOR_LIST, updated); + if (displayOptions.length === 1) { + setSearchValue(''); + } + }, [selectedOptions, displayOptions, options, onChange]); + + const handleCancel = useCallback((e, name) => { + const updated = selectedOptions.filter((option) => option.name !== name); + setSelectedOptions(updated); + onChange(SEARCH_FILTERS_KEY.CREATOR_LIST, updated); + }, [selectedOptions, onChange]); + + const handleInputChange = useCallback((e) => { + const v = e.target.value; + setSearchValue(v); + if (!selectedOptions) { + setOptions([]); + } + }, [selectedOptions]); + + const handleInputKeyDown = useCallback((e) => { + if (isHotkey('enter')(e)) { + e.preventDefault(); + e.stopPropagation(); + setSearchValue(''); + toggle(); + } + }, [toggle]); + + useEffect(() => { + if (!searchValue) return; + + const getUsers = async () => { + try { + const res = await seafileAPI.searchUsers(searchValue); + const userList = res.data.users.filter(user => user.name.toLowerCase().includes(searchValue.toLowerCase())); + setOptions(userList); + } catch (err) { + toaster.danger(Utils.getErrorMsg(err)); + } + }; + + getUsers(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchValue]); + + return ( +
+ + 0, + 'highlighted': selectedOptions.length > 0, + })}> +
{gettext('Deleted by')}
+ +
+ + +
+ {selectedOptions.map((option) => ( + + ))} +
+ setInputFocus(true)} + onBlur={() => setInputFocus(false)} + /> +
+
+ {displayOptions && displayOptions.map((option) => ( + e.preventDefault()} + onClick={onChangeOption} + toggle={false} + > + {isOpen && } + {selectedOptions.includes(option.name) && } + + ))} +
+
+
+
+ ); +}; + +FilterByCreator.propTypes = { + creatorList: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default FilterByCreator; diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-date.js b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-date.js new file mode 100644 index 0000000000..ed0ee33548 --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-date.js @@ -0,0 +1,196 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; +import dayjs from 'dayjs'; +import { gettext } from '../../../../../utils/constants'; +import { Utils } from '../../../../../utils/utils'; +import Picker from '../../../../date-and-time-picker'; +import ModalPortal from '../../../../modal-portal'; +import { SEARCH_FILTERS_KEY, SEARCH_FILTER_BY_DATE_OPTION_KEY } from '../../../../../constants'; +import classNames from 'classnames'; + +const DATE_INPUT_WIDTH = 118; + +const FilterByDate = ({ date, onChange }) => { + const [value, setValue] = useState(date.value); + const [isOpen, setIsOpen] = useState(false); + const [isCustomDate, setIsCustomDate] = useState(date.value === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM); + const [time, setTime] = useState({ + from: date.from, + to: date.to, + }); + + const label = useMemo(() => { + if (!value || value.length === 0) return gettext('Deleted time'); + return gettext('Deleted time'); + }, [value]); + + const options = useMemo(() => { + return [ + { + key: SEARCH_FILTER_BY_DATE_OPTION_KEY.TODAY, + label: gettext('Today'), + }, { + key: SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_7_DAYS, + label: gettext('Last 7 days'), + }, { + key: SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_30_DAYS, + label: gettext('Last 30 days'), + }, + 'Divider', + { + key: SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM, + label: gettext('Custom time'), + }, + ]; + }, []); + + const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + const onClearDate = useCallback(() => { + setValue(''); + setIsCustomDate(false); + setTime({ from: null, to: null }); + setIsOpen(false); + }, []); + + const onOptionClick = useCallback((e) => { + const option = Utils.getEventData(e, 'toggle') ?? e.currentTarget.getAttribute('data-toggle'); + if (option === value) return; + + const today = dayjs().endOf('day'); + const isCustomOption = option === SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM; + setIsCustomDate(isCustomOption); + setValue(option); + setIsOpen(isCustomOption); + + switch (option) { + case SEARCH_FILTER_BY_DATE_OPTION_KEY.TODAY: + setTime({ from: dayjs().startOf('day').unix(), to: today.unix() }); + break; + case SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_7_DAYS: + setTime({ from: dayjs().subtract(6, 'day').startOf('day').unix(), to: today.unix() }); + break; + case SEARCH_FILTER_BY_DATE_OPTION_KEY.LAST_30_DAYS: + setTime({ from: dayjs().subtract(30, 'day').startOf('day').unix(), to: today.unix() }); + break; + case SEARCH_FILTER_BY_DATE_OPTION_KEY.CUSTOM: + setTime({ from: null, to: null }); + break; + } + }, [value]); + + const disabledStartDate = useCallback((startDate) => { + if (!startDate) return false; + const today = dayjs(); + const endValue = time.to; + if (!endValue) return startDate.isAfter(today); + return endValue.isBefore(startDate) || startDate.isAfter(today); + }, [time]); + + const disabledEndDate = useCallback((endDate) => { + if (!endDate) return false; + const today = dayjs(); + const startValue = time.from; + if (!startValue) return endDate.isAfter(today); + return endDate.isBefore(startValue) || endDate.isAfter(today); + }, [time]); + + useEffect(() => { + if (!isOpen) { + if (value !== date.value || time.from !== date.from || time.to !== date.to) { + onChange(SEARCH_FILTERS_KEY.DATE, { + value, + from: time.from, + to: time.to, + }); + } + } + }, [isOpen, date, time, value, onChange]); + + return ( +
+ + +
{label}
+ { + e.stopPropagation(); + toggle(); + }} + /> +
+ + +
+
+ +
+
+ {options.map((option, i) => { + const isSelected = option.key === value; + if (option === 'Divider') return
; + return ( + e.preventDefault()} + onClick={onOptionClick} + toggle={false} + > + {option.label} + {isSelected && } + + ); + })} + {isCustomDate && ( +
+
+
{gettext('Start date')}
+ setTime({ ...time, from: dayjs(value).unix() })} + inputWidth={DATE_INPUT_WIDTH} + /> +
+
+
{gettext('End date')}
+ setTime({ ...time, to: dayjs(value).unix() })} + inputWidth={DATE_INPUT_WIDTH} + /> +
+
+ )} +
+
+
+
+ ); +}; + +FilterByDate.propTypes = { + date: PropTypes.shape({ + value: PropTypes.string, + from: PropTypes.oneOfType([PropTypes.number, PropTypes.object]), + to: PropTypes.oneOfType([PropTypes.number, PropTypes.object]), + }), + onChange: PropTypes.func.isRequired, +}; + +export default FilterByDate; diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-suffix.js b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-suffix.js new file mode 100644 index 0000000000..ee2b8b24fd --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/filter-by-suffix.js @@ -0,0 +1,84 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Dropdown, DropdownMenu, DropdownToggle } from 'reactstrap'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { gettext } from '../../../../../utils/constants'; +import ModalPortal from '../../../../modal-portal'; +import { SEARCH_FILTERS_KEY } from '../../../../../constants'; + + +const FilterBySuffix = ({ suffixes, onChange }) => { + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(suffixes); + const inputRef = useRef(null); + + const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + const handleInput = useCallback((e) => { + setInputValue(e.target.value); + }, []); + + const handleKeyDown = useCallback((e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + setIsOpen(false); + onChange(SEARCH_FILTERS_KEY.SUFFIXES, inputValue.replace(/\./g, '')); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleClearInput = useCallback(() => { + setInputValue(''); + setIsOpen(false); + }, []); + + useEffect(() => { + if (!isOpen && inputValue !== suffixes) { + onChange(SEARCH_FILTERS_KEY.SUFFIXES, inputValue.replace(/\./g, '')); + } + }, [isOpen, inputValue, suffixes, onChange]); + + return ( +
+ + 0, + 'highlighted': inputValue.length > 0, + })} onClick={toggle}> +
{gettext('File suffix')}
+ +
+ + + + {inputValue.length > 0 && ( + + )} + + +
+
+ ); +}; + +FilterBySuffix.propTypes = { + suffixes: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default FilterBySuffix; diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.css b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.css new file mode 100644 index 0000000000..7c69f3ae9f --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.css @@ -0,0 +1,233 @@ +.search-filters-container { + min-height: 32px; + position: relative; + display: flex; + justify-content: flex-start; + align-items: center; + margin-top: 12px; + padding: 0 16px 10px; + overflow: auto hidden; + scrollbar-color: #C1C1C1 rgba(0, 0, 0, 0); + border-bottom: 1px solid #eee; +} + +.search-filters-container .search-filter { + width: fit-content; + display: flex; + align-items: center; + margin-right: 8px; +} + +.search-filters-container .search-filter .search-filter-toggle, +.search-filter-menu .search-filter-toggle { + display: flex; + align-items: center; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; +} + +.search-filters-container .search-filter .search-filter-toggle:hover, +.search-filter-menu .search-filter-toggle:hover { + background-color: #efefef; +} + +.search-filter-toggle .sf3-font-down { + color: #666; +} + +.search-filters-container .search-filter .filter-label { + width: fit-content; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-filters-container .search-filter.filter-by-suffix-container .filter-label, +.search-filters-container .search-filter.filter-by-creator-container .filter-label { + max-width: 120px; +} + +.search-filter-menu.filter-by-text-menu, +.search-filter-menu.filter-by-date-menu, +.search-filter-menu.filter-by-creator-menu { + width: 280px; +} + +.search-filter-menu.filter-by-suffix-menu { + width: 400px; + position: relative; +} + +.search-filter-menu.filter-by-suffix-menu .clear-icon-right { + width: 20px; + height: 20px; + position: absolute; + top: 16px; + right: 16px; + display: flex; + justify-content: center; + align-items: center; + color: #666; + margin: 9px; + border: 0; + border-radius: 3px; + background-color: transparent; + padding: 0; +} + +.search-filter-menu.filter-by-suffix-menu .clear-icon-right:hover { + background-color: #efefef; +} + +.search-filters-container .search-filters-dropdown-item { + width: 100%; +} + +.search-filter-menu { + z-index: 1050; +} + +.search-filter-menu .dropdown-item { + position: relative; +} + +.search-filter-menu .dropdown-item .dropdown-item-tick { + width: 1rem; + left: auto; + right: 16px; +} + +.search-filter-menu .input-container { + position: relative; + border: 1px solid #eaeaea; + border-radius: 4px; + padding: 4px 6px; + margin: 8px 16px; + min-height: 28px; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.search-filter-menu .input-container.focus { + background-color: #fff; + border-color: #1991eb; + box-shadow: 0 0 0 2px rgba(70, 127, 207, .25); + color: #495057; + outline: 0; +} + +.search-filter-menu .input-container .search-input-wrapper { + display: flex; + align-items: center; + border: none; + padding: 0px; + width: auto; + background: transparent; + font-size: inherit; + line-height: 20px; + flex: 1 1 60px; + min-width: 60px; + border-radius: 4px; +} + +.search-filter-menu .input-container input { + border: none; + outline: none; + width: 100%; + display: block; + resize: none; + padding: 0px; + height: 20px; +} + +.search-filter-menu .user-item { + height: 20px; + position: relative; + display: inline-flex; + align-items: center; + justify-content: flex-start; +} + +.search-filter-menu .user-item .user-avatar { + width: 16px; + height: 16px; + margin-right: 4px; + border-radius: 50%; +} + +.search-filter-menu .input-container .user-item { + background-color: #eaeaea; + border-radius: 10px; + padding: 0 4px 0 2px; + margin-right: 2px; + font-size: 13px; +} + +.filter-by-date-menu .filter-by-date-menu-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + +} + +.filter-by-date-menu .filter-by-date-menu-toolbar .filter-by-date-type-toggle { + display: flex; + align-items: center; +} + +.filter-by-date-menu .filter-by-date-menu-toolbar .filter-by-date-type-toggle .filter-label { + color: #7d7d7d; +} + +.filter-by-date-menu .filter-by-date-menu-toolbar .delete-btn { + display: flex; + align-items: center; + justify-content: center; + margin-right: 4px; + cursor: pointer; +} + +.filter-by-date-menu .filter-by-date-custom-date-container { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 16px; + gap: 8px; +} + +.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + font-size: 14px; +} + +.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container .custom-date-label { + margin-bottom: 6px; +} + +.filter-by-date-menu .filter-by-date-custom-date-container .custom-date-container .form-control { + height: 28px; + font-size: 14px; +} + +.search-filters-container .search-filter-toggle.active:hover, +.search-filters-container .search-filter-toggle.highlighted:hover { + background-color: #efefef; +} + +.search-filters-container .search-filter-toggle.highlighted, +.search-filter-toggle.highlighted .sf3-font-down { + color: #ed7109; +} + +.search-filter-menu { + z-index: 9999 !important; + position: relative; +} \ No newline at end of file diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.js b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.js new file mode 100644 index 0000000000..da5ce2010a --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/index.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import FilterByCreator from './filter-by-creator'; +import FilterByDate from './filter-by-date'; +import FilterBySuffix from './filter-by-suffix'; + +import './index.css'; + +const TrashFilters = ({ filters, onChange }) => { + return ( +
+ + + +
+ ); +}; + +TrashFilters.propTypes = { + filters: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default TrashFilters; diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/user-item.js b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/user-item.js new file mode 100644 index 0000000000..3e4e1ebf1f --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-filters/user-item.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { mediaUrl } from '../../../../../utils/constants'; +import IconBtn from '../../../../icon-btn'; + +const UserItem = ({ user, isCancellable, onCancel }) => { + return ( +
+ {user.name} + {user.name} + {isCancellable && onCancel(e, user.name)} symbol="x-01" />} +
+ ); +}; + +UserItem.propTypes = { + user: PropTypes.object.isRequired, + isCancellable: PropTypes.bool, + onCancel: PropTypes.func, +}; + +export default UserItem; diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-trash.css b/frontend/src/components/dialog/trash-dialog/trash-search/search-trash.css new file mode 100644 index 0000000000..4a75b0716d --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-trash.css @@ -0,0 +1,57 @@ +.search-trash .search-trash-input { + width: 100%; + height: 38px; + min-width: 0; + flex: 1; +} + +.search-trash.search-container .input-icon { + margin: 0px; +} + +.search-trash .trash-input-icon { + position: absolute; + right: 16px; + display: flex; + justify-content: center; + align-items: center; +} + +.search-trash .input-icon .search-icon-right.search-filter-controller.active { + color: #ed7109; +} + +.search-trash .search-icon-left { + position: absolute; + top: 8px; + left: 12px; + color: #666; +} + +.search-trash .search-icon-right { + position: absolute; + top: 1px; + right: 32px; + display: flex; + justify-content: center; + align-items: center; + color: #666; +} + +.search-trash #search-filter-controller { + position: absolute; + top: 10px; + right: 12px; + color: #666; + cursor: pointer; + height: 20px; + width: 20px; + display: flex; + justify-content: center; + align-items: center; +} + +.search-trash #search-filter-controller:hover { + background-color: var(--bs-hover-bg); + border-radius: 3px; +} diff --git a/frontend/src/components/dialog/trash-dialog/trash-search/search-trash.js b/frontend/src/components/dialog/trash-dialog/trash-search/search-trash.js new file mode 100644 index 0000000000..0301502dd6 --- /dev/null +++ b/frontend/src/components/dialog/trash-dialog/trash-search/search-trash.js @@ -0,0 +1,161 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import axios from 'axios'; +import classnames from 'classnames'; +import { gettext } from '../../../../utils/constants'; +import { debounce, Utils } from '../../../../utils/utils'; +import toaster from '../../../toast'; +import Loading from '../../../loading'; +import IconBtn from '../../../icon-button'; +import { SEARCH_FILTERS_SHOW_KEY } from '../../../../constants'; +import { repoTrashAPI } from '../../../../utils/repo-trash-api'; +import TrashFilters from './search-filters'; + +import './search-trash.css'; + +const propTypes = { + repoID: PropTypes.string.isRequired, + onSearchResults: PropTypes.func.isRequired, + placeholder: PropTypes.string, +}; + +const PER_PAGE = 20; + +class SearchTrash extends Component { + + constructor(props) { + super(props); + this.state = { + value: '', + isLoading: false, + isFilterControllerActive: false, + filters: { + date: { + value: '', + from: null, + to: null, + }, + suffixes: '', + creator_list: [], + }, + }; + this.source = null; + this.inputRef = React.createRef(); + this.debouncedSearch = debounce(this.searchTrash, 300); + } + + handleError = (e) => { + if (!axios.isCancel(e)) { + const errMessage = Utils.getErrorMsg(e); + toaster.danger(errMessage); + } + this.setState({ isLoading: false }); + }; + + onChangeHandler = (event) => { + const newValue = event.target.value; + this.setState({ value: newValue }); + this.debouncedSearch(newValue); + }; + + handleFiltersShow = () => { + const { isFiltersShow } = this.state; + localStorage.setItem(SEARCH_FILTERS_SHOW_KEY, !isFiltersShow); + this.setState({ isFiltersShow: !isFiltersShow }); + }; + + searchTrash = (query) => { + if (this.source) { + this.source.cancel('prev request is cancelled'); + } + this.source = axios.CancelToken.source(); + + this.setState({ isLoading: true }); + + const { repoID } = this.props; + const page = 1; + const per_page = PER_PAGE; + const { suffixes, date, creator_list } = this.state.filters; + const creators = creator_list.map(user => user.email).join(','); + + repoTrashAPI.searchRepoFolderTrash(repoID, page, per_page, query.trim(), { suffixes, date, creators }).then(res => { + const items = Array.isArray(res.data.items) ? res.data.items : []; + const hasMore = Boolean(res.data.has_more); + this.props.onSearchResults({ items, hasMore }); + this.setState({ + isLoading: false + }); + }).catch(error => { + this.setState({ isLoading: false }); + toaster.danger(gettext('Search failed. Please try again.')); + }); + }; + + handleFiltersChange = (key, value) => { + const newFilters = { ...this.state.filters, [key]: value }; + const hasActiveFilter = newFilters.suffixes || newFilters.date.value; + this.setState({ + filters: newFilters, + isFilterControllerActive: hasActiveFilter + }, () => { + this.searchTrash(this.state.value); + }); + }; + + onClearSearch = () => { + this.setState({ value: '' }); + this.props.onSearchResults({ reset: true }); + }; + + render() { + const { placeholder } = this.props; + const { value, isLoading, isFilterControllerActive, filters, isFiltersShow } = this.state; + return ( +
+
+
+ + + {value && ( +
+ {isFiltersShow && } +
+ {isLoading && ( +
+ +
+ )} +
+ ); + } +} + +SearchTrash.propTypes = propTypes; + +export default SearchTrash; diff --git a/frontend/src/pages/wiki2/wiki-trash-dialog.js b/frontend/src/pages/wiki2/wiki-trash-dialog.js index 99ed020fd7..1533394b13 100644 --- a/frontend/src/pages/wiki2/wiki-trash-dialog.js +++ b/frontend/src/pages/wiki2/wiki-trash-dialog.js @@ -141,7 +141,7 @@ class Content extends React.Component { this.theadData = [ { width: '30%', text: gettext('Name') }, { width: '20%', text: gettext('Size') }, - { width: '30%', text: gettext('Delete Time') }, + { width: '30%', text: gettext('Deleted time') }, { width: '20%', text: '' } ]; } else { @@ -149,7 +149,7 @@ class Content extends React.Component { { width: '5%', text: gettext('Name') }, { width: '20%', text: '' }, { width: '30%', text: gettext('Size') }, - { width: '35%', text: gettext('Delete Time') }, + { width: '35%', text: gettext('Deleted time') }, { width: '10%', text: '' } ]; } diff --git a/frontend/src/repo-folder-trash.js b/frontend/src/repo-folder-trash.js index 245b9ec7e2..3bb00f6560 100644 --- a/frontend/src/repo-folder-trash.js +++ b/frontend/src/repo-folder-trash.js @@ -239,7 +239,7 @@ class Content extends React.Component { { width: '5%', text: '' }, { width: '20%', text: gettext('Name') }, { width: '40%', text: gettext('Original path') }, - { width: '12%', text: gettext('Delete Time') }, + { width: '12%', text: gettext('Deleted time') }, { width: '13%', text: gettext('Size') }, { width: '10%', text: '' } ]; diff --git a/frontend/src/utils/repo-trash-api.js b/frontend/src/utils/repo-trash-api.js index aa10150184..4df87fbfb6 100644 --- a/frontend/src/utils/repo-trash-api.js +++ b/frontend/src/utils/repo-trash-api.js @@ -42,6 +42,34 @@ class RepotrashAPI { }; return this.req.get(url, { params: params }); } + + searchRepoFolderTrash(repoID, page, per_page, searchQuery = '', filters = {}) { + const url = this.server + '/api/v2.1/repos/' + repoID + '/trash2/search/'; + let params = { + page: page || 1, + per_page: per_page + }; + + if (searchQuery && searchQuery.trim() !== '') { + params.q = searchQuery.trim(); + } + + if (filters.suffixes) { + params.suffixes = filters.suffixes; + } + + if (filters.date.from != null && filters.date.to != null) { + params.time_from = filters.date.from; + params.time_to = filters.date.to; + } + + if (filters.creators != null) { + params.op_users = filters.creators; + } + + return this.req.get(url, { params: params }); + } + } let repoTrashAPI = new RepotrashAPI(); diff --git a/seahub/api2/endpoints/repo_trash.py b/seahub/api2/endpoints/repo_trash.py index 4389359f5e..f25cf5c253 100644 --- a/seahub/api2/endpoints/repo_trash.py +++ b/seahub/api2/endpoints/repo_trash.py @@ -13,9 +13,10 @@ from rest_framework import status from seahub.api2.throttling import UserRateThrottle from seahub.api2.authentication import TokenAuthentication from seahub.api2.utils import api_error +from seahub.base.models import FileTrash from seahub.signals import clean_up_repo_trash -from seahub.utils import get_trash_records, is_org_context +from seahub.utils import is_org_context from seahub.utils.timeutils import timestamp_to_isoformat_timestr from seahub.utils.repo import get_repo_owner, is_repo_admin from seahub.views import check_folder_permission @@ -362,8 +363,134 @@ class RepoTrash2(APIView): except ValueError: current_page = 1 per_page = 100 + start = (current_page - 1) * per_page - limit = per_page + end = start + per_page + try: + dir_id = seafile_api.get_dir_id_by_path(repo_id, path) + except SearpcError as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if not dir_id: + error_msg = 'Folder %s not found.' % path + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + if check_folder_permission(request, repo_id, path) is None: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # keep_days + # -1 all history + # 0 no history + # n n-days history + keep_days = seafile_api.get_repo_history_limit(repo_id) + + if keep_days == 0: + result = { + 'items': [], + 'total_count': 0, + 'can_search': False + } + return Response(result) + + # pre-filter by repo history + + elif keep_days == -1: + qset = FileTrash.objects.by_repo_id(repo_id) + + else: + qset = FileTrash.objects.by_repo_id(repo_id).by_history_limit(keep_days) + + deleted_entries = qset[start:end] + total_count = qset.count() + + + items = [] + if len(deleted_entries) >= 1: + for item in deleted_entries: + item_info = self.get_item_info(item) + items.append(item_info) + + result = { + 'items': items, + 'total_count': total_count, + 'can_search': True + } + + return Response(result) + +class SearchRepoTrash2(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get_item_info(self, trash_item): + + item_info = { + 'parent_dir': '/' if trash_item.path == '/' else trash_item.path, + 'obj_name': trash_item.obj_name, + 'deleted_time': timestamp_to_isoformat_timestr(int(trash_item.delete_time.timestamp())), + 'commit_id': trash_item.commit_id, + } + + if trash_item.obj_type == 'dir': + is_dir = True + else: + is_dir = False + + item_info['is_dir'] = is_dir + item_info['size'] = trash_item.size if not is_dir else '' + item_info['obj_id'] = trash_item.obj_id if not is_dir else '' + + return item_info + + def get(self, request, repo_id): + """ Return deleted files/dirs of a repo/folder + + Permission checking: + 1. all authenticated user can perform this action. + """ + + path = '/' + # resource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + q = request.GET.get('q', None) + + op_users = request.GET.get('op_users', '').split(',') if request.GET.get('op_users') else None + op_users = [u for u in op_users if u] if op_users else None + + time_from = request.GET.get('time_from') + time_to = request.GET.get('time_to') + try: + time_from = int(time_from) if time_from else None + time_to = int(time_to) if time_to else None + except (TypeError, ValueError): + time_from = None + time_to = None + + suffixes = request.GET.get('suffixes', None) + if suffixes: + suffixes = suffixes.split(',') + else: + suffixes = None + suffixes = [s for s in suffixes if s] if suffixes else None + + try: + current_page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '20')) + except ValueError: + current_page = 1 + per_page = 20 + + start = (current_page - 1) * per_page + end = start + per_page try: dir_id = seafile_api.get_dir_id_by_path(repo_id, path) except SearpcError as e: @@ -380,12 +507,33 @@ class RepoTrash2(APIView): error_msg = 'Permission denied.' return api_error(status.HTTP_403_FORBIDDEN, error_msg) - try: - deleted_entries, total_count = get_trash_records(repo_id, SHOW_REPO_TRASH_DAYS, start, limit) - except Exception as e: - logger.error(e) - error_msg = 'Internal Server Error' - return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + keep_days = seafile_api.get_repo_history_limit(repo_id) + + if keep_days == 0: + result = { + 'items': [], + 'total_count': 0 + } + return Response(result) + + # pre-filter by repo history + elif keep_days == -1: + qset = FileTrash.objects.by_repo_id(repo_id) + + else: + qset = FileTrash.objects.by_repo_id(repo_id).by_history_limit(keep_days) + + if q: + qset = qset.by_keywords(q) + if op_users: + qset = qset.by_users(op_users) + if time_from and time_to: + qset = qset.by_time_range(time_from, time_to) + if suffixes: + qset = qset.by_suffixes(suffixes) + + deleted_entries = qset[start:end] + total_count = qset.count() items = [] if len(deleted_entries) >= 1: diff --git a/seahub/base/models.py b/seahub/base/models.py index 8671d38701..91bb535b3b 100644 --- a/seahub/base/models.py +++ b/seahub/base/models.py @@ -585,6 +585,59 @@ class OrgQuotaUsage(models.Model): +class FileTrashQuerySet(models.QuerySet): + + def by_repo_id(self, repo_id): + return self.filter(repo_id=repo_id) + + def by_history_limit(self, keep_days): + _timestamp = datetime.datetime.now() - datetime.timedelta(days=keep_days) + return self.filter(delete_time__gte=_timestamp) + + def by_time_range(self, start_timestamp=None, end_timestamp=None): + queryset = self + if start_timestamp is not None: + start_datetime = datetime.datetime.fromtimestamp(start_timestamp) + queryset = queryset.filter(delete_time__gte=start_datetime) + + if end_timestamp is not None: + end_datetime = datetime.datetime.fromtimestamp(end_timestamp) + queryset = queryset.filter(delete_time__lte=end_datetime) + return queryset + + def by_suffixes(self, suffixes): + queries = Q() + for ext in suffixes: + queries |= Q(obj_name__iendswith=ext) + return self.filter(queries) + + def by_users(self, users): + return self.filter(user__in=users) + + def by_keywords(self, keywords): + return self.filter(obj_name__icontains=keywords) + + +class FileTrash(models.Model): + user = models.CharField(max_length=255) + obj_type = models.CharField(max_length=128) + obj_id = models.CharField(max_length=40) + obj_name = models.CharField(max_length=255) + delete_time = models.DateTimeField() + repo_id = models.CharField(max_length=36, db_index=True) + commit_id = models.CharField(max_length=40) + path = models.TextField() + size = models.BigIntegerField() + objects = FileTrashQuerySet.as_manager() + + + class Meta: + db_table = 'FileTrash' + ordering = ["-delete_time"] + + + + ###### signal handler ############### from django.dispatch import receiver diff --git a/seahub/urls.py b/seahub/urls.py index ad88ff483d..a19316ce19 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -74,7 +74,7 @@ from seahub.api2.endpoints.file_history import FileHistoryView, NewFileHistoryVi from seahub.api2.endpoints.dir import DirView, DirDetailView from seahub.api2.endpoints.file_tag import FileTagView from seahub.api2.endpoints.file_tag import FileTagsView -from seahub.api2.endpoints.repo_trash import RepoTrash, RepoTrashRevertDirents, RepoTrash2 +from seahub.api2.endpoints.repo_trash import RepoTrash, RepoTrashRevertDirents, RepoTrash2, SearchRepoTrash2 from seahub.api2.endpoints.repo_commit import RepoCommitView from seahub.api2.endpoints.repo_commit_dir import RepoCommitDirView from seahub.api2.endpoints.repo_commit_revert import RepoCommitRevertView @@ -461,6 +461,7 @@ urlpatterns = [ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/dir/detail/$', DirDetailView.as_view(), name='api-v2.1-dir-detail-view'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/trash/$', RepoTrash.as_view(), name='api-v2.1-repo-trash'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/trash2/$', RepoTrash2.as_view(), name='api-v2.1-repo-trash2'), + re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/trash2/search/$', SearchRepoTrash2.as_view(), name='api-v2.1-repo-trash2-search'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/trash/revert-dirents/$', RepoTrashRevertDirents.as_view(), name='api-v2.1-repo-trash-revert-dirents'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/history/$', RepoHistory.as_view(), name='api-v2.1-repo-history'), re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/set-password/$', RepoSetPassword.as_view(), name="api-v2.1-repo-set-password"),