Merge branch 'master' into release

This commit is contained in:
Roland 2026-07-04 09:25:52 -06:00
commit 7d6a77a54e
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
55 changed files with 1507 additions and 183 deletions

View file

@ -25,7 +25,7 @@ You will need:
Steps:
1. Download the latest `WROLPi-v*-amd64.iso` from [wrolpi.org](https://wrolpi.org).
2. Flash the whole drive with the ISO using Etcher, Rufus, Raspberry Pi Imager, or `dd`:
2. Flash the whole drive with the ISO using Raspberry Pi Imager, Rufus, or `dd`:
```
sudo dd if=WROLPi-v<version>-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync
```

View file

@ -0,0 +1,43 @@
"""file_group_textsearch_abc
Add the `textsearch_abc` generated tsvector column to `file_group`. It indexes only a_text (title),
b_text (author), and c_text (description) — excluding d_text (captions/document body), which is ~90% of all
text data. Searches default to this much smaller column; the existing `textsearch` column remains for
opt-in "deep" search.
Adding a STORED generated column rewrites the table; expect a few minutes on large libraries.
Revision ID: e5f7a9c1d3b6
Revises: d4e6f8a0c2b5
Create Date: 2026-07-03
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'e5f7a9c1d3b6'
down_revision = 'd4e6f8a0c2b5'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
conn.execute(sa.text('''
ALTER TABLE file_group
ADD COLUMN textsearch_abc tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english'::regconfig, COALESCE(a_text, '')), 'A'::"char") ||
setweight(to_tsvector('english'::regconfig, COALESCE(b_text, '')), 'B'::"char") ||
setweight(to_tsvector('english'::regconfig, COALESCE(c_text, '')), 'C'::"char")
) STORED
'''))
conn.execute(sa.text(
'CREATE INDEX IF NOT EXISTS file_group_textsearch_abc_idx ON file_group USING GIN(textsearch_abc)'))
def downgrade():
conn = op.get_bind()
conn.execute(sa.text('DROP INDEX IF EXISTS file_group_textsearch_abc_idx'))
conn.execute(sa.text('ALTER TABLE file_group DROP COLUMN IF EXISTS textsearch_abc'))

View file

@ -454,10 +454,33 @@ tr.drag-deselecting td {
-ms-user-select: none;
}
/* Filter input and collapse-all button: right-aligned on desktop, full width on mobile (below). */
.file-browser-filter-container {
display: flex;
justify-content: flex-end;
margin-bottom: 0.5em;
}
/* Double the default input width on desktop. */
.ui.input.file-browser-filter {
width: 34em;
max-width: 100%;
}
@media only screen and (max-width: 700px) {
.hide-on-mobile {
display: none !important;
}
.file-browser-filter-container {
justify-content: flex-start;
}
/* Fill the row, but stay on one line with the collapse-all button. */
.ui.input.file-browser-filter {
flex: 1 1 auto;
width: auto;
}
}
/* Ensure step icons are always visible, even when disabled */

View file

@ -469,7 +469,7 @@ export function DashboardPage() {
}
return <div style={{display: 'flex', alignItems: 'center', gap: '0.5em', marginBottom: '2em'}}>
{getSearchResultsInput(inputProps)}
<SearchFilterButton fileFilterOptions={fileMimetypeFilterOptions} showDates={true}
<SearchFilterButton fileFilterOptions={fileMimetypeFilterOptions} showDates={true} showDeep={true}
size={big ? 'big' : undefined}/>
</div>;
};

View file

@ -187,7 +187,7 @@ export async function tagChannelInfo(channelId, tagName) {
});
}
export async function searchVideos(offset, limit, channelId, searchStr, order_by, tagNames, headline, censored) {
export async function searchVideos(offset, limit, channelId, searchStr, order_by, tagNames, headline, censored, deep) {
// Build a search query to retrieve a list of videos from the API
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
@ -208,6 +208,9 @@ export async function searchVideos(offset, limit, channelId, searchStr, order_by
if (censored) {
body['censored'] = true;
}
if (deep) {
body['deep'] = true;
}
console.debug('searching videos', body);
@ -697,7 +700,7 @@ export async function saveCatalog(items) {
}
export async function searchArchives(offset, limit, domain, searchStr, order, tagNames, headline) {
export async function searchArchives(offset, limit, domain, searchStr, order, tagNames, headline, deep) {
// Build a search query to retrieve a list of videos from the API
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
@ -717,6 +720,9 @@ export async function searchArchives(offset, limit, domain, searchStr, order, ta
if (headline) {
body['headline'] = true;
}
if (deep) {
body['deep'] = true;
}
console.debug('searching archives', body);
const response = await apiPost(`${ARCHIVES_API}/search`, body);
@ -1212,7 +1218,7 @@ export async function deleteDownload(downloadId) {
}
}
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path) {
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
@ -1244,6 +1250,9 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
if (order) {
body['order'] = order;
}
if (deep) {
body['deep'] = true;
}
console.info('searching files', body);
const response = await apiPost(`${API_URI}/files/search`, body);
@ -1988,6 +1997,7 @@ export async function searchEstimateFiles(search_str, tagNames, mimetypes, month
return {
fileGroups: content.file_groups,
fileGroupsDeep: content.file_groups_deep,
}
} else {
console.error('Failed to get file search estimates!');
@ -2220,7 +2230,7 @@ export async function getDoc(fileGroupId) {
}
export async function searchDocs(offset, limit, searchStr, order, tagNames, author, subject, mimetype) {
export async function searchDocs(offset, limit, searchStr, order, tagNames, author, subject, mimetype, deep) {
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
let body = {offset, limit};
@ -2242,6 +2252,9 @@ export async function searchDocs(offset, limit, searchStr, order, tagNames, auth
if (mimetype) {
body['mimetype'] = mimetype;
}
if (deep) {
body['deep'] = true;
}
console.debug('searching docs', body);
const response = await apiPost(`${DOCS_API}/search`, body);

View file

@ -75,6 +75,18 @@ export async function getHotspotStatus() {
return controllerFetch('/hotspot/status');
}
export async function getHotspotDevices() {
return controllerFetch('/hotspot/devices');
}
export async function getHotspotSettings() {
return controllerFetch('/hotspot/settings');
}
export async function updateHotspotSettings(settings) {
return controllerFetch('/hotspot/settings', {method: 'POST', body: JSON.stringify(settings)});
}
export async function enableHotspot() {
return controllerFetch('/hotspot/enable', {method: 'POST'});
}

View file

@ -77,7 +77,7 @@ import {
useSearchDomain,
useSearchOrder
} from "../hooks/customHooks";
import {FileCards, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import {DeepSearchHint, FileCards, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import _ from "lodash";
import {ThemeContext} from "../contexts/contexts";
@ -1259,8 +1259,10 @@ function ArchivesPage() {
inputRef={searchInputRef}
viewButton={viewButton}
sorts={archiveOrders}
showDeep={true}
/>
{body}
<DeepSearchHint searchStr={searchStr} files={archives}/>
{paginator}
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}

View file

@ -21,7 +21,7 @@ import {
import {Button, darkTheme, Header, Icon, Segment, Statistic, Tab} from "./Theme";
import {BulkTagModal} from "./BulkTagModal";
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
import {docMimetypeFilterOptions, FilesView, SearchControlBar} from "./Files";
import {DeepSearchHint, docMimetypeFilterOptions, FilesView, SearchControlBar} from "./Files";
import {useAuthors, useDoc, useOneQuery, useSearchDocs, useSubjects} from "../hooks/customHooks";
import {TagsSelector} from "../Tags";
import {toast} from "react-semantic-toasts-2";
@ -138,8 +138,10 @@ function DocsPage() {
viewButton={viewButton}
sorts={docOrders}
fileFilterOptions={docMimetypeFilterOptions}
showDeep={true}
/>
{body}
<DeepSearchHint searchStr={searchStr} files={docs}/>
{paginator}
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}

View file

@ -24,6 +24,7 @@ import {
} from "./Common";
import React, {useContext, useEffect, useState} from "react";
import {useDropzone} from "react-dropzone";
import {useHotkeys} from "react-hotkeys-hook";
import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api";
import _ from 'lodash';
import {SortableTable} from "./SortableTable";
@ -67,6 +68,32 @@ export function splitPathParentAndName(path) {
return [path.substring(0, slashIndex), path.substring(slashIndex + 1, path.length)];
}
export function filterBrowseFiles(browseFiles, filterStr) {
// Reduce the file tree to the rows whose name matches `filterStr`, keeping the ancestor
// folders of every match. A folder whose own name matches keeps its entire subtree.
if (!filterStr) {
return browseFiles;
}
const needle = filterStr.toLowerCase();
const filterList = (files) => {
// `files` may be a dict keyed by name (from the API) or an array.
const fileList = Array.isArray(files) ? files : Object.values(files);
const matches = [];
for (const file of fileList) {
if (pathName(file.path).toLowerCase().includes(needle)) {
matches.push(file);
} else if (file.children) {
const children = filterList(file.children);
if (children.length > 0) {
matches.push({...file, children});
}
}
}
return matches;
};
return filterList(browseFiles);
}
function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loadingFolders}) {
const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext);
// Creates a single table row for a folder, or a row for itself and indented rows for its children.
@ -219,6 +246,17 @@ export function FileBrowser() {
const pendingAutoSelectRef = React.useRef(null);
// Tagged file groups that would be deleted - shown in confirmation modal
const [taggedFileGroups, setTaggedFileGroups] = React.useState(null);
// Reduces the visible rows to those matching, and their parent folders.
const [filterStr, setFilterStr] = React.useState('');
const filterInputRef = React.useRef();
// 'f' focuses the filter input, like the search inputs on other pages.
useHotkeys('f', (e) => {
e.preventDefault();
if (filterInputRef.current) {
filterInputRef.current.focus();
}
}, {enableOnFormTags: false});
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {inverted} = React.useContext(ThemeContext);
@ -457,6 +495,17 @@ export function FileBrowser() {
/>
</div>;
const onCollapseAll = () => {
if (!openFolders) return;
// Deselect any paths that will no longer be visible (descendants of the open folders).
const newSelectedPaths = selectedPaths.filter(p =>
!openFolders.some(folder => p.startsWith(folder) && p !== folder));
if (newSelectedPaths.length !== selectedPaths.length) {
setSelectedPaths(newSelectedPaths);
}
setOpenFolders(null);
}
const onFolderClick = async (folder) => {
let newFolders;
if (openFolders.indexOf(folder) >= 0) {
@ -481,6 +530,11 @@ export function FileBrowser() {
setOpenFolders(newFolders);
}
const filteredFiles = React.useMemo(
() => browseFiles ? filterBrowseFiles(browseFiles, filterStr) : browseFiles,
[browseFiles, filterStr],
);
if (browseFiles === null) {
return <Placeholder>
<PlaceholderLine/>
@ -493,11 +547,40 @@ export function FileBrowser() {
return <DragSelectionProvider
selectedPaths={selectedPaths}
setSelectedPaths={setSelectedPaths}
browseFiles={browseFiles}
browseFiles={filteredFiles}
openFolders={openFolders}
>
<div className='file-browser-filter-container'>
<Input
icon='filter'
iconPosition='left'
placeholder='Filter files...'
value={filterStr}
onChange={(e, {value}) => setFilterStr(value)}
aria-label='Filter files'
className='file-browser-filter'
ref={filterInputRef}
action={<Button
icon='close'
type='button'
onClick={() => setFilterStr('')}
disabled={!filterStr}
className='search-clear'
aria-label='Clear filter'
/>}
/>
<Button
icon='angle double up'
type='button'
onClick={onCollapseAll}
disabled={!openFolders || openFolders.length === 0}
title='Close all folders'
aria-label='Close all folders'
style={{marginLeft: '0.5em'}}
/>
</div>
<FileBrowserContent
browseFiles={browseFiles}
browseFiles={filteredFiles}
headers={headers}
onFolderClick={onFolderClick}
setPreviewFile={setPreviewFile}

View file

@ -1,6 +1,6 @@
import React from 'react';
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
import {FileBrowser} from './FileBrowser';
import {FileBrowser, filterBrowseFiles} from './FileBrowser';
import {renderWithProviders} from '../test-utils';
import {SettingsContext} from '../contexts/contexts';
import {FilePreviewContext} from './FilePreview';
@ -478,6 +478,293 @@ describe('FileBrowser', () => {
});
});
describe('Filter', () => {
// foo/ and bar/ are open, their children are loaded.
const nestedBrowseFiles = [
{
path: 'foo/', is_empty: false, children: [
{path: 'foo/foo1.txt', size: 100},
{path: 'foo/foo2.txt', size: 200},
]
},
{
path: 'bar/', is_empty: false, children: [
{path: 'bar/bar.1.txt', size: 300},
]
},
];
describe('filterBrowseFiles', () => {
it('returns files unchanged when filter is empty', () => {
expect(filterBrowseFiles(nestedBrowseFiles, '')).toBe(nestedBrowseFiles);
});
it('keeps a matching folder and its entire subtree', () => {
const result = filterBrowseFiles(nestedBrowseFiles, 'bar');
expect(result).toHaveLength(1);
expect(result[0].path).toBe('bar/');
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].path).toBe('bar/bar.1.txt');
});
it('keeps the parent folders of a matching file', () => {
const result = filterBrowseFiles(nestedBrowseFiles, 'foo1');
expect(result).toHaveLength(1);
expect(result[0].path).toBe('foo/');
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].path).toBe('foo/foo1.txt');
});
it('matches case-insensitively', () => {
const result = filterBrowseFiles(nestedBrowseFiles, 'FOO1');
expect(result).toHaveLength(1);
expect(result[0].children[0].path).toBe('foo/foo1.txt');
});
it('handles the dict format returned by the API', () => {
const dictFiles = {
'foo/': {
path: 'foo/', is_empty: false, children: {
'foo1.txt': {path: 'foo/foo1.txt', size: 100},
}
},
'bar.txt': {path: 'bar.txt', size: 300},
};
const result = filterBrowseFiles(dictFiles, 'bar');
expect(result).toHaveLength(1);
expect(result[0].path).toBe('bar.txt');
});
it('returns no rows when nothing matches', () => {
expect(filterBrowseFiles(nestedBrowseFiles, 'does-not-exist')).toHaveLength(0);
});
});
it('reduces the rows to matches and their tree', async () => {
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: ['foo/', 'bar/'],
setOpenFolders: jest.fn(),
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
// All rows are visible before filtering.
expect(screen.getByText('foo1.txt')).toBeInTheDocument();
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
const filterInput = screen.getByPlaceholderText('Filter files...');
await act(async () => {
fireEvent.change(filterInput, {target: {value: 'bar'}});
});
// Only bar/ and its child remain.
expect(screen.getByText('bar/')).toBeInTheDocument();
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
expect(screen.queryByText('foo/')).not.toBeInTheDocument();
expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument();
expect(screen.queryByText('foo2.txt')).not.toBeInTheDocument();
});
it('restores all rows when the filter is cleared', async () => {
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: ['foo/', 'bar/'],
setOpenFolders: jest.fn(),
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
const filterInput = screen.getByPlaceholderText('Filter files...');
await act(async () => {
fireEvent.change(filterInput, {target: {value: 'bar'}});
});
expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument();
await act(async () => {
fireEvent.change(filterInput, {target: {value: ''}});
});
expect(screen.getByText('foo1.txt')).toBeInTheDocument();
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
});
it('does not filter the contents of an opened folder that matches the filter', async () => {
const mockSetOpenFolders = jest.fn();
// ebooks/ is closed, no children loaded yet.
useBrowseFiles.mockReturnValue({
browseFiles: [
{path: 'ebooks/', children: null, is_empty: false},
{path: 'other.txt', size: 100},
],
openFolders: [],
setOpenFolders: mockSetOpenFolders,
fetchFiles: jest.fn(),
});
const {rerender} = renderFileBrowser(<FileBrowser/>);
// Filter for the folder's name.
const filterInput = screen.getByPlaceholderText('Filter files...');
await act(async () => {
fireEvent.change(filterInput, {target: {value: 'ebooks'}});
});
expect(screen.queryByText('other.txt')).not.toBeInTheDocument();
// Open the matching folder.
await act(async () => {
fireEvent.click(screen.getByText('ebooks/'));
});
expect(mockSetOpenFolders).toHaveBeenCalledWith(['ebooks/']);
// Simulate the folder's children loading; their names do NOT match the filter.
useBrowseFiles.mockReturnValue({
browseFiles: [
{path: 'ebooks/', children: [
{path: 'ebooks/novel.epub', size: 100},
{path: 'ebooks/cover.jpg', size: 200},
], is_empty: false},
{path: 'other.txt', size: 100},
],
openFolders: ['ebooks/'],
setOpenFolders: mockSetOpenFolders,
fetchFiles: jest.fn(),
});
await act(async () => {
rerender(
<AllContextsWrapper>
<FileBrowser/>
</AllContextsWrapper>
);
});
// The matching folder's contents are shown unfiltered.
expect(screen.getByText('ebooks/')).toBeInTheDocument();
expect(screen.getByText('novel.epub')).toBeInTheDocument();
expect(screen.getByText('cover.jpg')).toBeInTheDocument();
// Non-matching rows outside the folder stay hidden.
expect(screen.queryByText('other.txt')).not.toBeInTheDocument();
});
it('focuses the filter input when f is pressed', async () => {
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: ['foo/', 'bar/'],
setOpenFolders: jest.fn(),
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
const filterInput = screen.getByPlaceholderText('Filter files...');
expect(filterInput).not.toHaveFocus();
await act(async () => {
fireEvent.keyDown(document.body, {key: 'f', code: 'KeyF'});
});
expect(filterInput).toHaveFocus();
});
it('clear button clears the filter and shows all rows again', async () => {
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: ['foo/', 'bar/'],
setOpenFolders: jest.fn(),
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
const clearButton = screen.getByLabelText('Clear filter');
expect(clearButton).toBeDisabled();
const filterInput = screen.getByPlaceholderText('Filter files...');
await act(async () => {
fireEvent.change(filterInput, {target: {value: 'bar'}});
});
expect(screen.queryByText('foo1.txt')).not.toBeInTheDocument();
expect(clearButton).not.toBeDisabled();
await act(async () => {
fireEvent.click(clearButton);
});
expect(filterInput).toHaveValue('');
expect(screen.getByText('foo1.txt')).toBeInTheDocument();
expect(screen.getByText('foo2.txt')).toBeInTheDocument();
expect(screen.getByText('bar.1.txt')).toBeInTheDocument();
});
it('collapse-all button is disabled when no folders are open', async () => {
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: [],
setOpenFolders: jest.fn(),
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
expect(screen.getByLabelText('Close all folders')).toBeDisabled();
});
it('collapse-all button closes all folders and deselects their hidden contents', async () => {
const mockSetOpenFolders = jest.fn();
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: ['foo/', 'bar/'],
setOpenFolders: mockSetOpenFolders,
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
// Select the foo/ folder and a file inside it.
const checkboxes = screen.getAllByRole('checkbox');
await act(async () => {
fireEvent.click(checkboxes[0]); // foo/
});
await act(async () => {
fireEvent.click(checkboxes[1]); // foo/foo1.txt
});
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).toBeChecked();
const collapseButton = screen.getByLabelText('Close all folders');
expect(collapseButton).not.toBeDisabled();
await act(async () => {
fireEvent.click(collapseButton);
});
// All folders are closed (clears the ?folders= query param).
expect(mockSetOpenFolders).toHaveBeenCalledWith(null);
// The folder stays selected, but its now-hidden child does not.
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).not.toBeChecked();
});
it('shows no results when nothing matches', async () => {
useBrowseFiles.mockReturnValue({
browseFiles: nestedBrowseFiles,
openFolders: ['foo/', 'bar/'],
setOpenFolders: jest.fn(),
fetchFiles: jest.fn(),
});
renderFileBrowser(<FileBrowser/>);
const filterInput = screen.getByPlaceholderText('Filter files...');
await act(async () => {
fireEvent.change(filterInput, {target: {value: 'does-not-exist'}});
});
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
describe('Drag Selection', () => {
const mockBrowseFilesMultiple = [
{path: 'file1.txt', size: 100},

View file

@ -40,6 +40,7 @@ import {
useSearchDate,
useSearchFiles,
useSearchCensored,
useSearchDeep,
useSearchFilter,
useSearchOrder,
useSearchView,
@ -544,6 +545,7 @@ export function SearchFilterModal(
showTags = true,
showLimit = true,
showCensored = false,
showDeep = false,
limitOptions = [12, 24, 48, 96],
},
) {
@ -553,6 +555,7 @@ export function SearchFilterModal(
const {dateRange, months} = useSearchDate();
const {activeTags, anyTag} = useSearch();
const {censored} = useSearchCensored();
const {deep} = useSearchDeep();
const {limit} = usePages(DEFAULT_SEARCH_LIMIT);
const {updateQuery} = useContext(QueryContext);
@ -570,6 +573,7 @@ export function SearchFilterModal(
const [draftMonths, setDraftMonths] = useState([]);
const [draftRange, setDraftRange] = useState(emptyRange);
const [draftCensored, setDraftCensored] = useState(false);
const [draftDeep, setDraftDeep] = useState(false);
// Seed the drafts from the URL each time the modal is opened.
React.useEffect(() => {
@ -582,6 +586,7 @@ export function SearchFilterModal(
setDraftMonths(seededMonths);
setDraftRange(seededRange);
setDraftCensored(censored || false);
setDraftDeep(deep || false);
}
}, [open]);
@ -622,6 +627,9 @@ export function SearchFilterModal(
if (showCensored && draftCensored !== censored) {
params.censored = draftCensored ? 'true' : null;
}
if (showDeep && draftDeep !== deep) {
params.deep = draftDeep ? 'true' : null;
}
if (Object.keys(params).length > 0) {
params.o = 0;
updateQuery(params);
@ -641,6 +649,7 @@ export function SearchFilterModal(
setDraftMonths([]);
setDraftRange(emptyRange);
setDraftCensored(false);
setDraftDeep(false);
}
// Sort section (operates on the draft).
@ -754,6 +763,18 @@ export function SearchFilterModal(
</Grid.Column>
</Grid.Row>}
{showDeep &&
<Grid.Row>
<Grid.Column>
<SearchFilterSection header='Search Depth'>
<Toggle label='Deep search (captions, document text; slower)'
checked={draftDeep}
onChange={setDraftDeep}
/>
</SearchFilterSection>
</Grid.Column>
</Grid.Row>}
{showDates &&
<Grid.Row>
<Grid.Column>
@ -783,6 +804,7 @@ export function SearchFilterButton(
showTags = true,
showLimit = true,
showCensored = false,
showDeep = false,
limitOptions = [12, 24, 48, 96],
size = 'medium',
content = 'Filter',
@ -794,6 +816,7 @@ export function SearchFilterButton(
const {dateRange, months} = useSearchDate();
const {activeTags, anyTag} = useSearch();
const {censored} = useSearchCensored();
const {deep} = useSearchDeep();
let count = 0;
if (fileFilterOptions && filter) {
@ -811,6 +834,9 @@ export function SearchFilterButton(
if (showCensored && censored) {
count += 1;
}
if (showDeep && deep) {
count += 1;
}
const active = count > 0;
return <>
@ -829,6 +855,7 @@ export function SearchFilterButton(
showTags={showTags}
showLimit={showLimit}
showCensored={showCensored}
showDeep={showDeep}
limitOptions={limitOptions}
/>
</>
@ -849,6 +876,7 @@ export function SearchControlBar(
fileFilterOptions = null,
showDates = false,
showCensored = false,
showDeep = false,
},
) {
const [localSearchStr, setLocalSearchStr] = useState(searchStr || '');
@ -866,17 +894,40 @@ export function SearchControlBar(
{viewButton}
<div style={{flexGrow: 1, minWidth: 0}}>{searchInput}</div>
<SearchFilterButton sorts={sorts} fileFilterOptions={fileFilterOptions} showDates={showDates}
showCensored={showCensored}/>
showCensored={showCensored} showDeep={showDeep}/>
</div>
}
// Reminds the user that the fast search only matches titles/authors/descriptions. Used by the module
// pages (Videos/Archives/Docs), which have no deep estimate, so it shows whenever a fast search is
// active; FilesSearchView has a count-aware variant.
export function DeepSearchHint({searchStr, files}) {
const {deep} = useSearchDeep();
const {updateQuery} = useContext(QueryContext);
if (!searchStr || deep || !Array.isArray(files)) {
return null;
}
return <Segment>
{files.length === 0 ? 'Not finding what you need?' : 'More results may be available in deep search.'}
<Button primary
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>
</Segment>
}
export function FilesSearchView({
showView = true,
emptySearch = false,
model,
deepEstimate = null,
}) {
const {searchFiles, pages} = useSearchFiles(24, emptySearch, model);
const {searchFiles, total, searchStr, pages} = useSearchFiles(24, emptySearch, model);
const {deep} = useSearchDeep();
const {updateQuery} = useContext(QueryContext);
const {body, paginator, viewButton} = FilesView(
{
@ -891,9 +942,38 @@ export function FilesSearchView({
},
);
// The default search only matches titles/authors/descriptions; offer deep search when it would find
// more. `deepEstimate` comes from the search estimates; without it, fall back to a generic hint on
// zero results.
let deepHint = null;
if (Boolean(searchStr) && !deep && searchFiles) {
const moreCount = deepEstimate !== null && deepEstimate !== undefined && total !== null
? deepEstimate - total : null;
// Enable deep search and reset pagination in a single update (like the filter modal does), so the
// user starts at page 1 of the deep results.
const deepButton = <Button primary
onClick={() => updateQuery({deep: 'true', o: 0})}
style={{marginLeft: '1em'}}
>Try deep search</Button>;
if (searchFiles.length === 0 && (moreCount === null || moreCount > 0)) {
deepHint = <Segment>
{moreCount > 0
? `No results in fast search. ${moreCount} result${moreCount === 1 ? '' : 's'} available in deep search.`
: 'Not finding what you need?'}
{deepButton}
</Segment>;
} else if (searchFiles.length > 0 && moreCount > 0) {
deepHint = <Segment>
{`${moreCount} more result${moreCount === 1 ? '' : 's'} available in deep search.`}
{deepButton}
</Segment>;
}
}
return <>
{showView && <div style={{marginBottom: '1em'}}>{viewButton}</div>}
{body}
{deepHint}
{paginator}
</>
}

View file

@ -1,7 +1,7 @@
import React, {useState} from "react";
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
import {FilesSearchView, SearchViewButton} from "./Files";
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchFilter} from "../hooks/customHooks";
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchDeep, useSearchFilter} from "../hooks/customHooks";
import {ShortcutHint} from "./ShortcutHint";
import {ZimSearchView} from "./Zim";
import {MapSearchView} from "./MapSearchView";
@ -159,6 +159,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
sendGeneralReqeust(async () => await searchSuggestions(searchStr));
sendMapRequest(async () => await searchEstimateMap(searchStr));
}
// The files estimate always uses deep search server-side, so users know results are possible.
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames, mimetypes, months, dateRange, anyTag));
sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames));
sendOtherRequest(async () => await searchEstimateOthers(tagNames));
@ -191,7 +192,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
React.useEffect(() => {
if (!_.isEmpty(filesData)) {
setSuggestions((prevState) => {
return {...prevState, fileGroups: filesData.fileGroups}
return {...prevState, fileGroups: filesData.fileGroups, fileGroupsDeep: filesData.fileGroupsDeep}
});
}
}, [setSuggestions, filesData]);
@ -232,6 +233,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext);
const {dateRange, months, setDates, clearDate} = useSearchDate();
const {suggestions, loading} = useSuggestions(searchStr, searchTags, filter, anyTag);
const {deep} = useSearchDeep();
const {getLocationStr} = React.useContext(QueryContext);
// The results that will be displayed by <Search>.
@ -251,19 +253,25 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
let results = {};
// The dropdown always suggests the deep count so users know what is findable. The search results
// page shows the active mode's count, and offers deep search when it found less than promised.
const dropdownEstimate = newSuggestions.fileGroupsDeep ?? newSuggestions.fileGroups;
// The tab badge shows the count matching the active search mode, so it agrees with the result list.
const fileGroupsEstimate = deep ? newSuggestions.fileGroupsDeep : newSuggestions.fileGroups;
// Suggested results are ordered.
if (newSuggestions.fileGroups > 0) {
if (dropdownEstimate > 0) {
results.fileGroups = {
name: 'Files', results: [
{
title: newSuggestions.fileGroups.toString(),
title: dropdownEstimate.toString(),
type: 'files',
// Add search query onto current location.
location: getLocationStr({q: searchStr}, '/search'),
}
]
};
} else if (newSuggestions.fileGroups === 0) {
} else if (dropdownEstimate === 0) {
// Tell the user there are no files.
results.fileGroups = {name: 'Files', results: noResults};
}
@ -389,7 +397,8 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
setSuggestionsResults(results);
setSuggestionsSums({
fileGroups: newSuggestions.fileGroups,
fileGroups: fileGroupsEstimate,
fileGroupsDeep: newSuggestions.fileGroupsDeep,
zims: zimSum,
otherSum: otherSum,
mapPlaces: newSuggestions.mapPlaces || 0,
@ -419,7 +428,7 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
normalizeSuggestionsResults(suggestions);
}
}, [JSON.stringify(suggestions)]);
}, [JSON.stringify(suggestions), deep]);
// Clear timer on unmount.
React.useEffect(() => {
@ -495,7 +504,8 @@ export function SearchView({suggestions, suggestionsSums, loading}) {
return <React.Fragment>
<TabLinks links={links} right={isFilesTab ? <SearchViewButton headlines/> : null}/>
<Routes>
<Route path='/*' element={<FilesSearchView showView={false}/>}/>
<Route path='/*' element={<FilesSearchView showView={false}
deepEstimate={suggestionsSums?.fileGroupsDeep}/>}/>
<Route path='/zim' exact element={<ZimSearchView suggestions={suggestions} loading={loading}/>}/>
<Route path='/map' exact element={<MapSearchView/>}/>
<Route path='/other' exact element={<OtherSearchView loading={loading}/>}/>

View file

@ -146,6 +146,36 @@ describe('SearchFilterButton', () => {
expect(screen.getByText('1')).toBeInTheDocument();
});
it('counts the deep filter and applies it on close when showDeep is set', () => {
const spy = jest.fn();
renderButton({sorts: videoOrders, showDeep: true}, {}, spy);
// No deep badge initially.
expect(screen.queryByText('1')).toBeNull();
fireEvent.click(screen.getByText('Filter'));
// The Search Depth section/toggle is shown.
expect(screen.getByText('Search Depth')).toBeInTheDocument();
fireEvent.mouseUp(screen.getByTestId('toggle'));
// Draft only — nothing applied yet.
expect(spy).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Done'));
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toMatchObject({deep: 'true'});
});
it('does not show the Search Depth section unless showDeep is set', () => {
renderButton({sorts: videoOrders});
fireEvent.click(screen.getByText('Filter'));
expect(screen.queryByText('Search Depth')).toBeNull();
});
it('shows a count badge when the deep filter is active in the URL', () => {
renderButton({sorts: videoOrders, showDeep: true}, {deep: 'true'});
expect(screen.getByText('1')).toBeInTheDocument();
});
it('shows the File Type section when fileFilterOptions are provided', () => {
renderButton({
sorts: videoOrders,

View file

@ -45,7 +45,7 @@ import {
TableCell
} from "semantic-ui-react";
import {useChannel, useSearchOrder, useSearchVideos, useVideo, useVideoStatistics} from "../hooks/customHooks";
import {FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import {DeepSearchHint, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
import {Button, Card, Header, Loader, Modal, Placeholder, Popup, Segment, Statistic} from "./Theme";
@ -241,8 +241,10 @@ export function VideosPage() {
viewButton={viewButton}
sorts={videoOrders}
showCensored={true}
showDeep={true}
/>
{body}
<DeepSearchHint searchStr={searchStr} files={videos}/>
{paginator}
<TaggedDeleteConfirmModal
open={taggedFileGroups !== null}

View file

@ -11,6 +11,8 @@ import {
enableService,
getDisks,
getFstabEntries,
getHotspotDevices,
getHotspotSettings,
getSambaStatus,
getServiceLogs,
getServices,
@ -23,7 +25,9 @@ import {
startService,
stopService,
unmountDisk,
updateHotspotSettings,
} from "../../api/controller";
import QRCode from "react-qr-code";
import {toast} from "react-semantic-toasts-2";
import {RestartButton, ShutdownButton} from "./Settings";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
@ -1345,6 +1349,114 @@ function SambaSection() {
}
function HotspotSettingsForm() {
const dockerized = useDockerized();
const [devices, setDevices] = React.useState([]);
const [form, setForm] = React.useState({device: '', ssid: '', password: ''});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [qrOpen, setQrOpen] = React.useState(false);
React.useEffect(() => {
const fetchSettings = async () => {
try {
const [settings, devicesResponse] = await Promise.all([getHotspotSettings(), getHotspotDevices()]);
setForm({device: settings.device, ssid: settings.ssid, password: settings.password});
setDevices(devicesResponse.devices || []);
} catch (e) {
console.error('Failed to fetch hotspot settings', e);
} finally {
setLoading(false);
}
};
fetchSettings();
}, []);
const handleSave = async () => {
setSaving(true);
try {
const settings = await updateHotspotSettings(form);
setForm(settings);
toast({type: 'success', title: 'Hotspot settings saved', time: 3000});
} catch (e) {
toast({type: 'error', title: 'Failed to save hotspot settings', description: e.message, time: 5000});
} finally {
setSaving(false);
}
};
if (loading) {
return <Segment>
<Header as='h4'>
<Icon name='wifi'/>
Hotspot Settings
</Header>
<Loader active inline size='small'/>
</Segment>;
}
// Keep the saved device selectable even if it is not currently present
// (e.g. a USB WiFi dongle is unplugged).
const deviceOptions = [...new Set([...devices, form.device].filter(Boolean))]
.map(device => ({key: device, value: device, text: device}));
// Special string which allows a mobile device to connect to a specific Wi-Fi.
// The WPA QR format requires backslash-escaping of \ ; , " in the SSID and password.
const escapeWifi = (s) => s.replace(/([\\;,"])/g, '\\$1');
const qrCodeValue = `WIFI:S:${escapeWifi(form.ssid)};T:WPA;P:${escapeWifi(form.password)};;`;
return <Segment>
<Header as='h4'>
<Icon name='wifi'/>
Hotspot Settings
</Header>
<Form>
<Form.Group widths='equal'>
<Form.Input
label='Hotspot SSID'
value={form.ssid}
disabled={dockerized || saving}
onChange={(e, {value}) => setForm({...form, ssid: value})}
/>
<Form.Input
label='Hotspot Password'
value={form.password}
disabled={dockerized || saving}
onChange={(e, {value}) => setForm({...form, password: value})}
/>
<Form.Dropdown
selection
label='Hotspot Device'
placeholder='No WiFi devices found'
options={deviceOptions}
value={form.device}
disabled={dockerized || saving}
onChange={(e, {value}) => setForm({...form, device: value})}
/>
</Form.Group>
<Button
color='violet'
disabled={dockerized}
loading={saving}
onClick={handleSave}
>Save</Button>
<Modal closeIcon
onClose={() => setQrOpen(false)}
onOpen={() => setQrOpen(true)}
open={qrOpen}
trigger={<Button icon='qrcode' color='violet'/>}
>
<Modal.Header>Scan this code to join the hotspot</Modal.Header>
<Modal.Content>
<div style={{display: 'inline-block', backgroundColor: '#ffffff', padding: '1em'}}>
<QRCode value={qrCodeValue} size={300}/>
</div>
</Modal.Content>
</Modal>
</Form>
</Segment>;
}
function AdminControlsSection() {
const dockerized = useDockerized();
@ -1359,6 +1471,8 @@ function AdminControlsSection() {
<BluetoothToggle/>
<ThrottleToggle/>
<HotspotSettingsForm/>
{!dockerized && (
<div style={{marginTop: '1em'}}>
<RestartButton/>

View file

@ -24,7 +24,6 @@ import {
useMessageDismissal,
WROLModeMessage
} from "../Common";
import QRCode from "react-qr-code";
import {useConfigs, useDockerized} from "../../hooks/customHooks";
import {toast} from "react-semantic-toasts-2";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
@ -296,8 +295,6 @@ function WROLModeSection() {
export function SettingsPage() {
const [disabled, setDisabled] = React.useState(false);
const [editSpecialDirectories, setEditSpecialDirectories] = React.useState(false);
const [qrCodeValue, setQrCodeValue] = React.useState('');
const [qrOpen, setQrOpen] = React.useState(false);
const [ready, setReady] = React.useState(false);
const [pendingSave, setPendingSave] = React.useState(false);
const {clearAll} = useMessageDismissal();
@ -446,12 +443,6 @@ export function SettingsPage() {
setTraceModalOpen(false);
}
const handleHotspotChange = async () => {
let {hotspot_ssid, hotspot_encryption, hotspot_password} = state;
// Special string which allows a mobile device to connect to a specific Wi-Fi.
setQrCodeValue(`WIFI:S:${hotspot_ssid};T:${hotspot_encryption};P:${hotspot_password};;`);
}
React.useEffect(() => {
console.debug('settings changed, replacing state...');
setReady(settings ? true : undefined);
@ -468,10 +459,6 @@ export function SettingsPage() {
download_window_start: settings.download_window_start || '',
download_window_end: settings.download_window_end || '',
timezone: settings.timezone || '',
hotspot_device: settings.hotspot_device,
hotspot_on_startup: settings.hotspot_on_startup,
hotspot_password: settings.hotspot_password,
hotspot_ssid: settings.hotspot_ssid,
check_for_upgrades: settings.check_for_upgrades,
ignore_outdated_zims: settings.ignore_outdated_zims,
log_level: fromApiLogLevel(settings.log_level),
@ -487,10 +474,6 @@ export function SettingsPage() {
});
}, [JSON.stringify(settings)]);
React.useEffect(() => {
handleHotspotChange();
}, [state.hotspot_ssid, state.hotspot_password, state.hotspot_device]);
const handleInputChange = async (e, name, value) => {
if (e) {
e.preventDefault()
@ -507,15 +490,6 @@ export function SettingsPage() {
setState({...state, [name]: value});
}
const handleQrOpen = async (e) => {
e.preventDefault();
setQrOpen(true);
}
const qrButton = <Button icon color='violet' style={{marginBottom: '1em'}}>
<Icon name='qrcode' size='big'/>
</Button>;
const timezoneOptions = Intl.supportedValuesOf('timeZone').map(tz => (
{key: tz, value: tz, text: tz}
));
@ -619,54 +593,6 @@ export function SettingsPage() {
</ButtonGroup>
</Grid.Column>
<Grid.Column>
<Segment>
<Header as='h3'>Hotspot Settings</Header>
<Form.Input
label='Hotspot SSID'
value={state.hotspot_ssid}
disabled={disabled || state.hotspot_ssid === null}
onChange={(e, i) => setState({...state, hotspot_ssid: i.value})}
style={{marginBottom: '0.5em'}}
/>
<Form.Input
label='Hotspot Password'
disabled={disabled || state.hotspot_password === null}
value={state.hotspot_password}
onChange={(e, i) => setState({...state, hotspot_password: i.value})}
style={{marginBottom: '0.5em'}}
/>
<Form.Input
label='Hotspot Device'
disabled={disabled || state.hotspot_password === null}
value={state.hotspot_device}
onChange={(e, i) => handleInputChange(e, 'hotspot_device', i.value)}
style={{marginBottom: '0.5em'}}
/>
<Modal closeIcon
onClose={() => setQrOpen(false)}
onOpen={handleQrOpen}
open={qrOpen}
trigger={qrButton}
>
<Modal.Header>
Scan this code to join the hotspot
</Modal.Header>
<Modal.Content>
<div style={{
display: 'inline-block',
backgroundColor: '#ffffff',
padding: '1em'
}}>
<QRCode value={qrCodeValue} size={300}/>
</div>
</Modal.Content>
</Modal>
</Segment>
</Grid.Column>
</Grid.Row>
</Grid>

View file

@ -511,6 +511,7 @@ export const useSearchArchives = (defaultLimit) => {
const activeTags = searchParams.getAll('tag');
const {view} = useSearchView();
const headline = view === 'headline';
const {deep} = useSearchDeep();
const [archives, setArchives] = useState(null);
const [totalPages, setTotalPages] = useState(0);
@ -519,7 +520,8 @@ export const useSearchArchives = (defaultLimit) => {
setArchives(null);
setTotalPages(0);
try {
let [archives_, total] = await searchArchives(offset, limit, domain, searchStr, order, activeTags, headline);
let [archives_, total] = await searchArchives(offset, limit, domain, searchStr, order, activeTags, headline,
deep);
setArchives(archives_);
setTotalPages(calculateTotalPages(total, limit));
} catch (e) {
@ -536,7 +538,7 @@ export const useSearchArchives = (defaultLimit) => {
useEffect(() => {
localSearchArchives();
}, [searchStr, limit, domain, order, offset, JSON.stringify(activeTags), headline]);
}, [searchStr, limit, domain, order, offset, JSON.stringify(activeTags), headline, deep]);
const setSearchStr = (value) => {
updateQuery({q: value, o: 0, order: undefined});
@ -580,6 +582,7 @@ export const useSearchDocs = (defaultLimit) => {
const author = searchParams.get('author') || '';
const subject = searchParams.get('subject') || '';
const filter = searchParams.get('filter') || '';
const {deep} = useSearchDeep();
const [docs, setDocs] = useState(null);
const [totalPages, setTotalPages] = useState(0);
@ -589,7 +592,8 @@ export const useSearchDocs = (defaultLimit) => {
setTotalPages(0);
try {
const mimetype = docFilterToMimetype(filter);
let [docs_, total] = await searchDocs(offset, limit, searchStr, order, activeTags, author, subject, mimetype);
let [docs_, total] = await searchDocs(offset, limit, searchStr, order, activeTags, author, subject,
mimetype, deep);
setDocs(docs_);
setTotalPages(calculateTotalPages(total, limit));
} catch (e) {
@ -600,7 +604,7 @@ export const useSearchDocs = (defaultLimit) => {
useEffect(() => {
localSearchDocs();
}, [searchStr, limit, order, offset, JSON.stringify(activeTags), author, subject, filter]);
}, [searchStr, limit, order, offset, JSON.stringify(activeTags), author, subject, filter, deep]);
const setSearchStr = (value) => {
updateQuery({q: value, o: 0, order: undefined});
@ -639,6 +643,7 @@ export const useSearchVideos = (defaultLimit, channelId, order_by) => {
const headline = view === 'headline';
const anyTag = searchParams.get('anyTag') === 'true';
const censored = searchParams.get('censored') === 'true';
const {deep} = useSearchDeep();
const [videos, setVideos] = useState(null);
const [totalPages, setTotalPages] = useState(0);
@ -649,7 +654,8 @@ export const useSearchVideos = (defaultLimit, channelId, order_by) => {
setLoading(true);
setTotalPages(0);
try {
let [videos_, total] = await searchVideos(offset, limit, channelId, searchStr, order, activeTags, headline, censored);
let [videos_, total] = await searchVideos(offset, limit, channelId, searchStr, order, activeTags, headline,
censored, deep);
setVideos(videos_);
setTotalPages(calculateTotalPages(total, limit));
} catch (e) {
@ -662,7 +668,7 @@ export const useSearchVideos = (defaultLimit, channelId, order_by) => {
useEffect(() => {
localSearchVideos();
}, [searchStr, limit, channelId, offset, order_by, JSON.stringify(activeTags), headline, censored]);
}, [searchStr, limit, channelId, offset, order_by, JSON.stringify(activeTags), headline, censored, deep]);
const setSearchStr = (value) => {
updateQuery({q: value, o: 0, order: undefined});
@ -912,8 +918,10 @@ export const useSearchFiles = (defaultLimit = 48, emptySearch = false, model) =>
dateRange,
} = useSearch(defaultLimit, emptySearch, model);
const {view} = useSearchView();
const {deep} = useSearchDeep();
const [searchFiles, setSearchFiles] = useState(null);
const [total, setTotal] = useState(null);
const [loading, setLoading] = useState(false);
const headline = view === 'headline';
@ -932,12 +940,14 @@ export const useSearchFiles = (defaultLimit = 48, emptySearch = false, model) =>
setLoading(true);
console.log('localSearchFiles', fromDate, toDate, months);
try {
let [file_groups, total] = await filesSearch(
let [file_groups, total_] = await filesSearch(
pages.offset, pages.limit, searchStr, mimetypes, model || model_, activeTags, headline,
months, fromDate, toDate, anyTag);
months, fromDate, toDate, anyTag, null, null, null, deep);
setSearchFiles(file_groups);
pages.setTotal(total);
setTotal(total_);
pages.setTotal(total_);
} catch (e) {
setTotal(null);
pages.setTotal(0);
console.error(e);
toast({
@ -974,10 +984,12 @@ export const useSearchFiles = (defaultLimit = 48, emptySearch = false, model) =>
JSON.stringify(months),
JSON.stringify(dateRange),
anyTag,
deep,
]);
return {
searchFiles,
total,
searchStr,
filter,
setSearchStr,
@ -1655,6 +1667,14 @@ export const useSearchCensored = () => {
return {censored, setCensored}
}
export const useSearchDeep = () => {
// Search all text including captions/document contents (?deep=true). Slower, but more thorough.
const [value, setValue] = useOneQuery('deep');
const deep = value === 'true';
const setDeep = (newValue) => setValue(newValue ? 'true' : undefined);
return {deep, setDeep}
}
export const useSearchDate = () => {
// Filters files using
// ?month=1&month=2&month=3

View file

@ -8,6 +8,9 @@ from controller.api.schemas import (
BluetoothActionResponse,
BluetoothStatusResponse,
HotspotActionResponse,
HotspotDevicesResponse,
HotspotSettingsRequest,
HotspotSettingsResponse,
HotspotStatusResponse,
RestartServicesResponse,
SambaShareAddRequest,
@ -30,13 +33,18 @@ from controller.lib.admin import (
enable_hotspot,
enable_throttle,
get_bluetooth_status_dict,
get_hotspot_device,
get_hotspot_password,
get_hotspot_ssid,
get_hotspot_status_dict,
get_throttle_status_dict,
get_timezone_status_dict,
get_wifi_devices,
reboot_system,
restart_all_services,
set_timezone,
shutdown_system,
update_hotspot_settings,
)
from controller.lib.config import is_docker_mode
from controller.lib.samba import (
@ -56,6 +64,34 @@ async def hotspot_status() -> HotspotStatusResponse:
return HotspotStatusResponse(**get_hotspot_status_dict())
@router.get("/api/hotspot/devices", response_model=HotspotDevicesResponse)
async def hotspot_devices() -> HotspotDevicesResponse:
"""List WiFi devices which could host the hotspot."""
return HotspotDevicesResponse(devices=get_wifi_devices())
@router.get("/api/hotspot/settings", response_model=HotspotSettingsResponse)
async def hotspot_settings() -> HotspotSettingsResponse:
"""Get WiFi hotspot settings."""
return HotspotSettingsResponse(
device=get_hotspot_device(),
ssid=get_hotspot_ssid(),
password=get_hotspot_password(),
)
@router.post("/api/hotspot/settings", response_model=HotspotSettingsResponse)
async def hotspot_settings_update(request: HotspotSettingsRequest) -> HotspotSettingsResponse:
"""Update WiFi hotspot settings; they are saved in controller.yaml."""
# 500 matches the other subsystem endpoints (hotspot/bluetooth/throttle) in Docker mode.
if is_docker_mode():
raise HTTPException(status_code=500, detail="Not available in Docker mode")
result = update_hotspot_settings(device=request.device, ssid=request.ssid, password=request.password)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
return HotspotSettingsResponse(device=result["device"], ssid=result["ssid"], password=result["password"])
@router.post("/api/hotspot/enable", response_model=HotspotActionResponse)
async def hotspot_enable() -> HotspotActionResponse:
"""Enable WiFi hotspot."""

View file

@ -157,6 +157,28 @@ class HotspotStatusResponse(BaseModel):
device: Optional[str] = Field(default=None, description="WiFi device name")
class HotspotDevicesResponse(BaseModel):
"""Response model for /api/hotspot/devices endpoint."""
devices: list[str] = Field(description="Available WiFi device names")
class HotspotSettingsRequest(BaseModel):
"""Request model for updating hotspot settings; only provided values are changed."""
device: Optional[str] = Field(default=None, description="WiFi device name")
ssid: Optional[str] = Field(default=None, description="Hotspot SSID")
password: Optional[str] = Field(default=None, description="Hotspot password (8 to 63 characters)")
class HotspotSettingsResponse(BaseModel):
"""Response model for /api/hotspot/settings endpoint."""
device: str = Field(description="WiFi device name")
ssid: str = Field(description="Hotspot SSID")
password: str = Field(description="Hotspot password")
class HotspotActionResponse(BaseModel):
"""Response model for hotspot enable/disable actions."""

View file

@ -12,7 +12,8 @@ wrol_mode) remain in the main WROLPi API at /api/status.
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from controller.lib.admin import get_bluetooth_status, get_hotspot_status, get_throttle_status, BluetoothStatus, HotspotStatus
from controller.lib.admin import get_bluetooth_status, get_hotspot_ssid, get_hotspot_status, get_throttle_status, \
BluetoothStatus, HotspotStatus
from controller.lib.config import is_docker_mode, is_rpi, is_rpi4, is_rpi5
from controller.lib.status import (
get_cpu_status,
@ -36,16 +37,12 @@ async def get_system_info() -> dict:
Get static system info that doesn't need frequent updates.
These are collected on each request as they rarely change.
"""
from controller.lib.config import get_config
hotspot_status = get_hotspot_status()
bluetooth_status = await get_bluetooth_status()
throttle_status = get_throttle_status()
# Get hotspot SSID from config if hotspot is connected
config = get_config()
hotspot_config = config.get("hotspot", {})
hotspot_ssid = hotspot_config.get("ssid", "WROLPi") if hotspot_status == HotspotStatus.connected else None
hotspot_ssid = get_hotspot_ssid() if hotspot_status == HotspotStatus.connected else None
return {
"dockerized": is_docker_mode(),

View file

@ -49,6 +49,14 @@ async def _noop_status_worker(app, base_sleep: float = 5.0):
raise
@pytest.fixture(autouse=True)
def _isolate_media_directory(tmp_path_factory, monkeypatch):
"""Point the media directory at an empty temp dir so tests never read a real
/media/wrolpi (e.g. hotspot settings from wrolpi.yaml)."""
media_dir = tmp_path_factory.mktemp("media")
monkeypatch.setenv("MEDIA_DIRECTORY", str(media_dir))
@pytest.fixture(autouse=True)
def _patch_status_worker():
"""Replace the real status worker with a no-op for every test.

View file

@ -13,6 +13,7 @@ from pathlib import Path
import yaml
from controller.lib import config as config_lib
from controller.lib.config import is_docker_mode, get_config_value, get_media_directory
logger = logging.getLogger(__name__)
@ -45,13 +46,146 @@ class GovernorStatus(enum.Enum):
unknown = enum.auto()
def get_hotspot_device() -> str:
return get_config_value('hotspot.device', 'wlan0')
def get_hotspot_ssid() -> str:
return get_config_value('hotspot.ssid', 'WROLPi')
def get_hotspot_password() -> str:
return get_config_value('hotspot.password', 'wrolpi hotspot')
def update_hotspot_settings(device: str = None, ssid: str = None, password: str = None) -> dict:
"""
Update hotspot settings in controller.yaml. Only provided values are changed.
Returns:
dict with success status and the current settings
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
if device is not None and not device.strip():
return {"success": False, "error": "Hotspot device must not be empty"}
if ssid is not None and not ssid.strip():
return {"success": False, "error": "Hotspot SSID must not be empty"}
# nmcli requires a WPA password of 8 to 63 characters.
if password is not None and not 8 <= len(password) <= 63:
return {"success": False, "error": "Hotspot password must be 8 to 63 characters"}
changed = False
if device:
config_lib.update_config('hotspot.device', device)
changed = True
if ssid:
config_lib.update_config('hotspot.ssid', ssid)
changed = True
if password:
config_lib.update_config('hotspot.password', password)
changed = True
if changed:
try:
config_lib.save_config()
except RuntimeError as e:
return {"success": False, "error": str(e)}
logger.info("Hotspot settings updated (device=%s, ssid=%s)", get_hotspot_device(), get_hotspot_ssid())
return {
"success": True,
"device": get_hotspot_device(),
"ssid": get_hotspot_ssid(),
"password": get_hotspot_password(),
}
def migrate_hotspot_settings_from_wrolpi_config():
"""
One-time copy of hotspot settings from wrolpi.yaml into controller.yaml.
Hotspot settings used to be saved in wrolpi.yaml by the Settings page; the
Controller now owns them in controller.yaml. Copy any values the user set
in wrolpi.yaml unless controller.yaml already sets them explicitly.
Called on Controller startup after the primary drive is mounted.
"""
if not config_lib.CONFIG_PATH_ON_DRIVE.exists():
return
try:
with open(config_lib.CONFIG_PATH_ON_DRIVE) as f:
controller_config = yaml.safe_load(f) or {}
except (IOError, yaml.YAMLError) as e:
logger.warning("Failed to read controller.yaml for hotspot migration: %s", e)
return
wrolpi_config_path = get_media_directory() / 'config' / 'wrolpi.yaml'
if not wrolpi_config_path.exists():
return
try:
with open(wrolpi_config_path) as f:
wrolpi_config = yaml.safe_load(f) or {}
except (IOError, yaml.YAMLError) as e:
logger.warning("Failed to read wrolpi.yaml for hotspot migration: %s", e)
return
explicit = controller_config.get('hotspot') or {}
key_map = {'device': 'hotspot_device', 'ssid': 'hotspot_ssid', 'password': 'hotspot_password'}
changed = False
for controller_key, wrolpi_key in key_map.items():
value = wrolpi_config.get(wrolpi_key)
if value and controller_key not in explicit and value != get_config_value(f'hotspot.{controller_key}'):
config_lib.update_config(f'hotspot.{controller_key}', value)
changed = True
if changed:
try:
config_lib.save_config()
logger.info("Migrated hotspot settings from wrolpi.yaml to controller.yaml")
except RuntimeError as e:
logger.warning("Could not save migrated hotspot settings: %s", e)
def get_wifi_devices() -> list[str]:
"""
List WiFi device names (e.g. wlan0, wlp2s0) using nmcli.
Returns an empty list in Docker mode or when nmcli is unavailable.
"""
if is_docker_mode():
return []
try:
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE", "device"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return []
devices = []
for line in result.stdout.strip().splitlines():
parts = line.split(":")
# Exact "wifi" match excludes wifi-p2p devices.
if len(parts) >= 2 and parts[1] == "wifi":
devices.append(parts[0])
return devices
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
return []
def get_current_ssid(interface: str = None) -> str | None:
"""
Returns the name of the SSID that is currently connected.
Returns None if Hotspot is active, or no Wi-Fi network is being used.
"""
if interface is None:
interface = get_config_value('hotspot.device', 'wlan0')
interface = get_hotspot_device()
try:
result = subprocess.run(
['iwgetid', interface, '--raw'],
@ -81,7 +215,7 @@ def get_hotspot_status() -> HotspotStatus:
if is_docker_mode():
return HotspotStatus.unknown
device = get_config_value('hotspot.device', 'wlan0')
device = get_hotspot_device()
# Check if device is connected to a Wi-Fi network (not hotspot)
if get_current_ssid(device):
@ -128,7 +262,7 @@ def get_hotspot_status_dict() -> dict:
ssid: Optional[str] - Hotspot SSID when enabled
device: Optional[str] - WiFi device name
"""
device = get_config_value('hotspot.device', 'wlan0')
device = get_hotspot_device()
status = get_hotspot_status()
# Map enum status to enabled/available flags
@ -147,7 +281,7 @@ def get_hotspot_status_dict() -> dict:
"enabled": enabled,
"available": available,
"reason": reason,
"ssid": get_config_value('hotspot.ssid', 'WROLPi') if enabled else None,
"ssid": get_hotspot_ssid() if enabled else None,
"device": device,
}
@ -162,9 +296,9 @@ def enable_hotspot() -> dict:
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
device = get_config_value('hotspot.device', 'wlan0')
ssid = get_config_value('hotspot.ssid', 'WROLPi')
password = get_config_value('hotspot.password', 'wrolpi hotspot')
device = get_hotspot_device()
ssid = get_hotspot_ssid()
password = get_hotspot_password()
try:
# First ensure radio is on

View file

@ -87,9 +87,12 @@ async def lifespan(app: FastAPI):
logger.info("Using default configuration (no controller.yaml found)")
# Apply timezone from wrolpi.yaml to the system.
from controller.lib.admin import apply_timezone_from_config
from controller.lib.admin import apply_timezone_from_config, migrate_hotspot_settings_from_wrolpi_config
apply_timezone_from_config()
# Copy legacy hotspot settings from wrolpi.yaml into controller.yaml.
migrate_hotspot_settings_from_wrolpi_config()
# Apply Samba config if enabled.
from controller.lib.samba import apply_samba_from_config
apply_samba_from_config()
@ -108,8 +111,9 @@ async def lifespan(app: FastAPI):
except RuntimeError as e:
logger.warning("Failed to create controller.yaml: %s", e)
from controller.lib.admin import apply_timezone_from_config
from controller.lib.admin import apply_timezone_from_config, migrate_hotspot_settings_from_wrolpi_config
apply_timezone_from_config()
migrate_hotspot_settings_from_wrolpi_config()
from controller.lib.samba import apply_samba_from_config
apply_samba_from_config()

View file

@ -959,6 +959,23 @@
<button id="hotspot-btn" class="btn" onclick="toggleHotspot()" disabled>Loading...</button>
<button id="bluetooth-btn" class="btn" onclick="toggleBluetooth()" disabled>Loading...</button>
</div>
<details style="margin-bottom: 1rem;">
<summary style="cursor: pointer;">Hotspot Settings</summary>
<div class="modal-form" style="max-width: 24rem; margin-top: 0.5rem;">
<label>SSID
<input type="text" id="hotspot-ssid">
</label>
<label>Password
<input type="text" id="hotspot-password">
</label>
<label>Device
<select id="hotspot-device"
style="width: 100%; padding: 0.5rem; margin-bottom: 1rem; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #fff; font-size: 1rem;">
</select>
</label>
<button class="btn btn-primary" onclick="saveHotspotSettings()">Save Hotspot Settings</button>
</div>
</details>
{% endif %}
<div class="action-buttons">
<button class="btn btn-warning" onclick="restartServices()">Restart All Services</button>
@ -1372,6 +1389,48 @@
await updateHotspotButton();
}
async function loadHotspotSettings() {
const ssidInput = document.getElementById('hotspot-ssid');
if (!ssidInput) return;
try {
const [settings, devices] = await Promise.all([
apiCall('api/hotspot/settings'),
apiCall('api/hotspot/devices'),
]);
ssidInput.value = settings.ssid;
document.getElementById('hotspot-password').value = settings.password;
const select = document.getElementById('hotspot-device');
select.innerHTML = '';
// Keep the saved device selectable even if it is not currently present.
const names = [...new Set([...(devices.devices || []), settings.device])].filter(Boolean);
for (const name of names) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
option.selected = name === settings.device;
select.appendChild(option);
}
} catch (e) {
console.error('Failed to load hotspot settings', e);
}
}
async function saveHotspotSettings() {
const btn = event.target;
btn.disabled = true;
try {
await apiCall('api/hotspot/settings', 'POST', {
ssid: document.getElementById('hotspot-ssid').value,
password: document.getElementById('hotspot-password').value,
device: document.getElementById('hotspot-device').value,
});
await updateHotspotButton();
} catch (e) {
alert(`Failed to save hotspot settings: ${e.message}`);
}
btn.disabled = false;
}
// --- Bluetooth ---
let bluetoothEnabled = null;
@ -2759,6 +2818,7 @@
// Fetch hardware button status immediately
updateHotspotButton();
updateBluetoothButton();
loadHotspotSettings();
// Initial update after a short delay (to not overlap with initial load)
setTimeout(() => {

View file

@ -5,7 +5,9 @@ Unit tests for controller.lib.admin module.
from unittest import mock
import pytest
import yaml
from controller.lib.config import reload_config_from_drive
from controller.lib.admin import (
apply_timezone_from_config,
disable_bluetooth,
@ -19,10 +21,16 @@ from controller.lib.admin import (
get_hotspot_status_dict,
get_throttle_status,
get_timezone_status_dict,
get_hotspot_device,
get_hotspot_password,
get_hotspot_ssid,
get_wifi_devices,
migrate_hotspot_settings_from_wrolpi_config,
reboot_system,
restart_all_services,
set_timezone,
shutdown_system,
update_hotspot_settings,
BluetoothStatus,
HotspotStatus,
GovernorStatus,
@ -295,6 +303,166 @@ class TestGetHotspotStatusDict:
assert result["enabled"] is True
class TestHotspotSettingsMigration:
"""Legacy hotspot settings in wrolpi.yaml are copied into controller.yaml on startup."""
@staticmethod
def _write_wrolpi_yaml(test_config_directory, **values):
lines = ''.join(f'{key}: {value}\n' for key, value in values.items())
(test_config_directory / 'wrolpi.yaml').write_text(lines)
def test_migrates_wrolpi_yaml_values(self, test_directory, test_config_directory, mock_config_path,
reset_runtime_config):
"""wrolpi.yaml hotspot settings should be copied to controller.yaml."""
mock_config_path.write_text("{}\n")
self._write_wrolpi_yaml(test_config_directory, hotspot_device='wlp2s0', hotspot_ssid='RafaelPi',
hotspot_password='password123')
with mock.patch("controller.lib.admin.get_media_directory", return_value=test_directory):
migrate_hotspot_settings_from_wrolpi_config()
assert get_hotspot_device() == 'wlp2s0'
assert get_hotspot_ssid() == 'RafaelPi'
assert get_hotspot_password() == 'password123'
# The migrated values are persisted in controller.yaml.
saved = yaml.safe_load(mock_config_path.read_text())
assert saved['hotspot'] == {'device': 'wlp2s0', 'ssid': 'RafaelPi', 'password': 'password123'}
def test_does_not_overwrite_explicit_controller_values(self, test_directory, test_config_directory,
mock_config_path, reset_runtime_config):
"""Explicit controller.yaml values win over legacy wrolpi.yaml values."""
mock_config_path.write_text("hotspot:\n device: wlan1\n")
reload_config_from_drive()
self._write_wrolpi_yaml(test_config_directory, hotspot_device='wlp2s0', hotspot_ssid='RafaelPi')
with mock.patch("controller.lib.admin.get_media_directory", return_value=test_directory):
migrate_hotspot_settings_from_wrolpi_config()
# The explicit device is kept, but the SSID is still migrated.
assert get_hotspot_device() == 'wlan1'
assert get_hotspot_ssid() == 'RafaelPi'
def test_noop_without_wrolpi_yaml(self, test_directory, test_config_directory, mock_config_path,
reset_runtime_config):
"""Nothing happens when wrolpi.yaml does not exist."""
mock_config_path.write_text("{}\n")
with mock.patch("controller.lib.admin.get_media_directory", return_value=test_directory):
migrate_hotspot_settings_from_wrolpi_config()
assert get_hotspot_device() == 'wlan0'
assert yaml.safe_load(mock_config_path.read_text()) == {}
def test_noop_when_drive_not_mounted(self, test_directory, test_config_directory, mock_config_path,
reset_runtime_config):
"""Nothing happens when controller.yaml does not exist (drive not mounted)."""
self._write_wrolpi_yaml(test_config_directory, hotspot_device='wlp2s0')
with mock.patch("controller.lib.admin.get_media_directory", return_value=test_directory):
migrate_hotspot_settings_from_wrolpi_config()
assert get_hotspot_device() == 'wlan0'
class TestUpdateHotspotSettings:
"""Tests for update_hotspot_settings function."""
def test_updates_and_saves(self, mock_config_path, reset_runtime_config):
"""New settings should be applied to the runtime config and saved to controller.yaml."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = update_hotspot_settings(device='wlp2s0', ssid='RafaelPi', password='password123')
assert result["success"] is True
assert get_hotspot_device() == 'wlp2s0'
saved = yaml.safe_load(mock_config_path.read_text())
assert saved['hotspot'] == {'device': 'wlp2s0', 'ssid': 'RafaelPi', 'password': 'password123'}
def test_partial_update(self, mock_config_path, reset_runtime_config):
"""Only provided values are changed."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = update_hotspot_settings(device='wlp2s0')
assert result["success"] is True
assert result["device"] == 'wlp2s0'
assert result["ssid"] == 'WROLPi'
assert result["password"] == 'wrolpi hotspot'
def test_rejects_short_password(self, mock_config_path, reset_runtime_config):
"""nmcli requires a WPA password of 8 to 63 characters."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
result = update_hotspot_settings(password='short')
assert result["success"] is False
assert "8" in result["error"]
assert get_hotspot_password() == 'wrolpi hotspot'
def test_rejects_in_docker_mode(self, reset_runtime_config):
"""Hotspot settings cannot be changed in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = update_hotspot_settings(device='wlp2s0')
assert result["success"] is False
assert "Docker" in result["error"]
def test_noop_does_not_save(self, tmp_path, reset_runtime_config):
"""An empty update succeeds without writing controller.yaml."""
missing_path = tmp_path / 'missing' / 'controller.yaml'
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
# save_config() would raise RuntimeError here; success proves it was skipped.
with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", missing_path):
result = update_hotspot_settings()
assert result["success"] is True
assert not missing_path.exists()
def test_error_when_drive_not_mounted(self, tmp_path, reset_runtime_config):
"""Saving fails cleanly when the primary drive is not mounted."""
missing_path = tmp_path / 'missing' / 'controller.yaml'
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", missing_path):
result = update_hotspot_settings(device='wlp2s0')
assert result["success"] is False
assert "not mounted" in result["error"]
class TestGetWifiDevices:
"""Tests for get_wifi_devices function."""
# Real terse nmcli output from an x86 PC: `nmcli -t -f DEVICE,TYPE device`
NMCLI_TERSE_DEVICES = (
"eno1:ethernet\n"
"wlp2s0:wifi\n"
"p2p-dev-wlp2s0:wifi-p2p\n"
"lo:loopback\n"
)
def test_returns_wifi_devices_only(self):
"""Should return only wifi-type devices, excluding wifi-p2p and ethernet."""
mock_result = mock.Mock(returncode=0, stdout=self.NMCLI_TERSE_DEVICES)
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
assert get_wifi_devices() == ["wlp2s0"]
def test_returns_empty_in_docker_mode(self):
"""Should return an empty list in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
assert get_wifi_devices() == []
def test_returns_empty_when_nmcli_missing(self):
"""Should return an empty list when nmcli is not installed."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
assert get_wifi_devices() == []
def test_returns_empty_when_nmcli_fails(self):
"""Should return an empty list when nmcli returns an error."""
mock_result = mock.Mock(returncode=8, stdout="")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", return_value=mock_result):
assert get_wifi_devices() == []
class TestDockerModeErrors:
"""Admin functions should return an error result when running in Docker mode."""

View file

@ -34,6 +34,54 @@ class TestStatusEndpoints:
assert data["available"] is False
class TestHotspotDevicesEndpoint:
"""GET /api/hotspot/devices lists WiFi interfaces for the Settings dropdown."""
def test_devices_endpoint_shape(self, test_client):
"""Should return the WiFi devices found by nmcli."""
from unittest import mock
with mock.patch("controller.api.admin.get_wifi_devices", return_value=["wlan0", "wlp2s0"]):
response = test_client.get("/api/hotspot/devices")
assert response.status_code == 200
assert response.json() == {"devices": ["wlan0", "wlp2s0"]}
def test_devices_empty_in_docker(self, test_client_docker_mode):
"""Should return an empty list in Docker mode."""
response = test_client_docker_mode.get("/api/hotspot/devices")
assert response.status_code == 200
assert response.json() == {"devices": []}
class TestHotspotSettingsEndpoint:
"""GET/POST /api/hotspot/settings manage hotspot settings in controller.yaml."""
def test_get_settings(self, test_client):
"""Should return the current hotspot settings."""
response = test_client.get("/api/hotspot/settings")
assert response.status_code == 200
assert response.json() == {"device": "wlan0", "ssid": "WROLPi", "password": "wrolpi hotspot"}
def test_post_settings(self, test_client, mock_config_path):
"""Should update settings and persist them to controller.yaml."""
response = test_client.post(
"/api/hotspot/settings",
json={"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123"},
)
assert response.status_code == 200
assert response.json() == {"device": "wlp2s0", "ssid": "RafaelPi", "password": "password123"}
assert "wlp2s0" in mock_config_path.read_text()
def test_post_rejects_short_password(self, test_client, mock_config_path):
"""Should reject a password shorter than 8 characters."""
response = test_client.post("/api/hotspot/settings", json={"password": "short"})
assert response.status_code == 400
def test_post_rejected_in_docker(self, test_client_docker_mode):
"""Should reject settings changes in Docker mode."""
response = test_client_docker_mode.post("/api/hotspot/settings", json={"device": "wlp2s0"})
assert response.status_code == 500
class TestDockerModeRejectsAdminActions:
"""Admin action endpoints should reject requests when running in Docker."""

View file

@ -59,7 +59,7 @@ progress "Preparing WROLPi..."
# --- Detect deployment mode ---------------------------------------------
# Three mutually-exclusive shapes:
#
# direct Live USB flashed with dd/Etcher: the ISO partition is a
# direct Live USB flashed with dd/Raspberry Pi Imager: the ISO partition is a
# real partition on a real disk. Create + manage a
# persistence partition on that disk.
#
@ -326,7 +326,7 @@ WROLPi can't safely write a persistence partition in that case, so
everything lives in RAM and will be lost when you reboot or power off.
To get a persistent install, flash the ISO directly to a USB drive
with dd or Etcher and boot from that drive instead.
with dd or Raspberry Pi Imager and boot from that drive instead.
EOF
else
cat > "$SUMMARY_FILE" <<EOF

@ -1 +1 @@
Subproject commit 002e747fba33057a176451ec7f5d5f347e0f1c16
Subproject commit 8bd80a152a08eed265d248f0acb4e4881f4035ce

View file

@ -61,7 +61,7 @@ async def search_archives(_: Request, body: schema.ArchiveSearchRequest):
offset = body.offset or 0
file_groups, total = lib.search_archives(search_str, domain, limit, offset, body.order_by, body.tag_names,
body.headline)
body.headline, body.deep)
ret = dict(file_groups=file_groups, totals=dict(file_groups=total))
return json_response(ret)

View file

@ -1400,7 +1400,7 @@ NO_NULL_ORDERS = {
def search_archives(search_str: str, domain: str, limit: int, offset: int, order: str, tag_names: List[str],
headline: bool = False) \
headline: bool = False, deep: bool = False) \
-> Tuple[List[dict], int]:
# Always filter FileGroups to Archives.
wheres = []
@ -1412,9 +1412,11 @@ def search_archives(search_str: str, domain: str, limit: int, offset: int, order
select_columns = ''
if search_str:
# A search_str was provided by the user, modify the query to filter by it.
select_columns = 'ts_rank(fg.textsearch, websearch_to_tsquery(%(search_str)s)) AS rank,' \
# `textsearch` includes d_text (page contents); `textsearch_abc` is much smaller and faster.
ts_col = 'textsearch' if deep else 'textsearch_abc'
select_columns = f'ts_rank(fg.{ts_col}, websearch_to_tsquery(%(search_str)s)) AS rank,' \
' COUNT(*) OVER() AS total'
wheres.append('fg.textsearch @@ websearch_to_tsquery(%(search_str)s)')
wheres.append(f'fg.{ts_col} @@ websearch_to_tsquery(%(search_str)s)')
params['search_str'] = search_str
group_by = f'{group_by}, rank'

View file

@ -24,6 +24,7 @@ class ArchiveSearchRequest:
order_by: Optional[str] = None
tag_names: List[str] = field(default_factory=list)
headline: bool = False
deep: bool = False # When True, search all text including page contents (d_text)
@dataclass

View file

@ -50,36 +50,43 @@ async def test_archives_search(test_session, archive_directory, archive_factory,
archive_factory('example.org') # has no contents
test_session.commit()
# Contents are d_text, so a deep search is required to match them.
# 1 and 2 contain "foo".
data = {'search_str': 'foo'}
data = {'search_str': 'foo', 'deep': True}
await check_results(async_client, data, [2, 1])
# The default (fast) search does not match contents.
data = {'search_str': 'foo'}
await check_results(async_client, data, [])
# 2 and 3 contain "baz".
data = {'search_str': 'baz'}
data = {'search_str': 'baz', 'deep': True}
await check_results(async_client, data, [3, 2])
# 1 contains "bar".
data = {'search_str': 'bar'}
data = {'search_str': 'bar', 'deep': True}
await check_results(async_client, data, [1, ])
# No archives contain "huzzah"
data = {'search_str': 'huzzah'}
data = {'search_str': 'huzzah', 'deep': True}
await check_results(async_client, data, [])
# Only 3 contains "baz" and is in domain "example.org"
data = {'search_str': 'baz', 'domain': 'example.org'}
data = {'search_str': 'baz', 'domain': 'example.org', 'deep': True}
await check_results(async_client, data, [3, ])
# 1's title contains "my", this is ignored by Postgres.
data = {'search_str': 'my'}
data = {'search_str': 'my', 'deep': True}
await check_results(async_client, data, [])
# 3's title contains "third".
# 3's title contains "third", found by the default (fast) search and the deep search.
data = {'search_str': 'third'}
await check_results(async_client, data, [3, ])
data = {'search_str': 'third', 'deep': True}
await check_results(async_client, data, [3, ])
# All contents contain "qux", but they contain different amounts. They are ordered by the amount.
data = {'search_str': 'qux'}
data = {'search_str': 'qux', 'deep': True}
await check_results(async_client, data, [3, 2, 1])
data = {'search_str': 'qux', 'order_by': 'bad order_by'}
@ -116,7 +123,8 @@ async def test_archives_search_headline(test_session, archive_directory, archive
archive_factory('example.org') # has no contents
test_session.commit()
content = dict(search_str='foo')
# "foo" is only in the contents (d_text), so a deep search is required to match it.
content = dict(search_str='foo', deep=True)
request, response = await async_client.post('/api/archive/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
@ -124,7 +132,7 @@ async def test_archives_search_headline(test_session, archive_directory, archive
assert response.json['file_groups'][0]['d_headline'] is None
assert response.json['file_groups'][1]['d_headline'] is None
content = dict(search_str='foo', headline=True)
content = dict(search_str='foo', headline=True, deep=True)
request, response = await async_client.post('/api/archive/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK

View file

@ -571,7 +571,8 @@ async def test_refresh_archives(test_session, test_directory, async_client, make
assert (test_directory / 'archive/example.com/2021-10-05-16-20-10_NA.html').is_file()
assert test_session.query(Archive).count() == 2
content = json.dumps({'search_str': 'text', 'model': 'archive'})
# "text" is only in the readability contents (d_text), so a deep search is required.
content = json.dumps({'search_str': 'text', 'model': 'archive', 'deep': True})
request, response = await async_client.post('/api/files/search', content=content)
assert response.status_code == HTTPStatus.OK
assert response.json['file_groups'], 'No files matched "text"'

View file

@ -56,6 +56,7 @@ async def search_docs(_: Request, body: schema.DocSearchRequest):
offset=offset,
order_by=body.order_by,
tag_names=body.tag_names,
deep=body.deep,
)
ret = dict(file_groups=file_groups, totals=dict(file_groups=total))
return json_response(ret)

View file

@ -347,15 +347,18 @@ def _get_doc(session, file_group_id: int):
def _search_docs(search_str=None, author=None, subject=None, language=None, mimetype=None,
limit=20, offset=0, order_by='published_datetime', tag_names=None):
limit=20, offset=0, order_by='published_datetime', tag_names=None, deep=False):
from .models import Doc
from wrolpi.files.models import FileGroup
# `textsearch` includes d_text (document contents); `textsearch_abc` is much smaller and faster.
ts_col = FileGroup.textsearch if deep else FileGroup.textsearch_abc
with get_db_session() as session:
query = session.query(FileGroup).join(Doc, Doc.file_group_id == FileGroup.id)
if search_str:
query = query.filter(FileGroup.textsearch.op('@@')(func.websearch_to_tsquery(search_str)))
query = query.filter(ts_col.op('@@')(func.websearch_to_tsquery(search_str)))
if author:
query = query.filter(FileGroup.b_text.ilike(f'%{author}%'))
@ -382,7 +385,7 @@ def _search_docs(search_str=None, author=None, subject=None, language=None, mime
# Ordering.
if order_by == 'rank' and search_str:
query = query.order_by(
desc(func.ts_rank(FileGroup.textsearch, func.websearch_to_tsquery(search_str))),
desc(func.ts_rank(ts_col, func.websearch_to_tsquery(search_str))),
desc(FileGroup.id),
)
elif order_by == 'published_datetime':

View file

@ -13,6 +13,7 @@ class DocSearchRequest:
order_by: Optional[str] = 'published_datetime'
limit: int = 20
offset: int = 0
deep: bool = False # When True, search all text including document contents (d_text)
@dataclasses.dataclass

View file

@ -289,7 +289,11 @@ async def test_search_docs_attaches_section_hint_epub(test_session, test_directo
(2, 'Chapter 2', 'unrelated content about pottery'),
])
# "mullen" is only in the document body (d_text), so a deep search is required to match it.
results, total = _search_docs(search_str='mullen', mimetype='application/epub')
assert total == 0, 'Document contents are not searched unless deep=True'
results, total = _search_docs(search_str='mullen', mimetype='application/epub', deep=True)
assert total == 1
assert results[0]['id'] == fg.id
hint = results[0]['section_hint']
@ -311,7 +315,7 @@ async def test_search_docs_attaches_section_hint_pdf(test_session, test_director
(3, 'Page 3', 'here is where mullen shows up'),
])
results, total = _search_docs(search_str='mullen', mimetype='application/pdf')
results, total = _search_docs(search_str='mullen', mimetype='application/pdf', deep=True)
assert total == 1
assert results[0]['id'] == fg.id
hint = results[0]['section_hint']

View file

@ -110,6 +110,71 @@ async def test_channel_download_relationships(test_session, download_channel):
assert test_session.query(Download).count() == 0
@pytest.mark.asyncio
async def test_channel_statistics_with_tagged_videos(async_client, test_session, channel_factory, video_factory,
tag_factory):
"""Tagging a Video must not inflate its Channel's statistics.
A Video with multiple Tags was counted once per Tag because of the tag_file join."""
channel = channel_factory()
video1 = video_factory(channel_id=channel.id)
video2 = video_factory(channel_id=channel.id)
video3 = video_factory(channel_id=channel.id)
test_session.commit()
expected_size = video1.file_group.size + video2.file_group.size + video3.file_group.size
# Statistics are correct before any Videos are tagged.
statistics = channel.get_statistics()
assert statistics['video_count'] == 3
assert statistics['size'] == expected_size
assert statistics['video_tags'] == 0
# video1 has two Tags, video2 has one Tag, video3 has none.
tag1, tag2 = await tag_factory(), await tag_factory()
video1.add_tag(test_session, tag1.name)
video1.add_tag(test_session, tag2.name)
video2.add_tag(test_session, tag1.name)
test_session.commit()
# Tagging Videos must not change the number of Videos in the Channel.
statistics = channel.get_statistics()
assert statistics['video_count'] == 3
assert statistics['size'] == expected_size
assert statistics['video_tags'] == 2
def test_channel_directory_video_counts(test_session, channel_factory, video_factory):
"""A Channel can count the video FileGroups in its directory, and which Channel they are assigned to.
These counts are logged after a Channel refresh to help debug Videos missing from their Channel."""
channel1 = channel_factory()
channel2 = channel_factory()
video_factory(channel_id=channel1.id)
video_factory(channel_id=channel1.id)
# These Videos' files are in channel1's directory, but they are not assigned to channel1.
other_channel_video = video_factory(with_video_file=channel1.directory / 'other channel.mp4')
unassigned_video = video_factory(with_video_file=channel1.directory / 'unassigned.mp4')
other_channel_video.channel_id = channel2.id
unassigned_video.channel_id = None
test_session.commit()
assert channel1.get_directory_video_counts() == dict(
video_file_groups=4,
assigned_to_channel=2,
assigned_to_other_channels=1,
unassigned=1,
)
# channel2's directory contains no files.
assert channel2.get_directory_video_counts() == dict(
video_file_groups=0,
assigned_to_channel=0,
assigned_to_other_channels=0,
unassigned=0,
)
def test_channel_info_json(test_session, test_directory):
from wrolpi.collections import Collection

View file

@ -336,6 +336,7 @@ def assert_video_search(async_client):
order_by: str = None,
channel_id: int = None,
censored: bool = None,
deep: bool = None,
):
content = dict()
if search_str is not None:
@ -352,6 +353,8 @@ def assert_video_search(async_client):
content['channel_id'] = channel_id
if censored is not None:
content['censored'] = censored
if deep is not None:
content['deep'] = deep
request, response = await async_client.post('/api/videos/search', content=json.dumps(content))

View file

@ -953,20 +953,42 @@ class Channel(ModelHelper, Base):
"""Get statistics about this channel."""
with get_db_curs() as curs:
stmt = '''
SELECT SUM(size) AS "size",
MAX(size) AS "largest_video",
COUNT(video.id) AS "video_count",
SUM(fg.length) AS "length",
-- Videos may use multiple tags.
COUNT(video.id) FILTER ( WHERE tf.file_group_id IS NOT NULL ) AS "video_tags"
SELECT SUM(size) AS "size",
MAX(size) AS "largest_video",
COUNT(video.id) AS "video_count",
SUM(fg.length) AS "length",
-- Count Videos with at least one Tag; a Video may use multiple Tags.
(SELECT COUNT(DISTINCT tf.file_group_id)
FROM tag_file tf
INNER JOIN video v ON v.file_group_id = tf.file_group_id
WHERE v.channel_id = %(id)s) AS "video_tags"
FROM video
LEFT JOIN file_group fg on fg.id = video.file_group_id
LEFT JOIN public.tag_file tf on fg.id = tf.file_group_id
WHERE channel_id = %(id)s \
'''
curs.execute(stmt, dict(id=self.id))
return dict(curs.fetchone())
def get_directory_video_counts(self) -> dict:
"""Count the video/audio FileGroups within my directory, and which Channel they are assigned to.
Logged after a refresh to help debug Videos missing from their Channel."""
with get_db_curs() as curs:
stmt = '''
SELECT COUNT(fg.id) AS "video_file_groups",
COUNT(fg.id) FILTER ( WHERE v.channel_id = %(id)s ) AS "assigned_to_channel",
COUNT(fg.id) FILTER ( WHERE v.channel_id IS NOT NULL
AND v.channel_id != %(id)s ) AS "assigned_to_other_channels",
COUNT(fg.id) FILTER ( WHERE v.id IS NULL
OR v.channel_id IS NULL ) AS "unassigned"
FROM file_group fg
LEFT JOIN video v ON v.file_group_id = fg.id
WHERE (fg.directory = %(directory)s OR fg.directory LIKE %(directory)s || '/%%')
AND (fg.mimetype LIKE 'video/%%' OR fg.mimetype LIKE 'audio/%%') \
'''
curs.execute(stmt, dict(id=self.id, directory=str(self.directory)))
return dict(curs.fetchone())
def set_tag(self, tag_id_or_name: int | str | None) -> Tag | None:
"""Change the Tag relationship of this Channel. Will clear the Tag if provided with None."""
session = Session.object_session(self)
@ -1016,6 +1038,12 @@ class Channel(ModelHelper, Base):
with get_db_session(commit=True) as session_:
channel_ = cls.find_by_id(session_, id_)
channel_.refreshed = True
counts = channel_.get_directory_video_counts()
logger.info(f'Refreshed {channel_}:'
f' {counts["video_file_groups"]} video FileGroups in directory,'
f' {counts["assigned_to_channel"]} assigned to this Channel,'
f' {counts["assigned_to_other_channels"]} assigned to other Channels,'
f' {counts["unassigned"]} unassigned')
background_task(_())
return job_id

View file

@ -178,6 +178,7 @@ class VideoSearchRequest:
tag_names: List[str] = field(default_factory=list)
headline: bool = False
censored: bool = False
deep: bool = False # When True, search all text including captions (d_text)
@dataclass

View file

@ -195,31 +195,36 @@ async def test_search_videos_file(test_session, test_directory, video_with_searc
video_with_search_factory(title=title, d_text=caption)
test_session.commit()
# Captions are d_text, so a deep search is required to match them.
# Repeated runs should return the same result
for _ in range(2):
# Only videos with a b are returned, ordered by the amount of b's
await assert_video_search(search_str='b', assert_ids=[1, 2, 3, 4])
await assert_video_search(search_str='b', deep=True, assert_ids=[1, 2, 3, 4])
# The default (fast) search does not match captions.
await assert_video_search(search_str='b', assert_ids=[])
# Only two captions have e
await assert_video_search(search_str='e', assert_ids=[4, 1])
await assert_video_search(search_str='e', deep=True, assert_ids=[4, 1])
# Only 2 contains 2
await assert_video_search(search_str='2', assert_ids=[2, ])
await assert_video_search(search_str='2', deep=True, assert_ids=[2, ])
# Only two captions have d
await assert_video_search(search_str='d', assert_ids=[1, 2])
await assert_video_search(search_str='d', deep=True, assert_ids=[1, 2])
# 5 can be gotten by it's title
# 5 can be gotten by it's title, in either mode.
await assert_video_search(search_str='5', assert_ids=[5, ])
await assert_video_search(search_str='5', deep=True, assert_ids=[5, ])
# only video 1 has e and d
await assert_video_search(search_str='e d', assert_ids=[1, ])
await assert_video_search(search_str='e d', deep=True, assert_ids=[1, ])
# video 1 and 4 have b and e, but 1 has more
await assert_video_search(search_str='b e', assert_ids=[1, 4])
await assert_video_search(search_str='b e', deep=True, assert_ids=[1, 4])
# Check totals are correct even with a limit
await assert_video_search(search_str='b', limit=2, assert_ids=[1, 2], assert_total=4)
await assert_video_search(search_str='b', deep=True, limit=2, assert_ids=[1, 2], assert_total=4)
@pytest.mark.asyncio

View file

@ -16,6 +16,7 @@ from modules.videos.models import Channel, Video
from wrolpi.conftest import test_directory, await_switches
from wrolpi.downloader import Download, DownloadResult, get_download_manager_config
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError
from wrolpi.test.common import skip_circleci
from wrolpi.vars import PROJECT_DIR
example_video_json = {
@ -130,6 +131,7 @@ async def test_download_video_tags(test_session, video_download_manager, video_f
assert [i.tag.name for i in video.file_group.tag_files] == [tag1.name, tag2.name]
@skip_circleci
@pytest.mark.asyncio
async def test_download_channel(test_session, test_directory, simple_channel, video_download_manager, video_file,
mock_video_extract_info, mock_video_prepare_filename, await_switches,

View file

@ -66,6 +66,7 @@ async def search_videos(_: Request, body: schema.VideoSearchRequest):
body.tag_names,
body.headline,
body.censored,
body.deep,
)
ret = {'file_groups': list(file_groups), 'totals': {'file_groups': videos_total}}

View file

@ -93,6 +93,7 @@ def search_videos(
tag_names: List[str] = None,
headline: bool = False,
censored: bool = False,
deep: bool = False,
) -> Tuple[List[dict], int]:
tag_names = tag_names or []
# Search videos and audio-only files.
@ -112,8 +113,10 @@ def search_videos(
if search_str:
# A search_str was provided by the user, modify the query to filter by it.
select_columns = 'fg.id, ts_rank(fg.textsearch, websearch_to_tsquery(%(search_str)s)), COUNT(*) OVER() AS total'
wheres.append('fg.textsearch @@ websearch_to_tsquery(%(search_str)s)')
# `textsearch` includes d_text (captions); `textsearch_abc` is much smaller and faster.
ts_col = 'textsearch' if deep else 'textsearch_abc'
select_columns = f'fg.id, ts_rank(fg.{ts_col}, websearch_to_tsquery(%(search_str)s)), COUNT(*) OVER() AS total'
wheres.append(f'fg.{ts_col} @@ websearch_to_tsquery(%(search_str)s)')
params['search_str'] = search_str
else:
# No search_str provided. Get id and total only.

View file

@ -165,7 +165,7 @@ ERROR: the new ISO is larger than the headroom on this USB.
To upgrade, you'll need to:
1. Back up /media/wrolpi/ to another drive.
2. Re-flash the new ISO from scratch with dd or Etcher.
2. Re-flash the new ISO from scratch with dd or Raspberry Pi Imager.
3. Restore /media/wrolpi/ from the backup.
Future versions flashed from a release ≥ 0.22 reserve 8 GiB of
@ -225,7 +225,7 @@ WROLPi USB without losing your files:
1. Back up everything on the drive to another disk.
2. Flash the whole drive with the ISO (this erases it):
sudo dd if=<iso> of=<device> bs=4M status=progress conv=fsync
(or use Etcher / Rufus / Raspberry Pi Imager)
(or use Raspberry Pi Imager / Rufus)
3. Boot the drive once. WROLPi creates its persistence partition and
sets itself up on first boot.
4. Copy your backed-up data into /media/wrolpi/ on the running system,

View file

@ -165,7 +165,7 @@ async def post_search_files(_: Request, body: schema.FilesSearchRequest):
with timer('Searching all files', 'info', logger__=logger):
file_groups, total = lib.search_files(body.search_str, body.limit, body.offset, body.mimetypes, body.model,
body.tag_names, body.headline, body.months, body.from_year, body.to_year,
body.any_tag, body.order, body.url, body.suffix, body.path)
body.any_tag, body.order, body.url, body.suffix, body.path, body.deep)
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)))

View file

@ -1039,7 +1039,7 @@ async def search_directories_by_name(session: Session, name: str, excluded: List
def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str] = None, model: str = None,
tag_names: List[str] = None, headline: bool = False, months: List[int] = None,
from_year: int = None, to_year: int = None, any_tag: bool = False, order: str = None,
url: str = None, suffix: str = None, path: str = None) -> \
url: str = None, suffix: str = None, path: str = None, deep: bool = False) -> \
Tuple[List[dict], int]:
"""Search the FileGroup table.
@ -1061,6 +1061,7 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str]
@param url: Filter by URL using case-insensitive partial match (ILIKE).
@param suffix: Only return files whose primary file has this suffix (e.g. ".bin"), case-insensitive.
@param path: Filter by primary_path using case-insensitive partial match (ILIKE).
@param deep: Search all text including captions/body (d_text); slower but more thorough.
"""
params = dict(offset=offset, limit=limit, search_str=search_str, url_search_str=f'%{search_str}%')
wheres = []
@ -1069,9 +1070,10 @@ def search_files(search_str: str, limit: int, offset: int, mimetypes: List[str]
joins = []
if search_str:
# Search by textsearch column.
wheres.append('textsearch @@ websearch_to_tsquery(%(search_str)s)')
selects.append('ts_rank(textsearch, websearch_to_tsquery(%(search_str)s))')
# `textsearch` includes d_text (captions/body); `textsearch_abc` is much smaller and faster.
ts_col = 'textsearch' if deep else 'textsearch_abc'
wheres.append(f'{ts_col} @@ websearch_to_tsquery(%(search_str)s)')
selects.append(f'ts_rank({ts_col}, websearch_to_tsquery(%(search_str)s))')
order_by = '2 DESC'
wheres, params = mimetypes_to_sql_wheres(wheres, params, mimetypes)
@ -1479,9 +1481,13 @@ def mimetypes_to_sql_wheres(wheres: List[str], params: dict, mimetypes: List[str
async def search_file_suggestion_count(search_str: str, tag_names: List[str], mimetypes: List[str],
months: List[int] = None, from_year: int = None, to_year: int = None,
any_tag: bool = False):
any_tag: bool = False) -> dict:
"""
Return FileGroup count of what will be returned if the search is actually performed.
Return FileGroup counts of what will be returned if the search is actually performed.
Returns both the fast (`textsearch_abc`) and deep (`textsearch`) counts so the UI can show the count
matching the current search mode, and offer deep search when it has more results. Both are cheap
index-bound COUNTs (no ts_rank).
"""
wheres = []
joins = list()
@ -1490,8 +1496,13 @@ async def search_file_suggestion_count(search_str: str, tag_names: List[str], mi
having = ''
if search_str:
# Search by textsearch, and by matching url.
# `textsearch` matches are a superset of `textsearch_abc`, so filter by the deep column and count
# the fast subset with FILTER in the same pass.
wheres.append('fg.textsearch @@ websearch_to_tsquery(%(search_str)s)')
selects = ('COUNT(*) FILTER (WHERE fg.textsearch_abc @@ websearch_to_tsquery(%(search_str)s)) AS estimate,'
' COUNT(*) AS deep_estimate')
else:
selects = 'COUNT(*) AS estimate, COUNT(*) AS deep_estimate'
wheres, params = tag_append_sub_select_where(wheres, params, tag_names, any_tag)
wheres, params = mimetypes_to_sql_wheres(wheres, params, mimetypes)
@ -1501,7 +1512,7 @@ async def search_file_suggestion_count(search_str: str, tag_names: List[str], mi
joins = "\n".join(joins)
wheres = 'WHERE ' + "\nAND ".join(wheres) if wheres else ''
stmt = f'''
SELECT COUNT(*) OVER() AS estimate
SELECT {selects}
FROM file_group fg
{joins}
{wheres}
@ -1514,8 +1525,8 @@ async def search_file_suggestion_count(search_str: str, tag_names: List[str], mi
curs.execute(stmt, params)
result = curs.fetchone()
if result:
return int(result['estimate'])
return 0
return dict(file_groups=int(result['estimate']), file_groups_deep=int(result['deep_estimate']))
return dict(file_groups=0, file_groups_deep=0)
MOVE_CHUNK_SIZE = 100

View file

@ -79,6 +79,7 @@ class FileGroup(ModelHelper, Base):
Index('file_group_published_datetime_idx', 'published_datetime'),
Index('file_group_published_modified_datetime_idx', 'published_modified_datetime'),
Index('file_group_size_ix', 'size'),
Index('file_group_textsearch_abc_idx', 'textsearch_abc'),
Index('file_group_textsearch_idx', 'textsearch'),
Index('file_group_url_idx', 'url'),
Index('file_group_viewed_idx', 'viewed'),
@ -126,6 +127,13 @@ class FileGroup(ModelHelper, Base):
setweight(to_tsvector('english'::regconfig, COALESCE(c_text, '')), 'C'::"char") ||
setweight(to_tsvector('english'::regconfig, COALESCE(d_text, '')), 'D'::"char")
''')))
# Excludes d_text (captions/body); much smaller than `textsearch`, used for the default fast search.
textsearch_abc = deferred(
Column(tsvector, Computed('''
setweight(to_tsvector('english'::regconfig, COALESCE(a_text, '')), 'A'::"char") ||
setweight(to_tsvector('english'::regconfig, COALESCE(b_text, '')), 'B'::"char") ||
setweight(to_tsvector('english'::regconfig, COALESCE(c_text, '')), 'C'::"char")
''')))
def __repr__(self):
m = f'model={self.model}' if self.model else f'mimetype={self.mimetype}'

View file

@ -44,6 +44,7 @@ class FilesSearchRequest:
url: Optional[str] = None # Filter by URL (partial match)
suffix: Optional[str] = None # Filter by primary file suffix (e.g. ".bin"), case-insensitive exact match
path: Optional[str] = None # Filter by primary_path (case-insensitive partial match)
deep: bool = False # When True, search all text including captions/body (d_text)
def __post_init__(self):
if self.any_tag and self.tag_names:

View file

@ -315,6 +315,51 @@ async def test_files_search(test_session, async_client, make_files_structure, as
await assert_files_search('nothing', [])
@pytest.mark.asyncio
async def test_files_search_deep(test_session, async_client, make_files_structure):
"""Search defaults to the fast ABC column (title/author/description); `deep` opts into d_text
(captions/file contents)."""
guide, lecture = make_files_structure([
'gardening guide.txt',
'lecture.txt',
])
guide.write_text('planting tips')
# "gardening" only appears in this file's contents (d_text), not its name.
lecture.write_text('a lecture about gardening')
request, response = await async_client.post('/api/files/refresh')
assert response.status_code == HTTPStatus.NO_CONTENT
async def search(**kwargs):
request, response = await async_client.post('/api/files/search', json=kwargs)
assert response.status_code == HTTPStatus.OK
return response.json['file_groups']
# Default (fast) search only matches the title.
results = await search(search_str='gardening')
assert [i['primary_path'] for i in results] == ['gardening guide.txt']
# Deep search also matches file contents; the title match ranks higher (weight A vs D).
results = await search(search_str='gardening', deep=True)
assert [i['primary_path'] for i in results] == ['gardening guide.txt', 'lecture.txt']
# Headlines work in both modes.
results = await search(search_str='gardening', headline=True)
assert [i['primary_path'] for i in results] == ['gardening guide.txt']
assert '<b>gardening</b>' in results[0]['title_headline']
results = await search(search_str='gardening', deep=True, headline=True)
assert [i['primary_path'] for i in results] == ['gardening guide.txt', 'lecture.txt']
assert '<b>gardening</b>' in results[1]['d_headline']
# The estimate endpoint returns both counts: `file_groups` matches the default fast search, and
# `file_groups_deep` shows what deep search would find, so the UI can offer deep search.
request, response = await async_client.post('/api/search_file_estimates',
json=dict(search_str='gardening'))
assert response.status_code == HTTPStatus.OK
assert response.json['file_groups'] == 1
assert response.json['file_groups_deep'] == 2
@pytest.mark.asyncio
async def test_files_search_by_suffix(test_session, async_client, make_files_structure):
"""Files can be filtered by their primary file's suffix (used by the firmware flasher to find .bin files)."""
@ -367,7 +412,8 @@ async def test_files_search_attaches_doc_section_hint(async_client, test_session
bug where only the docs-specific endpoint (/api/docs/search) attached hints."""
await refresh_files()
body = dict(search_str='chapter')
# "chapter" only appears in the EPUB body (d_text), so deep search is required to match it.
body = dict(search_str='chapter', deep=True)
request, response = await async_client.post('/api/files/search', json=body)
assert response.status_code == HTTPStatus.OK

View file

@ -580,18 +580,20 @@ async def test_files_indexer(async_client, test_session, make_files_structure, t
# Video are indexed by the modeler, not by an indexer.
assert video_file.mimetype == 'video/mp4' and video_file.indexer == indexers.DefaultIndexer
# File are indexed by their titles and contents.
# File are indexed by their titles and contents. Contents (d_text) require a deep search.
files, total = lib.search_files('file', 10, 0)
assert total == 5, 'All files contain "file" in their file name. The associated video file is hidden.'
files, total = lib.search_files('image', 10, 0)
assert total == 1 and files[0]['title'] == 'an image file.jpeg', 'The image file title contains "image".'
files, total = lib.search_files('contents', 10, 0)
files, total = lib.search_files('contents', 10, 0, deep=True)
assert total == 1 and files[0]['title'] == 'a text file.txt', 'The text file contains "contents".'
files, total = lib.search_files('contents', 10, 0)
assert total == 0, 'File contents are not searched unless deep=True.'
files, total = lib.search_files('video', 10, 0)
assert total == 1 and {i['title'] for i in files} == {'a video file'}, 'The video file contains "video".'
files, total = lib.search_files('yawn', 10, 0)
files, total = lib.search_files('yawn', 10, 0, deep=True)
assert total == 1 and files[0]['title'] == 'a video file', 'The video file captions contain "yawn".'
files, total = lib.search_files('bunny', 10, 0)
files, total = lib.search_files('bunny', 10, 0, deep=True)
assert total == 1 and {i['title'] for i in files} == {'a zip file.zip'}, \
'The zip file contains a file with "bunny" in the title.'
@ -603,7 +605,7 @@ async def test_files_indexer(async_client, test_session, make_files_structure, t
text_path.write_text('new text contents')
await refresh_files()
await await_background_tasks()
files, total = lib.search_files('new', 10, 0)
files, total = lib.search_files('new', 10, 0, deep=True)
assert total == 1

View file

@ -768,7 +768,7 @@ async def post_search_suggestions(request: Request, body: schema.SearchSuggestio
)
async def post_search_file_estimates(_: Request, body: schema.SearchFileEstimateRequest):
"""Used by the Global search to suggest FileGroup count to the user."""
file_groups = await search_file_suggestion_count(
counts = await search_file_suggestion_count(
body.search_str,
body.tag_names,
body.mimetypes,
@ -779,7 +779,8 @@ async def post_search_file_estimates(_: Request, body: schema.SearchFileEstimate
)
ret = dict(
file_groups=file_groups,
file_groups=counts['file_groups'],
file_groups_deep=counts['file_groups_deep'],
)
return json_response(ret)

View file

@ -666,7 +666,8 @@ async def test_search_file_estimates(test_session, async_client, archive_factory
request, response = await async_client.post('/api/search_file_estimates', json=body)
assert response.status_code == HTTPStatus.OK
assert response.json['file_groups'] == expected_file_groups or 0
# The deep count includes contents/captions matches; the fast (ABC) count is in `file_groups`.
assert response.json['file_groups_deep'] == expected_file_groups or 0
await assert_results(dict(search_str='foo'), 1)
@ -727,7 +728,8 @@ async def test_search_file_estimates_any_tag(test_session, async_client, archive
request, response = await async_client.post('/api/search_file_estimates', json=body)
assert response.status_code == HTTPStatus.OK
assert response.json['file_groups'] == expected_file_groups or 0
# The deep count includes contents/captions matches; the fast (ABC) count is in `file_groups`.
assert response.json['file_groups_deep'] == expected_file_groups or 0
await assert_results(dict(search_str='bunny'), 2)
await assert_results(dict(search_str='bunny', any_tag=True), 1)

View file

@ -1 +1 @@
0.26.0-beta
0.27.0-beta