mirror of
https://github.com/haiwen/seahub.git
synced 2025-09-27 23:56:18 +00:00
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>
This commit is contained in:
@@ -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 {
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{isLoading && <Loading />}
|
||||
{!isLoading && canSearch &&
|
||||
<SearchTrash
|
||||
repoID={this.props.repoID}
|
||||
onSearchResults={this.handleSearchResults}
|
||||
placeholder={gettext('Search in trash')}
|
||||
/>
|
||||
}
|
||||
{!isLoading && items.length === 0 &&
|
||||
<EmptyTip text={gettext('No file')} className="m-0" />
|
||||
}
|
||||
{!isLoading && items.length > 0 &&
|
||||
<>
|
||||
<div className="path-container dir-view-path mw-100 pb-2">
|
||||
<div className="path-container dir-view-path mw-100 pb-2 mt-1">
|
||||
<span className="path-label mr-1">{gettext('Current path: ')}</span>
|
||||
{showFolder ?
|
||||
this.renderFolderPath() :
|
||||
<span className="last-path-item" title={repoFolderName}>{repoFolderName}</span>
|
||||
}
|
||||
</div>
|
||||
<Table repoID={repoID} data={this.state} renderFolder={this.renderFolder} isDesktop={isDesktop} />
|
||||
<Table
|
||||
repoID={repoID}
|
||||
data={{
|
||||
items: items,
|
||||
showFolder: showFolder,
|
||||
commitID: this.state.commitID,
|
||||
baseDir: this.state.baseDir,
|
||||
folderPath: this.state.folderPath,
|
||||
folderItems: this.state.folderItems
|
||||
}}
|
||||
renderFolder={this.renderFolder}
|
||||
isDesktop={isDesktop}
|
||||
/>
|
||||
<Paginator
|
||||
gotoPreviousPage={this.getPreviousPage}
|
||||
gotoNextPage={this.getNextPage}
|
||||
|
@@ -11,7 +11,7 @@ const Table = ({ repoID, renderFolder, data, isDesktop }) => {
|
||||
{ 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: '' },
|
||||
], []);
|
||||
|
@@ -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 (
|
||||
<div className="search-filter filter-by-creator-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
|
||||
'active': isOpen && selectedOptions.length > 0,
|
||||
'highlighted': selectedOptions.length > 0,
|
||||
})}>
|
||||
<div className="filter-label" title={gettext('Deleted by')}>{gettext('Deleted by')}</div>
|
||||
<i className="sf3-font sf3-font-down sf3-font pl-1" />
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-creator-menu">
|
||||
<div className={classNames('input-container', { 'focus': inputFocus })}>
|
||||
{selectedOptions.map((option) => (
|
||||
<UserItem
|
||||
key={option.name}
|
||||
user={option}
|
||||
isCancellable={true}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
))}
|
||||
<div className="search-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={selectedOptions.length ? '' : gettext('Search user')}
|
||||
value={searchValue}
|
||||
autoFocus
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onFocus={() => setInputFocus(true)}
|
||||
onBlur={() => setInputFocus(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{displayOptions && displayOptions.map((option) => (
|
||||
<DropdownItem
|
||||
key={option.name}
|
||||
tag="div"
|
||||
tabIndex="-1"
|
||||
data-toggle={option.name}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onChangeOption}
|
||||
toggle={false}
|
||||
>
|
||||
{isOpen && <UserItem user={option} />}
|
||||
{selectedOptions.includes(option.name) && <i className="dropdown-item-tick sf2-icon-tick"></i>}
|
||||
</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FilterByCreator.propTypes = {
|
||||
creatorList: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FilterByCreator;
|
@@ -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 (
|
||||
<div className="search-filter filter-by-date-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle
|
||||
tag="div"
|
||||
className={classNames('search-filter-toggle', {
|
||||
'active': isOpen && value,
|
||||
'highlighted': value,
|
||||
})}
|
||||
onClick={toggle}
|
||||
>
|
||||
<div className="filter-label" style={{ maxWidth: 300 }} title={label}>{label}</div>
|
||||
<i
|
||||
className="sf3-font sf3-font-down pl-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
}}
|
||||
/>
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-date-menu">
|
||||
<div className="filter-by-date-menu-toolbar">
|
||||
<div className="delete-btn" onClick={onClearDate}>
|
||||
<i className="op-icon sf3-font-delete1 sf3-font"></i>
|
||||
</div>
|
||||
</div>
|
||||
{options.map((option, i) => {
|
||||
const isSelected = option.key === value;
|
||||
if (option === 'Divider') return <div key={i} className="seafile-divider dropdown-divider"></div>;
|
||||
return (
|
||||
<DropdownItem
|
||||
key={option.key}
|
||||
tag="div"
|
||||
tabIndex="-1"
|
||||
data-toggle={option.key}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onOptionClick}
|
||||
toggle={false}
|
||||
>
|
||||
{option.label}
|
||||
{isSelected && <i className="dropdown-item-tick sf2-icon-tick"></i>}
|
||||
</DropdownItem>
|
||||
);
|
||||
})}
|
||||
{isCustomDate && (
|
||||
<div className="filter-by-date-custom-date-container">
|
||||
<div className="custom-date-container">
|
||||
<div className="custom-date-label">{gettext('Start date')}</div>
|
||||
<Picker
|
||||
showHourAndMinute={false}
|
||||
disabledDate={disabledStartDate}
|
||||
value={time.from ? dayjs.unix(time.from) : null}
|
||||
onChange={(value) => setTime({ ...time, from: dayjs(value).unix() })}
|
||||
inputWidth={DATE_INPUT_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
<div className="custom-date-container">
|
||||
<div className="custom-date-label">{gettext('End date')}</div>
|
||||
<Picker
|
||||
showHourAndMinute={false}
|
||||
disabledDate={disabledEndDate}
|
||||
value={time.to ? dayjs.unix(time.to) : null}
|
||||
onChange={(value) => setTime({ ...time, to: dayjs(value).unix() })}
|
||||
inputWidth={DATE_INPUT_WIDTH}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
@@ -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 (
|
||||
<div className="search-filter filter-by-suffix-container">
|
||||
<Dropdown isOpen={isOpen} toggle={toggle}>
|
||||
<DropdownToggle tag="div" className={classNames('search-filter-toggle', {
|
||||
'active': isOpen && inputValue.length > 0,
|
||||
'highlighted': inputValue.length > 0,
|
||||
})} onClick={toggle}>
|
||||
<div className="filter-label" title={gettext('File suffix')}>{gettext('File suffix')}</div>
|
||||
<i className="sf3-font sf3-font-down pl-1" />
|
||||
</DropdownToggle>
|
||||
<ModalPortal>
|
||||
<DropdownMenu className="search-filter-menu filter-by-suffix-menu p-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder={gettext('Separate multiple suffixes by ","(like sdoc, pdf)')}
|
||||
value={inputValue}
|
||||
autoFocus
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{inputValue.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="clear-icon-right sf3-font sf3-font-x-01"
|
||||
onClick={handleClearInput}
|
||||
aria-label={gettext('Clear')}
|
||||
>
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</ModalPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FilterBySuffix.propTypes = {
|
||||
suffixes: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FilterBySuffix;
|
@@ -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;
|
||||
}
|
@@ -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 (
|
||||
<div className="search-filters-container px-0">
|
||||
<FilterBySuffix suffixes={filters.suffixes} onChange={onChange} />
|
||||
<FilterByCreator creatorList={filters.creator_list} onChange={onChange} />
|
||||
<FilterByDate date={filters.date} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TrashFilters.propTypes = {
|
||||
filters: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default TrashFilters;
|
@@ -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 (
|
||||
<div className="user-item">
|
||||
<img src={user.avatar_url || `${mediaUrl}avatars/default.png`} alt={user.name} className="user-avatar" />
|
||||
<span className="user-name">{user.name}</span>
|
||||
{isCancellable && <IconBtn className="user-remove" onClick={(e) => onCancel(e, user.name)} symbol="x-01" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UserItem.propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
isCancellable: PropTypes.bool,
|
||||
onCancel: PropTypes.func,
|
||||
};
|
||||
|
||||
export default UserItem;
|
@@ -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;
|
||||
}
|
@@ -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 (
|
||||
<div className="search-container search-trash">
|
||||
<div className="search-controls">
|
||||
<div className="input-icon">
|
||||
<i className="search-icon-left trash-input-icon-addon sf3-font sf3-font-search"></i>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control search-trash-input"
|
||||
name="query"
|
||||
placeholder={placeholder || gettext('Search in trash...')}
|
||||
value={this.state.value}
|
||||
onChange={this.onChangeHandler}
|
||||
autoComplete="off"
|
||||
ref={this.inputRef}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className="search-icon-right sf3-font sf3-font-x-01"
|
||||
onClick={this.onClearSearch}
|
||||
aria-label={gettext('Clear search')}
|
||||
title={gettext('Clear search')}
|
||||
/>
|
||||
)}
|
||||
<IconBtn
|
||||
icon="filter-circled"
|
||||
text={isFiltersShow ? gettext('Hide filters') : gettext('Show filters')}
|
||||
aria-label={isFiltersShow ? gettext('Hide advanced search') : gettext('Show advanced search')}
|
||||
size={20}
|
||||
className={classnames('search-icon-right input-icon-addon search-filter-controller', { 'active': isFilterControllerActive })}
|
||||
onClick={this.handleFiltersShow}
|
||||
id="search-filter-controller"
|
||||
/>
|
||||
</div>
|
||||
{isFiltersShow && <TrashFilters filters={filters} onChange={this.handleFiltersChange} />}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="search-loading-indicator">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchTrash.propTypes = propTypes;
|
||||
|
||||
export default SearchTrash;
|
@@ -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: '' }
|
||||
];
|
||||
}
|
||||
|
@@ -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: '' }
|
||||
];
|
||||
|
@@ -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();
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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<repo_id>[-0-9a-f]{36})/dir/detail/$', DirDetailView.as_view(), name='api-v2.1-dir-detail-view'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash/$', RepoTrash.as_view(), name='api-v2.1-repo-trash'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash2/$', RepoTrash2.as_view(), name='api-v2.1-repo-trash2'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/trash2/search/$', SearchRepoTrash2.as_view(), name='api-v2.1-repo-trash2-search'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-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<repo_id>[-0-9a-f]{36})/history/$', RepoHistory.as_view(), name='api-v2.1-repo-history'),
|
||||
re_path(r'^api/v2.1/repos/(?P<repo_id>[-0-9a-f]{36})/set-password/$', RepoSetPassword.as_view(), name="api-v2.1-repo-set-password"),
|
||||
|
Reference in New Issue
Block a user