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>
This commit is contained in:
Roland 2026-09-26 12:21:11 -06:00
parent 811793d767
commit b3c51c8b49
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
15 changed files with 1336 additions and 13 deletions

View file

@ -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>

View file

@ -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');
}

View file

@ -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>
}

View file

@ -0,0 +1,430 @@
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.
*/
function bookmarkItemProps(node) {
const {internal, href} = resolveBookmarkUrl(node.url);
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.
*/
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}em`}}>{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}em`}}
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>
: <a href={href} target={node.new_tab ? '_blank' : undefined} rel='noopener noreferrer'>
{node.name}
</a>}
{!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);
const handleAddBookmark = async () => {
if (!url.trim() || !name.trim()) return;
await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)});
setUrl('');
setName('');
};
const handleAddDirectory = async () => {
if (!directoryName.trim()) return;
await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)});
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) {
}
},
};
const handleAddBookmark = async (bookmark) => {
try {
await addBookmark(bookmark);
await refresh();
} catch (e) {
}
};
const handleAddDirectory = async (directory) => {
try {
await addBookmarkDirectory(directory);
await refresh();
} catch (e) {
}
};
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});
if (parent_id !== parentId) {
await moveBookmark(node.id, parent_id);
}
await refresh();
setModal(null);
} catch (e) {
}
};
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>;
}

View file

@ -0,0 +1,160 @@
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'};
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);
});
});
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('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('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']);
});
});

View file

@ -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}/>)}
@ -175,6 +200,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;
* a bookmark pointing at /videos does not make this tab own the Videos section.
*/
function BookmarksDropdown({link}) {
const {pathname} = useLocation();
const holdsCurrent = pathname === EDIT_BOOKMARKS_PATH || pathname.startsWith(`${EDIT_BOOKMARKS_PATH}/`);
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 +225,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>
}

View file

@ -231,3 +231,85 @@ 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('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();
});
});

View file

@ -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>

View file

@ -0,0 +1,65 @@
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);
/**
* 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.
*/
export function resolveBookmarkUrl(url, location = window.location) {
const value = (url || '').trim();
if (value.startsWith('/')) {
return {internal: true, href: value};
}
const hostRelative = value.match(/^(?:(https?:)\/\/)?(:\d+.*)$/i);
if (hostRelative) {
const scheme = hostRelative[1] ? hostRelative[1].toLowerCase() : location.protocol;
return {internal: false, href: `${scheme}//${location.hostname}${hostRelative[2]}`};
}
return {internal: false, href: value};
}

191
wrolpi/bookmarks.py Normal file
View file

@ -0,0 +1,191 @@
"""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 wrolpi.common import ConfigFile, logger
from wrolpi.errors import ValidationError
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
logger = logger.getChild(__name__)
# The forms a bookmark URL may take. See the module docstring.
URL_PATTERN = re.compile(r'^(/|:\d+|https?://)', re.IGNORECASE)
@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:
url = (url or '').strip()
if not url or not URL_PATTERN.match(url):
raise ValidationError('Bookmark URL must be a path (/videos), a port (:8096/) or an absolute URL (https://)')
return url
def validate_name(name: str) -> str:
name = (name or '').strip()
if not name:
raise ValidationError('Name is required')
return name
class BookmarksConfig(ConfigFile):
file_name = 'bookmarks.yaml'
default_config = dict(
version=0,
bookmarks=[],
)
validator = BookmarksConfigValidator
def import_config(self, file=None, send_events=False):
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
View 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)

View file

@ -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

View file

@ -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}')

View file

@ -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)

View file

@ -0,0 +1,146 @@
import yaml
from wrolpi.bookmarks import get_bookmarks_config
from wrolpi.common import get_media_directory
from wrolpi.errors import ValidationError
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."""
for url in ('', 'videos', 'ftp://example.com', 'javascript:alert(1)', '8096/'):
request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url))
assert response.status == 400, url
for url in ('/videos', ':8096/', ':8096', 'http://:8096/web', 'https://example.com', 'HTTP://EXAMPLE.COM'):
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=7, 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