diff --git a/app/src/Dashboard.js b/app/src/Dashboard.js index bc1d78b6..e4034598 100644 --- a/app/src/Dashboard.js +++ b/app/src/Dashboard.js @@ -76,13 +76,13 @@ export function Dashboard() { // Only show dashboard parts if not searching. let body; if (searchStr || (activeTags && activeTags.length > 0)) { - body = ; + body = ; } else { body = <> {!wrol_mode && downloads} - {/* Hide Status when user is starting a download */} + {/* Hide extras when user is starting a download */} {!downloadOpen && } - + {!downloadOpen && } ; } diff --git a/app/src/Tags.js b/app/src/Tags.js index 253594e6..3f4161d9 100644 --- a/app/src/Tags.js +++ b/app/src/Tags.js @@ -27,6 +27,7 @@ import {Segment} from "./components/Theme"; import _ from "lodash"; import {HexColorPicker} from "react-colorful"; import {useRecurringTimeout} from "./hooks/customHooks"; +import {Link} from "react-router-dom"; export const TagsContext = React.createContext({ NameToTagLabel: null, @@ -168,7 +169,7 @@ function EditTagsModal() { const localOnClose = () => { setOpen(false); setTagName(''); - setTagColor(null); + setTagColor(DEFAULT_TAG_COLOR); setTagId(null); } @@ -256,7 +257,14 @@ function EditTagsModal() { } -export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd = _.noop, onRemove = _.noop}) { +export function AddTagsButton({ + hideEdit, + active, + selectedTagNames = [], + onToggle = _.noop, + onAdd = _.noop, + onRemove = _.noop + }) { // A button which displays a modal in which the user can add or remove tags. const {tagNames, TagsGroup} = React.useContext(TagsContext); @@ -300,7 +308,7 @@ export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd = const unusedTagsGroup = ; return <> - + handleOpen(e)} @@ -316,7 +324,16 @@ export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd = {unusedTags && unusedTags.length > 0 ? unusedTagsGroup : 'You have no tags'} - + + + + {!hideEdit && } + + + setOpen(false)} floated='right'>Close + + + @@ -324,7 +341,15 @@ export function AddTagsButton({selectedTagNames = [], onToggle = _.noop, onAdd = export const taggedImageLabel = {corner: 'left', icon: 'tag', color: 'green'}; -export const TagsSelector = ({selectedTagNames = [], onToggle = _.noop, onAdd = _.noop, onRemove = _.noop}) => { +export const TagsSelector = ({ + hideEdit = false, + active, + hideGroup = false, + selectedTagNames = [], + onToggle = _.noop, + onAdd = _.noop, + onRemove = _.noop + }) => { // Provides a button to add tags to a list. Displays the tags of that list. const {TagsLinkGroup} = React.useContext(TagsContext); @@ -333,11 +358,23 @@ export const TagsSelector = ({selectedTagNames = [], onToggle = _.noop, onAdd = return <>; } + const button = ; + + if (hideGroup) { + return button; + } + return - + {button} @@ -389,7 +426,7 @@ export const TagsDropdown = ({value = [], onChange, ...props}) => { options = [...options, ...tagOptions]; } - return } -export function Domains() { +export function DomainsPage() { useTitle('Archive Domains'); const [domains] = useDomains(); @@ -297,20 +297,60 @@ export function Domains() { ; } +export function SearchDomain() { + // A Dropdown which allows the user to filter by Archive domains. + const {domain, domains, setDomain} = useSearchDomain(); + const [open, setOpen] = useState(false); + + const handleOpen = (e) => { + if (e) { + e.preventDefault(); + } + setOpen(true); + } + + const handleClose = (e) => { + if (e) { + e.preventDefault(); + } + setOpen(false); + } + + const handleChange = (e, {value}) => { + setDomain(value); + } + + if (domains && domains.length > 0) { + const domainOptions = domains.map(i => { + return {key: i['domain'], value: i['domain'], text: i['domain']} + }); + return <> + handleOpen()} + onClose={() => handleClose()} + > + + domains + + + + + } + return <> +} + function Archives() { - const [domains] = useDomains(); const [selectedArchives, setSelectedArchives] = useState([]); const [deleteOpen, setDeleteOpen] = useState(false); useTitle('Archives'); - - let filterOptions = []; - if (!_.isEmpty(domains)) { - domains.forEach(i => { - filterOptions = [...filterOptions, {text: i['domain'], key: i['domain'], value: i['domain']}] - }); - } - const {searchParams, updateQuery} = useQuery(); + const {searchParams} = useQuery(); let searchOrder = '-date'; if (searchParams.get('order')) { @@ -321,50 +361,25 @@ function Archives() { searchOrder = defaultSearchOrder; } - const domain = searchParams.get('domain'); const { archives, - limit, - setLimit, totalPages, activePage, setPage, searchStr, setSearchStr, - setOrderBy, fetchArchives, - } = useSearchArchives(24, domain, searchOrder); - const setView = (value) => updateQuery({view: value}); - const view = searchParams.get('view'); - const setDomain = (value) => updateQuery({'domain': value, 'o': 0}); + } = useSearchArchives(); let archiveOrders = [ - {key: '-date', value: '-date', text: 'Newest'}, - {key: 'date', value: 'date', text: 'Oldest'}, + {value: 'date', text: 'Date'}, + {value: 'size', text: 'Size'}, ]; if (searchStr) { - archiveOrders = [{key: 'rank', value: 'rank', text: 'Search Rank'}, ...archiveOrders]; + archiveOrders = [{value: 'rank', text: 'Rank'}, ...archiveOrders]; } - const menuColumns = (<> - - - - - setOrderBy(value)} - /> - - ); - const onSelect = (path, checked) => { if (checked && path) { setSelectedArchives([...selectedArchives, path]); @@ -421,27 +436,56 @@ function Archives() { ; - return + const {body, paginator, selectButton, viewButton, limitDropdown, tagQuerySelector} = FilesView( + archives, + activePage, + totalPages, + selectElm, + selectedArchives, + onSelect, + setPage, + ); + + const searchInput = ; + + return <> + + + + {selectButton} + {viewButton} + {limitDropdown} + {tagQuerySelector} + + + + {searchInput} + + + + + + + + {selectButton} + {viewButton} + {limitDropdown} + {tagQuerySelector} + + + + {searchInput} + + + + + {body} + {paginator} + } export function ArchiveRowCells({file}) { @@ -483,7 +527,7 @@ export function ArchiveRoute() { }/> - }/> + }/> }/> diff --git a/app/src/components/Common.js b/app/src/components/Common.js index d7b89253..d6ab0e1a 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1,8 +1,8 @@ import React, {useContext, useEffect, useState} from "react"; -import {Card, Container, IconGroup, Input, Modal, Pagination} from 'semantic-ui-react'; +import {Button as SButton, ButtonGroup, Card, Container, IconGroup, Input, Modal, Pagination} from 'semantic-ui-react'; import {Link, NavLink, useNavigate} from "react-router-dom"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {useDirectories, useDownloads, useHotspot, useSettings, useThrottle} from "../hooks/customHooks"; +import {useDirectories, useHotspot, useSearchOrder, useSettings, useThrottle} from "../hooks/customHooks"; import {Media, StatusContext, ThemeContext} from "../contexts/contexts"; import {Button, CardIcon, darkTheme, Form, Header, Icon, lightTheme, Menu, Popup, Statistic} from "./Theme"; import {FilePreviewContext} from "./FilePreview"; @@ -933,6 +933,8 @@ export const filterToMimetypes = (filter) => { return ['image']; } else if (filter === 'zip') { return ['application/zip', 'application/zlib', 'application/x-bzip2', 'application/x-xz', 'application/x-bzip', 'application/x-bzip2', 'application/gzip', 'application/vnd.rar', 'application/x-tar', 'application/x-7z-compressed']; + } else if (filter === 'model') { + return ['application/x-openscad', 'model/stl', 'application/sla', 'model/obj']; } } @@ -975,3 +977,64 @@ export const encodeMediaPath = (path) => { path = path.replaceAll(' ', '%20'); return path } + +export function SortButton({sorts = []}) { + const {sort, setSort} = useSearchOrder(); + + if (!sorts || (sorts && sorts.length === 0)) { + console.error('No sorts have been defined!'); + } + + // Use the order in the URL query, fallback to the first in the orders array. + const defaultDesc = sort ? sort.startsWith('-') : true; + const defaultSort = sort ? sort.replaceAll(/^-/g, '') : sorts[0]['value']; + + const [localSort, setLocalSort] = useState(defaultSort); + const [open, setOpen] = useState(false); + const [desc, setDesc] = useState(defaultDesc); + + useEffect(() => { + if (localSort) { + const newSort = desc ? `-${localSort}` : localSort; + console.debug(`Setting new sort: ${newSort}`) + setSort(newSort); + } + }, [localSort, desc]); + + const handleSortButton = (o) => { + setLocalSort(o); + setOpen(false); + } + + const toggleDesc = () => { + setDesc(!desc); + setOpen(false); + } + + let sortFields; + if (sorts && sorts.length) { + sortFields = sorts.map((i) => { + return handleSortButton(i['value'])}>{i['text']} + }) + } + + // Remove the - from the front of the query sort. + const sortKey = localSort.replaceAll(/^-/g, ''); + const selectedSort = sorts.find(i => i['value'] === sortKey); + + return <> + setOpen(false)} + > + Sort By + + {sortFields} + + + + ; + const {body, paginator, selectButton, viewButton, limitDropdown, tagQuerySelector} = FilesView( + videos, + activePage, + totalPages, + selectElm, + selectedVideos, + onSelect, + setPage, + ); + + const searchInput = ; + return <> {header} - - + + + + {selectButton} + {viewButton} + {limitDropdown} + {tagQuerySelector} + + + + {searchInput} + + + + + + + {selectButton} + {viewButton} + {limitDropdown} + {tagQuerySelector} + + + + {searchInput} + + + + {body} + {paginator} } diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 253d25ad..48aeb123 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1,6 +1,5 @@ import {useContext, useEffect, useRef, useState} from "react"; import { - addTag, fetchDomains, fetchFilesProgress, filesSearch, @@ -8,7 +7,6 @@ import { getChannel, getChannels, getDirectories, - getDownloaders, getDownloads, getFiles, getInventory, @@ -18,17 +16,17 @@ import { getThrottleStatus, getVideo, getVideosStatistics, - killDownloads, removeTag, searchArchives, searchVideos, setHotspot, setThrottle, - startDownloads } from "../api"; import {createSearchParams, useSearchParams} from "react-router-dom"; import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common"; import {StatusContext} from "../contexts/contexts"; import {toast} from "react-semantic-toasts-2"; +import {Modal} from "semantic-ui-react"; +import {Button} from "../components/Theme"; const calculatePage = (offset, limit) => { return offset && limit ? Math.round((offset / limit) + 1) : 1; @@ -128,6 +126,17 @@ export const useQuery = () => { return {searchParams, setSearchParams, setQuery, updateQuery} } +export const useOneQuery = (name) => { + const {searchParams, updateQuery} = useQuery(); + const value = searchParams.get(name); + + const setValue = (newValue) => { + updateQuery({[name]: newValue}); + } + + return [value, setValue] +} + export const usePages = (defaultLimit) => { const {searchParams, updateQuery} = useQuery(); const offset = searchParams.get('o') || 0; @@ -148,11 +157,12 @@ export const usePages = (defaultLimit) => { return {offset, limit, setLimit, activePage, setPage}; } -export const useSearchArchives = (defaultLimit, domain, order_by) => { - const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit) +export const useSearchArchives = (defaultLimit) => { + const {domain} = useSearchDomain(); + const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit); const {searchParams, updateQuery} = useQuery(); const searchStr = searchParams.get('q') || ''; - const order = searchParams.get('order') || order_by; + const order = searchParams.get('order'); const activeTags = searchParams.getAll('tag'); const [archives, setArchives] = useState(null); @@ -208,7 +218,7 @@ export const useSearchArchives = (defaultLimit, domain, order_by) => { export const useSearchVideos = (defaultLimit, channelId, order_by) => { const {searchParams, updateQuery} = useQuery(); - const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit) + const {offset, limit, setLimit, activePage, setPage} = usePages(defaultLimit); const searchStr = searchParams.get('q') || ''; const order = searchParams.get('order') || order_by; const activeTags = searchParams.getAll('tag'); @@ -724,3 +734,24 @@ export const useStatistics = () => { return {statistics}; } + +export const useSearchFilter = () => { + const [filter, setFilter] = useOneQuery('filter'); + return {filter, setFilter} +} + +export const useSearchDomain = () => { + const [domains] = useDomains(); + const [domain, setDomain] = useOneQuery('domain'); + return {domain, domains, setDomain} +} + +export const useSearchView = () => { + const [view, setView] = useOneQuery('view'); + return {view, setView} +} + +export const useSearchOrder = () => { + const [order, setOrder] = useOneQuery('order'); + return {sort: order, setSort: setOrder} +} diff --git a/modules/archive/lib.py b/modules/archive/lib.py index 5e1fb3e9..9f0ac953 100644 --- a/modules/archive/lib.py +++ b/modules/archive/lib.py @@ -370,6 +370,8 @@ ARCHIVE_ORDERS = { '-date': 'a.archive_datetime DESC NULLS LAST, LOWER(fg.primary_path) DESC', 'rank': '2 DESC, a.archive_datetime DESC', '-rank': '2 ASC, a.archive_datetime ASC', + 'size': 'fg.size ASC, LOWER(fg.primary_path) ASC', + '-size': 'fg.size DESC NULLS LAST, LOWER(fg.primary_path) DESC', } diff --git a/modules/videos/channel/test/test_api.py b/modules/videos/channel/test/test_api.py index 8a8c44aa..f3e6a501 100644 --- a/modules/videos/channel/test/test_api.py +++ b/modules/videos/channel/test/test_api.py @@ -507,7 +507,7 @@ def test_get_channel_videos_pagination(test_client, test_session, simple_channel ] last_ids = [] for offset, video_count in tests: - _, response = assert_video_search(channel_id=simple_channel.id, order_by='id', offset=offset, limit=20) + _, response = assert_video_search(channel_id=simple_channel.id, order_by='upload_date', offset=offset, limit=20) assert len(response.json['file_groups']) == video_count, 'Returned videos does not match' current_ids = [i['id'] for i in response.json['file_groups']] assert current_ids != last_ids, f'IDs are unchanged current_ids={current_ids}' diff --git a/modules/videos/video/lib.py b/modules/videos/video/lib.py index 85bc6a22..fe8ce560 100644 --- a/modules/videos/video/lib.py +++ b/modules/videos/video/lib.py @@ -42,8 +42,6 @@ VIDEO_ORDERS = { '-upload_date': 'v.upload_date DESC NULLS LAST, LOWER(fg.primary_path) ASC', 'rank': '2 DESC, LOWER(fg.primary_path) DESC', '-rank': '2 ASC, LOWER(fg.primary_path) ASC', - 'id': 'fg.id ASC', - '-id': 'fg.id DESC', 'size': 'fg.size ASC, LOWER(fg.primary_path) ASC', '-size': 'fg.size DESC, LOWER(fg.primary_path) DESC', 'duration': 'duration ASC, LOWER(fg.primary_path) ASC', diff --git a/wrolpi/conftest.py b/wrolpi/conftest.py index b94ac0e3..3e3cd21f 100644 --- a/wrolpi/conftest.py +++ b/wrolpi/conftest.py @@ -19,6 +19,7 @@ from uuid import uuid1, uuid4 import pytest import sqlalchemy +import yaml from PIL import Image from sanic_testing.testing import SanicTestClient from sqlalchemy.engine import Engine, create_engine @@ -561,3 +562,21 @@ def test_tags_config(test_directory): (test_directory / 'config').mkdir(exist_ok=True) config_path = test_directory / 'config/tags.yaml' yield config_path + + +@pytest.fixture +def assert_tags_config(test_tags_config): + def _(tags: dict = None): + from wrolpi.test.common import assert_dict_contains + with test_tags_config.open('rt') as fh: + contents = yaml.load(fh, Loader=yaml.Loader) + + if tags: + for key, item in tags.items(): + assert key in contents['tags'] + assert_dict_contains(contents['tags'][key], item) + else: + # Expect no tags to be saved. + assert not contents['tags'] + + return _ diff --git a/wrolpi/files/lib.py b/wrolpi/files/lib.py index 6b53d382..fe869852 100644 --- a/wrolpi/files/lib.py +++ b/wrolpi/files/lib.py @@ -146,8 +146,11 @@ def _mimetype_suffix_map(path: Path, mimetype: str): """Special handling for mimetypes. Python Magic may not return the correct mimetype.""" from wrolpi.files.ebooks import MOBI_MIMETYPE suffix = path.suffix.lower() - if mimetype == 'application/octet-stream' and suffix.endswith('.mobi'): - return MOBI_MIMETYPE + if mimetype == 'application/octet-stream': + if suffix.endswith('.mobi'): + return MOBI_MIMETYPE + if suffix.endswith('.stl'): + return 'model/stl' if suffix.endswith('.hgt'): return 'application/octet-stream' if mimetype == 'text/plain': @@ -173,6 +176,8 @@ def _mimetype_suffix_map(path: Path, mimetype: str): return 'text/yaml' if suffix.endswith('.azw3'): return 'application/vnd.amazon.mobi8-ebook' + if suffix.endswith('.obj'): + return 'model/obj' return mimetype diff --git a/wrolpi/tags.py b/wrolpi/tags.py index ec0a3d0d..92fa00b1 100644 --- a/wrolpi/tags.py +++ b/wrolpi/tags.py @@ -119,16 +119,16 @@ class TagsConfig(ConfigFile): def save_tags(self, session: Session): media_directory = get_media_directory() - from wrolpi.files.models import FileGroup - results = session.query(Tag, TagFile, FileGroup) \ - .filter(TagFile.tag_id == Tag.id, TagFile.file_group_id == FileGroup.id) \ - .order_by(FileGroup.primary_path) - tags = dict() tag_rows = session.query(Tag) for tag in tag_rows: tags[tag.name] = dict(color=tag.color) + from wrolpi.files.models import FileGroup + results = session.query(Tag, TagFile, FileGroup) \ + .filter(TagFile.tag_id == Tag.id, TagFile.file_group_id == FileGroup.id) \ + .order_by(FileGroup.primary_path) + tag_files = [] for tag, _, file_group in results: value = [tag.name, str(file_group.primary_path.relative_to(media_directory))] @@ -204,6 +204,8 @@ def upsert_tag(name: str, color: str, tag_id: int = None, session: Session = Non session.rollback() raise InvalidTag(f'Name already taken') from e + schedule_save() + return tag @@ -221,6 +223,8 @@ def delete_tag(tag_id: int, session: Session = None): session.delete(tag) session.commit() + schedule_save() + @optional_session def import_tags_config(session: Session = None): diff --git a/wrolpi/test/test_tags.py b/wrolpi/test/test_tags.py index f589be90..0f6c824a 100644 --- a/wrolpi/test/test_tags.py +++ b/wrolpi/test/test_tags.py @@ -106,7 +106,7 @@ async def test_tags_config_(test_session, test_directory, tag_factory, example_p video.remove_tag(tag1) -def test_tags_crud(test_session, test_client, example_pdf): +def test_tags_crud(test_session, test_client, example_pdf, assert_tags_config): """Test API Create/Retrieve/Update/Delete of Tags.""" pdf = FileGroup.from_paths(test_session, example_pdf) test_session.add(pdf) @@ -121,6 +121,7 @@ def test_tags_crud(test_session, test_client, example_pdf): content = dict(name='foo', color='#123456') request, response = test_client.post('/api/tag', content=json.dumps(content)) assert response.status_code == HTTPStatus.CREATED + assert_tags_config(tags={'foo': {'color': '#123456'}}) # The tag can be retrieved. request, response = test_client.get('/api/tag') @@ -138,11 +139,13 @@ def test_tags_crud(test_session, test_client, example_pdf): assert response.status_code == HTTPStatus.OK request, response = test_client.get('/api/tag') assert response.json['tags'] == [dict(name='bar', color='#000000', id=1)] + assert_tags_config(tags={'bar': {'color': '#000000'}}) # Conflicting names return an error. content = dict(name='bar', color='#111111') request, response = test_client.post('/api/tag', content=json.dumps(content)) assert response.status_code == HTTPStatus.BAD_REQUEST + assert_tags_config(tags={'bar': {'color': '#000000'}}) # Cannot delete Tag that is used. request, response = test_client.delete('/api/tag/1') @@ -155,6 +158,9 @@ def test_tags_crud(test_session, test_client, example_pdf): request, response = test_client.delete('/api/tag/1') assert response.status_code == HTTPStatus.NO_CONTENT + # Config is empty. + assert_tags_config() + @pytest.mark.asyncio async def test_delete_tagged_file(test_session, example_pdf, tag_factory):