mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Bookmarks: a nav menu of user links, stored in bookmarks.yaml (#729)
* bookmarks: user bookmarks in the navigation bar, stored in bookmarks.yaml A "Bookmarks" tab between Calculators and Flasher opens on hover or click and lists the user's bookmarks, with directories as flyout submenus (Menu.Sub). The mobile hamburger shows directories as labelled groups instead. The tab always shows, with an "Edit bookmarks" item, so the feature can be found before any bookmark exists. Bookmarks live in a new config-only bookmarks.yaml: a nested tree where a node with `children` is a directory and a node with `url` is a bookmark. Every node carries an integer id so the API can rename, move and delete without relying on names. A URL may be a path on this WROLPi, a port on this host (`:8096/` or `http://:8096/`, resolved against whatever hostname the browser used, so it works over LAN IP, mDNS and the hotspot alike), or an absolute URL. Each bookmark chooses whether to open in a new tab. The editor at /more/bookmarks follows the playlist items table: numbered rows, up/down/edit/delete, and inline "Add a bookmark" / "Add a directory" forms with a Location picker. Deleting a directory asks first, since its contents go with it. The menu and the editor are in the component gallery. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * bookmarks: mark More current when the editor is folded inside it; rem indents in the mobile menu Found on the QA Pi at a width where Bookmarks overflows into More: the More button carried no mark on /more/bookmarks, because the bookmarks link has no `to` for isLinkActive to match. isLinkActive now knows the bookmarks link owns its editor page, and the Bookmarks tab uses the same rule. The mobile menu's directory labels sat a few pixels left of the labels around them: the indent was in em, and a Menu.Label is set smaller than a Menu.Item. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * bookmarks: parse URLs instead of matching a prefix; validate the whole tree on every write The URL contract was checked by prefix on both sides, and a prefix is not a form: `//evil.com` starts like a path and `:8096@evil.com` starts like a port, and a browser takes both off this host. A path is now a single leading slash that resolves to this origin, a port form is an optional scheme, `:`, digits and then only a path, query or fragment (no `@`, no backslash, no control characters), and anything else must parse as http(s). The same rules live in wrolpi/bookmarks.py and BookmarksContext.js, with the same bypass strings in both test suites. The config's dataclass validator only saw that `bookmarks` was a list, so a hand-edited file or the generic config API could store a `javascript:` URL that the bookmarks API would refuse, and the nav rendered it. The config now validates the tree itself on every write and import: URL forms, exactly one of url/children, unique integer ids, boolean new_tab, and no cycles (a YAML anchor can build one). An invalid file is not imported and, since it was never successfully imported, is never overwritten. On the page, a stored URL that does not resolve is rendered as text, never as an href. A refused add keeps what was typed instead of clearing the form, and an edit whose move is refused still refreshes the nav (the rename was committed) and keeps the modal open. An IPv6 host is bracketed before a port. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * help: document Bookmarks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9caa140fb6
commit
86d4bbab98
16 changed files with 1626 additions and 14 deletions
|
|
@ -17,6 +17,7 @@ import {ArchiveRoute} from "./components/Archive";
|
|||
import {FilesRoute} from "./components/Files";
|
||||
import {QueryProvider, StatusProvider} from "./hooks/customHooks";
|
||||
import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext";
|
||||
import {BookmarksProvider} from "./contexts/BookmarksContext";
|
||||
import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels";
|
||||
import {MapRoute} from "./components/Map";
|
||||
import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts";
|
||||
|
|
@ -69,13 +70,15 @@ function Root() {
|
|||
<TagsProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
<FilePreviewProvider>
|
||||
<header>
|
||||
<NavBar/>
|
||||
</header>
|
||||
<ErrorBoundary>
|
||||
<Outlet/>
|
||||
</ErrorBoundary>
|
||||
<Footer/>
|
||||
<BookmarksProvider>
|
||||
<header>
|
||||
<NavBar/>
|
||||
</header>
|
||||
<ErrorBoundary>
|
||||
<Outlet/>
|
||||
</ErrorBoundary>
|
||||
<Footer/>
|
||||
</BookmarksProvider>
|
||||
</FilePreviewProvider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</TagsProvider>
|
||||
|
|
|
|||
|
|
@ -2438,3 +2438,46 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
|
|||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
throw Error(message);
|
||||
}
|
||||
|
||||
// Bookmarks: a tree of nodes, a node with `children` being a directory.
|
||||
export async function getBookmarks() {
|
||||
const response = await apiGet(`${API_URI}/bookmarks`);
|
||||
if (response.ok) {
|
||||
return (await response.json())['bookmarks'];
|
||||
}
|
||||
throw new Error('Could not fetch bookmarks');
|
||||
}
|
||||
|
||||
async function bookmarksModify(request, fallbackMessage) {
|
||||
const response = await request;
|
||||
if (response.ok) {
|
||||
return response.status === 204 ? null : await response.json();
|
||||
}
|
||||
const message = await getErrorMessage(response, fallbackMessage);
|
||||
toast({type: 'error', title: 'Bookmarks Error', description: message, time: 5000});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function addBookmark({name, url, new_tab = false, parent_id = null, position = null}) {
|
||||
const body = {name, url, new_tab, parent_id, position};
|
||||
return await bookmarksModify(apiPost(`${API_URI}/bookmarks`, body), 'Could not add bookmark');
|
||||
}
|
||||
|
||||
export async function addBookmarkDirectory({name, parent_id = null, position = null}) {
|
||||
const body = {name, parent_id, position};
|
||||
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/directory`, body), 'Could not add directory');
|
||||
}
|
||||
|
||||
export async function updateBookmark(nodeId, {name, url, new_tab}) {
|
||||
const body = {name, url, new_tab};
|
||||
return await bookmarksModify(apiPut(`${API_URI}/bookmarks/${nodeId}`, body), 'Could not update bookmark');
|
||||
}
|
||||
|
||||
export async function moveBookmark(nodeId, parent_id, position = null) {
|
||||
const body = {parent_id, position};
|
||||
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/${nodeId}/move`, body), 'Could not move bookmark');
|
||||
}
|
||||
|
||||
export async function deleteBookmark(nodeId) {
|
||||
return await bookmarksModify(apiDelete(`${API_URI}/bookmarks/${nodeId}`), 'Could not delete bookmark');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import {Header, Loading, Panel, Statistic, StatisticGroup} from "./ui";
|
||||
import {useStatistics} from "../hooks/customHooks";
|
||||
import {CalculatorsPage} from "./Calculators";
|
||||
import {BookmarksPage} from "./Bookmarks";
|
||||
|
||||
function StatisticsPage() {
|
||||
useTitle('Statistics');
|
||||
|
|
@ -104,6 +105,7 @@ export function MoreRoute(props) {
|
|||
<Routes>
|
||||
<Route path='calculators' element={<CalculatorsPage/>}/>
|
||||
<Route path='statistics' exact element={<StatisticsPage/>}/>
|
||||
<Route path='bookmarks' element={<BookmarksPage/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
}
|
||||
|
|
|
|||
453
app/src/components/Bookmarks.js
Normal file
453
app/src/components/Bookmarks.js
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
import React, {useState} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import {IconBookmark} from '@tabler/icons-react';
|
||||
import {Button, Group, Header, Icon, Menu, Message, Modal, Panel, Select, Table, Text, TextInput, Toggle} from './ui';
|
||||
import {APIButton, PageContainer, useTitle} from './Common';
|
||||
import {isBookmarkDirectory, resolveBookmarkUrl, useBookmarks} from '../contexts/BookmarksContext';
|
||||
import {addBookmark, addBookmarkDirectory, deleteBookmark, moveBookmark, updateBookmark} from '../api';
|
||||
|
||||
export const EDIT_BOOKMARKS_PATH = '/more/bookmarks';
|
||||
|
||||
/**
|
||||
* The props that make a Menu.Item open a bookmark: an in-app Link for a path, a plain
|
||||
* anchor for everything else, and a new tab when the bookmark asks for one. A URL that
|
||||
* does not resolve (see resolveBookmarkUrl) gets a disabled item, never a raw href.
|
||||
*/
|
||||
function bookmarkItemProps(node) {
|
||||
const {internal, href} = resolveBookmarkUrl(node.url);
|
||||
if (!href) {
|
||||
return {disabled: true};
|
||||
}
|
||||
const target = node.new_tab ? {target: '_blank', rel: 'noopener noreferrer'} : {};
|
||||
if (internal) {
|
||||
return {component: Link, to: href, ...target};
|
||||
}
|
||||
return {component: 'a', href, ...target};
|
||||
}
|
||||
|
||||
/**
|
||||
* The items of a bookmarks menu.
|
||||
*
|
||||
* `nested` draws a directory as a flyout (Menu.Sub), which opens beside the menu on hover.
|
||||
* Without it a directory is a labelled, indented run of items instead: the mobile
|
||||
* hamburger is the one place bookmarks are shown where a flyout cannot be hovered. The
|
||||
* indent is in rem: a label is set smaller than an item, so an em indent would put a
|
||||
* directory's label a few pixels left of the items beside it.
|
||||
*/
|
||||
export function BookmarkMenuItems({nodes, nested = true, depth = 0}) {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return nodes.map(node => {
|
||||
if (isBookmarkDirectory(node)) {
|
||||
if (nested) {
|
||||
return <Menu.Sub key={node.id}>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item leftSection={<Icon name='folder' size='small'/>}>{node.name}</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
{node.children.length > 0
|
||||
? <BookmarkMenuItems nodes={node.children} nested={nested}/>
|
||||
: <Menu.Item disabled>Empty</Menu.Item>}
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>;
|
||||
}
|
||||
return <React.Fragment key={node.id}>
|
||||
<Menu.Label style={{paddingLeft: `${0.75 + depth}rem`}}>{node.name}</Menu.Label>
|
||||
<BookmarkMenuItems nodes={node.children} nested={false} depth={depth + 1}/>
|
||||
</React.Fragment>;
|
||||
}
|
||||
return <Menu.Item
|
||||
key={node.id}
|
||||
{...bookmarkItemProps(node)}
|
||||
style={nested ? undefined : {paddingLeft: `${0.75 + depth}rem`}}
|
||||
rightSection={node.new_tab ? <Icon name='external' size='small'/> : undefined}
|
||||
>
|
||||
{node.name}
|
||||
</Menu.Item>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything inside a bookmarks dropdown: the bookmarks, then the link to edit them. The
|
||||
* edit link is always there, so a user with no bookmarks yet can find where to add one.
|
||||
*/
|
||||
export function BookmarksMenuContents({nested = true}) {
|
||||
const {bookmarks} = useBookmarks();
|
||||
const nodes = bookmarks || [];
|
||||
return <>
|
||||
<BookmarkMenuItems nodes={nodes} nested={nested}/>
|
||||
{nodes.length > 0 && <Menu.Divider/>}
|
||||
<Menu.Item component={Link} to={EDIT_BOOKMARKS_PATH} leftSection={<Icon name='edit' size='small'/>}>
|
||||
Edit bookmarks
|
||||
</Menu.Item>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Every directory in the tree with its full path, for the "Location" picker. */
|
||||
export function directoryOptions(nodes, prefix = '') {
|
||||
const options = [];
|
||||
for (const node of nodes || []) {
|
||||
if (isBookmarkDirectory(node)) {
|
||||
const label = `${prefix}${node.name}`;
|
||||
options.push({value: String(node.id), label, id: node.id});
|
||||
options.push(...directoryOptions(node.children, `${label} / `));
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The ids of `node` and everything beneath it. */
|
||||
const subtreeIds = (node) => {
|
||||
const ids = [node.id];
|
||||
if (isBookmarkDirectory(node)) {
|
||||
node.children.forEach(child => ids.push(...subtreeIds(child)));
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
const ROOT = 'root';
|
||||
|
||||
/** The places a node may live: the top level, and every directory not inside `node`. */
|
||||
export function locationOptions(nodes, node = null) {
|
||||
// A directory cannot be moved into itself or its own children.
|
||||
const excluded = node ? new Set(subtreeIds(node)) : new Set();
|
||||
return [
|
||||
{value: ROOT, label: 'Top level'},
|
||||
...directoryOptions(nodes).filter(i => !excluded.has(i.id)).map(({value, label}) => ({value, label})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The form for one node. `node` is null when adding; `directory` says which kind.
|
||||
* `parentId` is where a new node goes, or where an edited one currently lives.
|
||||
*/
|
||||
export function BookmarkNodeModal({open, node, directory, parentId, nodes, onSave, onClose}) {
|
||||
const [name, setName] = useState(node?.name || '');
|
||||
const [url, setUrl] = useState(node?.url || '');
|
||||
const [newTab, setNewTab] = useState(Boolean(node?.new_tab));
|
||||
const [location, setLocation] = useState(parentId === null || parentId === undefined ? ROOT : String(parentId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const locations = locationOptions(nodes, node);
|
||||
|
||||
const valid = name.trim() && (directory || url.trim());
|
||||
const title = node ? (directory ? 'Edit directory' : 'Edit bookmark') : (directory ? 'New directory' : 'New bookmark');
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!valid) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
new_tab: newTab,
|
||||
parent_id: location === ROOT ? null : Number(location),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <Modal open={open} onClose={onClose} size='tiny'>
|
||||
<Modal.Header>{title}</Modal.Header>
|
||||
<Modal.Content>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}>
|
||||
<TextInput
|
||||
autoFocus
|
||||
label='Name'
|
||||
value={name}
|
||||
onChange={e => setName(e.currentTarget.value)}
|
||||
/>
|
||||
{!directory && <TextInput
|
||||
label='URL'
|
||||
style={{marginTop: '1em'}}
|
||||
value={url}
|
||||
onChange={e => setUrl(e.currentTarget.value)}
|
||||
placeholder='/videos, :8096/, or https://example.com'
|
||||
description='A path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/), or a full URL.'
|
||||
/>}
|
||||
<Select
|
||||
label='Location'
|
||||
style={{marginTop: '1em'}}
|
||||
data={locations}
|
||||
value={location}
|
||||
onChange={value => setLocation(value || ROOT)}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{!directory && <Toggle
|
||||
style={{marginTop: '1em'}}
|
||||
label='Open in a new tab'
|
||||
checked={newTab}
|
||||
onChange={e => setNewTab(e.currentTarget.checked)}
|
||||
/>}
|
||||
</form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button role='cancel' onClick={onClose}>Cancel</Button>
|
||||
<Button role='save' disabled={!valid || saving} onClick={handleSave}>Save</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
/** The rows of the editor, one per node, children beneath their directory and indented. */
|
||||
function BookmarkTableRows({nodes, parentId, depth, editable, actions}) {
|
||||
return nodes.map((node, index) => {
|
||||
const directory = isBookmarkDirectory(node);
|
||||
const {href} = directory ? {} : resolveBookmarkUrl(node.url);
|
||||
return <React.Fragment key={node.id}>
|
||||
<Table.Row>
|
||||
<Table.Cell>{String(index + 1).padStart(2, '0')}</Table.Cell>
|
||||
<Table.Cell style={{width: '90px', textAlign: 'center'}}>
|
||||
{directory
|
||||
? <Icon name='folder' size='large'/>
|
||||
: <Icon name={node.new_tab ? 'external' : 'linkify'} size='large'/>}
|
||||
</Table.Cell>
|
||||
<Table.Cell style={{paddingLeft: depth ? `calc(0.75em + ${depth * 1.5}em)` : undefined}}>
|
||||
{directory && <b>{node.name}</b>}
|
||||
{!directory && (href
|
||||
? <a href={href} target={node.new_tab ? '_blank' : undefined} rel='noopener noreferrer'>
|
||||
{node.name}
|
||||
</a>
|
||||
: <span>{node.name}</span>)}
|
||||
{!directory && <Text size='xs' c='dimmed' truncate='end'>{node.url}</Text>}
|
||||
</Table.Cell>
|
||||
{editable && <Table.Cell>
|
||||
<div className='wrolpi-button-row' style={{justifyContent: 'flex-end'}}>
|
||||
<Button icon='arrow up' size='xs' aria-label={`Move ${node.name} up`} disabled={index === 0}
|
||||
onClick={() => actions.move(node, parentId, index - 1)}/>
|
||||
<Button icon='arrow down' size='xs' aria-label={`Move ${node.name} down`}
|
||||
disabled={index === nodes.length - 1}
|
||||
onClick={() => actions.move(node, parentId, index + 1)}/>
|
||||
<Button icon='edit' size='xs' aria-label={`Edit ${node.name}`}
|
||||
onClick={() => actions.edit(node, parentId)}/>
|
||||
{directory
|
||||
? <APIButton
|
||||
role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
|
||||
confirmContent={`Delete the directory "${node.name}" and everything in it?`}
|
||||
confirmButton='Delete'
|
||||
onClick={() => actions.remove(node)}/>
|
||||
: <Button role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
|
||||
onClick={() => actions.remove(node)}/>}
|
||||
</div>
|
||||
</Table.Cell>}
|
||||
</Table.Row>
|
||||
{directory && <BookmarkTableRows nodes={node.children} parentId={node.id} depth={depth + 1}
|
||||
editable={editable} actions={actions}/>}
|
||||
</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The bookmarks table, in the shape of the playlist items table. `actions` carries
|
||||
* edit/move/remove so the gallery can render it with none of them wired.
|
||||
*/
|
||||
export function BookmarkTable({nodes, editable = true, actions}) {
|
||||
return <Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>#</Table.HeaderCell>
|
||||
<Table.HeaderCell>Kind</Table.HeaderCell>
|
||||
<Table.HeaderCell>Bookmark</Table.HeaderCell>
|
||||
{editable && <Table.HeaderCell/>}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<BookmarkTableRows nodes={nodes} parentId={null} depth={0} editable={editable} actions={actions}/>
|
||||
</Table.Body>
|
||||
</Table>;
|
||||
}
|
||||
|
||||
/** The "Add a bookmark" and "Add a directory" forms beneath the table. */
|
||||
export function BookmarkAddForms({nodes, onAddBookmark, onAddDirectory}) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [newTab, setNewTab] = useState(false);
|
||||
const [location, setLocation] = useState(ROOT);
|
||||
const [directoryName, setDirectoryName] = useState('');
|
||||
const [directoryLocation, setDirectoryLocation] = useState(ROOT);
|
||||
const locations = locationOptions(nodes);
|
||||
const parentOf = (value) => value === ROOT ? null : Number(value);
|
||||
|
||||
// The form is cleared only once the server has accepted the node. A refused add has
|
||||
// already shown its toast; the typed URL stays so the user can correct it.
|
||||
const handleAddBookmark = async () => {
|
||||
if (!url.trim() || !name.trim()) return;
|
||||
try {
|
||||
await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
setUrl('');
|
||||
setName('');
|
||||
};
|
||||
|
||||
const handleAddDirectory = async () => {
|
||||
if (!directoryName.trim()) return;
|
||||
try {
|
||||
await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)});
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
setDirectoryName('');
|
||||
};
|
||||
|
||||
return <>
|
||||
<Header as='h4'>Add a bookmark</Header>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleAddBookmark();
|
||||
}}>
|
||||
<Group align='flex-end' wrap='wrap'>
|
||||
<TextInput
|
||||
style={{flex: '2 1 300px'}}
|
||||
aria-label='Bookmark URL'
|
||||
placeholder='URL (e.g. /videos, :8096/, or https://example.com)'
|
||||
value={url}
|
||||
onChange={e => setUrl(e.currentTarget.value)}/>
|
||||
<TextInput
|
||||
style={{flex: '1 1 200px'}}
|
||||
aria-label='Bookmark name'
|
||||
placeholder='Name'
|
||||
value={name}
|
||||
onChange={e => setName(e.currentTarget.value)}/>
|
||||
<Select
|
||||
style={{flex: '1 1 160px'}}
|
||||
aria-label='Bookmark location'
|
||||
data={locations}
|
||||
value={location}
|
||||
onChange={value => setLocation(value || ROOT)}
|
||||
allowDeselect={false}/>
|
||||
<Toggle
|
||||
label='New tab'
|
||||
checked={newTab}
|
||||
onChange={e => setNewTab(e.currentTarget.checked)}/>
|
||||
<Button role='primary' type='submit' disabled={!url.trim() || !name.trim()}>Add</Button>
|
||||
</Group>
|
||||
</form>
|
||||
<p style={{marginTop: '0.5em'}}>
|
||||
<Text size='xs' c='dimmed' component='span'>
|
||||
A URL is a path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/) that
|
||||
works whichever address you reach it by, or a full URL.
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
<Header as='h4'>Add a directory</Header>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleAddDirectory();
|
||||
}}>
|
||||
<Group align='flex-end' wrap='wrap'>
|
||||
<TextInput
|
||||
style={{flex: '2 1 300px'}}
|
||||
aria-label='Directory name'
|
||||
placeholder='Directory name'
|
||||
value={directoryName}
|
||||
onChange={e => setDirectoryName(e.currentTarget.value)}/>
|
||||
<Select
|
||||
style={{flex: '1 1 160px'}}
|
||||
aria-label='Directory location'
|
||||
data={locations}
|
||||
value={directoryLocation}
|
||||
onChange={value => setDirectoryLocation(value || ROOT)}
|
||||
allowDeselect={false}/>
|
||||
<Button role='primary' type='submit' disabled={!directoryName.trim()}>Add</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function BookmarksPage() {
|
||||
useTitle('Bookmarks');
|
||||
const {bookmarks, refresh} = useBookmarks();
|
||||
// {node, directory, parentId} while the edit modal is open; a `key` remounts the form fresh.
|
||||
const [modal, setModal] = useState(null);
|
||||
const [modalKey, setModalKey] = useState(0);
|
||||
const nodes = bookmarks || [];
|
||||
|
||||
const actions = {
|
||||
edit: (node, parentId) => {
|
||||
setModalKey(k => k + 1);
|
||||
setModal({node, directory: isBookmarkDirectory(node), parentId});
|
||||
},
|
||||
move: async (node, parentId, position) => {
|
||||
try {
|
||||
await moveBookmark(node.id, parentId, position);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
// Error toast already shown by the API client.
|
||||
}
|
||||
},
|
||||
remove: async (node) => {
|
||||
try {
|
||||
await deleteBookmark(node.id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// These reject on failure (the API client has already shown the toast) so the add
|
||||
// forms know to keep what was typed.
|
||||
const handleAddBookmark = async (bookmark) => {
|
||||
await addBookmark(bookmark);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleAddDirectory = async (directory) => {
|
||||
await addBookmarkDirectory(directory);
|
||||
await refresh();
|
||||
};
|
||||
|
||||
const handleEditSave = async ({name, url, new_tab, parent_id}) => {
|
||||
const {node, directory, parentId} = modal;
|
||||
try {
|
||||
await updateBookmark(node.id, directory ? {name} : {name, url, new_tab});
|
||||
} catch (e) {
|
||||
return; // Nothing changed; the modal stays open with the toast beside it.
|
||||
}
|
||||
// The rename is committed even if the move is refused, so the nav must show it
|
||||
// either way; only a fully successful edit closes the modal.
|
||||
let moved = true;
|
||||
if (parent_id !== parentId) {
|
||||
try {
|
||||
await moveBookmark(node.id, parent_id);
|
||||
} catch (e) {
|
||||
moved = false;
|
||||
}
|
||||
}
|
||||
await refresh();
|
||||
if (moved) {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
return <PageContainer>
|
||||
<Panel>
|
||||
<Header as='h1' icon={IconBookmark}>Bookmarks</Header>
|
||||
<p>
|
||||
Bookmarks appear in the Bookmarks menu of the navigation bar. They can point at pages on this
|
||||
WROLPi, at other services running beside it, or anywhere on the web.
|
||||
</p>
|
||||
|
||||
{nodes.length === 0
|
||||
? <Message title='There are no bookmarks yet'>Add a bookmark below.</Message>
|
||||
: <BookmarkTable nodes={nodes} actions={actions}/>}
|
||||
|
||||
<BookmarkAddForms nodes={nodes} onAddBookmark={handleAddBookmark} onAddDirectory={handleAddDirectory}/>
|
||||
</Panel>
|
||||
{modal && <BookmarkNodeModal
|
||||
key={modalKey}
|
||||
open
|
||||
node={modal.node}
|
||||
directory={modal.directory}
|
||||
parentId={modal.parentId}
|
||||
nodes={nodes}
|
||||
onSave={handleEditSave}
|
||||
onClose={() => setModal(null)}
|
||||
/>}
|
||||
</PageContainer>;
|
||||
}
|
||||
199
app/src/components/Bookmarks.test.js
Normal file
199
app/src/components/Bookmarks.test.js
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React from 'react';
|
||||
import {screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {render} from '../test-utils';
|
||||
import {BookmarkAddForms, BookmarkNodeModal, BookmarkTable, directoryOptions, locationOptions} from './Bookmarks';
|
||||
import {resolveBookmarkUrl} from '../contexts/BookmarksContext';
|
||||
|
||||
const tree = [
|
||||
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
|
||||
{
|
||||
id: 2, name: 'Services', children: [
|
||||
{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
|
||||
{id: 4, name: 'Radio', children: []},
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
describe('resolveBookmarkUrl', () => {
|
||||
const location = {protocol: 'https:', hostname: '10.0.0.2', host: '10.0.0.2'};
|
||||
|
||||
it.each([
|
||||
['/videos', {internal: true, href: '/videos'}],
|
||||
[' /videos/channel/3 ', {internal: true, href: '/videos/channel/3'}],
|
||||
// A port on this host takes the page's scheme, or its own when it names one.
|
||||
[':8096/web', {internal: false, href: 'https://10.0.0.2:8096/web'}],
|
||||
[':8096', {internal: false, href: 'https://10.0.0.2:8096'}],
|
||||
['http://:8096/web', {internal: false, href: 'http://10.0.0.2:8096/web'}],
|
||||
['HTTPS://:8085', {internal: false, href: 'https://10.0.0.2:8085'}],
|
||||
['https://example.com/a?b=c', {internal: false, href: 'https://example.com/a?b=c'}],
|
||||
])('resolves %s', (url, expected) => {
|
||||
expect(resolveBookmarkUrl(url, location)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// Paths a browser takes off this host: network paths, backslashes, and control
|
||||
// characters a URL parser strips before deciding.
|
||||
'//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
|
||||
// Port forms where the host would become credentials.
|
||||
':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':8096\\evil.com',
|
||||
// Not http(s).
|
||||
'javascript:alert(1)', 'data:text/html,hi', 'ftp://example.com', 'videos', '',
|
||||
])('refuses %j rather than guessing', (url) => {
|
||||
expect(resolveBookmarkUrl(url, location)).toEqual({internal: false, href: null});
|
||||
});
|
||||
|
||||
it('brackets an IPv6 host for a port bookmark', () => {
|
||||
// The browser reports an IPv6 hostname bracketed already.
|
||||
const v6 = {protocol: 'https:', hostname: '[::1]', host: '[::1]'};
|
||||
expect(resolveBookmarkUrl(':8096/web', v6)).toEqual({internal: false, href: 'https://[::1]:8096/web'});
|
||||
expect(resolveBookmarkUrl(':8096', {protocol: 'http:', hostname: '::1', host: '[::1]'}))
|
||||
.toEqual({internal: false, href: 'http://[::1]:8096'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('directoryOptions', () => {
|
||||
it('lists every directory with its path, and no bookmarks', () => {
|
||||
expect(directoryOptions(tree)).toEqual([
|
||||
{value: '2', label: 'Services', id: 2},
|
||||
{value: '4', label: 'Services / Radio', id: 4},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookmarkTable', () => {
|
||||
const actions = () => ({edit: jest.fn(), move: jest.fn(), remove: jest.fn()});
|
||||
|
||||
it('renders the tree with resolved links, numbered within each directory', () => {
|
||||
render(<BookmarkTable nodes={tree} actions={actions()}/>);
|
||||
const channels = screen.getByRole('link', {name: 'Channels'});
|
||||
expect(channels).toHaveAttribute('href', '/videos/channel');
|
||||
expect(channels).not.toHaveAttribute('target');
|
||||
const jellyfin = screen.getByRole('link', {name: 'Jellyfin'});
|
||||
expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
|
||||
expect(jellyfin).toHaveAttribute('target', '_blank');
|
||||
expect(screen.getByText('Radio')).toBeInTheDocument();
|
||||
// Channels, Services, then Services' two children.
|
||||
const numbers = screen.getAllByRole('row').slice(1).map(row => row.firstChild.textContent);
|
||||
expect(numbers).toEqual(['01', '02', '01', '02']);
|
||||
});
|
||||
|
||||
it('moves a node among its siblings and only within bounds', async () => {
|
||||
const a = actions();
|
||||
render(<BookmarkTable nodes={tree} actions={a}/>);
|
||||
// The first top-level node cannot move up; the last cannot move down.
|
||||
expect(screen.getByRole('button', {name: 'Move Channels up'})).toBeDisabled();
|
||||
expect(screen.getByRole('button', {name: 'Move Services down'})).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Move Channels down'}));
|
||||
expect(a.move).toHaveBeenCalledWith(tree[0], null, 1);
|
||||
// A child moves within its directory: parent 2, from index 1 to 0.
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Move Radio up'}));
|
||||
expect(a.move).toHaveBeenCalledWith(tree[1].children[1], 2, 0);
|
||||
});
|
||||
|
||||
it('shows a bookmark whose URL does not resolve as text, not a link', () => {
|
||||
const nodes = [{id: 1, name: 'Bad', url: '//evil.com', new_tab: false}];
|
||||
render(<BookmarkTable nodes={nodes} actions={actions()}/>);
|
||||
expect(screen.queryByRole('link', {name: 'Bad'})).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Bad')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits with the parent the node lives in, and deletes a bookmark without asking', async () => {
|
||||
const a = actions();
|
||||
render(<BookmarkTable nodes={tree} actions={a}/>);
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Edit Jellyfin'}));
|
||||
expect(a.edit).toHaveBeenCalledWith(tree[1].children[0], 2);
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Delete Channels'}));
|
||||
expect(a.remove).toHaveBeenCalledWith(tree[0]);
|
||||
});
|
||||
|
||||
it('asks before deleting a directory, since its contents go with it', async () => {
|
||||
const a = actions();
|
||||
render(<BookmarkTable nodes={tree} actions={a}/>);
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Delete Services'}));
|
||||
expect(a.remove).not.toHaveBeenCalled();
|
||||
await userEvent.click(await screen.findByRole('button', {name: 'Delete'}));
|
||||
expect(a.remove).toHaveBeenCalledWith(tree[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookmarkAddForms', () => {
|
||||
it('adds a bookmark at the top level and clears the form', async () => {
|
||||
const onAddBookmark = jest.fn();
|
||||
render(<BookmarkAddForms nodes={tree} onAddBookmark={onAddBookmark} onAddDirectory={jest.fn()}/>);
|
||||
const adds = screen.getAllByRole('button', {name: 'Add'});
|
||||
expect(adds[0]).toBeDisabled();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Bookmark URL'), ':8123/');
|
||||
expect(adds[0]).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText('Bookmark name'), 'Home Assistant');
|
||||
await userEvent.click(screen.getByLabelText('New tab'));
|
||||
await userEvent.click(adds[0]);
|
||||
|
||||
expect(onAddBookmark).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: null});
|
||||
expect(screen.getByLabelText('Bookmark URL')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('keeps what was typed when the server refuses the bookmark', async () => {
|
||||
const onAddBookmark = jest.fn().mockRejectedValue(new Error('refused'));
|
||||
render(<BookmarkAddForms nodes={tree} onAddBookmark={onAddBookmark} onAddDirectory={jest.fn()}/>);
|
||||
await userEvent.type(screen.getByLabelText('Bookmark URL'), '//evil.com');
|
||||
await userEvent.type(screen.getByLabelText('Bookmark name'), 'Oops');
|
||||
await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[0]);
|
||||
|
||||
expect(onAddBookmark).toHaveBeenCalled();
|
||||
expect(screen.getByLabelText('Bookmark URL')).toHaveValue('//evil.com');
|
||||
expect(screen.getByLabelText('Bookmark name')).toHaveValue('Oops');
|
||||
});
|
||||
|
||||
it('adds a directory', async () => {
|
||||
const onAddDirectory = jest.fn();
|
||||
render(<BookmarkAddForms nodes={tree} onAddBookmark={jest.fn()} onAddDirectory={onAddDirectory}/>);
|
||||
await userEvent.type(screen.getByLabelText('Directory name'), 'Tools');
|
||||
await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[1]);
|
||||
expect(onAddDirectory).toHaveBeenCalledWith({name: 'Tools', parent_id: null});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookmarkNodeModal', () => {
|
||||
// Mantine's Select scrolls the highlighted option into view; jsdom has no layout.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
it('saves a new bookmark with its location and new-tab choice', async () => {
|
||||
const onSave = jest.fn();
|
||||
render(<BookmarkNodeModal open node={null} directory={false} parentId={2} nodes={tree}
|
||||
onSave={onSave} onClose={jest.fn()}/>);
|
||||
const save = screen.getByRole('button', {name: 'Save'});
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'Home Assistant');
|
||||
expect(save).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText('URL'), ':8123/');
|
||||
await userEvent.click(screen.getByLabelText('Open in a new tab'));
|
||||
await userEvent.click(save);
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: 2});
|
||||
});
|
||||
|
||||
it('a directory has no URL or new-tab choice', () => {
|
||||
render(<BookmarkNodeModal open node={tree[1]} directory parentId={null} nodes={tree}
|
||||
onSave={jest.fn()} onClose={jest.fn()}/>);
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Services');
|
||||
expect(screen.queryByLabelText('URL')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Open in a new tab')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('locationOptions', () => {
|
||||
it('offers the top level and every directory to a new node', () => {
|
||||
expect(locationOptions(tree).map(o => o.label)).toEqual(['Top level', 'Services', 'Services / Radio']);
|
||||
});
|
||||
|
||||
it('never offers a directory itself, or its children', () => {
|
||||
expect(locationOptions(tree, tree[1]).map(o => o.label)).toEqual(['Top level']);
|
||||
expect(locationOptions(tree, tree[1].children[1]).map(o => o.label)).toEqual(['Top level', 'Services']);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,7 @@ import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars";
|
|||
import {useOverflowNav} from "../hooks/useOverflowNav";
|
||||
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
|
||||
import _ from "lodash";
|
||||
import {BookmarksMenuContents, EDIT_BOOKMARKS_PATH} from "./Bookmarks";
|
||||
|
||||
function updateFavicon(colorName) {
|
||||
// Fall back to violet if invalid color
|
||||
|
|
@ -64,12 +65,18 @@ const allLinks = [
|
|||
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
|
||||
// drop the mark the moment either grows a sub-page.
|
||||
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},
|
||||
// The user's bookmarks, read from BookmarksContext when rendered. No `to`: it is a
|
||||
// menu, and its items are not sections of the app.
|
||||
{text: 'Bookmarks', key: 'bookmarks', bookmarks: true},
|
||||
{text: 'Flasher', to: '/flasher', key: 'flasher'},
|
||||
{to: '/more/statistics', text: 'Statistics', key: 'statistics'},
|
||||
];
|
||||
|
||||
function MenuLink({link}) {
|
||||
// A top-level tab rendered directly in the bar (not inside a dropdown).
|
||||
if (link.bookmarks) {
|
||||
return <BookmarksDropdown link={link}/>
|
||||
}
|
||||
if (link.links) {
|
||||
return <DropdownLinks link={link}/>
|
||||
}
|
||||
|
|
@ -85,13 +92,31 @@ function MenuLink({link}) {
|
|||
</NavLink>
|
||||
}
|
||||
|
||||
function DropdownMenuItem({link}) {
|
||||
function DropdownMenuItem({link, nested = true}) {
|
||||
// An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop
|
||||
// "More" overflow menu.
|
||||
// "More" overflow menu. `nested` allows flyout submenus, which the hamburger
|
||||
// declines: a flyout opens on hover, and a phone has none.
|
||||
if (link.bookmarks) {
|
||||
if (nested) {
|
||||
return <Menu.Sub>
|
||||
<Menu.Sub.Target>
|
||||
<Menu.Sub.Item>{link.text}</Menu.Sub.Item>
|
||||
</Menu.Sub.Target>
|
||||
<Menu.Sub.Dropdown>
|
||||
<BookmarksMenuContents nested/>
|
||||
</Menu.Sub.Dropdown>
|
||||
</Menu.Sub>
|
||||
}
|
||||
return <React.Fragment>
|
||||
<Menu.Divider/>
|
||||
<Menu.Label>{link.text}</Menu.Label>
|
||||
<BookmarksMenuContents nested={false}/>
|
||||
<Menu.Divider/>
|
||||
</React.Fragment>
|
||||
}
|
||||
if (link.links) {
|
||||
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
|
||||
// indented group stands in for one. No current data uses this path (no
|
||||
// allLinks entry has its own `links`).
|
||||
// A labelled, indented group stands in for a submenu here. No current data uses
|
||||
// this path (no allLinks entry has its own `links`).
|
||||
return <React.Fragment>
|
||||
<Menu.Label>{link.text}</Menu.Label>
|
||||
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
|
||||
|
|
@ -141,6 +166,11 @@ NavDropdownTrigger.displayName = 'NavDropdownTrigger';
|
|||
* the hooks that would answer it cannot be called in a loop.
|
||||
*/
|
||||
export const isLinkActive = (pathname, link) => {
|
||||
// The Bookmarks menu owns only its editor page: a bookmark pointing at /videos does
|
||||
// not make the menu own the Videos section.
|
||||
if (link.bookmarks) {
|
||||
return pathname === EDIT_BOOKMARKS_PATH || pathname.startsWith(`${EDIT_BOOKMARKS_PATH}/`);
|
||||
}
|
||||
// An off-site link (Help) is never the current section.
|
||||
if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false;
|
||||
const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to;
|
||||
|
|
@ -175,6 +205,24 @@ function DropdownLinks({link}) {
|
|||
</Menu>
|
||||
}
|
||||
|
||||
/**
|
||||
* The Bookmarks tab: a dropdown that opens on hover as well as on click, holding the
|
||||
* user's bookmarks and the link to edit them. Marked current on the editor page only
|
||||
* (see isLinkActive).
|
||||
*/
|
||||
function BookmarksDropdown({link}) {
|
||||
const {pathname} = useLocation();
|
||||
const holdsCurrent = isLinkActive(pathname, link);
|
||||
return <Menu position='bottom-start' withinPortal trigger='click-hover' openDelay={100} closeDelay={200}>
|
||||
<Menu.Target>
|
||||
<NavDropdownTrigger text={link.text} className={holdsCurrent ? 'active' : undefined}/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<BookmarksMenuContents nested/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
|
||||
function MobileMenu({links}) {
|
||||
// The mobile hamburger menu: an icon-only trigger holding every link.
|
||||
return <Menu position='bottom-end' withinPortal>
|
||||
|
|
@ -182,7 +230,7 @@ function MobileMenu({links}) {
|
|||
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{links.map(link => <DropdownMenuItem key={link.key} link={link}/>)}
|
||||
{links.map(link => <DropdownMenuItem key={link.key} link={link} nested={false}/>)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -231,3 +231,91 @@ describe('NavBar current tab', () => {
|
|||
expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavBar bookmarks', () => {
|
||||
const {BookmarksContext} = require('../contexts/BookmarksContext');
|
||||
const bookmarks = [
|
||||
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
|
||||
{id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]},
|
||||
];
|
||||
const renderWithBookmarks = (route, nodes) => render(<NavBar/>, {
|
||||
route,
|
||||
withMedia: true,
|
||||
contexts: [
|
||||
[FileWorkerStatusContext, fileWorkerStatus],
|
||||
[BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}],
|
||||
],
|
||||
});
|
||||
|
||||
it('sits between Calculators and Flasher', () => {
|
||||
renderNav('/');
|
||||
const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')]
|
||||
.map(el => el.textContent.trim());
|
||||
const at = texts.indexOf('Bookmarks');
|
||||
expect(at).toBeGreaterThan(0);
|
||||
expect(texts[at - 1]).toBe('Calculators');
|
||||
expect(texts[at + 1]).toBe('Flasher');
|
||||
});
|
||||
|
||||
it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => {
|
||||
useRealRects();
|
||||
renderWithBookmarks('/', bookmarks);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
|
||||
|
||||
const channels = await screen.findByRole('menuitem', {name: 'Channels'});
|
||||
expect(channels).toHaveAttribute('href', '/videos/channel');
|
||||
expect(channels).not.toHaveAttribute('target');
|
||||
// A directory is a submenu; its bookmarks appear once it is hovered.
|
||||
expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument();
|
||||
await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'}));
|
||||
// `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it
|
||||
// stays hidden to accessibility queries even though it has rendered.
|
||||
const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true});
|
||||
expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
|
||||
expect(jellyfin).toHaveAttribute('target', '_blank');
|
||||
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks');
|
||||
});
|
||||
|
||||
it('offers the edit link even with no bookmarks, so the feature can be found', async () => {
|
||||
useRealRects();
|
||||
renderWithBookmarks('/', []);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
|
||||
|
||||
expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is marked current on the editor page, and never for a bookmark pointing at a section', () => {
|
||||
renderWithBookmarks('/more/bookmarks', bookmarks);
|
||||
expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active');
|
||||
expect(currentTab()).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks More when the editor page is folded inside it', () => {
|
||||
useBarWidth(200);
|
||||
renderWithBookmarks('/more/bookmarks', bookmarks);
|
||||
expect(screen.getByRole('button', {name: /More/})).toHaveClass('active');
|
||||
});
|
||||
|
||||
it('does not own the section a bookmark points at', () => {
|
||||
renderWithBookmarks('/videos/channel', bookmarks);
|
||||
expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active');
|
||||
expect(currentTab()).toEqual(['Videos']);
|
||||
});
|
||||
|
||||
it('lists bookmarks as a labelled group in the mobile menu', async () => {
|
||||
useViewport('mobile');
|
||||
useRealRects();
|
||||
renderWithBookmarks('/', bookmarks);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
|
||||
|
||||
expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument();
|
||||
// No flyout on a phone: the directory's bookmarks are listed directly.
|
||||
expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument();
|
||||
expect(screen.getByText('Services')).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {paletteColorNames} from '../themes/mantine';
|
|||
import {contrastingColor} from './Common';
|
||||
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
|
||||
import {IconMenu2} from '@tabler/icons-react';
|
||||
import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks';
|
||||
|
||||
/*
|
||||
* A gallery of every component in the library, in the current theme.
|
||||
|
|
@ -156,6 +157,19 @@ export const NavBarSample = ({color}) => {
|
|||
// press adds a toast rather than replacing the first. Module scope, so it survives re-renders.
|
||||
let toastCounter = 0;
|
||||
|
||||
// Sample data for the bookmarks section; the editor's actions do nothing here.
|
||||
const sampleBookmarks = [
|
||||
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
|
||||
{id: 2, name: 'Services', children: [
|
||||
{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
|
||||
{id: 4, name: 'Radio', children: [
|
||||
{id: 5, name: 'OpenWebRX', url: 'http://:8073/', new_tab: true},
|
||||
]},
|
||||
]},
|
||||
{id: 6, name: 'WROLPi.org', url: 'https://wrolpi.org', new_tab: true},
|
||||
];
|
||||
const noBookmarkActions = {edit: () => null, move: () => null, remove: () => null};
|
||||
|
||||
export function ThemeSamplePage() {
|
||||
const {theme, mediaFilterEnabled} = useContext(ThemeContext);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
|
@ -1078,6 +1092,41 @@ export function ThemeSamplePage() {
|
|||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Bookmarks'>
|
||||
<Panel>
|
||||
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 0}}>
|
||||
The Bookmarks menu in the navigation bar, and the editor under More. A
|
||||
directory is a flyout that opens beside the menu. In the mobile menu it is a
|
||||
labelled, indented run of items instead.
|
||||
</p>
|
||||
<Row>
|
||||
<Menu position='bottom-start' withinPortal trigger='click-hover'>
|
||||
<Menu.Target>
|
||||
<Button iconAfter='dropdown'>Bookmarks</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<BookmarkMenuItems nodes={sampleBookmarks}/>
|
||||
<Menu.Divider/>
|
||||
<Menu.Item leftSection={<Icon name='edit' size='small'/>}>Edit bookmarks</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu position='bottom-start' withinPortal>
|
||||
<Menu.Target>
|
||||
<Button iconAfter='dropdown'>As the mobile menu shows it</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Bookmarks</Menu.Label>
|
||||
<BookmarkMenuItems nodes={sampleBookmarks} nested={false}/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Row>
|
||||
<div style={{marginTop: 12}}>
|
||||
<BookmarkTable nodes={sampleBookmarks} actions={noBookmarkActions}/>
|
||||
<BookmarkAddForms nodes={sampleBookmarks} onAddBookmark={() => null} onAddDirectory={() => null}/>
|
||||
</div>
|
||||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Danger zone'>
|
||||
<Panel danger>
|
||||
<h2 style={{color: 'var(--danger)', fontSize: '0.875rem', margin: '0 0 4px'}}>Danger zone</h2>
|
||||
|
|
|
|||
114
app/src/contexts/BookmarksContext.js
Normal file
114
app/src/contexts/BookmarksContext.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
|
||||
import {getBookmarks} from '../api';
|
||||
|
||||
/*
|
||||
* The user's bookmarks, shared by the navigation bar and the editor page.
|
||||
*
|
||||
* A context rather than a prop: the bar and the page are far apart in the tree, and an
|
||||
* edit on the page must show in the bar at once. `bookmarks` is null until the first
|
||||
* fetch answers, so the bar can tell "not loaded yet" from "the user has none".
|
||||
*/
|
||||
export const BookmarksContext = createContext({bookmarks: null, error: null, refresh: async () => null});
|
||||
|
||||
export function BookmarksProvider({children}) {
|
||||
const [bookmarks, setBookmarks] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setBookmarks(await getBookmarks());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return <BookmarksContext.Provider value={{bookmarks, error, refresh}}>
|
||||
{children}
|
||||
</BookmarksContext.Provider>;
|
||||
}
|
||||
|
||||
export function useBookmarks() {
|
||||
return useContext(BookmarksContext);
|
||||
}
|
||||
|
||||
export const isBookmarkDirectory = (node) => Array.isArray(node.children);
|
||||
|
||||
// Characters a URL parser strips or that split a URL in surprising places. See the
|
||||
// same rule in wrolpi/bookmarks.py; the two must agree.
|
||||
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
||||
const FORBIDDEN = /[\x00-\x20\x7f\\]/;
|
||||
const PORT_FORM = /^(?:(https?):\/\/)?:(\d{1,5})(?=$|[/?#])(.*)$/i;
|
||||
const PORT_FORM_START = /^(?:https?:\/\/)?:/i;
|
||||
|
||||
const NONE = {internal: false, href: null};
|
||||
|
||||
/**
|
||||
* Where a bookmark URL really points, from the browser's point of view.
|
||||
*
|
||||
* Three forms are accepted, and only the first is an in-app link:
|
||||
* `/videos/channel/3` a path on this WROLPi -> {internal: true, href: '/videos/channel/3'}
|
||||
* `:8096/web` a port on this host, using the page's scheme
|
||||
* `http://:8096/web` a port on this host, with its own scheme
|
||||
* `https://example.com` anything else, verbatim
|
||||
*
|
||||
* The host-relative forms exist because a WROLPi is reached by LAN IP, by mDNS name, and
|
||||
* by its hotspot address, and a service running beside it is reached the same way. A
|
||||
* bookmark with the hostname written in would work from only one of them.
|
||||
*
|
||||
* Anything else, including a stored value the server would refuse today, resolves to a
|
||||
* null href and is rendered as text rather than a link: the form each value claims is
|
||||
* checked by parsing it, never by its prefix, because `//evil.com` starts like a path and
|
||||
* `:8096@evil.com` starts like a port, and a browser takes both off this host.
|
||||
*/
|
||||
export function resolveBookmarkUrl(url, location = window.location) {
|
||||
const value = (url || '').trim();
|
||||
if (!value || FORBIDDEN.test(value)) {
|
||||
return NONE;
|
||||
}
|
||||
const origin = `${location.protocol}//${location.host}`;
|
||||
|
||||
if (PORT_FORM_START.test(value)) {
|
||||
const match = value.match(PORT_FORM);
|
||||
if (!match || match[3].includes('@')) {
|
||||
return NONE;
|
||||
}
|
||||
const [, scheme, port, tail] = match;
|
||||
// An IPv6 literal has to be bracketed to sit before a port. Browsers report it
|
||||
// bracketed already; tests and older code may not.
|
||||
const bare = location.hostname.replace(/^\[|]$/g, '');
|
||||
const host = bare.includes(':') ? `[${bare}]` : bare;
|
||||
const href = `${scheme ? `${scheme.toLowerCase()}:` : location.protocol}//${host}:${port}${tail}`;
|
||||
const parsed = parse(href);
|
||||
if (!parsed || parsed.hostname !== host || parsed.port !== port) {
|
||||
return NONE;
|
||||
}
|
||||
return {internal: false, href};
|
||||
}
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
const parsed = parse(value, origin);
|
||||
if (value.startsWith('//') || !parsed || parsed.origin !== origin) {
|
||||
return NONE;
|
||||
}
|
||||
return {internal: true, href: value};
|
||||
}
|
||||
|
||||
const parsed = parse(value);
|
||||
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
||||
return NONE;
|
||||
}
|
||||
return {internal: false, href: value};
|
||||
}
|
||||
|
||||
function parse(value, base) {
|
||||
try {
|
||||
return new URL(value, base);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 662a4dd7b1cf66bb3ec517c69e99f70cb7a4941b
|
||||
Subproject commit c4362d807977c2719dbe9a870ad86f4c14580431
|
||||
278
wrolpi/bookmarks.py
Normal file
278
wrolpi/bookmarks.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""User bookmarks shown in the navigation bar.
|
||||
|
||||
Bookmarks are config-only (no database table). The config holds a tree: a node with
|
||||
`children` is a directory, a node with `url` is a bookmark. Every node has an integer `id`
|
||||
so the API can address it without relying on names, which need not be unique.
|
||||
|
||||
A bookmark `url` may take three forms:
|
||||
* a path, `/videos/channel/3`, which the UI renders as an in-app link;
|
||||
* a host-relative port, `:8096/web` or `http://:8096/web`, which the UI resolves against
|
||||
whatever hostname the browser reached this WROLPi by (LAN IP, mDNS name, hotspot);
|
||||
* an absolute URL, `https://example.com`.
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from wrolpi.common import ConfigFile, logger
|
||||
from wrolpi.errors import ValidationError
|
||||
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
# Characters that a URL parser strips or that split a URL in surprising places. Any of
|
||||
# them in a bookmark is a sign of an attempt to smuggle a host past the checks below.
|
||||
_FORBIDDEN = re.compile(r'[\x00-\x20\x7f\\]')
|
||||
# A port on this host: an optional scheme, then `:` and the port, then the end of the URL
|
||||
# or the start of a path, query or fragment. `:8096@evil.com` does not match, because
|
||||
# `@` may not follow the port.
|
||||
_PORT_FORM = re.compile(r'^(?:(https?)://)?:(\d{1,5})(?=$|[/?#])(.*)$', re.IGNORECASE)
|
||||
_PORT_FORM_START = re.compile(r'^(?:https?://)?:', re.IGNORECASE)
|
||||
|
||||
URL_HELP = 'Bookmark URL must be a path (/videos), a port (:8096/) or an absolute URL (https://)'
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookmarksConfigValidator:
|
||||
version: int = None
|
||||
bookmarks: list = field(default_factory=list)
|
||||
|
||||
|
||||
def is_directory(node: dict) -> bool:
|
||||
return 'children' in node
|
||||
|
||||
|
||||
def walk(nodes: List[dict], parent: Optional[dict] = None):
|
||||
"""Yield (node, parent, siblings) for every node in the tree, depth first."""
|
||||
for node in nodes:
|
||||
yield node, parent, nodes
|
||||
if is_directory(node):
|
||||
yield from walk(node['children'], node)
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Return `url` stripped, or raise if it is not one of the three forms.
|
||||
|
||||
The forms are checked by parsing, not by prefix. A prefix check accepted `//evil.com`
|
||||
as a path and `:8096@evil.com` as a port, both of which a browser takes off this host.
|
||||
"""
|
||||
url = (url or '').strip()
|
||||
if not url or _FORBIDDEN.search(url):
|
||||
raise ValidationError(URL_HELP)
|
||||
|
||||
if _PORT_FORM_START.match(url):
|
||||
# A port on this host. Everything after the port must be a path, query or
|
||||
# fragment; `@` anywhere in it would turn the host into credentials.
|
||||
match = _PORT_FORM.match(url)
|
||||
if not match or not 0 < int(match.group(2)) < 65536 or '@' in match.group(3):
|
||||
raise ValidationError(URL_HELP)
|
||||
return url
|
||||
|
||||
if url.startswith('/'):
|
||||
# A path on this WROLPi. A second slash (`//evil.com`) is a network path, which
|
||||
# leaves this host. `urlsplit` agrees only when the netloc comes back empty.
|
||||
parts = urlsplit(url)
|
||||
if url.startswith('//') or parts.scheme or parts.netloc:
|
||||
raise ValidationError(URL_HELP)
|
||||
return url
|
||||
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme.lower() not in ('http', 'https') or not parts.netloc:
|
||||
raise ValidationError(URL_HELP)
|
||||
return url
|
||||
|
||||
|
||||
def validate_name(name: str) -> str:
|
||||
name = (name or '').strip()
|
||||
if not name:
|
||||
raise ValidationError('Name is required')
|
||||
return name
|
||||
|
||||
|
||||
def validate_tree(nodes) -> None:
|
||||
"""Raise ValidationError unless `nodes` is a well-formed bookmarks tree.
|
||||
|
||||
Applied to every write, including a hand-edited config file and the config API, so
|
||||
the nav never renders a URL that `validate_url` would have refused.
|
||||
"""
|
||||
if not isinstance(nodes, list):
|
||||
raise ValidationError('bookmarks must be a list')
|
||||
seen_ids = set()
|
||||
# Iterative, with the objects on the path tracked, so a cyclic structure (which a
|
||||
# YAML anchor can build) is rejected rather than recursed into forever.
|
||||
stack = [(nodes, ())]
|
||||
while stack:
|
||||
siblings, path = stack.pop()
|
||||
if id(siblings) in path:
|
||||
raise ValidationError('bookmarks tree contains a cycle')
|
||||
for node in siblings:
|
||||
if not isinstance(node, dict):
|
||||
raise ValidationError(f'Bookmark is not a mapping: {node!r}')
|
||||
node_id = node.get('id')
|
||||
if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id in seen_ids:
|
||||
raise ValidationError(f'Bookmark id is missing or duplicated: {node_id!r}')
|
||||
seen_ids.add(node_id)
|
||||
validate_name(node.get('name'))
|
||||
has_url, has_children = 'url' in node, 'children' in node
|
||||
if has_url == has_children:
|
||||
raise ValidationError(f'Bookmark {node_id} must have a url or children, not both')
|
||||
if has_url:
|
||||
validate_url(node['url'])
|
||||
if 'new_tab' in node and not isinstance(node['new_tab'], bool):
|
||||
raise ValidationError(f'Bookmark {node_id} new_tab must be true or false')
|
||||
else:
|
||||
if not isinstance(node['children'], list):
|
||||
raise ValidationError(f'Bookmark {node_id} children must be a list')
|
||||
stack.append((node['children'], path + (id(siblings),)))
|
||||
|
||||
|
||||
class BookmarksConfig(ConfigFile):
|
||||
file_name = 'bookmarks.yaml'
|
||||
default_config = dict(
|
||||
version=0,
|
||||
bookmarks=[],
|
||||
)
|
||||
validator = BookmarksConfigValidator
|
||||
|
||||
def validate(self, config: dict) -> bool:
|
||||
# The dataclass validator sees only that `bookmarks` is a list. Every writer
|
||||
# (the bookmarks API, the config API, import) comes through here, so the tree
|
||||
# itself is checked too.
|
||||
if not super().validate(config):
|
||||
return False
|
||||
if 'bookmarks' in config:
|
||||
try:
|
||||
validate_tree(config['bookmarks'])
|
||||
except ValidationError as e:
|
||||
logger.error(f'{self.file_name}: {e}')
|
||||
return False
|
||||
return True
|
||||
|
||||
def import_config(self, file=None, send_events=False):
|
||||
# An invalid file raises here, leaving successful_import False so save() will not
|
||||
# overwrite it. A missing file is imported as empty, so the first save creates it.
|
||||
super().import_config(file, send_events)
|
||||
self.successful_import = True
|
||||
|
||||
@property
|
||||
def bookmarks(self) -> List[dict]:
|
||||
# A deep copy: the tree is nested, and callers mutate what they get back before
|
||||
# assigning it again through the setter.
|
||||
from copy import deepcopy
|
||||
return deepcopy(list(self._config.get('bookmarks', [])))
|
||||
|
||||
@bookmarks.setter
|
||||
def bookmarks(self, value: List[dict]):
|
||||
self.update({'bookmarks': value})
|
||||
save_bookmarks_config.activate_switch()
|
||||
|
||||
def _next_id(self, bookmarks: List[dict]) -> int:
|
||||
ids = [node.get('id', 0) for node, _, _ in walk(bookmarks)]
|
||||
return (max(ids) if ids else 0) + 1
|
||||
|
||||
@staticmethod
|
||||
def _find(bookmarks: List[dict], node_id: int) -> Tuple[dict, Optional[dict], List[dict]]:
|
||||
for node, parent, siblings in walk(bookmarks):
|
||||
if node.get('id') == node_id:
|
||||
return node, parent, siblings
|
||||
raise ValidationError(f'Bookmark {node_id} not found')
|
||||
|
||||
@staticmethod
|
||||
def _container(bookmarks: List[dict], parent_id: Optional[int]) -> List[dict]:
|
||||
"""The list of children that `parent_id` holds; the root list when parent_id is None."""
|
||||
if parent_id is None:
|
||||
return bookmarks
|
||||
parent, _, _ = BookmarksConfig._find(bookmarks, parent_id)
|
||||
if not is_directory(parent):
|
||||
raise ValidationError(f'Bookmark {parent_id} is not a directory')
|
||||
return parent['children']
|
||||
|
||||
@staticmethod
|
||||
def _insert(container: List[dict], node: dict, position: Optional[int]):
|
||||
if position is None or position >= len(container):
|
||||
container.append(node)
|
||||
else:
|
||||
container.insert(max(position, 0), node)
|
||||
|
||||
def add_bookmark(self, name: str, url: str, new_tab: bool = False, parent_id: int = None,
|
||||
position: int = None) -> dict:
|
||||
bookmarks = self.bookmarks
|
||||
node = dict(
|
||||
id=self._next_id(bookmarks),
|
||||
name=validate_name(name),
|
||||
url=validate_url(url),
|
||||
new_tab=bool(new_tab),
|
||||
)
|
||||
self._insert(self._container(bookmarks, parent_id), node, position)
|
||||
self.bookmarks = bookmarks
|
||||
return node
|
||||
|
||||
def add_directory(self, name: str, parent_id: int = None, position: int = None) -> dict:
|
||||
bookmarks = self.bookmarks
|
||||
node = dict(id=self._next_id(bookmarks), name=validate_name(name), children=[])
|
||||
self._insert(self._container(bookmarks, parent_id), node, position)
|
||||
self.bookmarks = bookmarks
|
||||
return node
|
||||
|
||||
def update_node(self, node_id: int, name: str = None, url: str = None, new_tab: bool = None) -> dict:
|
||||
bookmarks = self.bookmarks
|
||||
node, _, _ = self._find(bookmarks, node_id)
|
||||
if name is not None:
|
||||
node['name'] = validate_name(name)
|
||||
if is_directory(node):
|
||||
if url is not None or new_tab is not None:
|
||||
raise ValidationError('A directory has no URL')
|
||||
else:
|
||||
if url is not None:
|
||||
node['url'] = validate_url(url)
|
||||
if new_tab is not None:
|
||||
node['new_tab'] = bool(new_tab)
|
||||
self.bookmarks = bookmarks
|
||||
return node
|
||||
|
||||
def delete_node(self, node_id: int) -> dict:
|
||||
"""Delete a bookmark, or a directory and everything in it."""
|
||||
bookmarks = self.bookmarks
|
||||
node, _, siblings = self._find(bookmarks, node_id)
|
||||
siblings.remove(node)
|
||||
self.bookmarks = bookmarks
|
||||
return node
|
||||
|
||||
def move_node(self, node_id: int, parent_id: Optional[int], position: Optional[int] = None) -> dict:
|
||||
"""Move a node into `parent_id` (None = root) at `position` (None = end)."""
|
||||
bookmarks = self.bookmarks
|
||||
node, _, siblings = self._find(bookmarks, node_id)
|
||||
if parent_id == node_id:
|
||||
raise ValidationError('Cannot move a directory into itself')
|
||||
if is_directory(node) and parent_id is not None:
|
||||
descendants = {n['id'] for n, _, _ in walk(node['children'])}
|
||||
if parent_id in descendants:
|
||||
raise ValidationError('Cannot move a directory into its own child')
|
||||
container = self._container(bookmarks, parent_id)
|
||||
# Remove after resolving the container so an unknown parent leaves the tree untouched.
|
||||
siblings.remove(node)
|
||||
self._insert(container, node, position)
|
||||
self.bookmarks = bookmarks
|
||||
return node
|
||||
|
||||
|
||||
BOOKMARKS_CONFIG: BookmarksConfig = BookmarksConfig()
|
||||
|
||||
# Test override.
|
||||
TEST_BOOKMARKS_CONFIG = None
|
||||
|
||||
|
||||
def get_bookmarks_config() -> BookmarksConfig:
|
||||
global TEST_BOOKMARKS_CONFIG
|
||||
if isinstance(TEST_BOOKMARKS_CONFIG, ConfigFile):
|
||||
return TEST_BOOKMARKS_CONFIG
|
||||
return BOOKMARKS_CONFIG
|
||||
|
||||
|
||||
@register_switch_handler('save_bookmarks_config')
|
||||
def save_bookmarks_config():
|
||||
get_bookmarks_config().save()
|
||||
|
||||
|
||||
save_bookmarks_config: ActivateSwitchMethod
|
||||
86
wrolpi/bookmarks_api.py
Normal file
86
wrolpi/bookmarks_api.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from sanic import Blueprint, Request, response
|
||||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from wrolpi.api_utils import json_response
|
||||
from wrolpi.bookmarks import get_bookmarks_config
|
||||
|
||||
bookmarks_bp = Blueprint('Bookmarks', '/api/bookmarks')
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookmarkRequest:
|
||||
name: str
|
||||
url: str
|
||||
new_tab: bool = False
|
||||
parent_id: Optional[int] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DirectoryRequest:
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeUpdateRequest:
|
||||
name: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
new_tab: Optional[bool] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeMoveRequest:
|
||||
parent_id: Optional[int] = None
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
@bookmarks_bp.get('/')
|
||||
@openapi.description('Get the bookmarks tree')
|
||||
def get_bookmarks(_: Request):
|
||||
return json_response(dict(bookmarks=get_bookmarks_config().bookmarks), HTTPStatus.OK)
|
||||
|
||||
|
||||
@bookmarks_bp.post('/')
|
||||
@openapi.description('Add a bookmark, optionally inside a directory')
|
||||
@validate(BookmarkRequest)
|
||||
async def post_bookmark(_: Request, body: BookmarkRequest):
|
||||
node = get_bookmarks_config().add_bookmark(body.name, body.url, body.new_tab, body.parent_id, body.position)
|
||||
return json_response(dict(bookmark=node), HTTPStatus.CREATED)
|
||||
|
||||
|
||||
@bookmarks_bp.post('/directory')
|
||||
@openapi.description('Add a bookmark directory, optionally inside another directory')
|
||||
@validate(DirectoryRequest)
|
||||
async def post_directory(_: Request, body: DirectoryRequest):
|
||||
node = get_bookmarks_config().add_directory(body.name, body.parent_id, body.position)
|
||||
return json_response(dict(bookmark=node), HTTPStatus.CREATED)
|
||||
|
||||
|
||||
@bookmarks_bp.put('/<node_id:int>')
|
||||
@openapi.description('Rename a bookmark or directory; change a bookmark URL or new-tab setting')
|
||||
@validate(NodeUpdateRequest)
|
||||
async def put_node(_: Request, node_id: int, body: NodeUpdateRequest):
|
||||
node = get_bookmarks_config().update_node(node_id, body.name, body.url, body.new_tab)
|
||||
return json_response(dict(bookmark=node), HTTPStatus.OK)
|
||||
|
||||
|
||||
@bookmarks_bp.post('/<node_id:int>/move')
|
||||
@openapi.description('Move a bookmark or directory into a directory (null parent = top level) at a position')
|
||||
@validate(NodeMoveRequest)
|
||||
async def post_move(_: Request, node_id: int, body: NodeMoveRequest):
|
||||
node = get_bookmarks_config().move_node(node_id, body.parent_id, body.position)
|
||||
return json_response(dict(bookmark=node), HTTPStatus.OK)
|
||||
|
||||
|
||||
@bookmarks_bp.delete('/<node_id:int>')
|
||||
@openapi.description('Delete a bookmark, or a directory and its contents')
|
||||
async def delete_node(_: Request, node_id: int):
|
||||
get_bookmarks_config().delete_node(node_id)
|
||||
return response.empty(HTTPStatus.NO_CONTENT)
|
||||
|
|
@ -1169,6 +1169,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
|
|||
if flasher_config := get_flasher_config():
|
||||
all_configs[flasher_config.file_name] = flasher_config
|
||||
|
||||
from wrolpi.bookmarks import get_bookmarks_config
|
||||
if bookmarks_config := get_bookmarks_config():
|
||||
all_configs[bookmarks_config.file_name] = bookmarks_config
|
||||
|
||||
from wrolpi.collections.config import get_playlists_config
|
||||
if playlists_config := get_playlists_config():
|
||||
all_configs[playlists_config.file_name] = playlists_config
|
||||
|
|
@ -1269,6 +1273,16 @@ async def import_all_db_configs() -> dict[str, bool]:
|
|||
logger.warning(f'Failed to import map pins config: {e}')
|
||||
results['map_pins'] = False
|
||||
|
||||
# Bookmarks (YAML-only, no DB)
|
||||
try:
|
||||
from wrolpi.bookmarks import get_bookmarks_config
|
||||
await asyncio.to_thread(get_bookmarks_config().import_config)
|
||||
results['bookmarks'] = True
|
||||
logger.debug('bookmarks config imported')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to import bookmarks config: {e}')
|
||||
results['bookmarks'] = False
|
||||
|
||||
# Flasher saved firmware configurations (YAML-only, no DB)
|
||||
try:
|
||||
from modules.flasher.config import get_flasher_config
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ def attach_shared_contexts(app: Sanic):
|
|||
app.shared_ctx.download_cache_config = manager.dict()
|
||||
app.shared_ctx.map_pins_config = manager.dict()
|
||||
app.shared_ctx.flasher_config = manager.dict()
|
||||
app.shared_ctx.bookmarks_config = manager.dict()
|
||||
# Shared dicts.
|
||||
app.shared_ctx.uploaded_files = manager.dict()
|
||||
app.shared_ctx.status = manager.dict()
|
||||
|
|
@ -317,3 +318,9 @@ def initialize_configs_contexts(app: Sanic):
|
|||
FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize in-memory flasher config: {e}')
|
||||
|
||||
try:
|
||||
from wrolpi.bookmarks import BOOKMARKS_CONFIG
|
||||
BOOKMARKS_CONFIG.initialize(app.shared_ctx.bookmarks_config)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize in-memory bookmarks config: {e}')
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from modules.zim.api import zim_bp
|
|||
from wrolpi import flags, schema, dates
|
||||
from wrolpi import tags
|
||||
from wrolpi.api_utils import json_response, api_app
|
||||
from wrolpi.bookmarks_api import bookmarks_bp
|
||||
from wrolpi.collections.api import collection_bp
|
||||
from wrolpi.common import logger, get_wrolpi_config, wrol_mode_enabled, get_media_directory, \
|
||||
wrol_mode_check, native_only, disable_wrol_mode, enable_wrol_mode, get_global_statistics, url_strip_host, \
|
||||
|
|
@ -55,6 +56,7 @@ api_bp = Blueprint('RootAPI', url_prefix='/api')
|
|||
# Blueprints order here defines what order they are displayed in OpenAPI Docs.
|
||||
api_app.blueprint(api_bp)
|
||||
api_app.blueprint(archive_bp)
|
||||
api_app.blueprint(bookmarks_bp)
|
||||
api_app.blueprint(collection_bp) # Unified collection endpoints
|
||||
api_app.blueprint(config_bp)
|
||||
api_app.blueprint(docs_bp)
|
||||
|
|
|
|||
226
wrolpi/test/test_bookmarks.py
Normal file
226
wrolpi/test/test_bookmarks.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import pytest
|
||||
import yaml
|
||||
|
||||
from wrolpi.bookmarks import get_bookmarks_config
|
||||
from wrolpi.common import get_media_directory
|
||||
from wrolpi.errors import InvalidConfig
|
||||
|
||||
|
||||
def read_config():
|
||||
return yaml.safe_load((get_media_directory() / 'config/bookmarks.yaml').read_text())
|
||||
|
||||
|
||||
async def test_bookmarks_tree_api(async_client, test_directory):
|
||||
"""Bookmarks and directories can be added, edited, moved and deleted, and are persisted to YAML."""
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert response.status == 200
|
||||
assert response.json['bookmarks'] == []
|
||||
|
||||
# A top-level bookmark; new_tab defaults to false.
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name='Videos', url='/videos'))
|
||||
assert response.status == 201, response.json
|
||||
videos = response.json['bookmark']
|
||||
assert videos == dict(id=1, name='Videos', url='/videos', new_tab=False)
|
||||
|
||||
# A directory, then a bookmark inside it that opens in a new tab.
|
||||
request, response = await async_client.post('/api/bookmarks/directory', json=dict(name='Services'))
|
||||
assert response.status == 201
|
||||
services = response.json['bookmark']
|
||||
assert services == dict(id=2, name='Services', children=[])
|
||||
request, response = await async_client.post('/api/bookmarks',
|
||||
json=dict(name='Jellyfin', url=':8096/', new_tab=True,
|
||||
parent_id=services['id']))
|
||||
assert response.status == 201
|
||||
jellyfin = response.json['bookmark']
|
||||
assert jellyfin['id'] == 3 and jellyfin['new_tab'] is True
|
||||
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert response.json['bookmarks'] == [
|
||||
dict(id=1, name='Videos', url='/videos', new_tab=False),
|
||||
dict(id=2, name='Services', children=[
|
||||
dict(id=3, name='Jellyfin', url=':8096/', new_tab=True),
|
||||
]),
|
||||
]
|
||||
# The tree was written to the config file.
|
||||
assert read_config()['bookmarks'] == response.json['bookmarks']
|
||||
|
||||
# Rename and re-point a bookmark.
|
||||
request, response = await async_client.put('/api/bookmarks/3', json=dict(name='Movies', url='http://:8096/web'))
|
||||
assert response.status == 200
|
||||
assert response.json['bookmark'] == dict(id=3, name='Movies', url='http://:8096/web', new_tab=True)
|
||||
|
||||
# A directory cannot take a URL.
|
||||
request, response = await async_client.put('/api/bookmarks/2', json=dict(url='/videos'))
|
||||
assert response.status == 400
|
||||
|
||||
# Move the top-level bookmark into the directory, at the front.
|
||||
request, response = await async_client.post('/api/bookmarks/1/move', json=dict(parent_id=2, position=0))
|
||||
assert response.status == 200
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert [i['id'] for i in response.json['bookmarks']] == [2]
|
||||
assert [i['id'] for i in response.json['bookmarks'][0]['children']] == [1, 3]
|
||||
|
||||
# Move it back to the top level, at the end.
|
||||
request, response = await async_client.post('/api/bookmarks/1/move', json=dict(parent_id=None))
|
||||
assert response.status == 200
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert [i['id'] for i in response.json['bookmarks']] == [2, 1]
|
||||
|
||||
# Deleting a directory deletes its contents.
|
||||
request, response = await async_client.delete('/api/bookmarks/2')
|
||||
assert response.status == 204
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert response.json['bookmarks'] == [dict(id=1, name='Videos', url='/videos', new_tab=False)]
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name='Docs', url='/docs'))
|
||||
assert response.json['bookmark']['id'] == 2
|
||||
assert read_config()['bookmarks'] == [
|
||||
dict(id=1, name='Videos', url='/videos', new_tab=False),
|
||||
dict(id=2, name='Docs', url='/docs', new_tab=False),
|
||||
]
|
||||
|
||||
# Unknown nodes are 400s, not crashes.
|
||||
request, response = await async_client.delete('/api/bookmarks/999')
|
||||
assert response.status == 400
|
||||
request, response = await async_client.put('/api/bookmarks/999', json=dict(name='x'))
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
async def test_bookmarks_validation(async_client, test_directory):
|
||||
"""Names are required and URLs must be a path, a port, or an absolute URL."""
|
||||
rejected = (
|
||||
'', 'videos', 'ftp://example.com', 'javascript:alert(1)', 'data:text/html,hi', '8096/',
|
||||
# Paths that a browser takes off this host: network paths, backslash tricks, and
|
||||
# control characters a URL parser strips before deciding.
|
||||
'//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
|
||||
# Port forms where the host becomes credentials, or where the port is not a port.
|
||||
':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':0', ':70000',
|
||||
':8096\\evil.com', 'ftp://:8096/',
|
||||
# An absolute URL with no host, or with a scheme other than http(s).
|
||||
'http://', 'https:///x', 'file:///etc/passwd',
|
||||
)
|
||||
for url in rejected:
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url))
|
||||
assert response.status == 400, url
|
||||
for url in ('/videos', '/videos?x=1#y', ':8096/', ':8096', ':8096?x=1', 'http://:8096/web', 'HTTP://:80/',
|
||||
'https://example.com', 'HTTP://EXAMPLE.COM', 'https://user@example.com/x'):
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url))
|
||||
assert response.status == 201, url
|
||||
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name=' ', url='/videos'))
|
||||
assert response.status == 400
|
||||
request, response = await async_client.post('/api/bookmarks/directory', json=dict(name=''))
|
||||
assert response.status == 400
|
||||
|
||||
# Whitespace is trimmed.
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name=' Map ', url=' /map '))
|
||||
assert response.json['bookmark'] == dict(id=11, name='Map', url='/map', new_tab=False)
|
||||
|
||||
|
||||
async def test_bookmarks_move_guards(async_client, test_directory):
|
||||
"""A directory cannot be moved into itself or a descendant, and only directories hold children."""
|
||||
config = get_bookmarks_config()
|
||||
outer = config.add_directory('Outer')
|
||||
inner = config.add_directory('Inner', parent_id=outer['id'])
|
||||
leaf = config.add_bookmark('Leaf', '/files', parent_id=inner['id'])
|
||||
|
||||
request, response = await async_client.post(f'/api/bookmarks/{outer["id"]}/move', json=dict(parent_id=outer['id']))
|
||||
assert response.status == 400
|
||||
request, response = await async_client.post(f'/api/bookmarks/{outer["id"]}/move', json=dict(parent_id=inner['id']))
|
||||
assert response.status == 400
|
||||
# A bookmark is not a directory.
|
||||
request, response = await async_client.post(f'/api/bookmarks/{inner["id"]}/move', json=dict(parent_id=leaf['id']))
|
||||
assert response.status == 400
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url='/x', parent_id=leaf['id']))
|
||||
assert response.status == 400
|
||||
|
||||
# The failed moves left the tree untouched.
|
||||
assert [i['id'] for i, _, _ in __import__('wrolpi.bookmarks', fromlist=['walk']).walk(config.bookmarks)] == \
|
||||
[outer['id'], inner['id'], leaf['id']]
|
||||
|
||||
|
||||
def test_bookmarks_config_import(test_directory, test_wrolpi_config):
|
||||
"""A hand-edited bookmarks.yaml is imported as-is, including nested directories."""
|
||||
config_file = test_directory / 'config/bookmarks.yaml'
|
||||
config_file.parent.mkdir(exist_ok=True)
|
||||
config_file.write_text(yaml.dump(dict(version=0, bookmarks=[
|
||||
dict(id=1, name='Radio', children=[
|
||||
dict(id=2, name='OpenWebRX', url=':8073/', new_tab=True),
|
||||
]),
|
||||
])))
|
||||
|
||||
config = get_bookmarks_config()
|
||||
config.initialize()
|
||||
config.import_config()
|
||||
assert config.successful_import is True
|
||||
assert config.bookmarks[0]['children'][0]['name'] == 'OpenWebRX'
|
||||
|
||||
# Later additions continue the id sequence past the hand-written ids.
|
||||
node = config.add_bookmark('Docs', '/docs')
|
||||
assert node['id'] == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bookmarks', [
|
||||
# A URL the bookmarks API would refuse.
|
||||
[dict(id=1, name='x', url='javascript:alert(1)')],
|
||||
[dict(id=1, name='x', url='//evil.com')],
|
||||
[dict(id=1, name='x', url=':8096@evil.com')],
|
||||
# Shape problems: duplicate ids, a missing id, a node that is both, a node that is neither.
|
||||
[dict(id=1, name='x', url='/a'), dict(id=1, name='y', url='/b')],
|
||||
[dict(name='x', url='/a')],
|
||||
[dict(id=1, name='x', url='/a', children=[])],
|
||||
[dict(id=1, name='x')],
|
||||
[dict(id=1, name='x', children='nope')],
|
||||
[dict(id=1, name='', url='/a')],
|
||||
[dict(id=1, name='x', url='/a', new_tab='yes')],
|
||||
['not a node'],
|
||||
'not a list',
|
||||
])
|
||||
def test_bookmarks_config_import_rejects_bad_tree(test_directory, test_wrolpi_config, bookmarks):
|
||||
"""A hand-edited file that the API would refuse is not imported, and is not overwritten."""
|
||||
config_file = test_directory / 'config/bookmarks.yaml'
|
||||
config_file.parent.mkdir(exist_ok=True)
|
||||
original = yaml.dump(dict(version=0, bookmarks=bookmarks))
|
||||
config_file.write_text(original)
|
||||
|
||||
config = get_bookmarks_config()
|
||||
with pytest.raises(InvalidConfig):
|
||||
config.initialize()
|
||||
with pytest.raises(InvalidConfig):
|
||||
config.import_config()
|
||||
assert config.successful_import is False
|
||||
assert config.bookmarks == []
|
||||
# The in-memory tree is empty, and the API refuses to write it over the user's file.
|
||||
with pytest.raises(RuntimeError):
|
||||
config.save()
|
||||
assert config_file.read_text() == original
|
||||
|
||||
|
||||
def test_bookmarks_config_import_rejects_cycle(test_directory, test_wrolpi_config):
|
||||
"""A YAML anchor can make a directory contain itself; walking that would never end."""
|
||||
config_file = test_directory / 'config/bookmarks.yaml'
|
||||
config_file.parent.mkdir(exist_ok=True)
|
||||
config_file.write_text('version: 0\nbookmarks: &root\n- id: 1\n name: Loop\n children: *root\n')
|
||||
|
||||
config = get_bookmarks_config()
|
||||
with pytest.raises(InvalidConfig):
|
||||
config.import_config()
|
||||
assert config.successful_import is False
|
||||
|
||||
|
||||
async def test_bookmarks_config_api_rejects_bad_tree(async_client, test_directory):
|
||||
"""The generic config API applies the same rules as the bookmarks API."""
|
||||
request, response = await async_client.post('/api/bookmarks', json=dict(name='ok', url='/videos'))
|
||||
assert response.status == 201
|
||||
|
||||
bad = dict(version=1, bookmarks=[dict(id=1, name='x', url='javascript:alert(1)')])
|
||||
request, response = await async_client.post('/api/config?file_name=bookmarks.yaml', json=dict(config=bad))
|
||||
assert response.status == 400, response.json
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert response.json['bookmarks'] == [dict(id=1, name='ok', url='/videos', new_tab=False)]
|
||||
|
||||
good = dict(version=100, bookmarks=[dict(id=5, name='Docs', url='/docs', new_tab=True)])
|
||||
request, response = await async_client.post('/api/config?file_name=bookmarks.yaml', json=dict(config=good))
|
||||
assert response.status == 204, response.json
|
||||
request, response = await async_client.get('/api/bookmarks')
|
||||
assert response.json['bookmarks'] == good['bookmarks']
|
||||
assert read_config()['bookmarks'] == good['bookmarks']
|
||||
Loading…
Add table
Add a link
Reference in a new issue