Tag management window (#24)

Add a window to manage tags.
ytm_integration
Sander Vocke 5 years ago
parent 04e47349dd
commit a689613a45
  1. 1219
      client/package-lock.json
  2. 6
      client/package.json
  3. 16
      client/src/api.ts
  4. 20
      client/src/components/MainWindow.tsx
  5. 8
      client/src/components/appbar/AddTabMenu.tsx
  6. 13
      client/src/components/common/DiscardChangesButton.tsx
  7. 23
      client/src/components/common/MenuEditText.tsx
  8. 12
      client/src/components/querybuilder/QBSelectWithRequest.tsx
  9. 21
      client/src/components/windows/Windows.tsx
  10. 94
      client/src/components/windows/album/AlbumWindow.tsx
  11. 94
      client/src/components/windows/artist/ArtistWindow.tsx
  12. 97
      client/src/components/windows/manage_tags/ManageTagMenu.tsx
  13. 479
      client/src/components/windows/manage_tags/ManageTagsWindow.tsx
  14. 54
      client/src/components/windows/manage_tags/NewTagMenu.tsx
  15. 147
      client/src/components/windows/manage_tags/TagChange.tsx
  16. 112
      client/src/components/windows/query/QueryWindow.tsx
  17. 57
      client/src/components/windows/song/SongWindow.tsx
  18. 115
      client/src/components/windows/tag/TagWindow.tsx
  19. 64
      client/src/lib/backend/queries.tsx
  20. 61
      client/src/lib/backend/tags.tsx
  21. 14
      client/src/lib/query/Query.tsx
  22. 4
      server/app.ts
  23. 76
      server/endpoints/DeleteTagEndpointHandler.ts
  24. 73
      server/endpoints/MergeTagEndpointHandler.ts
  25. 31
      server/endpoints/QueryEndpointHandler.ts

File diff suppressed because it is too large Load Diff

@ -15,6 +15,7 @@
"@types/react-dom": "^16.9.0",
"@types/react-router": "^5.1.8",
"@types/react-router-dom": "^5.1.5",
"@types/uuid": "^8.3.0",
"jsurl": "^0.1.5",
"lodash": "^4.17.20",
"material-table": "^1.69.0",
@ -24,8 +25,9 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-scripts": "3.4.1",
"typescript": "~3.7.2"
"react-scripts": "^3.4.3",
"typescript": "~3.7.2",
"uuid": "^8.3.0"
},
"scripts": {
"dev": "BROWSER=none react-scripts start",

@ -302,4 +302,20 @@ export interface TagDetailsResponse {
}
export function checkTagDetailsRequest(req: any): boolean {
return true;
}
// Delete tag (DELETE).
export const DeleteTagEndpoint = '/tag/:id';
export interface DeleteTagRequest { }
export interface DeleteTagResponse { }
export function checkDeleteTagRequest(req: any): boolean {
return true;
}
// Merge tag (POST).
export const MergeTagEndpoint = '/tag/:id/merge/:toId';
export interface MergeTagRequest { }
export interface MergeTagResponse { }
export function checkMergeTagRequest(req: any): boolean {
return true;
}

@ -2,13 +2,14 @@ import React, { useReducer, Reducer } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors';
import AppBar from './appbar/AppBar';
import QueryWindow from './windows/QueryWindow';
import QueryWindow from './windows/query/QueryWindow';
import { NewTabProps } from './appbar/AddTabMenu';
import { newWindowState, newWindowReducer, WindowType } from './windows/Windows';
import ArtistWindow from './windows/ArtistWindow';
import AlbumWindow from './windows/AlbumWindow';
import TagWindow from './windows/TagWindow';
import SongWindow from './windows/SongWindow';
import ArtistWindow from './windows/artist/ArtistWindow';
import AlbumWindow from './windows/album/AlbumWindow';
import TagWindow from './windows/tag/TagWindow';
import SongWindow from './windows/song/SongWindow';
import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow';
var _ = require('lodash');
const darkTheme = createMuiTheme({
@ -76,6 +77,7 @@ export default function MainWindow(props: any) {
newWindowState[WindowType.Album](),
newWindowState[WindowType.Artist](),
newWindowState[WindowType.Tag](),
newWindowState[WindowType.ManageTags](),
],
tabReducers: [
newWindowReducer[WindowType.Query],
@ -83,6 +85,7 @@ export default function MainWindow(props: any) {
newWindowReducer[WindowType.Album],
newWindowReducer[WindowType.Artist],
newWindowReducer[WindowType.Tag],
newWindowReducer[WindowType.ManageTags],
],
tabTypes: [
WindowType.Query,
@ -90,6 +93,7 @@ export default function MainWindow(props: any) {
WindowType.Album,
WindowType.Artist,
WindowType.Tag,
WindowType.ManageTags,
],
activeTab: 0
})
@ -134,6 +138,12 @@ export default function MainWindow(props: any) {
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
case WindowType.ManageTags:
return <ManageTagsWindow
state={tabState}
dispatch={tabDispatch}
mainDispatch={dispatch}
/>
default:
throw new Error("Unimplemented window type");
}

@ -28,5 +28,13 @@ export default function AddTabMenu(props: IProps) {
})
}}
>{WindowType.Query}</MenuItem>
<MenuItem
onClick={() => {
props.onClose();
props.onCreateTab({
windowType: WindowType.ManageTags,
})
}}
>Manage Tags</MenuItem>
</Menu>
}

@ -0,0 +1,13 @@
import React from 'react';
import { Box, Button } from '@material-ui/core';
export default function DiscardChangesButton(props: any) {
return <Box>
<Button
{...props}
variant="contained" color="primary"
>
Discard Changes
</Button>
</Box>
}

@ -0,0 +1,23 @@
import React, { useState } from 'react';
import { TextField } from '@material-ui/core';
export default function MenuEditText(props: {
label: string,
onSubmit: (s: string) => void,
}) {
const [input, setInput] = useState("");
return <TextField
label={props.label}
variant="outlined"
value={input}
onChange={(e: any) => setInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === 'Enter') {
// User submitted free-form value.
props.onSubmit(input);
e.preventDefault();
}
}}
/>
}

@ -43,18 +43,6 @@ export default function QBSelectWithRequest(props: IProps & any) {
})();
};
// // Ensure a new request is made whenever the loading option is enabled.
// useEffect(() => {
// startRequest(input);
// }, []);
// Ensure options are cleared whenever the element is closed.
// useEffect(() => {
// if (!open) {
// setOptions(null);
// }
// }, [open]);
useEffect(() => {
startRequest(input);
}, [input]);

@ -1,15 +1,17 @@
import React from 'react';
import { QueryWindowReducer } from "./QueryWindow";
import { ArtistWindowReducer } from "./ArtistWindow";
import { QueryWindowReducer } from "./query/QueryWindow";
import { ArtistWindowReducer } from "./artist/ArtistWindow";
import SearchIcon from '@material-ui/icons/Search';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import { SongWindowReducer } from './SongWindow';
import { AlbumWindowReducer } from './AlbumWindow';
import { TagWindowReducer } from './TagWindow';
import LoyaltyIcon from '@material-ui/icons/Loyalty';
import { SongWindowReducer } from './song/SongWindow';
import { AlbumWindowReducer } from './album/AlbumWindow';
import { TagWindowReducer } from './tag/TagWindow';
import { songGetters } from '../../lib/songGetters';
import { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow';
export enum WindowType {
Query = "Query",
@ -17,6 +19,7 @@ export enum WindowType {
Album = "Album",
Tag = "Tag",
Song = "Song",
ManageTags = "ManageTags",
}
export interface WindowState {
@ -29,6 +32,7 @@ export const newWindowReducer = {
[WindowType.Album]: AlbumWindowReducer,
[WindowType.Song]: SongWindowReducer,
[WindowType.Tag]: TagWindowReducer,
[WindowType.ManageTags]: ManageTagsWindowReducer,
}
export const newWindowState = {
@ -78,4 +82,11 @@ export const newWindowState = {
songsWithTag: null,
}
},
[WindowType.ManageTags]: () => {
return {
tabLabel: <><LoyaltyIcon/>Manage Tags</>,
fetchedTags: null,
pendingChanges: [],
}
}
}

@ -1,13 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
import { saveAlbumChanges } from '../../lib/saveChanges';
import * as serverApi from '../../../api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveAlbumChanges } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries';
var _ = require('lodash');
export type AlbumMetadata = serverApi.AlbumDetails;
@ -50,38 +52,15 @@ export interface IProps {
}
export async function getAlbumMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.albumId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
albumOffset: 0,
albumLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
return (await queryAlbums({
query: {
a: QueryLeafBy.AlbumId,
b: id,
leafOp: QueryLeafOp.Equals,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let album = json.albums[0];
return album;
})();
offset: 0,
limit: 1,
}))[0];
}
export default function AlbumWindow(props: IProps) {
@ -103,37 +82,20 @@ export default function AlbumWindow(props: IProps) {
useEffect(() => {
if (props.state.songsOnAlbum) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.albumId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.albumId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const songs = await querySongs({
query: {
a: QueryLeafBy.AlbumId,
b: props.state.albumId,
leafOp: QueryLeafOp.Equals,
},
offset: 0,
limit: -1,
});
props.dispatch({
type: AlbumWindowStateActions.SetSongs,
value: json.songs,
});
value: songs,
});
})();
}, [props.state.songsOnAlbum]);

@ -1,13 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, Button, CircularProgress } from '@material-ui/core';
import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
import { saveArtistChanges } from '../../lib/saveChanges';
import * as serverApi from '../../../api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveArtistChanges } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, querySongs } from '../../../lib/backend/queries';
var _ = require('lodash');
export type ArtistMetadata = serverApi.ArtistDetails;
@ -50,38 +52,15 @@ export interface IProps {
}
export async function getArtistMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.artistId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
artistOffset: 0,
artistLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
return (await queryArtists({
query: {
a: QueryLeafBy.ArtistId,
b: id,
leafOp: QueryLeafOp.Equals,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let artist = json.artists[0];
return artist;
})();
offset: 0,
limit: 1,
}))[0];
}
export default function ArtistWindow(props: IProps) {
@ -103,37 +82,20 @@ export default function ArtistWindow(props: IProps) {
useEffect(() => {
if (props.state.songsByArtist) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.artistId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.artistId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const songs = await querySongs({
query: {
a: QueryLeafBy.ArtistId,
b: props.state.artistId,
leafOp: QueryLeafOp.Equals,
},
offset: 0,
limit: -1,
});
props.dispatch({
type: ArtistWindowStateActions.SetSongs,
value: json.songs,
});
value: songs,
});
})();
}, [props.state.songsByArtist]);

@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { Menu, MenuItem, TextField, Input } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item";
import MenuEditText from '../../common/MenuEditText';
export function PickTag(props: {
tags: any[]
open: boolean
root: boolean
onPick: (v: string | null) => void
}) {
return <>
{props.root && <MenuItem onClick={() => props.onPick(null)}>/</MenuItem>}
{props.tags.map((tag: any) => {
if ('children' in tag && tag.children.length > 0) {
return <NestedMenuItem
parentMenuOpen={props.open}
label={tag.name}
onClick={() => props.onPick(tag.tagId.toString())}
>
<PickTag tags={tag.children} open={props.open} root={false} onPick={props.onPick} />
</NestedMenuItem>
}
return <MenuItem onClick={() => props.onPick(tag.tagId.toString())}>{tag.name}</MenuItem>
})
}</>
}
export default function ManageTagMenu(props: {
position: null | number[],
open: boolean,
onClose: () => void,
onRename: (s: string) => void,
onDelete: () => void,
onMove: (to: string | null) => void,
onMergeInto: (to: string) => void,
onOpenInTab: () => void,
tag: any,
changedTags: any[], // Tags organized hierarchically with "children" fields
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<MenuItem
onClick={() => {
props.onClose();
props.onOpenInTab();
}}
>Browse</MenuItem>
<MenuItem
onClick={() => {
props.onClose();
props.onDelete();
}}
>Delete</MenuItem>
<NestedMenuItem
parentMenuOpen={props.open}
label="Rename"
>
<MenuEditText
label="New name"
onSubmit={(s: string) => {
props.onClose();
props.onRename(s);
}}
/>
</NestedMenuItem>
<NestedMenuItem
parentMenuOpen={props.open}
label="Move to"
>
<PickTag tags={props.changedTags} open={props.open} root={true} onPick={(v: string | null) => {
props.onClose();
props.onMove(v);
}} />
</NestedMenuItem>
<NestedMenuItem
parentMenuOpen={props.open}
label="Merge into"
>
<PickTag tags={props.changedTags} open={props.open} root={false} onPick={(v: string | null) => {
if(v === null) { return; }
props.onClose();
props.onMergeInto(v);
}} />
</NestedMenuItem>
</Menu>
}

@ -0,0 +1,479 @@
import React, { useEffect, useState, ReactFragment } from 'react';
import { WindowState, newWindowReducer, WindowType } from '../Windows';
import { Box, Typography, Chip, IconButton, useTheme, Button } from '@material-ui/core';
import LoyaltyIcon from '@material-ui/icons/Loyalty';
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
import ManageTagMenu from './ManageTagMenu';
import ControlTagChanges, { TagChange, TagChangeType, submitTagChanges } from './TagChange';
import { queryTags } from '../../../lib/backend/queries';
import NewTagMenu from './NewTagMenu';
import { v4 as genUuid } from 'uuid';
import { MainWindowStateActions } from '../../MainWindow';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import { songGetters } from '../../../lib/songGetters';
import Alert from '@material-ui/lab/Alert';
var _ = require('lodash');
export interface ManageTagsWindowState extends WindowState {
// Tags are indexed by a string ID. This can be a stringified MuDBase ID integer,
// or a UID for tags which only exist in the front-end and haven't been committed
// to the database.
fetchedTags: Record<string, any> | null,
pendingChanges: TagChange[],
alert: ReactFragment | null, // For notifications such as errors
}
export enum ManageTagsWindowActions {
SetFetchedTags = "SetFetchedTags",
SetPendingChanges = "SetPendingChanges",
Reset = "Reset",
SetAlert = "SetAlert",
}
export function ManageTagsWindowReducer(state: ManageTagsWindowState, action: any) {
switch (action.type) {
case ManageTagsWindowActions.SetFetchedTags:
return {
...state,
fetchedTags: action.value,
}
case ManageTagsWindowActions.SetPendingChanges:
return {
...state,
pendingChanges: action.value,
}
case ManageTagsWindowActions.Reset:
return {
...state,
pendingChanges: [],
fetchedTags: null,
alert: null,
}
case ManageTagsWindowActions.SetAlert:
return {
...state,
alert: action.value,
}
default:
throw new Error("Unimplemented ManageTagsWindow state update.")
}
}
export function organiseTags(allTags: Record<string, any>, fromId: string | null): any[] {
const base = Object.values(allTags).filter((tag: any) => {
var par: any = ("proposedParent" in tag) ? tag.proposedParent : tag.parentId;
return (fromId === null && !par) ||
(par && par === fromId)
});
return base.map((tag: any) => {
return {
...tag,
children: organiseTags(allTags, tag.tagId),
}
});
}
export async function getAllTags() {
return (async () => {
var retval: Record<string, any> = {};
const tags = await queryTags({
query: undefined,
offset: 0,
limit: -1,
});
// Convert numeric IDs to string IDs because that is
// what we work with within this component.
tags.forEach((tag: any) => {
retval[tag.tagId.toString()] = {
...tag,
tagId: tag.tagId && tag.tagId.toString(),
parentId: tag.parentId && tag.parentId.toString(),
childIds: tag.childIds && tag.childIds.map((c: number) => c.toString()),
}
});
return retval;
})();
}
export function ExpandArrow(props: {
expanded: boolean,
onSetExpanded: (v: boolean) => void,
}) {
return props.expanded ?
<IconButton size="small" onClick={() => props.onSetExpanded(false)}><ArrowDropDownIcon /></IconButton> :
<IconButton size="small" onClick={() => props.onSetExpanded(true)}><ArrowRightIcon /></IconButton>;
}
export function CreateTagButton(props: any) {
return <Box display="flex">
<Box visibility='hidden'>
<ExpandArrow expanded={false} onSetExpanded={(v: boolean) => { }} />
</Box>
<Button style={{ textTransform: 'none' }} variant="outlined" {...props}>New Tag...</Button>
</Box>
}
export function SingleTag(props: {
tag: any,
prependElems: any[],
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
state: ManageTagsWindowState,
changedTags: any[],
}) {
const tag = props.tag;
const hasChildren = 'children' in tag && tag.children.length > 0;
const [menuPos, setMenuPos] = React.useState<null | number[]>(null);
const [expanded, setExpanded] = useState<boolean>(false);
const theme = useTheme();
const onOpenMenu = (e: any) => {
setMenuPos([e.clientX, e.clientY])
};
const onCloseMenu = () => {
setMenuPos(null);
};
var tagLabel: any = tag.name;
if ("proposedName" in tag) {
tagLabel = <><del style={{ color: theme.palette.text.secondary }}>{tag.name}</del>{tag.proposedName}</>;
} else if ("proposeDelete" in tag && tag.proposeDelete) {
tagLabel = <><del style={{ color: theme.palette.text.secondary }}>{tag.name}</del></>;
}
const TagChip = (props: any) => <Box
style={{ opacity: props.transparent ? 0.5 : 1.0 }}
>
<Chip
size="small"
label={props.label}
onClick={onOpenMenu}
/>
</Box>;
return <>
<Box display="flex" alignItems="center">
<Box visibility={hasChildren ? 'visible' : 'hidden'}>
<ExpandArrow expanded={expanded} onSetExpanded={setExpanded} />
</Box>
{props.prependElems}
<TagChip transparent={tag.proposeDelete} label={tagLabel} />
</Box>
{hasChildren && expanded && tag.children.map((child: any) => <SingleTag
tag={child}
prependElems={[...props.prependElems,
<TagChip transparent={true} label={tagLabel} />,
<Typography variant="h5">/</Typography>]}
dispatch={props.dispatch}
mainDispatch={props.mainDispatch}
state={props.state}
changedTags={props.changedTags}
/>)}
<ManageTagMenu
position={menuPos}
open={menuPos !== null}
onClose={onCloseMenu}
onOpenInTab={() => {
props.mainDispatch({
type: MainWindowStateActions.AddTab,
tabState: {
tabLabel: <><LocalOfferIcon />{tag.name}</>,
tagId: tag.tagId,
metadata: null,
songGetters: songGetters,
songsWithTag: null,
},
tabReducer: newWindowReducer[WindowType.Tag],
tabType: WindowType.Tag,
})
}}
onRename={(s: string) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.Rename,
name: s,
id: tag.tagId,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onDelete={() => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.Delete,
id: tag.tagId,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onMove={(to: string | null) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.MoveTo,
id: tag.tagId,
parent: to,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onMergeInto={(into: string) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.MergeTo,
id: tag.tagId,
into: into,
}
]
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
tag={tag}
changedTags={props.changedTags}
/>
</>
}
function annotateTagsWithChanges(tags: Record<string, any>, changes: TagChange[]) {
var retval: Record<string, any> = _.cloneDeep(tags);
const applyDelete = (id: string) => {
retval[id].proposeDelete = true;
Object.values(tags).filter((t: any) => t.parentId === id)
.forEach((child: any) => applyDelete(child.tagId));
}
changes.forEach((change: TagChange) => {
switch (change.type) {
case TagChangeType.Rename:
retval[change.id].proposedName = change.name;
break;
case TagChangeType.Delete:
applyDelete(change.id);
break;
case TagChangeType.MoveTo:
retval[change.id].proposedParent = change.parent;
break;
case TagChangeType.MergeTo:
retval[change.id].proposedMergeInto = change.into;
break;
case TagChangeType.Create:
retval[change.id] = {
isNewTag: true,
name: change.name,
parentId: change.parent,
tagId: change.id,
}
if (change.parent) {
retval[change.parent].childIds =
[...retval[change.parent].childIds, change.id]
}
break;
default:
throw new Error("Unimplemented tag change")
}
})
return retval;
}
function applyTagsChanges(tags: Record<string, any>, changes: TagChange[]) {
var retval = _.cloneDeep(tags);
const applyDelete = (id: string) => {
Object.values(tags).filter((t: any) => t.parentId === id)
.forEach((child: any) => applyDelete(child.tagId));
delete retval[id].proposeDelete;
}
changes.forEach((change: TagChange) => {
switch (change.type) {
case TagChangeType.Rename:
retval[change.id].name = change.name;
break;
case TagChangeType.Delete:
applyDelete(change.id);
break;
case TagChangeType.MoveTo:
retval[change.id].parentId = change.parent;
if (change.parent === null) { delete retval[change.id].parentId; }
break;
case TagChangeType.MergeTo:
applyDelete(change.id);
break;
case TagChangeType.Create:
retval[change.id] = {
name: change.name,
tagId: change.id,
parentId: change.parent,
isNewTag: true,
}
if (change.parent) {
retval[change.parent].childIds =
[...retval[change.parent].childIds, change.id]
}
break;
default:
throw new Error("Unimplemented tag change")
}
})
return retval;
}
export default function ManageTagsWindow(props: {
state: ManageTagsWindowState,
dispatch: (action: any) => void,
mainDispatch: (action: any) => void,
}) {
const [newTagMenuPos, setNewTagMenuPos] = React.useState<null | number[]>(null);
const onOpenNewTagMenu = (e: any) => {
setNewTagMenuPos([e.clientX, e.clientY])
};
const onCloseNewTagMenu = () => {
setNewTagMenuPos(null);
};
useEffect(() => {
if (props.state.fetchedTags !== null) {
return;
}
(async () => {
const allTags = await getAllTags();
// We have the tags in list form. Now, we want to organize
// them hierarchically by giving each tag a "children" prop.
props.dispatch({
type: ManageTagsWindowActions.SetFetchedTags,
value: allTags,
});
})();
}, [props.state.fetchedTags]);
const tagsWithChanges = annotateTagsWithChanges(props.state.fetchedTags || {}, props.state.pendingChanges)
const changedTags = organiseTags(
applyTagsChanges(props.state.fetchedTags || {}, props.state.pendingChanges),
null);
const tags = organiseTags(tagsWithChanges, null);
return <>
<Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="80%"
>
<LoyaltyIcon style={{ fontSize: 80 }} />
</Box>
<Box
m={1}
mt={4}
width="80%"
>
<Typography variant="h4">Manage Tags</Typography>
</Box>
{props.state.pendingChanges.length > 0 && <Box
m={1}
mt={4}
width="80%"
>
<ControlTagChanges
changes={props.state.pendingChanges}
onDiscard={() => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [],
})
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: null,
})
}}
onSave={() => {
submitTagChanges(props.state.pendingChanges).then(() => {
props.dispatch({
type: ManageTagsWindowActions.Reset
});
}).catch((e: Error) => {
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: <Alert severity="error">Failed to save changes: {e.message}</Alert>,
})
})
}}
getTagDetails={(id: string) => tagsWithChanges[id]}
/>
</Box>}
{props.state.alert && <Box
m={1}
mt={4}
width="80%"
>{props.state.alert}</Box>}
<Box
m={1}
mt={4}
width="80%"
>
{tags && tags.length && tags.map((tag: any) => {
return <SingleTag
tag={tag}
prependElems={[]}
dispatch={props.dispatch}
mainDispatch={props.mainDispatch}
state={props.state}
changedTags={changedTags}
/>;
})}
<Box mt={3}><CreateTagButton onClick={(e: any) => { onOpenNewTagMenu(e) }} /></Box>
</Box>
</Box>
<NewTagMenu
position={newTagMenuPos}
open={newTagMenuPos !== null}
onCreate={(name: string, parentId: string | null) => {
props.dispatch({
type: ManageTagsWindowActions.SetPendingChanges,
value: [
...props.state.pendingChanges,
{
type: TagChangeType.Create,
id: genUuid(),
parent: parentId,
name: name,
}
]
})
}}
onClose={onCloseNewTagMenu}
changedTags={changedTags}
/>
</>
}

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { Menu, MenuItem, TextField, Input } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item";
import MenuEditText from '../../common/MenuEditText';
export function PickCreateTag(props: {
tags: any[],
open: boolean,
parentId: string | null,
onCreate: (name: string, parentId: string | null) => void,
}) {
return <>
<MenuEditText
label="Name"
onSubmit={(s: string) => {
props.onCreate(s, props.parentId);
}}
/>
{props.tags.map((tag: any) => {
return <NestedMenuItem
parentMenuOpen={props.open}
label={tag.name}
>
<PickCreateTag tags={tag.children} open={props.open} parentId={tag.tagId} onCreate={props.onCreate} />
</NestedMenuItem>
})}
</>
}
export default function NewTagMenu(props: {
position: null | number[],
open: boolean,
onCreate: (name: string, parentId: string | null) => void,
onClose: () => void,
changedTags: any[], // Tags organized hierarchically with "children" fields
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<PickCreateTag tags={props.changedTags} open={props.open} parentId={null} onCreate={(n: string, v: string | null) => {
props.onClose();
props.onCreate(n, v);
}} />
</Menu>
}

@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { Typography, Chip, CircularProgress, Box, Paper } from '@material-ui/core';
import { queryTags } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import DiscardChangesButton from '../../common/DiscardChangesButton';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import { createTag, modifyTag, deleteTag, mergeTag } from '../../../lib/backend/tags';
export enum TagChangeType {
Delete = "Delete",
Create = "Create",
MoveTo = "MoveTo",
MergeTo = "MergeTo",
Rename = "Rename",
}
export interface TagChange {
type: TagChangeType,
id: string, // Stringified integer == MuDBase ID. Other string == not yet committed to DB.
parent?: string | null, // Stringified integer == MuDBase ID. Other string == not yet committed to DB.
// null refers to the tags root.
name?: string,
into?: string, // Used for storing the tag ID to merge into, if applicable. As in the other ID fields.
}
export async function submitTagChanges(changes: TagChange[]) {
// Upon entering this function, some tags have a real numeric MuDBase ID (stringified),
// while others have a UUID string which is a placeholder until the tag is created.
// While applying the changes, UUIDs will be replaced by real numeric IDs.
// Therefore we maintain a lookup table for mapping the old to the new.
var id_lookup: Record<string, number> = {}
const getId = (id_string: string) => {
return (Number(id_string) === NaN) ?
id_lookup[id_string] : Number(id_string);
}
for (const change of changes) {
// If string is of form "1", convert to ID number directly.
// Otherwise, look it up in the table.
const parentId = change.parent ? getId(change.parent) : undefined;
const numericId = change.id ? getId(change.id) : undefined;
const intoId = change.into ? getId(change.into) : undefined;
switch (change.type) {
case TagChangeType.Create:
if (!change.name) { throw new Error("Cannot create tag without name"); }
const { id } = await createTag({
name: change.name,
parentId: parentId,
});
id_lookup[change.id] = id;
break;
case TagChangeType.MoveTo:
if (!numericId) { throw new Error("Cannot modify tag with no numeric ID"); }
await modifyTag(
numericId,
{
parentId: parentId,
})
break;
case TagChangeType.Rename:
if (!numericId) { throw new Error("Cannot modify tag with no numeric ID"); }
await modifyTag(
numericId,
{
name: change.name,
})
break;
case TagChangeType.Delete:
if (!numericId) { throw new Error("Cannot delete tag with no numeric ID"); }
await deleteTag(numericId)
break;
case TagChangeType.MergeTo:
if (!numericId) { throw new Error("Cannot merge tag with no numeric ID"); }
if (!intoId) { throw new Error("Cannot merge tag into tag with no numeric ID"); }
await mergeTag(numericId, intoId);
break;
default:
throw new Error("Unimplemented tag change");
}
}
}
export function TagChangeDisplay(props: {
change: TagChange,
getTagDetails: (id: string) => any,
}) {
const tag = props.getTagDetails(props.change.id);
const oldParent = tag.parentId ? props.getTagDetails(tag.parentId) : null;
const newParent = props.change.parent ? props.getTagDetails(props.change.parent) : null;
const MakeTag = (props: { name: string }) => <Chip label={props.name} />
const MainTag = tag ?
<MakeTag name={tag.name} /> :
<CircularProgress />;
switch (props.change.type) {
case TagChangeType.Delete:
return <Typography>Delete {MainTag}</Typography>
case TagChangeType.Rename:
const NewTag = tag ?
<MakeTag name={props.change.name || "unknown"} /> :
<CircularProgress />;
return <Typography>Rename {MainTag} to {NewTag}</Typography>
case TagChangeType.MoveTo:
const OldParent = oldParent !== undefined ?
<MakeTag name={oldParent ? oldParent.name : "/"} /> :
<CircularProgress />;
const NewParent = newParent !== undefined ?
<MakeTag name={newParent ? newParent.name : "/"} /> :
<CircularProgress />;
return <Typography>Move {MainTag} from {OldParent} to {NewParent}</Typography>
case TagChangeType.MergeTo:
const intoTag = props.getTagDetails(props.change.into || "unknown");
const IntoTag = <MakeTag name={intoTag.name} />
return <Typography>Merge {MainTag} into {IntoTag}</Typography>
case TagChangeType.Create:
return props.change.parent ?
<Typography>Create {MainTag} under <MakeTag name={newParent.name} /></Typography> :
<Typography>Create {MainTag}</Typography>
default:
throw new Error("Unhandled tag change type")
}
}
export default function ControlTagChanges(props: {
changes: TagChange[],
onSave: () => void,
onDiscard: () => void,
getTagDetails: (id: string) => any,
}) {
return <Box display="flex"><Paper style={{ padding: 10, minWidth: 0 }}>
<Typography variant="h5">Pending changes</Typography>
<Box mt={2}>
{props.changes.map((change: any) =>
<Box display="flex">
<Typography>-&nbsp;</Typography>
<TagChangeDisplay change={change} getTagDetails={props.getTagDetails} />
</Box>
)}
</Box>
<Box mt={2} display="flex">
<Box m={1}><SubmitChangesButton onClick={props.onSave}>Save Changes</SubmitChangesButton></Box>
<Box m={1}><DiscardChangesButton onClick={props.onDiscard}>Discard Changes</DiscardChangesButton></Box>
</Box>
</Paper></Box>
}

@ -1,13 +1,13 @@
import React, { useEffect } from 'react';
import { createMuiTheme, Box, LinearProgress } from '@material-ui/core';
import { QueryElem, toApiQuery } from '../../lib/query/Query';
import QueryBuilder from '../querybuilder/QueryBuilder';
import * as serverApi from '../../api';
import SongTable from '../tables/ResultsTable';
import { songGetters } from '../../lib/songGetters';
import { getArtists, getSongTitles, getAlbums, getTags } from '../../lib/query/Getters';
import { QueryElem, toApiQuery, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder from '../../querybuilder/QueryBuilder';
import * as serverApi from '../../../api';
import SongTable from '../../tables/ResultsTable';
import { songGetters } from '../../../lib/songGetters';
import { queryArtists, querySongs, queryAlbums, queryTags } from '../../../lib/backend/queries';
import { grey } from '@material-ui/core/colors';
import { WindowState } from './Windows';
import { WindowState } from '../Windows';
var _ = require('lodash');
const darkTheme = createMuiTheme({
@ -36,6 +36,56 @@ export enum QueryWindowStateActions {
SetResultsForQuery = "setResultsForQuery",
}
async function getArtistNames(filter: string) {
const artists = await queryArtists({
query: filter.length > 0 ? {
a: QueryLeafBy.ArtistName,
b: '%' + filter + '%',
leafOp: QueryLeafOp.Like
} : undefined,
offset: 0,
limit: -1,
});
return [...(new Set([...(artists.map((a:any) => a.name))]))];
}
async function getAlbumNames(filter: string) {
const albums = await queryAlbums({
query: filter.length > 0 ? {
a: QueryLeafBy.AlbumName,
b: '%' + filter + '%',
leafOp: QueryLeafOp.Like
} : undefined,
offset: 0,
limit: -1,
});
return [...(new Set([...(albums.map((a:any) => a.name))]))];
}
async function getSongTitles(filter: string) {
const songs = await querySongs({
query: filter.length > 0 ? {
a: QueryLeafBy.SongTitle,
b: '%' + filter + '%',
leafOp: QueryLeafOp.Like
} : undefined,
offset: 0,
limit: -1,
});
return [...(new Set([...(songs.map((s:any) => s.title))]))];
}
async function getTagItems() {
return await queryTags({
query: undefined,
offset: 0,
limit: -1,
});
}
export function QueryWindowReducer(state: QueryWindowState, action: any) {
switch (action.type) {
case QueryWindowStateActions.SetQuery:
@ -73,36 +123,18 @@ export default function QueryWindow(props: IProps) {
const showResults = (query && resultsFor && query == resultsFor.for) ? resultsFor.results : [];
const doQuery = async (_query: QueryElem) => {
var q: serverApi.QueryRequest = {
query: toApiQuery(_query),
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
if (_.isEqual(query, _query)) {
setResultsForQuery({
for: _query,
results: json.songs,
})
}
})();
const songs = await querySongs({
query: _query,
offset: 0,
limit: 100, //TODO: pagination
});
if (_.isEqual(query, _query)) {
setResultsForQuery({
for: _query,
results: songs,
})
}
}
useEffect(() => {
@ -124,10 +156,10 @@ export default function QueryWindow(props: IProps) {
editing={editing}
onChangeEditing={setEditingQuery}
requestFunctions={{
getArtists: getArtists,
getArtists: getArtistNames,
getSongTitles: getSongTitles,
getAlbums: getAlbums,
getTags: getTags,
getAlbums: getAlbumNames,
getTags: getTagItems,
}}
/>
</Box>

@ -3,14 +3,16 @@ import { Box, Typography, IconButton, Button, CircularProgress } from '@material
import AudiotrackIcon from '@material-ui/icons/Audiotrack';
import PersonIcon from '@material-ui/icons/Person';
import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
import { ArtistMetadata } from './ArtistWindow';
import { AlbumMetadata } from './AlbumWindow';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import { saveSongChanges } from '../../lib/saveChanges';
import * as serverApi from '../../../api';
import { WindowState } from '../Windows';
import { ArtistMetadata } from '../artist/ArtistWindow';
import { AlbumMetadata } from '../album/AlbumWindow';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import { saveSongChanges } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { querySongs } from '../../../lib/backend/queries';
export type SongMetadata = serverApi.SongDetails;
export type SongMetadataChanges = serverApi.ModifySongRequest;
@ -47,38 +49,15 @@ export interface IProps {
}
export async function getSongMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.songId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
songOffset: 0,
songLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
return (await querySongs({
query: {
a: QueryLeafBy.SongId,
b: id,
leafOp: QueryLeafOp.Equals,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let song = json.songs[0];
return song;
})();
offset: 0,
limit: 1,
}))[0];
}
export default function SongWindow(props: IProps) {

@ -1,13 +1,15 @@
import React, { useEffect, useState } from 'react';
import { Box, Typography, IconButton, CircularProgress } from '@material-ui/core';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../api';
import { WindowState } from './Windows';
import StoreLinkIcon, { whichStore } from '../common/StoreLinkIcon';
import EditableText from '../common/EditableText';
import SubmitChangesButton from '../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../tables/ResultsTable';
import { saveTagChanges } from '../../lib/saveChanges';
import * as serverApi from '../../../api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import SongTable, { SongGetters } from '../../tables/ResultsTable';
import { saveTagChanges } from '../../../lib/saveChanges';
import { queryTags, querySongs } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
var _ = require('lodash');
export interface FullTagMetadata extends serverApi.TagDetails {
@ -55,49 +57,27 @@ export interface IProps {
}
export async function getTagMetadata(id: number) {
const query = {
prop: serverApi.QueryElemProperty.tagId,
propOperand: id,
propOperator: serverApi.QueryFilterOp.Eq,
};
var q: serverApi.QueryRequest = {
query: query,
offsetsLimits: {
tagOffset: 0,
tagLimit: 1,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
var tag = (await queryTags({
query: {
a: QueryLeafBy.TagId,
b: id,
leafOp: QueryLeafOp.Equals,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
let tag = json.tags[0];
// Recursively fetch parent tags to build the full metadata.
if (tag.parentId) {
const parent = await getTagMetadata(tag.parentId);
tag.fullName = [...parent.fullName, tag.name];
tag.fullId = [...parent.fullId, tag.tagId];
} else {
tag.fullName = [tag.name];
tag.fullId = [tag.tagId];
}
offset: 0,
limit: 1,
}))[0];
// Recursively fetch parent tags to build the full metadata.
if (tag.parentId) {
const parent = await getTagMetadata(tag.parentId);
tag.fullName = [...parent.fullName, tag.name];
tag.fullId = [...parent.fullId, tag.tagId];
} else {
tag.fullName = [tag.name];
tag.fullId = [tag.tagId];
}
return tag;
})();
return tag;
}
export default function TagWindow(props: IProps) {
@ -119,37 +99,20 @@ export default function TagWindow(props: IProps) {
useEffect(() => {
if (props.state.songsWithTag) { return; }
var q: serverApi.QueryRequest = {
query: {
prop: serverApi.QueryElemProperty.tagId,
propOperator: serverApi.QueryFilterOp.Eq,
propOperand: props.state.tagId,
},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
},
ordering: {
orderBy: {
type: serverApi.OrderByType.Name,
},
ascending: true,
},
};
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(q),
};
(async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const songs = await querySongs({
query: {
a: QueryLeafBy.TagId,
b: props.state.tagId,
leafOp: QueryLeafOp.Equals,
},
offset: 0,
limit: -1,
});
props.dispatch({
type: TagWindowStateActions.SetSongs,
value: json.songs,
});
value: songs,
});
})();
}, [props.state.songsWithTag]);

@ -1,17 +1,18 @@
import * as serverApi from '../../api';
import { QueryElem, toApiQuery } from '../query/Query';
export async function getArtists(filter: string) {
const query = filter.length > 0 ? {
prop: serverApi.QueryElemProperty.artistName,
propOperand: filter,
propOperator: serverApi.QueryFilterOp.Like,
} : {};
export interface QueryArgs {
query?: QueryElem,
offset: number,
limit: number,
}
export async function queryArtists(args: QueryArgs) {
var q: serverApi.QueryRequest = {
query: query,
query: args.query ? toApiQuery(args.query) : {},
offsetsLimits: {
artistOffset: 0,
artistLimit: 100,
artistOffset: args.offset,
artistLimit: args.limit,
},
ordering: {
orderBy: {
@ -30,23 +31,16 @@ export async function getArtists(filter: string) {
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const names: string[] = json.artists.map((elem: any) => { return elem.name; });
return [...new Set(names)];
return json.artists;
})();
}
export async function getAlbums(filter: string) {
const query = filter.length > 0 ? {
prop: serverApi.QueryElemProperty.albumName,
propOperand: filter,
propOperator: serverApi.QueryFilterOp.Like,
} : {};
export async function queryAlbums(args: QueryArgs) {
var q: serverApi.QueryRequest = {
query: query,
query: args.query ? toApiQuery(args.query) : {},
offsetsLimits: {
albumOffset: 0,
albumLimit: 100,
albumOffset: args.offset,
albumLimit: args.limit,
},
ordering: {
orderBy: {
@ -65,23 +59,16 @@ export async function getAlbums(filter: string) {
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const names: string[] = json.albums.map((elem: any) => { return elem.name; });
return [...new Set(names)];
return json.albums;
})();
}
export async function getSongTitles(filter: string) {
const query = filter.length > 0 ? {
prop: serverApi.QueryElemProperty.songTitle,
propOperand: filter,
propOperator: serverApi.QueryFilterOp.Like,
} : {};
export async function querySongs(args: QueryArgs) {
var q: serverApi.QueryRequest = {
query: query,
query: args.query ? toApiQuery(args.query) : {},
offsetsLimits: {
songOffset: 0,
songLimit: 100,
songOffset: args.offset,
songLimit: args.limit,
},
ordering: {
orderBy: {
@ -100,17 +87,16 @@ export async function getSongTitles(filter: string) {
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
const titles: string[] = json.songs.map((elem: any) => { return elem.title; });
return [...new Set(titles)];
return json.songs;
})();
}
export async function getTags() {
export async function queryTags(args: QueryArgs) {
var q: serverApi.QueryRequest = {
query: {},
query: args.query ? toApiQuery(args.query) : {},
offsetsLimits: {
tagOffset: 0,
tagLimit: 100,
tagOffset: args.offset,
tagLimit: args.limit,
},
ordering: {
orderBy: {

@ -0,0 +1,61 @@
import * as serverApi from '../../api';
export async function createTag(details: serverApi.CreateTagRequest) {
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to tag creation not OK: " + JSON.stringify(response));
}
return await response.json();
}
export async function modifyTag(id: number, details: serverApi.ModifyTagRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to tag modification not OK: " + JSON.stringify(response));
}
}
export async function deleteTag(id: number) {
const requestOpts = {
method: 'DELETE',
};
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to tag deletion not OK: " + JSON.stringify(response));
}
}
export async function mergeTag(fromId: number, toId: number) {
const requestOpts = {
method: 'POST',
};
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint
.replace(':id', fromId.toString())
.replace(':toId', toId.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to tag merge not OK: " + JSON.stringify(response));
}
}

@ -2,9 +2,13 @@ import * as serverApi from '../../api';
export enum QueryLeafBy {
ArtistName = 0,
ArtistId,
AlbumName,
AlbumId,
TagInfo,
SongTitle
TagId,
SongTitle,
SongId,
}
export enum QueryLeafOp {
@ -14,11 +18,11 @@ export enum QueryLeafOp {
}
export interface TagQueryInfo {
fullName: string[],
matchIds: number[],
fullName: string[],
}
export function isTagQueryInfo(e: any): e is TagQueryInfo {
return (typeof e === 'object') && 'fullName' in e && 'matchIds' in e;
return (typeof e === 'object') && 'matchIds' in e && 'fullName' in e;
}
export type QueryLeafOperand = string | number | TagQueryInfo;
@ -166,6 +170,10 @@ export function toApiQuery(q: QueryElem) : serverApi.Query {
[QueryLeafBy.SongTitle]: serverApi.QueryElemProperty.songTitle,
[QueryLeafBy.ArtistName]: serverApi.QueryElemProperty.artistName,
[QueryLeafBy.AlbumName]: serverApi.QueryElemProperty.albumName,
[QueryLeafBy.AlbumId]: serverApi.QueryElemProperty.albumId,
[QueryLeafBy.ArtistId]: serverApi.QueryElemProperty.artistId,
[QueryLeafBy.TagId]: serverApi.QueryElemProperty.tagId,
[QueryLeafBy.SongId]: serverApi.QueryElemProperty.songId,
}
const leafOpsMapping: any = {
[QueryLeafOp.Equals]: serverApi.QueryFilterOp.Eq,

@ -15,6 +15,8 @@ import { TagDetailsEndpointHandler } from './endpoints/TagDetailsEndpointHandler
import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbumEndpointHandler';
import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbumEndpointHandler';
import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetailsEndpointHandler';
import { DeleteTagEndpointHandler } from './endpoints/DeleteTagEndpointHandler';
import { MergeTagEndpointHandler } from './endpoints/MergeTagEndpointHandler';
import * as endpointTypes from './endpoints/types';
const invokeHandler = (handler:endpointTypes.EndpointHandler, knex: Knex) => {
@ -53,6 +55,8 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
app.post(apiBaseUrl + api.CreateAlbumEndpoint, invokeWithKnex(CreateAlbumEndpointHandler));
app.put(apiBaseUrl + api.ModifyAlbumEndpoint, invokeWithKnex(ModifyAlbumEndpointHandler));
app.get(apiBaseUrl + api.AlbumDetailsEndpoint, invokeWithKnex(AlbumDetailsEndpointHandler));
app.delete(apiBaseUrl + api.DeleteTagEndpoint, invokeWithKnex(DeleteTagEndpointHandler));
app.post(apiBaseUrl + api.MergeTagEndpoint, invokeWithKnex(MergeTagEndpointHandler));
}
export { SetupApp }

@ -0,0 +1,76 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
async function getChildrenRecursive(id: number, trx: any) {
const directChildren = (await trx.select('id')
.from('tags')
.where({ 'parentId': id })).map((r: any) => r.id);
const indirectChildrenPromises = directChildren.map(
(child: number) => getChildrenRecursive(child, trx)
);
const indirectChildrenNested = await Promise.all(indirectChildrenPromises);
const indirectChildren = indirectChildrenNested.flat();
return [
...directChildren,
...indirectChildren,
]
}
export const DeleteTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
console.log("Delete Tag:", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving any child tags.
const childTagsPromise =
getChildrenRecursive(req.params.id, trx);
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, children] = await Promise.all([tagPromise, childTagsPromise]);
// Merge all IDs.
const toDelete = [ tag, ...children ];
console.log ("deleting tags: ", toDelete);
// Check that we found all objects we need.
if (!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Delete the tag and its children.
await trx('tags')
.whereIn('id', toDelete)
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -0,0 +1,73 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const MergeTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkMergeTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
console.log("Merge Tag:", reqObject);
const fromId = req.params.id;
const toId = req.params.toId;
await knex.transaction(async (trx) => {
try {
// Start retrieving the "from" tag.
const fromTagPromise = trx.select('id')
.from('tags')
.where({ id: fromId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving the "to" tag.
const toTagPromise = trx.select('id')
.from('tags')
.where({ id: toId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]);
// Check that we found all objects we need.
if (!fromTag || !toTag) {
const e: EndpointError = {
internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Assign new tag ID to any objects referencing the to-be-merged tag.
const cPromise = trx('tags')
.where({ 'parentId': fromId })
.update({ 'parentId': toId });
const sPromise = trx('songs_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const arPromise = trx('artists_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const alPromise = trx('albums_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
await Promise.all([sPromise, arPromise, alPromise, cPromise]);
// Delete the original tag.
await trx('tags')
.where({ 'id': fromId })
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -179,7 +179,7 @@ const objectColumns = {
};
function constructQuery(knex: Knex, queryFor: ObjectType, queryElem: api.QueryElem, ordering: api.Ordering,
offset: number, limit: number) {
offset: number, limit: number | null) {
const joinObjects = getRequiredDatabaseObjects(queryElem);
joinObjects.delete(queryFor); // We are already querying this object in the base query.
@ -213,7 +213,12 @@ function constructQuery(knex: Knex, queryFor: ObjectType, queryElem: api.QueryEl
(ordering.ascending ? 'asc' : 'desc'));
// Apply limiting.
q = q.limit(limit).offset(offset);
if(limit !== null) {
q = q.limit(limit)
}
// Apply offsetting.
q = q.offset(offset);
return q;
}
@ -271,43 +276,43 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any,
const albumLimit = reqObject.offsetsLimits.albumLimit;
const albumOffset = reqObject.offsetsLimits.albumOffset;
const artistsPromise: Promise<any> = (artistLimit && artistLimit > 0) ?
const artistsPromise: Promise<any> = (artistLimit && artistLimit !== 0) ?
constructQuery(knex,
ObjectType.Artist,
reqObject.query,
reqObject.ordering,
artistOffset || 0,
artistLimit
artistLimit >= 0 ? artistLimit : null,
) :
(async () => [])();
const albumsPromise: Promise<any> = (albumLimit && albumLimit > 0) ?
const albumsPromise: Promise<any> = (albumLimit && albumLimit !== 0) ?
constructQuery(knex,
ObjectType.Album,
reqObject.query,
reqObject.ordering,
artistOffset || 0,
albumLimit
albumLimit >= 0 ? albumLimit : null,
) :
(async () => [])();
const songsPromise: Promise<any> = (songLimit && songLimit > 0) ?
const songsPromise: Promise<any> = (songLimit && songLimit !== 0) ?
constructQuery(knex,
ObjectType.Song,
reqObject.query,
reqObject.ordering,
songOffset || 0,
songLimit
songLimit >= 0 ? songLimit : null,
) :
(async () => [])();
const tagsPromise: Promise<any> = (tagLimit && tagLimit > 0) ?
const tagsPromise: Promise<any> = (tagLimit && tagLimit !== 0) ?
constructQuery(knex,
ObjectType.Tag,
reqObject.query,
reqObject.ordering,
tagOffset || 0,
tagLimit
tagLimit >= 0 ? tagLimit : null,
) :
(async () => [])();
@ -318,12 +323,12 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any,
const ids = songs.map((song: any) => song['songs.id']);
return ids;
})();
const songsArtistsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit > 0) ?
const songsArtistsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit !== 0) ?
(async () => {
return await getLinkedObjects(knex, ObjectType.Song, ObjectType.Artist, await songIdsPromise);
})() :
(async () => { return {}; })();
const songsTagsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit > 0) ?
const songsTagsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit !== 0) ?
(async () => {
const tagsPerSong: Record<number, any> = await getLinkedObjects(knex, ObjectType.Song, ObjectType.Tag, await songIdsPromise);
var result: Record<number, any> = {};
@ -338,7 +343,7 @@ export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any,
return result;
})() :
(async () => { return {}; })();
const songsAlbumsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit > 0) ?
const songsAlbumsPromise: Promise<Record<number, any[]>> = (songLimit && songLimit !== 0) ?
(async () => {
return await getLinkedObjects(knex, ObjectType.Song, ObjectType.Album, await songIdsPromise);
})() :

Loading…
Cancel
Save