diff --git a/app/src/App.js b/app/src/App.js
index 1e372a53..25881b13 100644
--- a/app/src/App.js
+++ b/app/src/App.js
@@ -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() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/app/src/api.js b/app/src/api.js
index b06ef557..f80d9ced 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -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');
+}
diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js
index 724ae665..3360afac 100644
--- a/app/src/components/Apps.js
+++ b/app/src/components/Apps.js
@@ -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) {
}/>
}/>
+ }/>
}
diff --git a/app/src/components/Bookmarks.js b/app/src/components/Bookmarks.js
new file mode 100644
index 00000000..270d0aef
--- /dev/null
+++ b/app/src/components/Bookmarks.js
@@ -0,0 +1,453 @@
+import React, {useState} from 'react';
+import {Link} from 'react-router';
+import {IconBookmark} from '@tabler/icons-react';
+import {Button, Group, Header, Icon, Menu, Message, Modal, Panel, Select, Table, Text, TextInput, Toggle} from './ui';
+import {APIButton, PageContainer, useTitle} from './Common';
+import {isBookmarkDirectory, resolveBookmarkUrl, useBookmarks} from '../contexts/BookmarksContext';
+import {addBookmark, addBookmarkDirectory, deleteBookmark, moveBookmark, updateBookmark} from '../api';
+
+export const EDIT_BOOKMARKS_PATH = '/more/bookmarks';
+
+/**
+ * The props that make a Menu.Item open a bookmark: an in-app Link for a path, a plain
+ * anchor for everything else, and a new tab when the bookmark asks for one. A URL that
+ * does not resolve (see resolveBookmarkUrl) gets a disabled item, never a raw href.
+ */
+function bookmarkItemProps(node) {
+ const {internal, href} = resolveBookmarkUrl(node.url);
+ if (!href) {
+ return {disabled: true};
+ }
+ const target = node.new_tab ? {target: '_blank', rel: 'noopener noreferrer'} : {};
+ if (internal) {
+ return {component: Link, to: href, ...target};
+ }
+ return {component: 'a', href, ...target};
+}
+
+/**
+ * The items of a bookmarks menu.
+ *
+ * `nested` draws a directory as a flyout (Menu.Sub), which opens beside the menu on hover.
+ * Without it a directory is a labelled, indented run of items instead: the mobile
+ * hamburger is the one place bookmarks are shown where a flyout cannot be hovered. The
+ * indent is in rem: a label is set smaller than an item, so an em indent would put a
+ * directory's label a few pixels left of the items beside it.
+ */
+export function BookmarkMenuItems({nodes, nested = true, depth = 0}) {
+ if (!nodes || nodes.length === 0) {
+ return null;
+ }
+ return nodes.map(node => {
+ if (isBookmarkDirectory(node)) {
+ if (nested) {
+ return
+
+ }>{node.name}
+
+
+ {node.children.length > 0
+ ?
+ : Empty}
+
+ ;
+ }
+ return
+ {node.name}
+
+ ;
+ }
+ return : undefined}
+ >
+ {node.name}
+ ;
+ });
+}
+
+/**
+ * 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 <>
+
+ {nodes.length > 0 && }
+ }>
+ Edit bookmarks
+
+ >;
+}
+
+/** 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
+ {title}
+
+
+
+
+
+
+
+ ;
+}
+
+/** 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
+
+ {String(index + 1).padStart(2, '0')}
+
+ {directory
+ ?
+ : }
+
+
+ {directory && {node.name}}
+ {!directory && (href
+ ?
+ {node.name}
+
+ : {node.name})}
+ {!directory && {node.url}}
+
+ {editable &&
+
+
+ }
+
+ {directory && }
+ ;
+ });
+}
+
+/**
+ * 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
;
+}
+
+/** The "Add a bookmark" and "Add a directory" forms beneath the table. */
+export function BookmarkAddForms({nodes, onAddBookmark, onAddDirectory}) {
+ const [url, setUrl] = useState('');
+ const [name, setName] = useState('');
+ const [newTab, setNewTab] = useState(false);
+ const [location, setLocation] = useState(ROOT);
+ const [directoryName, setDirectoryName] = useState('');
+ const [directoryLocation, setDirectoryLocation] = useState(ROOT);
+ const locations = locationOptions(nodes);
+ const parentOf = (value) => value === ROOT ? null : Number(value);
+
+ // The form is cleared only once the server has accepted the node. A refused add has
+ // already shown its toast; the typed URL stays so the user can correct it.
+ const handleAddBookmark = async () => {
+ if (!url.trim() || !name.trim()) return;
+ try {
+ await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)});
+ } catch (e) {
+ return;
+ }
+ setUrl('');
+ setName('');
+ };
+
+ const handleAddDirectory = async () => {
+ if (!directoryName.trim()) return;
+ try {
+ await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)});
+ } catch (e) {
+ return;
+ }
+ setDirectoryName('');
+ };
+
+ return <>
+ Add a bookmark
+
+
+
+ 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.
+
+
+
+ Add a directory
+
+ >;
+}
+
+export function BookmarksPage() {
+ useTitle('Bookmarks');
+ const {bookmarks, refresh} = useBookmarks();
+ // {node, directory, parentId} while the edit modal is open; a `key` remounts the form fresh.
+ const [modal, setModal] = useState(null);
+ const [modalKey, setModalKey] = useState(0);
+ const nodes = bookmarks || [];
+
+ const actions = {
+ edit: (node, parentId) => {
+ setModalKey(k => k + 1);
+ setModal({node, directory: isBookmarkDirectory(node), parentId});
+ },
+ move: async (node, parentId, position) => {
+ try {
+ await moveBookmark(node.id, parentId, position);
+ await refresh();
+ } catch (e) {
+ // Error toast already shown by the API client.
+ }
+ },
+ remove: async (node) => {
+ try {
+ await deleteBookmark(node.id);
+ await refresh();
+ } catch (e) {
+ }
+ },
+ };
+
+ // These reject on failure (the API client has already shown the toast) so the add
+ // forms know to keep what was typed.
+ const handleAddBookmark = async (bookmark) => {
+ await addBookmark(bookmark);
+ await refresh();
+ };
+
+ const handleAddDirectory = async (directory) => {
+ await addBookmarkDirectory(directory);
+ await refresh();
+ };
+
+ const handleEditSave = async ({name, url, new_tab, parent_id}) => {
+ const {node, directory, parentId} = modal;
+ try {
+ await updateBookmark(node.id, directory ? {name} : {name, url, new_tab});
+ } catch (e) {
+ return; // Nothing changed; the modal stays open with the toast beside it.
+ }
+ // The rename is committed even if the move is refused, so the nav must show it
+ // either way; only a fully successful edit closes the modal.
+ let moved = true;
+ if (parent_id !== parentId) {
+ try {
+ await moveBookmark(node.id, parent_id);
+ } catch (e) {
+ moved = false;
+ }
+ }
+ await refresh();
+ if (moved) {
+ setModal(null);
+ }
+ };
+
+ return
+
+ Bookmarks
+
+ 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.
+
+
+ {nodes.length === 0
+ ? Add a bookmark below.
+ : }
+
+
+
+ {modal && setModal(null)}
+ />}
+ ;
+}
diff --git a/app/src/components/Bookmarks.test.js b/app/src/components/Bookmarks.test.js
new file mode 100644
index 00000000..60b12b3a
--- /dev/null
+++ b/app/src/components/Bookmarks.test.js
@@ -0,0 +1,199 @@
+import React from 'react';
+import {screen} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {render} from '../test-utils';
+import {BookmarkAddForms, BookmarkNodeModal, BookmarkTable, directoryOptions, locationOptions} from './Bookmarks';
+import {resolveBookmarkUrl} from '../contexts/BookmarksContext';
+
+const tree = [
+ {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
+ {
+ id: 2, name: 'Services', children: [
+ {id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
+ {id: 4, name: 'Radio', children: []},
+ ]
+ },
+];
+
+describe('resolveBookmarkUrl', () => {
+ const location = {protocol: 'https:', hostname: '10.0.0.2', host: '10.0.0.2'};
+
+ it.each([
+ ['/videos', {internal: true, href: '/videos'}],
+ [' /videos/channel/3 ', {internal: true, href: '/videos/channel/3'}],
+ // A port on this host takes the page's scheme, or its own when it names one.
+ [':8096/web', {internal: false, href: 'https://10.0.0.2:8096/web'}],
+ [':8096', {internal: false, href: 'https://10.0.0.2:8096'}],
+ ['http://:8096/web', {internal: false, href: 'http://10.0.0.2:8096/web'}],
+ ['HTTPS://:8085', {internal: false, href: 'https://10.0.0.2:8085'}],
+ ['https://example.com/a?b=c', {internal: false, href: 'https://example.com/a?b=c'}],
+ ])('resolves %s', (url, expected) => {
+ expect(resolveBookmarkUrl(url, location)).toEqual(expected);
+ });
+
+ it.each([
+ // Paths a browser takes off this host: network paths, backslashes, and control
+ // characters a URL parser strips before deciding.
+ '//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
+ // Port forms where the host would become credentials.
+ ':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':8096\\evil.com',
+ // Not http(s).
+ 'javascript:alert(1)', 'data:text/html,hi', 'ftp://example.com', 'videos', '',
+ ])('refuses %j rather than guessing', (url) => {
+ expect(resolveBookmarkUrl(url, location)).toEqual({internal: false, href: null});
+ });
+
+ it('brackets an IPv6 host for a port bookmark', () => {
+ // The browser reports an IPv6 hostname bracketed already.
+ const v6 = {protocol: 'https:', hostname: '[::1]', host: '[::1]'};
+ expect(resolveBookmarkUrl(':8096/web', v6)).toEqual({internal: false, href: 'https://[::1]:8096/web'});
+ expect(resolveBookmarkUrl(':8096', {protocol: 'http:', hostname: '::1', host: '[::1]'}))
+ .toEqual({internal: false, href: 'http://[::1]:8096'});
+ });
+});
+
+describe('directoryOptions', () => {
+ it('lists every directory with its path, and no bookmarks', () => {
+ expect(directoryOptions(tree)).toEqual([
+ {value: '2', label: 'Services', id: 2},
+ {value: '4', label: 'Services / Radio', id: 4},
+ ]);
+ });
+});
+
+describe('BookmarkTable', () => {
+ const actions = () => ({edit: jest.fn(), move: jest.fn(), remove: jest.fn()});
+
+ it('renders the tree with resolved links, numbered within each directory', () => {
+ render();
+ 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();
+ // The first top-level node cannot move up; the last cannot move down.
+ expect(screen.getByRole('button', {name: 'Move Channels up'})).toBeDisabled();
+ expect(screen.getByRole('button', {name: 'Move Services down'})).toBeDisabled();
+
+ await userEvent.click(screen.getByRole('button', {name: 'Move Channels down'}));
+ expect(a.move).toHaveBeenCalledWith(tree[0], null, 1);
+ // A child moves within its directory: parent 2, from index 1 to 0.
+ await userEvent.click(screen.getByRole('button', {name: 'Move Radio up'}));
+ expect(a.move).toHaveBeenCalledWith(tree[1].children[1], 2, 0);
+ });
+
+ it('shows a bookmark whose URL does not resolve as text, not a link', () => {
+ const nodes = [{id: 1, name: 'Bad', url: '//evil.com', new_tab: false}];
+ render();
+ expect(screen.queryByRole('link', {name: 'Bad'})).not.toBeInTheDocument();
+ expect(screen.getByText('Bad')).toBeInTheDocument();
+ });
+
+ it('edits with the parent the node lives in, and deletes a bookmark without asking', async () => {
+ const a = actions();
+ render();
+ 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();
+ 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();
+ const adds = screen.getAllByRole('button', {name: 'Add'});
+ expect(adds[0]).toBeDisabled();
+
+ await userEvent.type(screen.getByLabelText('Bookmark URL'), ':8123/');
+ expect(adds[0]).toBeDisabled();
+ await userEvent.type(screen.getByLabelText('Bookmark name'), 'Home Assistant');
+ await userEvent.click(screen.getByLabelText('New tab'));
+ await userEvent.click(adds[0]);
+
+ expect(onAddBookmark).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: null});
+ expect(screen.getByLabelText('Bookmark URL')).toHaveValue('');
+ });
+
+ it('keeps what was typed when the server refuses the bookmark', async () => {
+ const onAddBookmark = jest.fn().mockRejectedValue(new Error('refused'));
+ render();
+ await userEvent.type(screen.getByLabelText('Bookmark URL'), '//evil.com');
+ await userEvent.type(screen.getByLabelText('Bookmark name'), 'Oops');
+ await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[0]);
+
+ expect(onAddBookmark).toHaveBeenCalled();
+ expect(screen.getByLabelText('Bookmark URL')).toHaveValue('//evil.com');
+ expect(screen.getByLabelText('Bookmark name')).toHaveValue('Oops');
+ });
+
+ it('adds a directory', async () => {
+ const onAddDirectory = jest.fn();
+ render();
+ 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();
+ 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();
+ 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']);
+ });
+});
diff --git a/app/src/components/Nav.js b/app/src/components/Nav.js
index 8b3c1117..188de708 100644
--- a/app/src/components/Nav.js
+++ b/app/src/components/Nav.js
@@ -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
+ }
if (link.links) {
return
}
@@ -85,13 +92,31 @@ function MenuLink({link}) {
}
-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
+
+ {link.text}
+
+
+
+
+
+ }
+ return
+
+ {link.text}
+
+
+
+ }
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 {link.text}
{link.links.map(l => )}
@@ -141,6 +166,11 @@ NavDropdownTrigger.displayName = 'NavDropdownTrigger';
* the hooks that would answer it cannot be called in a loop.
*/
export const isLinkActive = (pathname, link) => {
+ // The Bookmarks menu owns only its editor page: a bookmark pointing at /videos does
+ // not make the menu own the Videos section.
+ if (link.bookmarks) {
+ return pathname === EDIT_BOOKMARKS_PATH || pathname.startsWith(`${EDIT_BOOKMARKS_PATH}/`);
+ }
// An off-site link (Help) is never the current section.
if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false;
const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to;
@@ -175,6 +205,24 @@ function DropdownLinks({link}) {
}
+/**
+ * The Bookmarks tab: a dropdown that opens on hover as well as on click, holding the
+ * user's bookmarks and the link to edit them. Marked current on the editor page only
+ * (see isLinkActive).
+ */
+function BookmarksDropdown({link}) {
+ const {pathname} = useLocation();
+ const holdsCurrent = isLinkActive(pathname, link);
+ return
+}
+
function MobileMenu({links}) {
// The mobile hamburger menu: an icon-only trigger holding every link.
return
}
diff --git a/app/src/components/Nav.test.js b/app/src/components/Nav.test.js
index 82cb7433..3e4f88a6 100644
--- a/app/src/components/Nav.test.js
+++ b/app/src/components/Nav.test.js
@@ -231,3 +231,91 @@ describe('NavBar current tab', () => {
expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/);
});
});
+
+describe('NavBar bookmarks', () => {
+ const {BookmarksContext} = require('../contexts/BookmarksContext');
+ const bookmarks = [
+ {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
+ {id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]},
+ ];
+ const renderWithBookmarks = (route, nodes) => render(, {
+ route,
+ withMedia: true,
+ contexts: [
+ [FileWorkerStatusContext, fileWorkerStatus],
+ [BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}],
+ ],
+ });
+
+ it('sits between Calculators and Flasher', () => {
+ renderNav('/');
+ const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')]
+ .map(el => el.textContent.trim());
+ const at = texts.indexOf('Bookmarks');
+ expect(at).toBeGreaterThan(0);
+ expect(texts[at - 1]).toBe('Calculators');
+ expect(texts[at + 1]).toBe('Flasher');
+ });
+
+ it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => {
+ useRealRects();
+ renderWithBookmarks('/', bookmarks);
+
+ await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
+
+ const channels = await screen.findByRole('menuitem', {name: 'Channels'});
+ expect(channels).toHaveAttribute('href', '/videos/channel');
+ expect(channels).not.toHaveAttribute('target');
+ // A directory is a submenu; its bookmarks appear once it is hovered.
+ expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument();
+ await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'}));
+ // `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it
+ // stays hidden to accessibility queries even though it has rendered.
+ const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true});
+ expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
+ expect(jellyfin).toHaveAttribute('target', '_blank');
+ expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks');
+ });
+
+ it('offers the edit link even with no bookmarks, so the feature can be found', async () => {
+ useRealRects();
+ renderWithBookmarks('/', []);
+
+ await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
+
+ expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
+ expect(screen.getAllByRole('menuitem')).toHaveLength(1);
+ });
+
+ it('is marked current on the editor page, and never for a bookmark pointing at a section', () => {
+ renderWithBookmarks('/more/bookmarks', bookmarks);
+ expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active');
+ expect(currentTab()).toEqual([]);
+ });
+
+ it('marks More when the editor page is folded inside it', () => {
+ useBarWidth(200);
+ renderWithBookmarks('/more/bookmarks', bookmarks);
+ expect(screen.getByRole('button', {name: /More/})).toHaveClass('active');
+ });
+
+ it('does not own the section a bookmark points at', () => {
+ renderWithBookmarks('/videos/channel', bookmarks);
+ expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active');
+ expect(currentTab()).toEqual(['Videos']);
+ });
+
+ it('lists bookmarks as a labelled group in the mobile menu', async () => {
+ useViewport('mobile');
+ useRealRects();
+ renderWithBookmarks('/', bookmarks);
+
+ await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
+
+ expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument();
+ // No flyout on a phone: the directory's bookmarks are listed directly.
+ expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument();
+ expect(screen.getByText('Services')).toBeInTheDocument();
+ expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js
index 3f95df25..9e14efa1 100644
--- a/app/src/components/ThemeSamplePage.js
+++ b/app/src/components/ThemeSamplePage.js
@@ -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() {
+
+
+
+ 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.
+
+
+
+
+
+
+
+ null} onAddDirectory={() => null}/>
+
+
+
+
Danger zone
diff --git a/app/src/contexts/BookmarksContext.js b/app/src/contexts/BookmarksContext.js
new file mode 100644
index 00000000..e3d639e6
--- /dev/null
+++ b/app/src/contexts/BookmarksContext.js
@@ -0,0 +1,114 @@
+import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
+import {getBookmarks} from '../api';
+
+/*
+ * The user's bookmarks, shared by the navigation bar and the editor page.
+ *
+ * A context rather than a prop: the bar and the page are far apart in the tree, and an
+ * edit on the page must show in the bar at once. `bookmarks` is null until the first
+ * fetch answers, so the bar can tell "not loaded yet" from "the user has none".
+ */
+export const BookmarksContext = createContext({bookmarks: null, error: null, refresh: async () => null});
+
+export function BookmarksProvider({children}) {
+ const [bookmarks, setBookmarks] = useState(null);
+ const [error, setError] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setBookmarks(await getBookmarks());
+ setError(null);
+ } catch (err) {
+ setError(err);
+ }
+ }, []);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return
+ {children}
+ ;
+}
+
+export function useBookmarks() {
+ return useContext(BookmarksContext);
+}
+
+export const isBookmarkDirectory = (node) => Array.isArray(node.children);
+
+// Characters a URL parser strips or that split a URL in surprising places. See the
+// same rule in wrolpi/bookmarks.py; the two must agree.
+// eslint-disable-next-line no-control-regex -- matching control characters is the point
+const FORBIDDEN = /[\x00-\x20\x7f\\]/;
+const PORT_FORM = /^(?:(https?):\/\/)?:(\d{1,5})(?=$|[/?#])(.*)$/i;
+const PORT_FORM_START = /^(?:https?:\/\/)?:/i;
+
+const NONE = {internal: false, href: null};
+
+/**
+ * Where a bookmark URL really points, from the browser's point of view.
+ *
+ * Three forms are accepted, and only the first is an in-app link:
+ * `/videos/channel/3` a path on this WROLPi -> {internal: true, href: '/videos/channel/3'}
+ * `:8096/web` a port on this host, using the page's scheme
+ * `http://:8096/web` a port on this host, with its own scheme
+ * `https://example.com` anything else, verbatim
+ *
+ * The host-relative forms exist because a WROLPi is reached by LAN IP, by mDNS name, and
+ * by its hotspot address, and a service running beside it is reached the same way. A
+ * bookmark with the hostname written in would work from only one of them.
+ *
+ * Anything else, including a stored value the server would refuse today, resolves to a
+ * null href and is rendered as text rather than a link: the form each value claims is
+ * checked by parsing it, never by its prefix, because `//evil.com` starts like a path and
+ * `:8096@evil.com` starts like a port, and a browser takes both off this host.
+ */
+export function resolveBookmarkUrl(url, location = window.location) {
+ const value = (url || '').trim();
+ if (!value || FORBIDDEN.test(value)) {
+ return NONE;
+ }
+ const origin = `${location.protocol}//${location.host}`;
+
+ if (PORT_FORM_START.test(value)) {
+ const match = value.match(PORT_FORM);
+ if (!match || match[3].includes('@')) {
+ return NONE;
+ }
+ const [, scheme, port, tail] = match;
+ // An IPv6 literal has to be bracketed to sit before a port. Browsers report it
+ // bracketed already; tests and older code may not.
+ const bare = location.hostname.replace(/^\[|]$/g, '');
+ const host = bare.includes(':') ? `[${bare}]` : bare;
+ const href = `${scheme ? `${scheme.toLowerCase()}:` : location.protocol}//${host}:${port}${tail}`;
+ const parsed = parse(href);
+ if (!parsed || parsed.hostname !== host || parsed.port !== port) {
+ return NONE;
+ }
+ return {internal: false, href};
+ }
+
+ if (value.startsWith('/')) {
+ const parsed = parse(value, origin);
+ if (value.startsWith('//') || !parsed || parsed.origin !== origin) {
+ return NONE;
+ }
+ return {internal: true, href: value};
+ }
+
+ const parsed = parse(value);
+ if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
+ return NONE;
+ }
+ return {internal: false, href: value};
+}
+
+function parse(value, base) {
+ try {
+ return new URL(value, base);
+ } catch {
+ return null;
+ }
+}
diff --git a/docker/help/wrolpi-help b/docker/help/wrolpi-help
index 662a4dd7..c4362d80 160000
--- a/docker/help/wrolpi-help
+++ b/docker/help/wrolpi-help
@@ -1 +1 @@
-Subproject commit 662a4dd7b1cf66bb3ec517c69e99f70cb7a4941b
+Subproject commit c4362d807977c2719dbe9a870ad86f4c14580431
diff --git a/wrolpi/bookmarks.py b/wrolpi/bookmarks.py
new file mode 100644
index 00000000..b3e4a34a
--- /dev/null
+++ b/wrolpi/bookmarks.py
@@ -0,0 +1,278 @@
+"""User bookmarks shown in the navigation bar.
+
+Bookmarks are config-only (no database table). The config holds a tree: a node with
+`children` is a directory, a node with `url` is a bookmark. Every node has an integer `id`
+so the API can address it without relying on names, which need not be unique.
+
+A bookmark `url` may take three forms:
+ * a path, `/videos/channel/3`, which the UI renders as an in-app link;
+ * a host-relative port, `:8096/web` or `http://:8096/web`, which the UI resolves against
+ whatever hostname the browser reached this WROLPi by (LAN IP, mDNS name, hotspot);
+ * an absolute URL, `https://example.com`.
+"""
+import re
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+from urllib.parse import urlsplit
+
+from wrolpi.common import ConfigFile, logger
+from wrolpi.errors import ValidationError
+from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
+
+logger = logger.getChild(__name__)
+
+# Characters that a URL parser strips or that split a URL in surprising places. Any of
+# them in a bookmark is a sign of an attempt to smuggle a host past the checks below.
+_FORBIDDEN = re.compile(r'[\x00-\x20\x7f\\]')
+# A port on this host: an optional scheme, then `:` and the port, then the end of the URL
+# or the start of a path, query or fragment. `:8096@evil.com` does not match, because
+# `@` may not follow the port.
+_PORT_FORM = re.compile(r'^(?:(https?)://)?:(\d{1,5})(?=$|[/?#])(.*)$', re.IGNORECASE)
+_PORT_FORM_START = re.compile(r'^(?:https?://)?:', re.IGNORECASE)
+
+URL_HELP = 'Bookmark URL must be a path (/videos), a port (:8096/) or an absolute URL (https://)'
+
+
+@dataclass
+class BookmarksConfigValidator:
+ version: int = None
+ bookmarks: list = field(default_factory=list)
+
+
+def is_directory(node: dict) -> bool:
+ return 'children' in node
+
+
+def walk(nodes: List[dict], parent: Optional[dict] = None):
+ """Yield (node, parent, siblings) for every node in the tree, depth first."""
+ for node in nodes:
+ yield node, parent, nodes
+ if is_directory(node):
+ yield from walk(node['children'], node)
+
+
+def validate_url(url: str) -> str:
+ """Return `url` stripped, or raise if it is not one of the three forms.
+
+ The forms are checked by parsing, not by prefix. A prefix check accepted `//evil.com`
+ as a path and `:8096@evil.com` as a port, both of which a browser takes off this host.
+ """
+ url = (url or '').strip()
+ if not url or _FORBIDDEN.search(url):
+ raise ValidationError(URL_HELP)
+
+ if _PORT_FORM_START.match(url):
+ # A port on this host. Everything after the port must be a path, query or
+ # fragment; `@` anywhere in it would turn the host into credentials.
+ match = _PORT_FORM.match(url)
+ if not match or not 0 < int(match.group(2)) < 65536 or '@' in match.group(3):
+ raise ValidationError(URL_HELP)
+ return url
+
+ if url.startswith('/'):
+ # A path on this WROLPi. A second slash (`//evil.com`) is a network path, which
+ # leaves this host. `urlsplit` agrees only when the netloc comes back empty.
+ parts = urlsplit(url)
+ if url.startswith('//') or parts.scheme or parts.netloc:
+ raise ValidationError(URL_HELP)
+ return url
+
+ parts = urlsplit(url)
+ if parts.scheme.lower() not in ('http', 'https') or not parts.netloc:
+ raise ValidationError(URL_HELP)
+ return url
+
+
+def validate_name(name: str) -> str:
+ name = (name or '').strip()
+ if not name:
+ raise ValidationError('Name is required')
+ return name
+
+
+def validate_tree(nodes) -> None:
+ """Raise ValidationError unless `nodes` is a well-formed bookmarks tree.
+
+ Applied to every write, including a hand-edited config file and the config API, so
+ the nav never renders a URL that `validate_url` would have refused.
+ """
+ if not isinstance(nodes, list):
+ raise ValidationError('bookmarks must be a list')
+ seen_ids = set()
+ # Iterative, with the objects on the path tracked, so a cyclic structure (which a
+ # YAML anchor can build) is rejected rather than recursed into forever.
+ stack = [(nodes, ())]
+ while stack:
+ siblings, path = stack.pop()
+ if id(siblings) in path:
+ raise ValidationError('bookmarks tree contains a cycle')
+ for node in siblings:
+ if not isinstance(node, dict):
+ raise ValidationError(f'Bookmark is not a mapping: {node!r}')
+ node_id = node.get('id')
+ if not isinstance(node_id, int) or isinstance(node_id, bool) or node_id in seen_ids:
+ raise ValidationError(f'Bookmark id is missing or duplicated: {node_id!r}')
+ seen_ids.add(node_id)
+ validate_name(node.get('name'))
+ has_url, has_children = 'url' in node, 'children' in node
+ if has_url == has_children:
+ raise ValidationError(f'Bookmark {node_id} must have a url or children, not both')
+ if has_url:
+ validate_url(node['url'])
+ if 'new_tab' in node and not isinstance(node['new_tab'], bool):
+ raise ValidationError(f'Bookmark {node_id} new_tab must be true or false')
+ else:
+ if not isinstance(node['children'], list):
+ raise ValidationError(f'Bookmark {node_id} children must be a list')
+ stack.append((node['children'], path + (id(siblings),)))
+
+
+class BookmarksConfig(ConfigFile):
+ file_name = 'bookmarks.yaml'
+ default_config = dict(
+ version=0,
+ bookmarks=[],
+ )
+ validator = BookmarksConfigValidator
+
+ def validate(self, config: dict) -> bool:
+ # The dataclass validator sees only that `bookmarks` is a list. Every writer
+ # (the bookmarks API, the config API, import) comes through here, so the tree
+ # itself is checked too.
+ if not super().validate(config):
+ return False
+ if 'bookmarks' in config:
+ try:
+ validate_tree(config['bookmarks'])
+ except ValidationError as e:
+ logger.error(f'{self.file_name}: {e}')
+ return False
+ return True
+
+ def import_config(self, file=None, send_events=False):
+ # An invalid file raises here, leaving successful_import False so save() will not
+ # overwrite it. A missing file is imported as empty, so the first save creates it.
+ super().import_config(file, send_events)
+ self.successful_import = True
+
+ @property
+ def bookmarks(self) -> List[dict]:
+ # A deep copy: the tree is nested, and callers mutate what they get back before
+ # assigning it again through the setter.
+ from copy import deepcopy
+ return deepcopy(list(self._config.get('bookmarks', [])))
+
+ @bookmarks.setter
+ def bookmarks(self, value: List[dict]):
+ self.update({'bookmarks': value})
+ save_bookmarks_config.activate_switch()
+
+ def _next_id(self, bookmarks: List[dict]) -> int:
+ ids = [node.get('id', 0) for node, _, _ in walk(bookmarks)]
+ return (max(ids) if ids else 0) + 1
+
+ @staticmethod
+ def _find(bookmarks: List[dict], node_id: int) -> Tuple[dict, Optional[dict], List[dict]]:
+ for node, parent, siblings in walk(bookmarks):
+ if node.get('id') == node_id:
+ return node, parent, siblings
+ raise ValidationError(f'Bookmark {node_id} not found')
+
+ @staticmethod
+ def _container(bookmarks: List[dict], parent_id: Optional[int]) -> List[dict]:
+ """The list of children that `parent_id` holds; the root list when parent_id is None."""
+ if parent_id is None:
+ return bookmarks
+ parent, _, _ = BookmarksConfig._find(bookmarks, parent_id)
+ if not is_directory(parent):
+ raise ValidationError(f'Bookmark {parent_id} is not a directory')
+ return parent['children']
+
+ @staticmethod
+ def _insert(container: List[dict], node: dict, position: Optional[int]):
+ if position is None or position >= len(container):
+ container.append(node)
+ else:
+ container.insert(max(position, 0), node)
+
+ def add_bookmark(self, name: str, url: str, new_tab: bool = False, parent_id: int = None,
+ position: int = None) -> dict:
+ bookmarks = self.bookmarks
+ node = dict(
+ id=self._next_id(bookmarks),
+ name=validate_name(name),
+ url=validate_url(url),
+ new_tab=bool(new_tab),
+ )
+ self._insert(self._container(bookmarks, parent_id), node, position)
+ self.bookmarks = bookmarks
+ return node
+
+ def add_directory(self, name: str, parent_id: int = None, position: int = None) -> dict:
+ bookmarks = self.bookmarks
+ node = dict(id=self._next_id(bookmarks), name=validate_name(name), children=[])
+ self._insert(self._container(bookmarks, parent_id), node, position)
+ self.bookmarks = bookmarks
+ return node
+
+ def update_node(self, node_id: int, name: str = None, url: str = None, new_tab: bool = None) -> dict:
+ bookmarks = self.bookmarks
+ node, _, _ = self._find(bookmarks, node_id)
+ if name is not None:
+ node['name'] = validate_name(name)
+ if is_directory(node):
+ if url is not None or new_tab is not None:
+ raise ValidationError('A directory has no URL')
+ else:
+ if url is not None:
+ node['url'] = validate_url(url)
+ if new_tab is not None:
+ node['new_tab'] = bool(new_tab)
+ self.bookmarks = bookmarks
+ return node
+
+ def delete_node(self, node_id: int) -> dict:
+ """Delete a bookmark, or a directory and everything in it."""
+ bookmarks = self.bookmarks
+ node, _, siblings = self._find(bookmarks, node_id)
+ siblings.remove(node)
+ self.bookmarks = bookmarks
+ return node
+
+ def move_node(self, node_id: int, parent_id: Optional[int], position: Optional[int] = None) -> dict:
+ """Move a node into `parent_id` (None = root) at `position` (None = end)."""
+ bookmarks = self.bookmarks
+ node, _, siblings = self._find(bookmarks, node_id)
+ if parent_id == node_id:
+ raise ValidationError('Cannot move a directory into itself')
+ if is_directory(node) and parent_id is not None:
+ descendants = {n['id'] for n, _, _ in walk(node['children'])}
+ if parent_id in descendants:
+ raise ValidationError('Cannot move a directory into its own child')
+ container = self._container(bookmarks, parent_id)
+ # Remove after resolving the container so an unknown parent leaves the tree untouched.
+ siblings.remove(node)
+ self._insert(container, node, position)
+ self.bookmarks = bookmarks
+ return node
+
+
+BOOKMARKS_CONFIG: BookmarksConfig = BookmarksConfig()
+
+# Test override.
+TEST_BOOKMARKS_CONFIG = None
+
+
+def get_bookmarks_config() -> BookmarksConfig:
+ global TEST_BOOKMARKS_CONFIG
+ if isinstance(TEST_BOOKMARKS_CONFIG, ConfigFile):
+ return TEST_BOOKMARKS_CONFIG
+ return BOOKMARKS_CONFIG
+
+
+@register_switch_handler('save_bookmarks_config')
+def save_bookmarks_config():
+ get_bookmarks_config().save()
+
+
+save_bookmarks_config: ActivateSwitchMethod
diff --git a/wrolpi/bookmarks_api.py b/wrolpi/bookmarks_api.py
new file mode 100644
index 00000000..663e510e
--- /dev/null
+++ b/wrolpi/bookmarks_api.py
@@ -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('/')
+@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('//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('/')
+@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)
diff --git a/wrolpi/common.py b/wrolpi/common.py
index de392d04..434516e5 100644
--- a/wrolpi/common.py
+++ b/wrolpi/common.py
@@ -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
diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py
index 2f55cabc..86a74b90 100644
--- a/wrolpi/contexts.py
+++ b/wrolpi/contexts.py
@@ -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}')
diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py
index 01d459c2..5b9b64e3 100644
--- a/wrolpi/root_api.py
+++ b/wrolpi/root_api.py
@@ -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)
diff --git a/wrolpi/test/test_bookmarks.py b/wrolpi/test/test_bookmarks.py
new file mode 100644
index 00000000..83634105
--- /dev/null
+++ b/wrolpi/test/test_bookmarks.py
@@ -0,0 +1,226 @@
+import pytest
+import yaml
+
+from wrolpi.bookmarks import get_bookmarks_config
+from wrolpi.common import get_media_directory
+from wrolpi.errors import InvalidConfig
+
+
+def read_config():
+ return yaml.safe_load((get_media_directory() / 'config/bookmarks.yaml').read_text())
+
+
+async def test_bookmarks_tree_api(async_client, test_directory):
+ """Bookmarks and directories can be added, edited, moved and deleted, and are persisted to YAML."""
+ request, response = await async_client.get('/api/bookmarks')
+ assert response.status == 200
+ assert response.json['bookmarks'] == []
+
+ # A top-level bookmark; new_tab defaults to false.
+ request, response = await async_client.post('/api/bookmarks', json=dict(name='Videos', url='/videos'))
+ assert response.status == 201, response.json
+ videos = response.json['bookmark']
+ assert videos == dict(id=1, name='Videos', url='/videos', new_tab=False)
+
+ # A directory, then a bookmark inside it that opens in a new tab.
+ request, response = await async_client.post('/api/bookmarks/directory', json=dict(name='Services'))
+ assert response.status == 201
+ services = response.json['bookmark']
+ assert services == dict(id=2, name='Services', children=[])
+ request, response = await async_client.post('/api/bookmarks',
+ json=dict(name='Jellyfin', url=':8096/', new_tab=True,
+ parent_id=services['id']))
+ assert response.status == 201
+ jellyfin = response.json['bookmark']
+ assert jellyfin['id'] == 3 and jellyfin['new_tab'] is True
+
+ request, response = await async_client.get('/api/bookmarks')
+ assert response.json['bookmarks'] == [
+ dict(id=1, name='Videos', url='/videos', new_tab=False),
+ dict(id=2, name='Services', children=[
+ dict(id=3, name='Jellyfin', url=':8096/', new_tab=True),
+ ]),
+ ]
+ # The tree was written to the config file.
+ assert read_config()['bookmarks'] == response.json['bookmarks']
+
+ # Rename and re-point a bookmark.
+ request, response = await async_client.put('/api/bookmarks/3', json=dict(name='Movies', url='http://:8096/web'))
+ assert response.status == 200
+ assert response.json['bookmark'] == dict(id=3, name='Movies', url='http://:8096/web', new_tab=True)
+
+ # A directory cannot take a URL.
+ request, response = await async_client.put('/api/bookmarks/2', json=dict(url='/videos'))
+ assert response.status == 400
+
+ # Move the top-level bookmark into the directory, at the front.
+ request, response = await async_client.post('/api/bookmarks/1/move', json=dict(parent_id=2, position=0))
+ assert response.status == 200
+ request, response = await async_client.get('/api/bookmarks')
+ assert [i['id'] for i in response.json['bookmarks']] == [2]
+ assert [i['id'] for i in response.json['bookmarks'][0]['children']] == [1, 3]
+
+ # Move it back to the top level, at the end.
+ request, response = await async_client.post('/api/bookmarks/1/move', json=dict(parent_id=None))
+ assert response.status == 200
+ request, response = await async_client.get('/api/bookmarks')
+ assert [i['id'] for i in response.json['bookmarks']] == [2, 1]
+
+ # Deleting a directory deletes its contents.
+ request, response = await async_client.delete('/api/bookmarks/2')
+ assert response.status == 204
+ request, response = await async_client.get('/api/bookmarks')
+ assert response.json['bookmarks'] == [dict(id=1, name='Videos', url='/videos', new_tab=False)]
+ request, response = await async_client.post('/api/bookmarks', json=dict(name='Docs', url='/docs'))
+ assert response.json['bookmark']['id'] == 2
+ assert read_config()['bookmarks'] == [
+ dict(id=1, name='Videos', url='/videos', new_tab=False),
+ dict(id=2, name='Docs', url='/docs', new_tab=False),
+ ]
+
+ # Unknown nodes are 400s, not crashes.
+ request, response = await async_client.delete('/api/bookmarks/999')
+ assert response.status == 400
+ request, response = await async_client.put('/api/bookmarks/999', json=dict(name='x'))
+ assert response.status == 400
+
+
+async def test_bookmarks_validation(async_client, test_directory):
+ """Names are required and URLs must be a path, a port, or an absolute URL."""
+ rejected = (
+ '', 'videos', 'ftp://example.com', 'javascript:alert(1)', 'data:text/html,hi', '8096/',
+ # Paths that a browser takes off this host: network paths, backslash tricks, and
+ # control characters a URL parser strips before deciding.
+ '//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
+ # Port forms where the host becomes credentials, or where the port is not a port.
+ ':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':0', ':70000',
+ ':8096\\evil.com', 'ftp://:8096/',
+ # An absolute URL with no host, or with a scheme other than http(s).
+ 'http://', 'https:///x', 'file:///etc/passwd',
+ )
+ for url in rejected:
+ request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url))
+ assert response.status == 400, url
+ for url in ('/videos', '/videos?x=1#y', ':8096/', ':8096', ':8096?x=1', 'http://:8096/web', 'HTTP://:80/',
+ 'https://example.com', 'HTTP://EXAMPLE.COM', 'https://user@example.com/x'):
+ request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url=url))
+ assert response.status == 201, url
+
+ request, response = await async_client.post('/api/bookmarks', json=dict(name=' ', url='/videos'))
+ assert response.status == 400
+ request, response = await async_client.post('/api/bookmarks/directory', json=dict(name=''))
+ assert response.status == 400
+
+ # Whitespace is trimmed.
+ request, response = await async_client.post('/api/bookmarks', json=dict(name=' Map ', url=' /map '))
+ assert response.json['bookmark'] == dict(id=11, name='Map', url='/map', new_tab=False)
+
+
+async def test_bookmarks_move_guards(async_client, test_directory):
+ """A directory cannot be moved into itself or a descendant, and only directories hold children."""
+ config = get_bookmarks_config()
+ outer = config.add_directory('Outer')
+ inner = config.add_directory('Inner', parent_id=outer['id'])
+ leaf = config.add_bookmark('Leaf', '/files', parent_id=inner['id'])
+
+ request, response = await async_client.post(f'/api/bookmarks/{outer["id"]}/move', json=dict(parent_id=outer['id']))
+ assert response.status == 400
+ request, response = await async_client.post(f'/api/bookmarks/{outer["id"]}/move', json=dict(parent_id=inner['id']))
+ assert response.status == 400
+ # A bookmark is not a directory.
+ request, response = await async_client.post(f'/api/bookmarks/{inner["id"]}/move', json=dict(parent_id=leaf['id']))
+ assert response.status == 400
+ request, response = await async_client.post('/api/bookmarks', json=dict(name='x', url='/x', parent_id=leaf['id']))
+ assert response.status == 400
+
+ # The failed moves left the tree untouched.
+ assert [i['id'] for i, _, _ in __import__('wrolpi.bookmarks', fromlist=['walk']).walk(config.bookmarks)] == \
+ [outer['id'], inner['id'], leaf['id']]
+
+
+def test_bookmarks_config_import(test_directory, test_wrolpi_config):
+ """A hand-edited bookmarks.yaml is imported as-is, including nested directories."""
+ config_file = test_directory / 'config/bookmarks.yaml'
+ config_file.parent.mkdir(exist_ok=True)
+ config_file.write_text(yaml.dump(dict(version=0, bookmarks=[
+ dict(id=1, name='Radio', children=[
+ dict(id=2, name='OpenWebRX', url=':8073/', new_tab=True),
+ ]),
+ ])))
+
+ config = get_bookmarks_config()
+ config.initialize()
+ config.import_config()
+ assert config.successful_import is True
+ assert config.bookmarks[0]['children'][0]['name'] == 'OpenWebRX'
+
+ # Later additions continue the id sequence past the hand-written ids.
+ node = config.add_bookmark('Docs', '/docs')
+ assert node['id'] == 3
+
+
+@pytest.mark.parametrize('bookmarks', [
+ # A URL the bookmarks API would refuse.
+ [dict(id=1, name='x', url='javascript:alert(1)')],
+ [dict(id=1, name='x', url='//evil.com')],
+ [dict(id=1, name='x', url=':8096@evil.com')],
+ # Shape problems: duplicate ids, a missing id, a node that is both, a node that is neither.
+ [dict(id=1, name='x', url='/a'), dict(id=1, name='y', url='/b')],
+ [dict(name='x', url='/a')],
+ [dict(id=1, name='x', url='/a', children=[])],
+ [dict(id=1, name='x')],
+ [dict(id=1, name='x', children='nope')],
+ [dict(id=1, name='', url='/a')],
+ [dict(id=1, name='x', url='/a', new_tab='yes')],
+ ['not a node'],
+ 'not a list',
+])
+def test_bookmarks_config_import_rejects_bad_tree(test_directory, test_wrolpi_config, bookmarks):
+ """A hand-edited file that the API would refuse is not imported, and is not overwritten."""
+ config_file = test_directory / 'config/bookmarks.yaml'
+ config_file.parent.mkdir(exist_ok=True)
+ original = yaml.dump(dict(version=0, bookmarks=bookmarks))
+ config_file.write_text(original)
+
+ config = get_bookmarks_config()
+ with pytest.raises(InvalidConfig):
+ config.initialize()
+ with pytest.raises(InvalidConfig):
+ config.import_config()
+ assert config.successful_import is False
+ assert config.bookmarks == []
+ # The in-memory tree is empty, and the API refuses to write it over the user's file.
+ with pytest.raises(RuntimeError):
+ config.save()
+ assert config_file.read_text() == original
+
+
+def test_bookmarks_config_import_rejects_cycle(test_directory, test_wrolpi_config):
+ """A YAML anchor can make a directory contain itself; walking that would never end."""
+ config_file = test_directory / 'config/bookmarks.yaml'
+ config_file.parent.mkdir(exist_ok=True)
+ config_file.write_text('version: 0\nbookmarks: &root\n- id: 1\n name: Loop\n children: *root\n')
+
+ config = get_bookmarks_config()
+ with pytest.raises(InvalidConfig):
+ config.import_config()
+ assert config.successful_import is False
+
+
+async def test_bookmarks_config_api_rejects_bad_tree(async_client, test_directory):
+ """The generic config API applies the same rules as the bookmarks API."""
+ request, response = await async_client.post('/api/bookmarks', json=dict(name='ok', url='/videos'))
+ assert response.status == 201
+
+ bad = dict(version=1, bookmarks=[dict(id=1, name='x', url='javascript:alert(1)')])
+ request, response = await async_client.post('/api/config?file_name=bookmarks.yaml', json=dict(config=bad))
+ assert response.status == 400, response.json
+ request, response = await async_client.get('/api/bookmarks')
+ assert response.json['bookmarks'] == [dict(id=1, name='ok', url='/videos', new_tab=False)]
+
+ good = dict(version=100, bookmarks=[dict(id=5, name='Docs', url='/docs', new_tab=True)])
+ request, response = await async_client.post('/api/config?file_name=bookmarks.yaml', json=dict(config=good))
+ assert response.status == 204, response.json
+ request, response = await async_client.get('/api/bookmarks')
+ assert response.json['bookmarks'] == good['bookmarks']
+ assert read_config()['bookmarks'] == good['bookmarks']