mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
811793d767
commit
b3c51c8b49
15 changed files with 1336 additions and 13 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>
|
||||
}
|
||||
|
|
|
|||
430
app/src/components/Bookmarks.js
Normal file
430
app/src/components/Bookmarks.js
Normal 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>;
|
||||
}
|
||||
160
app/src/components/Bookmarks.test.js
Normal file
160
app/src/components/Bookmarks.test.js
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
65
app/src/contexts/BookmarksContext.js
Normal file
65
app/src/contexts/BookmarksContext.js
Normal 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
191
wrolpi/bookmarks.py
Normal 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
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)
|
||||
|
|
|
|||
146
wrolpi/test/test_bookmarks.py
Normal file
146
wrolpi/test/test_bookmarks.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue