Compare commits

..

No commits in common. 'master' and 'editsong' have entirely different histories.

  1. 10
      KNOWN_ISSUES
  2. 6
      client/src/api/endpoints/data.ts
  3. 4
      client/src/api/endpoints/query.ts
  4. 12
      client/src/api/endpoints/resources.ts
  5. 9
      client/src/api/types/resources.ts
  6. 118
      client/src/components/common/EditItemDialog.tsx
  7. 93
      client/src/components/common/EditableText.tsx
  8. 93
      client/src/components/querybuilder/QBAddElemMenu.tsx
  9. 65
      client/src/components/querybuilder/QBLeafElem.tsx
  10. 16
      client/src/components/querybuilder/QBNodeElem.tsx
  11. 1
      client/src/components/querybuilder/QBPlaceholder.tsx
  12. 13
      client/src/components/querybuilder/QueryBuilder.tsx
  13. 309
      client/src/components/tables/ResultsTable.tsx
  14. 117
      client/src/components/windows/album/AlbumWindow.tsx
  15. 116
      client/src/components/windows/artist/ArtistWindow.tsx
  16. 13
      client/src/components/windows/manage_links/BatchLinkDialog.tsx
  17. 23
      client/src/components/windows/manage_links/LinksStatusWidget.tsx
  18. 243
      client/src/components/windows/query/QueryWindow.tsx
  19. 103
      client/src/components/windows/tag/TagWindow.tsx
  20. 126
      client/src/components/windows/track/EditTrackDialog.tsx
  21. 29
      client/src/components/windows/track/TrackWindow.tsx
  22. 59
      client/src/lib/backend/queries.tsx
  23. 2
      client/src/lib/backend/tags.tsx
  24. 8
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  25. 86
      client/src/lib/integration/youtubemusic/YoutubeMusicWebScraper.tsx
  26. 177
      client/src/lib/query/Query.tsx
  27. 26
      server/db/Album.ts
  28. 8
      server/db/Artist.ts
  29. 4
      server/db/Data.ts
  30. 12
      server/db/Tag.ts
  31. 32
      server/db/Track.ts

@ -3,15 +3,7 @@ last updated: 0a9bec1c874a9b62000b4156ae75e23991121ed0
- Youtube web scraper integration broken
- Spotify integration only finds artists, not albums or tracks
- Tag management shows only top-level tags
- (Maybe) patch requests broken?
- Checked and fixed track
- (Maybe) editing of items broken?
- Lots of front-end typescript warnings
- When not logged in, an exception may occur trying to visit a page
instead of redirecting properly
- Google Play Music still listed although the service has been
terminated by Google
- during batch linking, linking dialog closes when clicking outside.
this shouldn't happen.
- during batch linking, if the page is left or the dialog closes,
the jobs are still continuing to execute. This shouldn't happen.
- no way to exit the edit dialog

@ -7,7 +7,7 @@
// Upon import, they might be replaced, and upon export, they might be randomly
// generated.
import { Album, Id, AlbumRefs, Artist, ArtistRefs, Tag, TagParentId, Track, TrackRefs, isTrackRefs, isAlbumRefs, isArtistRefs, isTagParentId } from "../types/resources";
import { Album, Id, AlbumRefs, Artist, ArtistRefs, Tag, TagRefs, Track, TrackRefs, isTrackRefs, isAlbumRefs, isArtistRefs, isTagRefs } from "../types/resources";
// The import/export DB format is just a set of lists of objects.
// Each object has an ID and references others by ID.
@ -17,7 +17,7 @@ export interface DBDataFormat {
tracks: (Track & Id & TrackRefs)[],
albums: (Album & Id & AlbumRefs)[],
artists: (Artist & Id & ArtistRefs)[],
tags: (Tag & Id & TagParentId)[],
tags: (Tag & Id & TagRefs)[],
}
// Get a full export of a user's database (GET).
@ -49,7 +49,7 @@ export const checkDBImportRequest: (v: any) => boolean = (v: any) => {
return prev && isArtistRefs(cur);
}, true) &&
v.tags.reduce((prev: boolean, cur: any) => {
return prev && isTagParentId(cur);
return prev && isTagRefs(cur);
}, true);
}

@ -1,6 +1,6 @@
// Query for items (POST).
import { Album, Id, Artist, Tag, Track, Name, StoreLinks, TagParentId, AlbumRefs, TrackDetails, ArtistDetails } from "../types/resources";
import { Album, Id, Artist, Tag, Track, Name, StoreLinks, TagRefs, AlbumRefs, TrackDetails, ArtistDetails } from "../types/resources";
export const QueryEndpoint = '/query';
@ -79,7 +79,7 @@ export interface QueryRequest {
// Query response structure
export type QueryResponseTrackDetails = (Track & Name & StoreLinks & TrackDetails & Id);
export type QueryResponseArtistDetails = (Artist & Name & StoreLinks & Id);
export type QueryResponseTagDetails = (Tag & Name & TagParentId & Id);
export type QueryResponseTagDetails = (Tag & Name & TagRefs & Id);
export type QueryResponseAlbumDetails = (Album & Name & StoreLinks & Id);
export interface QueryResponse {
tracks: QueryResponseTrackDetails[] | number[] | number, // Details | IDs | count, depending on QueryResponseType

@ -12,11 +12,11 @@ import {
TrackRefs,
ArtistRefs,
AlbumRefs,
TagParentId,
TagRefs,
isTrackRefs,
isAlbumRefs,
isArtistRefs,
isTagParentId,
isTagRefs,
isName,
Name,
isTrack,
@ -83,9 +83,9 @@ export const checkPostAlbumRequest: (v: any) => boolean = (v: any) => isAlbumRef
// Post new tag (POST).
export const PostTagEndpoint = "/tag";
export type PostTagRequest = (Tag & TagParentId & Name);
export type PostTagRequest = (Tag & TagRefs & Name);
export interface PostTagResponse { id: number };
export const checkPostTagRequest: (v: any) => boolean = (v: any) => isTagParentId(v) && isName(v);
export const checkPostTagRequest: (v: any) => boolean = (v: any) => isTagRefs(v) && isName(v);
// Post new integration (POST).
export const PostIntegrationEndpoint = "/integration";
@ -113,9 +113,9 @@ export const checkPutAlbumRequest: (v: any) => boolean = (v: any) => isAlbumRefs
// Replace tag (PUT).
export const PutTagEndpoint = "/tag/:id";
export type PutTagRequest = (Tag & TagParentId);
export type PutTagRequest = (Tag & TagRefs);
export type PutTagResponse = void;
export const checkPutTagRequest: (v: any) => boolean = (v: any) => isTagParentId(v) && isName(v);;
export const checkPutTagRequest: (v: any) => boolean = (v: any) => isTagRefs(v) && isName(v);;
// Replace integration (PUT).
export const PutIntegrationEndpoint = "/integration/:id";

@ -147,17 +147,12 @@ export interface Tag {
id?: number,
parentId?: number | null,
parent?: (Tag & Id) | null,
childIds?: number[],
}
export interface TagParentId {
export interface TagRefs {
parentId: number | null,
}
export interface TagChildIds {
childIds: number[],
}
export interface TagDetails {
parent: (Tag & Id) | null,
}
@ -166,7 +161,7 @@ export function isTag(q: any): q is Tag {
return q.mbApi_typename && q.mbApi_typename === "tag";
}
export function isTagParentId(q: any): q is TagParentId {
export function isTagRefs(q: any): q is TagRefs {
return isTag(q) && 'parentId' in q;
}

@ -1,118 +0,0 @@
import React, { useState } from 'react';
import { Button, Dialog, DialogActions, Divider, Typography, Box, TextField, IconButton } from "@material-ui/core";
import { ExternalLinksEditor } from './ExternalLinksEditor';
import UndoIcon from '@material-ui/icons/Undo';
import { ResourceType } from '../../api/api';
let _ = require('lodash')
export enum EditablePropertyType {
Text = 0,
}
export interface EditableProperty {
metadataKey: string,
title: string,
type: EditablePropertyType
}
function EditTextProperty(props: {
title: string,
originalValue: string,
currentValue: string,
onChange: (v: string) => void
}) {
return <Box display="flex" alignItems="center" width="100%">
<TextField
// Here we "abuse" the label to show the original title.
// emptying the text box means going back to the original.
variant="outlined"
value={props.currentValue}
label={props.title}
helperText={(props.currentValue != props.originalValue) &&
"Current: " + props.originalValue || undefined}
error={(props.currentValue != props.originalValue)}
onChange={(e: any) => {
props.onChange((e.target.value == "") ?
props.originalValue : e.target.value)
}}
fullWidth={true}
/>
{props.currentValue != props.originalValue && <IconButton
onClick={() => {
props.onChange(props.originalValue)
}}
><UndoIcon /></IconButton>}
</Box>
}
function PropertyEditor(props: {
originalMetadata: any,
currentMetadata: any,
onChange: (metadata: any) => void,
editableProperties: EditableProperty[]
}) {
return <Box display="flex" width="100%">
{props.editableProperties.map(
(p: EditableProperty) => {
if (p.type == EditablePropertyType.Text) {
return <EditTextProperty
title={p.title}
originalValue={props.originalMetadata[p.metadataKey]}
currentValue={props.currentMetadata[p.metadataKey]}
onChange={(v: string) => props.onChange({ ...props.currentMetadata, [p.metadataKey]: v })}
/>
}
return undefined;
}
)}
</Box >
}
export default function EditItemDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: any) => void,
id: number,
metadata: any,
defaultExternalLinksQuery: string,
editableProperties: EditableProperty[],
resourceType: ResourceType,
editStoreLinks: boolean,
}) {
let [editingMetadata, setEditingMetadata] = useState<any>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<PropertyEditor
originalMetadata={props.metadata}
currentMetadata={editingMetadata}
onChange={setEditingMetadata}
editableProperties={props.editableProperties}
/>
{props.editStoreLinks && <><Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: any) => setEditingMetadata(v)}
defaultQuery={props.defaultExternalLinksQuery}
resourceType={props.resourceType}
/></>}
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { Box, IconButton, TextField } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import UndoIcon from '@material-ui/icons/Undo';
import { useTheme } from '@material-ui/core/styles';
// This component is an editable text. It shows up as normal text,
// but will display an edit icon on hover. When clicked, this
// enables a text input to make a new suggestion.
// The text can show a striked-through version of the old text,
// with the new value next to it and an undo button.
export interface IProps {
defaultValue: string,
changedValue: string | null, // Null == not changed
editingValue: string | null, // Null == not editing
editingLabel: string,
onChangeEditingValue: (v: string | null) => void,
onChangeChangedValue: (v: string | null) => void,
}
export default function EditableText(props: IProps) {
let editingValue = props.editingValue;
let defaultValue = props.defaultValue;
let changedValue = props.changedValue;
let onChangeEditingValue = props.onChangeEditingValue;
let onChangeChangedValue = props.onChangeChangedValue;
let editing = editingValue !== null;
let editingLabel = props.editingLabel;
const theme = useTheme();
const [hovering, setHovering] = useState<Boolean>(false);
const editButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => onChangeEditingValue(changedValue || defaultValue)}
>
<EditIcon />
</IconButton>
</Box>
const discardChangesButton = <Box
visibility={(hovering && !editing) ? "visible" : "hidden"}>
<IconButton
onClick={() => {
onChangeChangedValue(null);
onChangeEditingValue(null);
}}
>
<UndoIcon />
</IconButton>
</Box>
if (editing) {
return <Box display="flex" alignItems="center">
<TextField
variant="outlined"
value={editingValue || ""}
label={editingLabel}
inputProps={{ style: { fontSize: '2rem' } }}
onChange={(e: any) => onChangeEditingValue(e.target.value)}
/>
<IconButton
onClick={() => {
onChangeChangedValue(editingValue === defaultValue ? null : editingValue);
onChangeEditingValue(null);
}}
><CheckIcon /></IconButton>
</Box>
} else if (changedValue) {
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>
<del style={{ color: theme.palette.text.secondary }}>{defaultValue}</del>
{changedValue}
{editButton}
{discardChangesButton}
</Box>
}
return <Box
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
display="flex"
alignItems="center"
>{defaultValue}{editButton}</Box>;
}

@ -3,10 +3,7 @@ import { Menu, MenuItem } from '@material-ui/core';
import NestedMenuItem from "material-ui-nested-menu-item";
import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query';
import QBSelectWithRequest from './QBSelectWithRequest';
import { Requests, QueryBuilderTag } from './QueryBuilder';
import SpotifyClientCreds from '../../lib/integration/spotify/SpotifyClientCreds';
import { IntegrationUrls, IntegrationWith, QueryNodeOp } from '../../api/api';
import { $enum } from 'ts-enum-util';
import { Requests } from './QueryBuilder';
export interface MenuProps {
anchorEl: null | HTMLElement,
@ -15,19 +12,19 @@ export interface MenuProps {
requestFunctions: Requests,
}
export function createTagInfo(tag: QueryBuilderTag, allTags: QueryBuilderTag[]): TagQueryInfo {
const resolveName: (t: QueryBuilderTag) => string[] = (t: QueryBuilderTag) => {
export function createTagInfo(tag: any, allTags: any[]): TagQueryInfo {
const resolveName: (t: any) => string[] = (t: any) => {
if (t.parentId) {
const parent = allTags.filter((o: QueryBuilderTag) => o.id === t.parentId)[0];
return resolveName(parent).concat(t.name);
const parent = allTags.filter((o: any) => o.tagId === t.parentId)[0];
return [resolveName(parent), t.name];
}
return [t.name];
}
const resolveChildren: (t: QueryBuilderTag) => Set<number> = (t: QueryBuilderTag) => {
const resolveChildren: (t: any) => Set<number> = (t: any) => {
if (t.childIds.length > 0) {
const childSets: Set<number>[] = allTags.filter((o: QueryBuilderTag) => t.childIds.includes(o.id))
.map((child: QueryBuilderTag) => resolveChildren(child));
const childSets: Set<number>[] = allTags.filter((o: any) => t.childIds.includes(o.tagId))
.map((child: any) => resolveChildren(child));
var r = new Set<number>();
childSets.forEach((c: any) => {
@ -36,7 +33,7 @@ export function createTagInfo(tag: QueryBuilderTag, allTags: QueryBuilderTag[]):
return r;
}
return new Set([t.id]);
return new Set([t.tagId]);
}
return {
@ -50,14 +47,13 @@ export function QBAddElemMenu(props: MenuProps) {
let onClose = props.onClose;
interface TagItemProps {
tag: QueryBuilderTag,
allTags: QueryBuilderTag[],
tag: any,
allTags: any[],
}
const TagItem = (_props: TagItemProps) => {
if (_props.tag.childIds.length > 0) {
const children = _props.allTags.filter(
(tag: QueryBuilderTag) =>
_props.tag.childIds.includes(tag.id)
(tag: any) => _props.tag.childIds.includes(tag.tagId)
);
return <NestedMenuItem
@ -72,19 +68,12 @@ export function QBAddElemMenu(props: MenuProps) {
});
}}
>
{children.map((child: QueryBuilderTag) => <TagItem tag={child} allTags={_props.allTags} />)}
{children.map((child: any) => <TagItem tag={child} allTags={_props.allTags} />)}
</NestedMenuItem>
}
return <MenuItem
onClick={() => {
console.log("onCreateQuery: adding:", {
a: QueryLeafBy.TagInfo,
leafOp: QueryLeafOp.Equals,
b: createTagInfo(_props.tag, _props.allTags),
});
onClose();
props.onCreateQuery({
a: QueryLeafBy.TagInfo,
@ -98,7 +87,7 @@ export function QBAddElemMenu(props: MenuProps) {
}
const BaseTagsItem = (_props: any) => {
const [tags, setTags] = useState<QueryBuilderTag[] | null>(null);
const [tags, setTags] = useState<any[] | null>(null);
useEffect(() => {
(async () => {
@ -108,53 +97,13 @@ export function QBAddElemMenu(props: MenuProps) {
return tags ?
<>
{tags.filter((tag: QueryBuilderTag) => !tag.parentId).map((tag: QueryBuilderTag) => {
{tags.filter((tag: any) => !tag.parentId).map((tag: any) => {
return <TagItem tag={tag} allTags={tags} />
})}
</>
: <>...</>
}
const LinksItem = (_props: any) => {
let createLinksQuery = (store: IntegrationWith, isLinked: boolean) => {
let isLinkedQuery : QueryElem = {
a: QueryLeafBy.StoreLinks,
leafOp: QueryLeafOp.Like,
b: '%' + IntegrationUrls[store] + '%'
};
if (isLinked) {
return isLinkedQuery;
}
return {
operands: [isLinkedQuery],
nodeOp: QueryNodeOp.Not,
};
};
return <>
{$enum(IntegrationWith).getValues().map((store: IntegrationWith) => {
return <NestedMenuItem
label={store}
parentMenuOpen={Boolean(anchorEl)}
>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, true));
}}
>Linked</MenuItem>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, false));
}}
>Not Linked</MenuItem>
</NestedMenuItem>
})}
</>
}
return <Menu
anchorEl={anchorEl}
keepMounted
@ -222,17 +171,5 @@ export function QBAddElemMenu(props: MenuProps) {
>
<BaseTagsItem />
</NestedMenuItem>
<NestedMenuItem
label="Metadata"
parentMenuOpen={Boolean(anchorEl)}
>
{/*TODO: generalize for other types of metadata in a scalable way*/}
<NestedMenuItem
label="Links"
parentMenuOpen={Boolean(anchorEl)}
>
<LinksItem />
</NestedMenuItem>
</NestedMenuItem>
</Menu >
}

@ -1,12 +1,10 @@
import React from 'react';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo, isLeafElem } from '../../lib/query/Query';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo } from '../../lib/query/Query';
import { Chip, Typography, IconButton, Box } from '@material-ui/core';
import { QBPlaceholder } from './QBPlaceholder';
import DeleteIcon from '@material-ui/icons/Delete';
import { Requests } from './QueryBuilder';
import stringifyList from '../../lib/stringifyList';
import { IntegrationUrls, IntegrationWith } from '../../api/api';
import { $enum } from 'ts-enum-util';
export interface ElemChipProps {
label: any,
@ -23,9 +21,8 @@ export function LabeledElemChip(props: ElemChipProps) {
export interface LeafProps {
elem: QueryLeafElem,
onReplace: (q: QueryElem | null) => void,
onReplace: (q: QueryElem) => void,
extraElements?: any,
modifier?: Modifier,
}
export function QBQueryElemArtistEquals(props: LeafProps) {
@ -81,47 +78,6 @@ export function QBQueryElemTagEquals(props: LeafProps) {
/>
}
export function QBQueryElemStoreLinked(props: LeafProps) {
// The store match string should be "%STORE%"
let storeUrl: string = (props.elem.b as string).replace(/%/g, '');
let store: string = '';
for (const [key, value] of Object.entries(IntegrationUrls)) {
if (value == storeUrl) {
store = key;
}
}
if (store == '') {
throw "Could not find store name for 'Store Linked' element";
}
if (props.modifier && props.modifier == Modifier.Not) {
return <LabeledElemChip
label={"No link to " + store}
extraElements={props.extraElements}
/>
}
return <LabeledElemChip
label={"Has link to " + store}
extraElements={props.extraElements}
/>
}
export function isStoreLinkedLeafElem(e: QueryElem): boolean {
if (isLeafElem(e) &&
e.leafOp === QueryLeafOp.Like &&
e.a === QueryLeafBy.StoreLinks) {
// There are multiple kinds of ops done on
// on storelinks. We need to examine the match
// string.
let isLinked_matchstrings: string[] =
$enum(IntegrationWith).getValues().map(
(store: IntegrationWith) => '%' + IntegrationUrls[store] + '%');
if (isLinked_matchstrings.includes(e.b as string)) {
return true;
}
}
return false;
}
export interface DeleteButtonProps {
onClick?: (e: any) => void,
}
@ -136,17 +92,8 @@ export function QBQueryElemDeleteButton(props: DeleteButtonProps) {
</IconButton>
}
// Modifiers are used to encode a node op's meaning
// into a leaf op element for visual representation.
// E.g. a NOT modifier can be added to show a "artist"
// leaf as "not by artist".
export enum Modifier {
Not = "NOT",
}
export interface IProps {
elem: QueryLeafElem,
modifier?: Modifier,
onReplace: (q: QueryElem | null) => void,
editingQuery: boolean,
requestFunctions: Requests,
@ -212,18 +159,12 @@ export function QBLeafElem(props: IProps) {
{...props}
extraElements={extraElements}
/>
} else if (e.leafOp === QueryLeafOp.Placeholder) {
}else if (e.leafOp === QueryLeafOp.Placeholder) {
return <QBPlaceholder
onReplace={props.onReplace}
requestFunctions={props.requestFunctions}
/>
} else if (isStoreLinkedLeafElem(e)) {
return <QBQueryElemStoreLinked
{...props}
extraElements={extraElements}
/>;
}
console.log("Unsupported leaf element:", e);
throw new Error("Unsupported leaf element");
}

@ -1,10 +1,9 @@
import React from 'react';
import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock';
import { QueryNodeElem, QueryNodeOp, QueryElem, simplify, QueryLeafElem, isLeafElem } from '../../lib/query/Query';
import { QueryNodeElem, QueryNodeOp, QueryElem, simplify } from '../../lib/query/Query';
import { QBQueryElem } from './QBQueryElem';
import { Requests } from './QueryBuilder';
import { Modifier, QBLeafElem } from './QBLeafElem';
export interface NodeProps {
elem: QueryNodeElem,
@ -23,8 +22,7 @@ export function QBNodeElem(props: NodeProps) {
} else {
ops.splice(idx, 1);
}
let newq = { operands: ops, nodeOp: e.nodeOp };
let newNode = simplify(newq, null);
let newNode = simplify({ operands: ops, nodeOp: e.nodeOp });
props.onReplace(newNode);
}
@ -41,17 +39,7 @@ export function QBNodeElem(props: NodeProps) {
return <QBAndBlock>{children}</QBAndBlock>
} else if (e.nodeOp === QueryNodeOp.Or) {
return <QBOrBlock>{children}</QBOrBlock>
} else if (e.nodeOp === QueryNodeOp.Not &&
isLeafElem(e.operands[0])) {
return <QBLeafElem
elem={e.operands[0] as QueryLeafElem}
onReplace={props.onReplace}
editingQuery={props.editingQuery}
requestFunctions={props.requestFunctions}
modifier={Modifier.Not}
/>
}
console.log("Unsupported node element:", e);
throw new Error("Unsupported node element");
}

@ -19,7 +19,6 @@ export function QBPlaceholder(props: IProps & any) {
setAnchorEl(null);
};
const onCreate = (q: QueryElem) => {
console.log("Replacing placeholder by:", q);
props.onReplace(q);
};

@ -3,15 +3,19 @@ import { Box } from '@material-ui/core';
import QBQueryButton from './QBEditButton';
import { QBQueryElem } from './QBQueryElem';
import { QueryElem, addPlaceholders, removePlaceholders, simplify } from '../../lib/query/Query';
import { Tag, TagChildIds, TagParentId, Name, Id } from '../../api/api';
export type QueryBuilderTag = (Tag & TagChildIds & TagParentId & Name & Id);
export interface TagItem {
name: string,
id: number,
childIds: number[],
parentId?: number,
}
export interface Requests {
getArtists: (filter: string) => Promise<string[]>,
getAlbums: (filter: string) => Promise<string[]>,
getTrackNames: (filter: string) => Promise<string[]>,
getTags: () => Promise<QueryBuilderTag[]>,
getTags: () => Promise<TagItem[]>,
}
export interface IProps {
@ -23,13 +27,12 @@ export interface IProps {
}
export default function QueryBuilder(props: IProps) {
const simpleQuery = simplify(props.query, null);
const simpleQuery = simplify(props.query);
const showQuery = props.editing ?
addPlaceholders(simpleQuery, null) : simpleQuery;
const onReplace = (q: any) => {
const newQ = removePlaceholders(q);
console.log("Removed placeholders:", q, newQ)
props.onChangeEditing(false);
props.onChangeQuery(newQ);
}

@ -2,69 +2,34 @@ import React from 'react';
import { TableContainer, Table, TableHead, TableRow, TableCell, Paper, makeStyles, TableBody, Chip, Box, Button } from '@material-ui/core';
import stringifyList from '../../lib/stringifyList';
import { useHistory } from 'react-router';
import { Artist, QueryResponseTrackDetails, Tag, Name, Id, TagDetails, QueryResponseArtistDetails, QueryResponseAlbumDetails } from '../../api/api';
import { isTemplateHead } from 'typescript';
import { Artist, QueryResponseTrackDetails, Tag, Name } from '../../api/api';
function getFullTagNames(item: any,
getTagName: (tag: any) => string,
getTagParent: (tag: any) => any,
getItemTags: (item: any) => any[]): string[][] {
function getTagNames (track: QueryResponseTrackDetails) : string[][] {
// Recursively resolve the name.
const resolveTag = (tag: any) => {
var r = [getTagName(tag)];
const parent = getTagParent(tag);
if (parent) { r = resolveTag(parent).concat(r); }
var r = [tag.name];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return (getItemTags(item) || []).map((tag: Tag) => resolveTag(tag));
return track.tags.map((tag: Tag) => resolveTag(tag));
}
function getFullTagIds(item: any,
getTagId: (tag: any) => number,
getTagParent: (tag: any) => any,
getItemTags: (item: any) => any[]): number[][] {
// Recursively resolve the name.
function getTagIds (track: QueryResponseTrackDetails) : number[][] {
// Recursively resolve the id.
const resolveTag = (tag: any) => {
var r = [getTagId(tag)];
const parent = getTagParent(tag);
if (parent) { r = resolveTag(parent).concat(r); }
var r = [tag.tagId];
if (tag.parent) { r.unshift(resolveTag(tag.parent)); }
return r;
}
return (getItemTags(item) || []).map((tag: Tag) => resolveTag(tag));
}
export enum ColumnType {
Text = 0,
Tags,
}
export interface TextColumnData {
return track.tags.map((tag: any) => resolveTag(tag));
}
export interface TagsColumnData {
}
export interface ColumnDescription {
type: ColumnType,
title: string,
getText?: (item: any) => string,
getMaybeOnClick?: (item: any) => () => void,
getTags?: (item: any) => any[],
getTagName?: (tag: any) => string,
getTagId?: (tag: any) => number,
getTagParent?: (tag: any) => any,
getTagOnClick?: (tag: any) => () => void,
}
export function RenderItem(props: {
columnDescription: ColumnDescription,
item: any
export default function TrackTable(props: {
tracks: QueryResponseTrackDetails[]
}) {
let { columnDescription: cd, item } = props;
const history = useHistory();
const classes = makeStyles({
button: {
@ -73,59 +38,6 @@ export function RenderItem(props: {
paddingLeft: '0',
textAlign: 'left',
},
})();
const TextCell = (props: any) => {
return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box
width="100%"
display="flex"
alignItems="center"
paddingLeft="16px"
>
{props.children}
</Box>
</Button>
</TableCell>;
}
switch (props.columnDescription.type) {
case ColumnType.Text:
const text = cd.getText && cd.getText(item) || "Unknown";
const onClick = cd.getMaybeOnClick && cd.getMaybeOnClick(item) || null;
return <TextCell align="left" _onClick={onClick}>{text}</TextCell>
break;
case ColumnType.Tags:
const tags: any[] = cd.getTags && cd.getTags(item) || [];
const fullTagNames: string[][] = getFullTagNames(
item,
cd.getTagName || (() => "Unknown"),
cd.getTagParent || (() => null),
cd.getTags || (() => []),
);
return <>{fullTagNames.map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
return <Box ml={0.5} mr={0.5}>
<Chip size="small"
label={fullTag}
onClick={cd.getTagOnClick && cd.getTagOnClick(tags[tags.length - 1])}
/>
</Box>;
})}</>;
break;
default:
throw 'Unknown column type';
}
}
export function ItemsTable(props: {
items: any[],
columns: ColumnDescription[],
}) {
const classes = makeStyles({
table: {
minWidth: 650,
},
@ -136,136 +48,83 @@ export function ItemsTable(props: {
<Table className={classes.table} aria-label="a dense table">
<TableHead>
<TableRow>
{props.columns.map((c: ColumnDescription) =>
<TableCell align="left">{c.title}</TableCell>)}
<TableCell align="left">Title</TableCell>
<TableCell align="left">Artist</TableCell>
<TableCell align="left">Album</TableCell>
<TableCell align="left">Tags</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.items.map((item: any, idx: number) => {
return <TableRow key={idx}>
{props.columns.map((c: ColumnDescription) =>
<RenderItem
columnDescription={c}
item={item}
/>)}
</TableRow>;
{props.tracks.map((track: QueryResponseTrackDetails) => {
const name = track.name;
// TODO: display artists and albums separately!
const artistNames = track.artists
.filter( (a: Artist) => a.name )
.map( (a: (Artist & Name)) => a.name );
const artist = stringifyList(artistNames);
const mainArtistId =
(track.artists.length > 0 && track.artists[0].id) || undefined;
const album = track.album?.name || undefined;
const albumId = track.album?.id || undefined;
const trackId = track.id;
const tagIds = getTagIds(track);
const onClickArtist = () => {
history.push('/artist/' + mainArtistId);
}
const onClickAlbum = () => {
history.push('/album/' + albumId || '');
}
const onClickTrack = () => {
history.push('/track/' + trackId);
}
const onClickTag = (id: number, name: string) => {
history.push('/tag/' + id);
}
const tags = getTagNames(track).map((tag: string[], i: number) => {
const fullTag = stringifyList(tag, undefined, (idx: number, e: string) => {
return (idx === 0) ? e : " / " + e;
})
return <Box ml={0.5} mr={0.5}>
<Chip size="small"
label={fullTag}
onClick={() => onClickTag(tagIds[i][tagIds[i].length - 1], fullTag)}
/>
</Box>
});
const TextCell = (props: any) => {
return <TableCell padding="none" {...props}>
<Button className={classes.button} fullWidth={true} onClick={props._onClick}>
<Box
width="100%"
display="flex"
alignItems="center"
paddingLeft="16px"
>
{props.children}
</Box>
</Button>
</TableCell>;
}
return <TableRow key={name}>
<TextCell align="left" _onClick={onClickTrack}>{name}</TextCell>
<TextCell align="left" _onClick={onClickArtist}>{artist}</TextCell>
{album ? <TextCell align="left" _onClick={onClickAlbum}>{album}</TextCell> : <TextCell/>}
<TableCell padding="none" align="left" width="25%">
<Box display="flex" alignItems="center">
{tags}
</Box>
</TableCell>
</TableRow>
})}
</TableBody>
</Table>
</TableContainer>
);
}
export function TracksTable(props: {
tracks: QueryResponseTrackDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.tracks}
columns={[
{
title: 'Title', type: ColumnType.Text, getText: (i: QueryResponseTrackDetails) => i.name,
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
history.push('/track/' + i.id);
},
},
{
title: 'Artist', type: ColumnType.Text,
getText: (i: QueryResponseTrackDetails) => {
const artistNames = i.artists
.filter((a: Artist) => a.name)
.map((a: (Artist & Name)) => a.name);
return stringifyList(artistNames);
},
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
// TODO
const mainArtistId =
(i.artists.length > 0 && i.artists[0].id) || undefined;
history.push('/artist/' + mainArtistId || 'undefined');
},
},
{
title: 'Album', type: ColumnType.Text, getText: (i: QueryResponseTrackDetails) => i.album?.name || "Unknown",
getMaybeOnClick: (i: QueryResponseTrackDetails) => () => {
history.push('/album/' + i.album?.id || 'undefined');
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseTrackDetails) => i.tags,
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
}
]}
/>
}
export function ArtistsTable(props: {
artists: QueryResponseArtistDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.artists}
columns={[
{
title: 'Name', type: ColumnType.Text, getText: (i: QueryResponseArtistDetails) => i.name,
getMaybeOnClick: (i: QueryResponseArtistDetails) => () => {
history.push('/artist/' + i.id);
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseArtistDetails) => (i.tags || []),
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
}
]}
/>
}
export function AlbumsTable(props: {
albums: QueryResponseAlbumDetails[]
}) {
const history = useHistory();
return <ItemsTable
items={props.albums}
columns={[
{
title: 'Name', type: ColumnType.Text, getText: (i: QueryResponseAlbumDetails) => i.name,
getMaybeOnClick: (i: QueryResponseAlbumDetails) => () => {
history.push('/album/' + i.id);
},
},
{
title: 'Artist', type: ColumnType.Text,
getText: (i: QueryResponseAlbumDetails) => {
const artistNames = (i.artists || [])
.filter((a: Artist) => a.name)
.map((a: Artist) => a.name || "Unknown");
return stringifyList(artistNames);
},
getMaybeOnClick: (i: QueryResponseAlbumDetails) => () => {
// TODO
const mainArtistId =
((i.artists || []).length > 0 && (i.artists || [])[0].id) || undefined;
history.push('/artist/' + mainArtistId || 'undefined');
},
},
{
title: 'Tags', type: ColumnType.Tags,
getTags: (i: QueryResponseTrackDetails) => i.tags,
getTagId: (t: Tag & Id) => t.id,
getTagName: (t: Tag & Name) => t.name,
getTagParent: (t: Tag & TagDetails) => t.parent,
getTagOnClick: (t: Tag & Id) => () => { history.push('/tag/' + t.id) }
}
]}
/>
}

@ -4,16 +4,16 @@ import AlbumIcon from '@material-ui/icons/Album';
import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable';
import { modifyAlbum, modifyTrack } from '../../../lib/saveChanges';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable';
import { modifyAlbum } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
import { Album, Name, Id, StoreLinks, AlbumRefs, Artist, Tag, Track, ResourceType } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
import { Album, Name, Id, StoreLinks, AlbumRefs } from '../../../api/api';
export type AlbumMetadata = serverApi.QueryResponseAlbumDetails;
export type AlbumMetadataChanges = serverApi.PatchAlbumRequest;
@ -47,7 +47,7 @@ export function AlbumWindowReducer(state: AlbumWindowState, action: any) {
}
}
export async function getAlbumMetadata(id: number): Promise<AlbumMetadata> {
export async function getAlbumMetadata(id: number) : Promise<AlbumMetadata> {
let result: any = await queryAlbums(
{
a: QueryLeafBy.AlbumId,
@ -77,21 +77,18 @@ export function AlbumWindowControlled(props: {
let { id: albumId, metadata, pendingChanges, tracksOnAlbum } = props.state;
let { dispatch } = props;
let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the album's metadata.
useEffect(() => {
if (metadata === null) {
getAlbumMetadata(albumId)
.then((m: AlbumMetadata) => {
dispatch({
type: AlbumWindowStateActions.SetMetadata,
value: m
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}
}, [albumId, dispatch, metadata]);
getAlbumMetadata(albumId)
.then((m: AlbumMetadata) => {
dispatch({
type: AlbumWindowStateActions.SetMetadata,
value: m
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}, [albumId, dispatch]);
// Effect to get the album's tracks.
useEffect(() => {
@ -113,7 +110,23 @@ export function AlbumWindowControlled(props: {
})();
}, [tracksOnAlbum, albumId, dispatch]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown album name)"}</Typography>
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: AlbumWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
@ -128,6 +141,23 @@ export function AlbumWindowControlled(props: {
</a>
});
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyAlbum(props.state.id, pendingChanges || { mbApi_typename: 'album' })
.then(() => {
setApplying(false);
props.dispatch({
type: AlbumWindowStateActions.Reload
})
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
@ -149,13 +179,14 @@ export function AlbumWindowControlled(props: {
{storeLinks}
</Box>
</Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
@ -163,42 +194,10 @@ export function AlbumWindowControlled(props: {
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks in this album in your library:</Typography>
</Box>
{props.state.tracksOnAlbum && <TracksTable tracks={props.state.tracksOnAlbum}/>}
{props.state.tracksOnAlbum && <TrackTable
tracks={props.state.tracksOnAlbum}
/>}
{!props.state.tracksOnAlbum && <CircularProgress />}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchAlbumRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
artists: undefined,
tags: undefined,
trackIds: v.trackIds || v.tracks?.map(
(a: (Track & Id)) => { return a.id }
) || undefined,
artistIds: v.artistIds || v.artists?.map(
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyAlbum(albumId, v_modified)
.then(() => dispatch({
type: AlbumWindowStateActions.Reload
}))
}}
id={albumId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Album}
editStoreLinks={true}
/>}
</Box>
}

@ -4,16 +4,15 @@ import PersonIcon from '@material-ui/icons/Person';
import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable';
import { modifyAlbum, modifyArtist } from '../../../lib/saveChanges';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable';
import { modifyArtist } from '../../../lib/saveChanges';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
import { Track, Id, Artist, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export type ArtistMetadata = serverApi.QueryResponseArtistDetails;
export type ArtistMetadataChanges = serverApi.PatchArtistRequest;
@ -52,7 +51,7 @@ export interface IProps {
dispatch: (action: any) => void,
}
export async function getArtistMetadata(id: number): Promise<ArtistMetadata> {
export async function getArtistMetadata(id: number) : Promise<ArtistMetadata> {
let response: any = await queryArtists(
{
a: QueryLeafBy.ArtistId,
@ -82,21 +81,18 @@ export function ArtistWindowControlled(props: {
let { metadata, id: artistId, pendingChanges, tracksByArtist } = props.state;
let { dispatch } = props;
let auth = useAuth();
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the artist's metadata.
useEffect(() => {
if (metadata === null) {
getArtistMetadata(artistId)
.then((m: ArtistMetadata) => {
dispatch({
type: ArtistWindowStateActions.SetMetadata,
value: m
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}
}, [artistId, dispatch, metadata]);
getArtistMetadata(artistId)
.then((m: ArtistMetadata) => {
dispatch({
type: ArtistWindowStateActions.SetMetadata,
value: m
});
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}, [artistId, dispatch]);
// Effect to get the artist's tracks.
useEffect(() => {
@ -118,7 +114,23 @@ export function ArtistWindowControlled(props: {
})();
}, [tracksByArtist, dispatch, artistId]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown artist)"}</Typography>
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: ArtistWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const storeLinks = metadata?.storeLinks && metadata?.storeLinks.map((link: string) => {
const store = whichStore(link);
@ -133,6 +145,23 @@ export function ArtistWindowControlled(props: {
</a>
});
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyArtist(props.state.id, pendingChanges || { mbApi_typename: 'artist' })
.then(() => {
setApplying(false);
props.dispatch({
type: ArtistWindowStateActions.Reload
})
})
.catch((e: any) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
@ -154,13 +183,14 @@ export function ArtistWindowControlled(props: {
{storeLinks}
</Box>
</Box>
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>}
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
width="80%"
@ -168,42 +198,10 @@ export function ArtistWindowControlled(props: {
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks by this artist in your library:</Typography>
</Box>
{props.state.tracksByArtist && <TracksTable tracks={props.state.tracksByArtist}/>}
{props.state.tracksByArtist && <TrackTable
tracks={props.state.tracksByArtist}
/>}
{!props.state.tracksByArtist && <CircularProgress />}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchArtistRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
tracks: undefined,
albums: undefined,
tags: undefined,
albumIds: v.albumIds || v.albums?.map(
(a: (Album & Id)) => { return a.id }
) || undefined,
trackIds: v.trackIds || v.tracks?.map(
(t: (Track & Id)) => { return t.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map(
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyArtist(artistId, v_modified)
.then(() => dispatch({
type: ArtistWindowStateActions.Reload
}))
}}
id={artistId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={true}
/>}
</Box>
}

@ -5,14 +5,13 @@ import { $enum } from 'ts-enum-util';
import { IntegrationState, useIntegrations } from '../../../lib/integration/useIntegrations';
import { IntegrationWith, ImplIntegratesWith, IntegrationImpl, ResourceType, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { start } from 'repl';
import { QueryFor, QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot, simplify } from '../../../lib/query/Query';
import { QueryLeafBy, QueryLeafOp, QueryNodeOp, queryNot } from '../../../lib/query/Query';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries';
import asyncPool from "tiny-async-pool";
import { getTrack } from '../../../lib/backend/tracks';
import { getAlbum } from '../../../lib/backend/albums';
import { getArtist } from '../../../lib/backend/artists';
import { modifyAlbum, modifyArtist, modifyTrack } from '../../../lib/saveChanges';
import { QueryItemType } from '../query/QueryWindow';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@ -50,6 +49,11 @@ async function makeTasks(
linkAlbums: boolean,
addTaskCb: (t: Task) => void,
) {
let whichProp: any = {
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
}
let whichElem: any = {
[ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists',
@ -60,12 +64,11 @@ async function makeTasks(
return;
}
let store = maybeStore as IntegrationWith;
let doForType = async (type: ResourceType) => {
let ids: number[] = ((await queryItems(
type,
[type],
queryNot({
a: QueryLeafBy.StoreLinks,
a: whichProp[type],
leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`,
}),

@ -2,7 +2,7 @@ import { Box, LinearProgress, Typography } from '@material-ui/core';
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { $enum } from 'ts-enum-util';
import { IntegrationWith, ResourceType, QueryElemProperty, QueryResponseType, IntegrationUrls } from '../../../api/api';
import { queryAlbums, queryArtists, queryItems, queryTracks } from '../../../lib/backend/queries';
import { queryItems } from '../../../lib/backend/queries';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import StoreLinkIcon from '../../common/StoreLinkIcon';
@ -21,15 +21,20 @@ export default function LinksStatusWidget(props: {
let [linkedCounts, setLinkedCounts] = useState<Record<string, Counts>>({});
let queryStoreCount = async (store: IntegrationWith, type: ResourceType) => {
let whichProp: any = {
[ResourceType.Track]: QueryLeafBy.TrackStoreLinks,
[ResourceType.Artist]: QueryLeafBy.ArtistStoreLinks,
[ResourceType.Album]: QueryLeafBy.AlbumStoreLinks,
}
let whichElem: any = {
[ResourceType.Track]: 'tracks',
[ResourceType.Artist]: 'artists',
[ResourceType.Album]: 'albums',
}
let r: any = await queryItems(
type,
[type],
{
a: QueryLeafBy.StoreLinks,
a: whichProp[type],
leafOp: QueryLeafOp.Like,
b: `%${IntegrationUrls[store]}%`,
},
@ -44,11 +49,13 @@ export default function LinksStatusWidget(props: {
// Start retrieving total counts
useEffect(() => {
(async () => {
let counts: Counts = {
albums: await queryAlbums(undefined, undefined, undefined, QueryResponseType.Count) as number,
tracks: await queryTracks(undefined, undefined, undefined, QueryResponseType.Count) as number,
artists: await queryArtists(undefined, undefined, undefined, QueryResponseType.Count) as number,
}
let counts: any = await queryItems(
[ResourceType.Track, ResourceType.Artist, ResourceType.Album],
undefined,
undefined,
undefined,
QueryResponseType.Count
);
console.log("Got total counts: ", counts)
setTotalCounts(counts);
}

@ -1,51 +1,29 @@
import React, { useEffect, useReducer, useCallback } from 'react';
import { Box, LinearProgress, Typography } from '@material-ui/core';
import { QueryElem, QueryLeafBy, QueryLeafElem, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder, { QueryBuilderTag } from '../../querybuilder/QueryBuilder';
import { AlbumsTable, ArtistsTable, ColumnType, ItemsTable, TracksTable } from '../../tables/ResultsTable';
import { Box, LinearProgress } from '@material-ui/core';
import { QueryElem, QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import QueryBuilder from '../../querybuilder/QueryBuilder';
import TrackTable from '../../tables/ResultsTable';
import { queryArtists, queryTracks, queryAlbums, queryTags } from '../../../lib/backend/queries';
import { WindowState } from '../Windows';
import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails, Artist, Name } from '../../../api/api';
import { QueryResponseType, QueryResponseAlbumDetails, QueryResponseTagDetails, QueryResponseArtistDetails, QueryResponseTrackDetails} from '../../../api/api';
import { ServerStreamResponseOptions } from 'http2';
import { TrackChangesSharp } from '@material-ui/icons';
import { v4 as genUuid } from 'uuid';
import stringifyList from '../../../lib/stringifyList';
var _ = require('lodash');
export enum QueryItemType {
Artists,
Tracks,
Albums,
Tags,
};
export interface ResultsForQuery {
kind: QueryItemType,
results: (
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]
),
}
for: QueryElem,
results: any[],
};
export interface QueryWindowState extends WindowState {
editingQuery: boolean, // Is the editor in "edit mode"
query: QueryElem | null, // The actual on-screen query
includeTypes: QueryItemType[], // which item types do we actually request results for?
// Whenever queries change, new requests are fired to the server.
// Each request gets a unique id hash.
// In this results record, we store the query IDs which
// we want to show results for.
resultsForQueries: Record<string, ResultsForQuery | null>;
editingQuery: boolean,
query: QueryElem | null,
resultsForQuery: ResultsForQuery | null,
}
export enum QueryWindowStateActions {
FiredNewQueries = "firedNewQueries",
SetQuery = "setQuery",
SetEditingQuery = "setEditingQuery",
ReceivedResult = "receivedResult",
SetResultsForQuery = "setResultsForQuery",
}
async function getArtistNames(filter: string) {
@ -87,73 +65,32 @@ async function getTrackNames(filter: string) {
return [...(new Set([...(tracks.map((s: any) => s.name))]))];
}
async function getTagItems(): Promise<QueryBuilderTag[]> {
let tags: QueryResponseTagDetails[] = (await queryTags(
async function getTagItems(): Promise<any> {
let tags: any = await queryTags(
undefined,
0, -1, QueryResponseType.Details
)) as QueryResponseTagDetails[];
// We need to resolve the child ids.
let tags_with_children : QueryBuilderTag[] = tags.map((t: QueryResponseTagDetails) => {
return {
...t,
childIds: tags.filter((t2: QueryResponseTagDetails) => t2.parentId === t.id)
.map((t2: QueryResponseTagDetails) => t2.id)
}
})
return tags_with_children;
}
export interface FireNewQueriesData {
query: QueryElem | null,
includeTypes: QueryItemType[],
resultIds: string[],
}
);
export interface ReceivedResultData {
result: ResultsForQuery,
id: string,
return tags;
}
export function QueryWindowReducer(state: QueryWindowState, action: any) {
switch (action.type) {
case QueryWindowStateActions.ReceivedResult:
var arr = action.value as ReceivedResultData;
if (Object.keys(state.resultsForQueries).includes(arr.id)) {
//console.log("Storing result:", arr);
var _n = _.cloneDeep(state);
_n.resultsForQueries[arr.id] = arr.result;
return _n;
}
//console.log("Discarding result:", arr);
return state;
case QueryWindowStateActions.FiredNewQueries:
var newState: QueryWindowState = _.cloneDeep(state);
let _action = action.value as FireNewQueriesData;
// Invalidate results
newState.resultsForQueries = {};
// Add a null result for each of the new IDs.
// Results will be added in as they come.
_action.resultIds && _action.resultIds.forEach((r: string) => {
newState.resultsForQueries[r] = null;
})
newState.query = _action.query;
newState.includeTypes = _action.includeTypes;
return newState;
case QueryWindowStateActions.SetQuery:
return { ...state, query: action.value }
case QueryWindowStateActions.SetEditingQuery:
return { ...state, editingQuery: action.value }
case QueryWindowStateActions.SetResultsForQuery:
return { ...state, resultsForQuery: action.value }
default:
throw new Error("Unimplemented QueryWindow state update.")
}
}
export default function QueryWindow(props: {}) {
const [state, dispatch] = useReducer(QueryWindowReducer, {
editingQuery: false,
query: null,
resultsForQueries: {},
includeTypes: [QueryItemType.Tracks, QueryItemType.Artists, QueryItemType.Albums, QueryItemType.Tags],
resultsForQuery: null,
});
return <QueryWindowControlled state={state} dispatch={dispatch} />
@ -163,71 +100,45 @@ export function QueryWindowControlled(props: {
state: QueryWindowState,
dispatch: (action: any) => void,
}) {
let { query, editingQuery, resultsForQueries, includeTypes } = props.state;
let { query, editingQuery: editing, resultsForQuery: resultsFor } = props.state;
let { dispatch } = props;
// Call this function to fire new queries and prepare to receive their results.
// This will also set the query into the window state.
const doQueries = async (_query: QueryElem | null, itemTypes: QueryItemType[]) => {
var promises: Promise<any>[] = [];
var ids: string[] = itemTypes.map((i: any) => genUuid());
var query_fns = {
[QueryItemType.Albums]: queryAlbums,
[QueryItemType.Artists]: queryArtists,
[QueryItemType.Tracks]: queryTracks,
[QueryItemType.Tags]: queryTags,
};
let stateUpdateData: FireNewQueriesData = {
query: _query,
includeTypes: itemTypes,
resultIds: ids
};
// First dispatch to the state that we are firing new queries.
// This will update the query on the window page and invalidate
// any previous results on-screen.
dispatch({
type: QueryWindowStateActions.FiredNewQueries,
value: stateUpdateData
})
if (_query) {
console.log("Dispatching queries for:", _query);
itemTypes.forEach((itemType: QueryItemType, idx: number) => {
(promises as any[]).push(
(async () => {
let results = (await query_fns[itemType](
_query,
0, // TODO: pagination
100,
QueryResponseType.Details
)) as (
QueryResponseAlbumDetails[] |
QueryResponseArtistDetails[] |
QueryResponseTagDetails[] |
QueryResponseTrackDetails[]);
let r: ReceivedResultData = {
id: ids[idx],
result: {
kind: itemType,
results: results
}
};
dispatch({ type: QueryWindowStateActions.ReceivedResult, value: r })
})()
);
})
}
await Promise.all(promises);
};
let setQuery = (q: QueryElem | null) => {
props.dispatch({ type: QueryWindowStateActions.SetQuery, value: q });
}
let setEditingQuery = (e: boolean) => {
props.dispatch({ type: QueryWindowStateActions.SetEditingQuery, value: e });
}
let setResultsForQuery = useCallback((r: ResultsForQuery | null) => {
dispatch({ type: QueryWindowStateActions.SetResultsForQuery, value: r });
}, [dispatch]);
const loading = query && (!resultsFor || !_.isEqual(resultsFor.for, query));
const showResults = (query && resultsFor && query === resultsFor.for) ? resultsFor.results : [];
const doQuery = useCallback(async (_query: QueryElem) => {
const tracks: QueryResponseTrackDetails[] = await queryTracks(
_query,
0,
100, //TODO: pagination
QueryResponseType.Details
) as QueryResponseTrackDetails[];
if (_.isEqual(query, _query)) {
setResultsForQuery({
for: _query,
results: tracks,
})
}
}, [query, setResultsForQuery]);
useEffect(() => {
if (query) {
doQuery(query);
} else {
setResultsForQuery(null);
}
}, [query, doQuery, setResultsForQuery]);
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
@ -236,10 +147,8 @@ export function QueryWindowControlled(props: {
>
<QueryBuilder
query={query}
onChangeQuery={(q: QueryElem | null) => {
doQueries(q, includeTypes)
}}
editing={editingQuery}
onChangeQuery={setQuery}
editing={editing}
onChangeEditing={setEditingQuery}
requestFunctions={{
getArtists: getArtistNames,
@ -253,38 +162,10 @@ export function QueryWindowControlled(props: {
m={1}
width="80%"
>
{(() => {
var rr = Object.values(resultsForQueries);
rr = rr.sort((r: ResultsForQuery | null) => {
if (r === null) { return 99; }
return {
[QueryItemType.Tracks]: 0,
[QueryItemType.Albums]: 1,
[QueryItemType.Artists]: 2,
[QueryItemType.Tags]: 3
}[r.kind];
});
// TODO: the sorting is not working
return rr.map((r: ResultsForQuery | null) => <>
{r !== null && r.kind == QueryItemType.Tracks && <>
<Typography variant="h5">Tracks</Typography>
<TracksTable tracks={r.results as QueryResponseTrackDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Albums && <>
<Typography variant="h5">Albums</Typography>
<AlbumsTable albums={r.results as QueryResponseAlbumDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Artists && <>
<Typography variant="h5">Artists</Typography>
<ArtistsTable artists={r.results as QueryResponseArtistDetails[]}/>
</>}
{r !== null && r.kind == QueryItemType.Tags && <>
<Typography variant="h5">Tags</Typography>
<Typography>Found {r.results.length} tags.</Typography>
</>}
{r === null && <LinearProgress />}
</>);
})()}
<TrackTable
tracks={showResults}
/>
{loading && <LinearProgress />}
</Box>
</Box>
}

@ -4,14 +4,13 @@ import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import * as serverApi from '../../../api/api';
import { WindowState } from '../Windows';
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { ItemsTable, ColumnType, TracksTable } from '../../tables/ResultsTable';
import EditableText from '../../common/EditableText';
import SubmitChangesButton from '../../common/SubmitChangesButton';
import TrackTable from '../../tables/ResultsTable';
import { modifyTag } from '../../../lib/backend/tags';
import { queryTags, queryTracks } from '../../../lib/backend/queries';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { useParams } from 'react-router';
import { Id, Track, Tag, ResourceType, Album } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
import EditIcon from '@material-ui/icons/Edit';
export interface FullTagMetadata extends serverApi.QueryResponseTagDetails {
fullName: string[],
@ -50,7 +49,7 @@ export function TagWindowReducer(state: TagWindowState, action: any) {
}
}
export async function getTagMetadata(id: number): Promise<FullTagMetadata> {
export async function getTagMetadata(id: number) : Promise<FullTagMetadata> {
let tags: any = await queryTags(
{
a: QueryLeafBy.TagId,
@ -94,20 +93,17 @@ export function TagWindowControlled(props: {
let pendingChanges = props.state.pendingChanges;
let { id: tagId, tracksWithTag } = props.state;
let dispatch = props.dispatch;
let [editing, setEditing] = useState<boolean>(false);
// Effect to get the tag's metadata.
useEffect(() => {
if (metadata === null) {
getTagMetadata(tagId)
.then((m: TagMetadata) => {
dispatch({
type: TagWindowStateActions.SetMetadata,
value: m
});
})
}
}, [tagId, dispatch, metadata]);
getTagMetadata(tagId)
.then((m: TagMetadata) => {
dispatch({
type: TagWindowStateActions.SetMetadata,
value: m
});
})
}, [tagId, dispatch]);
// Effect to get the tag's tracks.
useEffect(() => {
@ -128,8 +124,23 @@ export function TagWindowControlled(props: {
})();
}, [tracksWithTag, tagId, dispatch]);
const name = <Typography variant="h4">{metadata?.name || "(Unknown tag name)"}</Typography>
const [editingName, setEditingName] = useState<string | null>(null);
const name = <Typography variant="h4"><EditableText
defaultValue={metadata?.name || "(Unknown name)"}
changedValue={pendingChanges?.name || null}
editingValue={editingName}
editingLabel="Name"
onChangeEditingValue={(v: string | null) => setEditingName(v)}
onChangeChangedValue={(v: string | null) => {
let newVal: any = { ...pendingChanges };
if (v) { newVal.name = v }
else { delete newVal.name }
props.dispatch({
type: TagWindowStateActions.SetPendingChanges,
value: newVal,
})
}}
/></Typography>
const fullName = <Box display="flex" alignItems="center">
{metadata?.fullName.map((n: string, i: number) => {
if (metadata?.fullName && i === metadata?.fullName.length - 1) {
@ -142,6 +153,22 @@ export function TagWindowControlled(props: {
})}
</Box>
const [applying, setApplying] = useState(false);
const maybeSubmitButton = pendingChanges && Object.keys(pendingChanges).length > 0 &&
<Box>
<SubmitChangesButton onClick={() => {
setApplying(true);
modifyTag(props.state.id, pendingChanges || { mbApi_typename: 'tag' })
.then(() => {
setApplying(false);
props.dispatch({
type: TagWindowStateActions.Reload
})
})
}} />
{applying && <CircularProgress />}
</Box>
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
@ -159,11 +186,12 @@ export function TagWindowControlled(props: {
{fullName}
</Box>
</Box>}
<Box m={1}>
<IconButton
onClick={() => { setEditing(true); }}
><EditIcon /></IconButton>
</Box>
</Box>
<Box
m={1}
width="80%"
>
{maybeSubmitButton}
</Box>
<Box
m={1}
@ -172,33 +200,10 @@ export function TagWindowControlled(props: {
<Box display="flex" flexDirection="column" alignItems="left">
<Typography>Tracks with this tag in your library:</Typography>
</Box>
{props.state.tracksWithTag && <TracksTable tracks={props.state.tracksWithTag}/>}
{props.state.tracksWithTag && <TrackTable
tracks={props.state.tracksWithTag}
/>}
{!props.state.tracksWithTag && <CircularProgress />}
</Box>
{metadata && <EditItemDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTagRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified: serverApi.PatchTagRequest = {
mbApi_typename: 'tag',
name: v.name,
parent: undefined,
parentId: v.parentId || v.parent?.id || undefined,
};
modifyTag(tagId, v_modified)
.then(() => dispatch({
type: TagWindowStateActions.Reload
}))
}}
id={tagId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Name', type: EditablePropertyType.Text },
]}
defaultExternalLinksQuery={metadata.name}
resourceType={ResourceType.Artist}
editStoreLinks={false}
/>}
</Box>
}

@ -1,33 +1,33 @@
import { IntegrationWith, Name, ResourceType, StoreLinks } from '../../api/api';
import { IntegrationState, useIntegrations } from '../../lib/integration/useIntegrations';
import StoreLinkIcon, { whichStore } from './StoreLinkIcon';
import { $enum } from "ts-enum-util";
import React, { useEffect, useState } from 'react';
import { IntegrationAlbum, IntegrationArtist, IntegrationFeature, IntegrationTrack } from '../../lib/integration/Integration';
import { Box, List, ListItem, ListItemIcon, ListItemText, IconButton, Typography, FormControl, FormControlLabel, MenuItem, Radio, RadioGroup, Select, TextField } from '@material-ui/core';
import { AppBar, Box, Button, Dialog, DialogActions, Divider, FormControl, FormControlLabel, IconButton, Link, List, ListItem, ListItemIcon, ListItemText, MenuItem, Radio, RadioGroup, Select, Tab, Tabs, TextField, Typography } from "@material-ui/core";
import { TrackMetadata } from "./TrackWindow";
import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import CheckIcon from '@material-ui/icons/Check';
import SearchIcon from '@material-ui/icons/Search';
import CancelIcon from '@material-ui/icons/Cancel';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
import DeleteIcon from '@material-ui/icons/Delete';
import { $enum } from "ts-enum-util";
import { useIntegrations, IntegrationsState, IntegrationState } from '../../../lib/integration/useIntegrations';
import { IntegrationFeature, IntegrationTrack } from '../../../lib/integration/Integration';
import { TabPanel } from '@material-ui/lab';
import { v1 } from 'uuid';
import { IntegrationWith } from '../../../api/api';
let _ = require('lodash')
export type ItemWithExternalLinksProperties = StoreLinks & Name;
export function ProvideLinksWidget(props: {
providers: IntegrationState[],
metadata: ItemWithExternalLinksProperties,
metadata: TrackMetadata,
store: IntegrationWith,
onChange: (link: string | undefined) => void,
defaultQuery: string,
resourceType: ResourceType,
}) {
let defaultQuery = `${props.metadata.name}${props.metadata.artists && ` ${props.metadata.artists[0].name}`}${props.metadata.album && ` ${props.metadata.album.name}`}`;
let [selectedProviderIdx, setSelectedProviderIdx] = useState<number | undefined>(
props.providers.length > 0 ? 0 : undefined
);
let [query, setQuery] = useState<string>(props.defaultQuery)
let [results, setResults] = useState<
IntegrationTrack[] | IntegrationAlbum[] | IntegrationArtist[] | undefined>(undefined);
let [query, setQuery] = useState<string>(defaultQuery)
let [results, setResults] = useState<IntegrationTrack[] | undefined>(undefined);
let selectedProvider: IntegrationState | undefined = selectedProviderIdx !== undefined ?
props.providers[selectedProviderIdx] : undefined;
@ -39,7 +39,7 @@ export function ProvideLinksWidget(props: {
// Ensure results are cleared when input state changes.
useEffect(() => {
setResults(undefined);
setQuery(props.defaultQuery);
setQuery(defaultQuery);
}, [props.store, props.providers, props.metadata])
return <Box display="flex" flexDirection="column" alignItems="left">
@ -63,44 +63,17 @@ export function ProvideLinksWidget(props: {
/>
<IconButton
onClick={() => {
switch (props.resourceType) {
case ResourceType.Track:
selectedProvider?.integration.searchTrack(query, 10)
.then((tracks: IntegrationTrack[]) => setResults(tracks))
break;
case ResourceType.Album:
selectedProvider?.integration.searchAlbum(query, 10)
.then((albums: IntegrationAlbum[]) => setResults(albums))
break;
case ResourceType.Artist:
selectedProvider?.integration.searchArtist(query, 10)
.then((artists: IntegrationArtist[]) => setResults(artists))
break;
}
selectedProvider?.integration.searchTrack(query, 10)
.then((tracks: IntegrationTrack[]) => setResults(tracks))
}}
><SearchIcon /></IconButton>
{results && results.length > 0 && <Typography>Suggestions:</Typography>}
<FormControl>
<RadioGroup value={currentLink} onChange={(e: any) => props.onChange(e.target.value)}>
{results && (results as any).map((result: IntegrationTrack | IntegrationAlbum | IntegrationArtist, idx: number) => {
var pretty = "";
switch (props.resourceType) {
case ResourceType.Track:
let rt = result as IntegrationTrack;
pretty = `"${rt.title}"
${rt.artist && ` by ${rt.artist.name}`}
${rt.album && ` (${rt.album.name})`}`;
break;
case ResourceType.Album:
let ral = result as IntegrationAlbum;
pretty = `"${ral.name}"
${ral.artist && ` by ${ral.artist.name}`}`;
break;
case ResourceType.Artist:
let rar = result as IntegrationArtist;
pretty = rar.name || "(Unknown Artist)";
break;
}
{results && results.map((result: IntegrationTrack, idx: number) => {
let pretty = `"${result.title}"
${result.artist && ` by ${result.artist.name}`}
${result.album && ` (${result.album.name})`}`;
return <FormControlLabel
value={result.url || idx}
control={<Radio checked={(result.url || idx) === currentLink} />}
@ -119,16 +92,14 @@ export function ProvideLinksWidget(props: {
}
export function ExternalLinksEditor(props: {
metadata: ItemWithExternalLinksProperties,
original: ItemWithExternalLinksProperties,
onChange: (v: any) => void,
defaultQuery: string,
resourceType: ResourceType,
metadata: TrackMetadata,
original: TrackMetadata,
onChange: (v: TrackMetadata) => void,
}) {
let [selectedIdx, setSelectedIdx] = useState<number>(0);
let integrations = useIntegrations();
let getLinksSet = (metadata: ItemWithExternalLinksProperties) => {
let getLinksSet = (metadata: TrackMetadata) => {
return $enum(IntegrationWith).getValues().reduce((prev: any, store: string) => {
var maybeLink: string | null = null;
metadata.storeLinks && metadata.storeLinks.forEach((link: string) => {
@ -174,7 +145,7 @@ export function ExternalLinksEditor(props: {
>
<ListItemIcon>{linksSet[store] !== null ? <CheckIcon style={{ color: color }} /> : <CancelIcon style={{ color: color }} />}</ListItemIcon>
<ListItemIcon><StoreLinkIcon whichStore={store} /></ListItemIcon>
<ListItemText style={{ color: color }} primary={store} />
<ListItemText style={{ color: color }} primary={store} />
{maybeLink && <a href={maybeLink} target="_blank">
<ListItemIcon><IconButton><OpenInNewIcon style={{ color: color }} /></IconButton></ListItemIcon>
</a>}
@ -213,10 +184,51 @@ export function ExternalLinksEditor(props: {
})
}
}}
defaultQuery={props.defaultQuery}
resourceType={props.resourceType}
/>
}
</Box>
</Box >
}
export default function EditTrackDialog(props: {
open: boolean,
onClose: () => void,
onSubmit: (v: TrackMetadata) => void,
id: number,
metadata: TrackMetadata,
}) {
enum EditTrackTabs {
Details = 0,
ExternalLinks,
}
let [editingMetadata, setEditingMetadata] = useState<TrackMetadata>(props.metadata);
return <Dialog
maxWidth="lg"
fullWidth
open={props.open}
onClose={props.onClose}
disableBackdropClick={true}>
<Typography variant="h5">Properties</Typography>
<Typography>Under construction</Typography>
<Divider />
<Typography variant="h5">External Links</Typography>
<ExternalLinksEditor
metadata={editingMetadata}
original={props.metadata}
onChange={(v: TrackMetadata) => setEditingMetadata(v)}
/>
<Divider />
{!_.isEqual(editingMetadata, props.metadata) && <DialogActions>
<Button variant="contained" color="secondary"
onClick={() => {
props.onSubmit(editingMetadata);
props.onClose();
}}>Save all changes</Button>
<Button variant="outlined"
onClick={() => setEditingMetadata(props.metadata)}>Discard changes</Button>
</DialogActions>}
</Dialog>
}

@ -11,11 +11,10 @@ import StoreLinkIcon, { whichStore } from '../../common/StoreLinkIcon';
import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryTracks } from '../../../lib/backend/queries';
import { useParams } from 'react-router';
import EditTrackDialog from './EditTrackDialog';
import EditIcon from '@material-ui/icons/Edit';
import { modifyTrack } from '../../../lib/saveChanges';
import { getTrack } from '../../../lib/backend/tracks';
import { Artist, Id, ResourceType, Tag } from '../../../api/api';
import EditItemDialog, { EditablePropertyType } from '../../common/EditItemDialog';
export type TrackMetadata = serverApi.QueryResponseTrackDetails;
@ -70,7 +69,7 @@ export function TrackWindowControlled(props: {
}
}, [trackId, dispatch, metadata]);
const title = <Typography variant="h4">{metadata?.name || "(Unknown track title)"}</Typography>
const title = <Typography variant="h4">{metadata?.name || "(Unknown title)"}</Typography>
const artists = metadata?.artists && metadata?.artists.map((artist: (serverApi.Artist & serverApi.Name)) => {
return <Typography>
@ -139,37 +138,17 @@ export function TrackWindowControlled(props: {
</Box>
</Box>}
</Box>
{metadata && <EditItemDialog
{metadata && <EditTrackDialog
open={editing}
onClose={() => { setEditing(false); }}
onSubmit={(v: serverApi.PatchTrackRequest) => {
// Remove any details about linked resources and leave only their IDs.
let v_modified = {
...v,
album: undefined,
artists: undefined,
tags: undefined,
albumId: v.albumId || v.album?.id || undefined,
artistIds: v.artistIds || v.artists?.map (
(a: (Artist & Id)) => { return a.id }
) || undefined,
tagIds: v.tagIds || v.tags?.map (
(t: (Tag & Id)) => { return t.id }
) || undefined,
};
modifyTrack(trackId, v_modified)
modifyTrack(trackId, v)
.then(() => dispatch({
type: TrackWindowStateActions.Reload
}))
}}
id={trackId}
metadata={metadata}
editableProperties={[
{ metadataKey: 'name', title: 'Title', type: EditablePropertyType.Text },
]}
resourceType={ResourceType.Track}
editStoreLinks={true}
defaultExternalLinksQuery={`${metadata.name}${metadata.artists && ` ${metadata.artists[0].name}`}${metadata.album && ` ${metadata.album.name}`}`}
/>}
</Box>
}

@ -1,53 +1,26 @@
import * as serverApi from '../../api/api';
import { QueryElem, QueryFor, simplify, toApiQuery } from '../query/Query';
import { QueryElem, toApiQuery } from '../query/Query';
import backendRequest from './request';
export async function queryItems(
type: serverApi.ResourceType,
types: serverApi.ResourceType[],
query: QueryElem | undefined,
offset: number | undefined,
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponse> {
const queryForMapping : any = {
[serverApi.ResourceType.Album]: QueryFor.Albums,
[serverApi.ResourceType.Artist]: QueryFor.Artists,
[serverApi.ResourceType.Tag]: QueryFor.Tags,
[serverApi.ResourceType.Track]: QueryFor.Tracks,
};
const simplified = simplify(query || null, queryForMapping[type]);
if (simplified === null && query != undefined) {
// Invalid query, return no results.
if (responseType === serverApi.QueryResponseType.Count) {
return (async () => { return {
tracks: 0,
artists: 0,
tags: 0,
albums: 0,
}; })();
} else {
return (async () => { return {
tracks: [],
artists: [],
tags: [],
albums: [],
}; })();
}
}
console.log("Types:", types);
var q: serverApi.QueryRequest = {
query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {},
query: query ? toApiQuery(query) : {},
offsetsLimits: {
artistOffset: (type == serverApi.ResourceType.Artist) ? (offset || 0) : undefined,
artistLimit: (type == serverApi.ResourceType.Artist) ? (limit || -1) : undefined,
albumOffset: (type == serverApi.ResourceType.Album) ? (offset || 0) : undefined,
albumLimit: (type == serverApi.ResourceType.Album) ? (limit || -1) : undefined,
trackOffset: (type == serverApi.ResourceType.Track) ? (offset || 0) : undefined,
trackLimit: (type == serverApi.ResourceType.Track) ? (limit || -1) : undefined,
tagOffset: (type == serverApi.ResourceType.Tag) ? (offset || 0) : undefined,
tagLimit: (type == serverApi.ResourceType.Tag) ? (limit || -1) : undefined,
artistOffset: (types.includes(serverApi.ResourceType.Artist)) ? (offset || 0) : undefined,
artistLimit: (types.includes(serverApi.ResourceType.Artist)) ? (limit || -1) : undefined,
albumOffset: (types.includes(serverApi.ResourceType.Album)) ? (offset || 0) : undefined,
albumLimit: (types.includes(serverApi.ResourceType.Album)) ? (limit || -1) : undefined,
trackOffset: (types.includes(serverApi.ResourceType.Track)) ? (offset || 0) : undefined,
trackLimit: (types.includes(serverApi.ResourceType.Track)) ? (limit || -1) : undefined,
tagOffset: (types.includes(serverApi.ResourceType.Tag)) ? (offset || 0) : undefined,
tagLimit: (types.includes(serverApi.ResourceType.Tag)) ? (limit || -1) : undefined,
},
ordering: {
orderBy: {
@ -77,7 +50,7 @@ export async function queryArtists(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseArtistDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Artist, query, offset, limit, responseType);
let r = await queryItems([serverApi.ResourceType.Artist], query, offset, limit, responseType);
return r.artists;
}
@ -87,7 +60,7 @@ export async function queryAlbums(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseAlbumDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Album, query, offset, limit, responseType);
let r = await queryItems([serverApi.ResourceType.Album], query, offset, limit, responseType);
return r.albums;
}
@ -97,7 +70,7 @@ export async function queryTracks(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTrackDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Track, query, offset, limit, responseType);
let r = await queryItems([serverApi.ResourceType.Track], query, offset, limit, responseType);
return r.tracks;
}
@ -107,6 +80,6 @@ export async function queryTags(
limit: number | undefined,
responseType: serverApi.QueryResponseType,
): Promise<serverApi.QueryResponseTagDetails[] | number[] | number> {
let r = await queryItems(serverApi.ResourceType.Tag, query, offset, limit, responseType);
let r = await queryItems([serverApi.ResourceType.Tag], query, offset, limit, responseType);
return r.tags;
}

@ -17,7 +17,7 @@ export async function createTag(details: serverApi.PostTagRequest) {
export async function modifyTag(id: number, details: serverApi.PatchTagRequest) {
const requestOpts = {
method: 'PATCH',
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};

@ -40,10 +40,8 @@ export default class SpotifyClientCreds extends Integration {
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
if (!response.ok) {
throw new Error("Spotify Client Credentials test failed: " + JSON.stringify(response));
throw new Error("Spttify Client Credentials test failed: " + JSON.stringify(response));
}
console.log("Spotify test response:", await response.json())
}
async searchTrack(query: string, limit: number): Promise<IntegrationTrack[]> {
@ -51,10 +49,10 @@ export default class SpotifyClientCreds extends Integration {
}
async searchAlbum(query: string, limit: number): Promise<IntegrationAlbum[]> {
return this.search(query, SearchType.Album, limit);
}
}
async searchArtist(query: string, limit: number): Promise<IntegrationArtist[]> {
return this.search(query, SearchType.Artist, limit);
}
}
async search(query: string, type: SearchType, limit: number):
Promise<IntegrationTrack[] | IntegrationAlbum[] | IntegrationArtist[]> {

@ -6,9 +6,6 @@ import { runInNewContext } from 'vm';
import { TextRotateVertical } from '@material-ui/icons';
import AlbumWindow from '../../../components/windows/album/AlbumWindow';
import { isUndefined } from 'util';
import { keys } from '@material-ui/core/styles/createBreakpoints';
import stringifyList from '../../stringifyList';
import { convertCompilerOptionsFromJson } from 'typescript';
let _ = require('lodash');
enum SearchType {
@ -68,38 +65,20 @@ export function extractInitialData(text: string): any | undefined {
// Return either one that worked.
let result = json1 || json2;
//console.log("initial data:", result);
console.log("initial data:", result);
return result;
}
// Helper function to recursively find key-value pairs in an Object.
function findRecursive (obj : Object | any[],
match_fn : (keys: any[], keys_str: string, value: any) => boolean,
find_inside_matches: boolean,
prev_keys : any[] = []) : any[] {
var retval : any[] = [];
for (const [key, value] of Object.entries(obj)) {
var keys : any[] = prev_keys.concat([key]);
let keys_str : string = keys.map((k:any) => String(k)).join('.');
if (match_fn (keys, keys_str, value)) {
retval.push(value);
if (!find_inside_matches) {
continue;
}
}
if (typeof value === 'object' && value !== null) {
retval = retval.concat(findRecursive(value, match_fn, find_inside_matches, keys));
}
}
return retval;
}
export function parseItems(initialData: any): {
tracks: IntegrationTrack[],
albums: IntegrationAlbum[],
artists: IntegrationArtist[],
} {
try {
var musicResponsiveListItemRenderers: { type: 'track' | 'album' | 'artist', content: any }[] = [];
console.log('initialData', initialData)
let retval: any = {
tracks: [],
albums: [],
@ -182,36 +161,33 @@ export function parseItems(initialData: any): {
return artist;
}
// Gather all the items.
var musicResponsiveListItemRenderers =
findRecursive(initialData, (keys: any[], keys_str : string, val: any) => {
return keys_str.match(/.*musicResponsiveListItemRenderer$/g) !== null;
}, false);
musicResponsiveListItemRenderers.forEach((renderer: any) => {
let runs = _.get(renderer, 'flexColumns').map((column: any) => {
return _.get(column, 'musicResponsiveListItemFlexColumnRenderer.text.runs');
}).flat();
switch (_.get(renderer, 'flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text', '')) {
case "Song": {
retval.tracks.push(parseTrack(renderer, runs));
break;
}
case "Artist": {
retval.artists.push(parseArtist(renderer, runs));
break;
}
case "Album":
case "Single": {
retval.albums.push(parseAlbum(renderer, runs));
break;
}
default: {
break;
// Scrape for songs, artists and albums.
_.get(initialData, 'contents.sectionListRenderer.contents', []).forEach((contents: any) => {
_.get(contents, 'musicShelfRenderer.contents', []).forEach((_contents: any) => {
let runs = _.get(_contents, 'musicResponsiveListItemRenderer.flexColumns').map((column: any) => {
return _.get(column, 'musicResponsiveListItemFlexColumnRenderer.text.runs');
}).flat();
switch (_.get(_contents, 'musicResponsiveListItemRenderer.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text', '')) {
case "Song": {
retval.tracks.push(parseTrack(_.get(_contents, 'musicResponsiveListItemRenderer'), runs));
break;
}
case "Artist": {
retval.artists.push(parseArtist(_.get(_contents, 'musicResponsiveListItemRenderer'), runs));
break;
}
case "Album":
case "Single": {
retval.albums.push(parseAlbum(_.get(_contents, 'musicResponsiveListItemRenderer'), runs));
break;
}
default: {
break;
}
}
}
})
})
});
return retval;
} catch (e) {

@ -1,29 +1,23 @@
import * as serverApi from '../../api/api';
export enum QueryFor {
Artists = "artists",
Albums = "albums",
Tags = "tags",
Tracks = "tracks",
}
export enum QueryLeafBy {
ArtistName = "artistName",
ArtistId = "artistId",
AlbumName = "albumName",
AlbumId = "albumId",
TagInfo = "tagInfo",
TagId = "tagId",
TrackName = "trackName",
TrackId = "trackId",
StoreLinks = "storeLinks",
NotApplicable = "n/a", // Some query nodes don't need an operand.
ArtistName = 0,
ArtistId,
AlbumName,
AlbumId,
TagInfo,
TagId,
TrackName,
TrackId,
TrackStoreLinks,
ArtistStoreLinks,
AlbumStoreLinks,
}
export enum QueryLeafOp {
Equals = "equals",
Like = "like",
Placeholder = "placeholder", // Special op which indicates that this leaf is not filled in yet.
Equals = 0,
Like,
Placeholder, // Special op which indicates that this leaf is not filled in yet.
}
export interface TagQueryInfo {
@ -32,7 +26,7 @@ export interface TagQueryInfo {
}
export function isTagQueryInfo(e: any): e is TagQueryInfo {
return (typeof e === 'object') && 'matchIds' in e && 'fullName' in e;
}
}
export type QueryLeafOperand = string | number | TagQueryInfo;
@ -46,9 +40,9 @@ export function isLeafElem(q: QueryElem): q is QueryLeafElem {
}
export enum QueryNodeOp {
And = "AND",
Or = "OR",
Not = "NOT",
And = 0,
Or,
Not,
}
export interface QueryNodeElem {
@ -83,44 +77,6 @@ export function queryNot(arg: QueryElem) {
export type QueryElem = QueryLeafElem | QueryNodeElem;
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null):
serverApi.QueryElemProperty | null {
return {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
[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.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.StoreLinks]:
(queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks :
(queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks :
(queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks :
null,
[QueryLeafBy.TagInfo]: null,
[QueryLeafBy.NotApplicable]: null,
}[l];
}
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null):
serverApi.QueryLeafOp | null {
return {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
[QueryLeafOp.Placeholder]: null,
}[l];
}
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null):
serverApi.QueryNodeOp | null {
return {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
}[l];
}
// Take a query and add placeholders. The placeholders are empty
// leaves. They should be placed so that all possible node combinations
// from the existing nodes could have an added combinational leaf.
@ -131,9 +87,9 @@ export function addPlaceholders(
inNode: null | QueryNodeOp,
): QueryElem {
const makePlaceholder: () => QueryElem = () => {
const makePlaceholder = () => {
return {
a: QueryLeafBy.NotApplicable,
a: 0,
leafOp: QueryLeafOp.Placeholder,
b: ""
}
@ -147,19 +103,7 @@ export function addPlaceholders(
if (q == null) {
return makePlaceholder();
} else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not &&
isLeafElem(q.operands[0]) &&
inNode !== null) {
// Not only modifies its sub-node, so this is handled like a leaf.
return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] };
} else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not &&
isLeafElem(q.operands[0]) &&
inNode === null) {
// Not only modifies its sub-node, so this is handled like a leaf.
return { operands: [q, makePlaceholder()], nodeOp: QueryNodeOp.And };
} else if (isNodeElem(q) && q.nodeOp != QueryNodeOp.Not) {
// Combinational operators.
} else if (isNodeElem(q)) {
var operands = q.operands.map((op: any, idx: number) => {
return addPlaceholders(op, q.nodeOp);
});
@ -207,7 +151,7 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
if (newOperands.length === 0) {
return null;
}
if ((newOperands.length === 1 && [QueryNodeOp.Or, QueryNodeOp.And].includes(q.nodeOp))) {
if (newOperands.length === 1) {
return newOperands[0];
}
return { operands: newOperands, nodeOp: q.nodeOp };
@ -218,32 +162,45 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
return q;
}
// Note: null means an invalidating node. It should make the whole query invalid, so it should
// be propagated to the root.
export function simplify(q: QueryElem | null, queryFor: QueryFor | null): QueryElem | null {
export function simplify(q: QueryElem | null): QueryElem | null {
if (q && isNodeElem(q)) {
var newOperands: (QueryElem | null)[] = q.operands.map((op: QueryElem) => simplify(op, queryFor));
if (newOperands.filter((op: QueryElem | null) => op === null).length > 0) {
console.log("nullifying op:", q, queryFor)
return null;
}
return { operands: newOperands as QueryElem[], nodeOp: q.nodeOp };
}
// Nullify any queries that contain operations which are invalid
// for the current queried object type.
if (q && isLeafElem(q) && queryFor !== null &&
(mapToServerLeafOp(q.leafOp, queryFor) === null ||
mapToServerProperty(q.a, queryFor) === null)) {
return null;
var newOperands: QueryElem[] = [];
q.operands.forEach((o: QueryElem) => {
const s = simplify(o);
if (s !== null) { newOperands.push(s); }
})
if (newOperands.length === 0) { return null; }
if (newOperands.length === 1) { return newOperands[0]; }
return { operands: newOperands, nodeOp: q.nodeOp };
}
return q;
}
export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Query {
if (isLeafElem(q) && isTagQueryInfo(q.b)) {
export function toApiQuery(q: QueryElem) : serverApi.Query {
const propsMapping: any = {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
[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.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.TrackStoreLinks]: serverApi.QueryElemProperty.trackStoreLinks,
[QueryLeafBy.ArtistStoreLinks]: serverApi.QueryElemProperty.artistStoreLinks,
[QueryLeafBy.AlbumStoreLinks]: serverApi.QueryElemProperty.albumStoreLinks,
}
const leafOpsMapping: any = {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
[QueryLeafOp.Like]: serverApi.QueryLeafOp.Like,
}
const nodeOpsMapping: any = {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
[QueryNodeOp.Or]: serverApi.QueryNodeOp.Or,
[QueryNodeOp.Not]: serverApi.QueryNodeOp.Not,
}
if(isLeafElem(q) && isTagQueryInfo(q.b)) {
// Special case for tag queries by ID
const r: serverApi.QueryElem = {
prop: serverApi.QueryElemProperty.tagId,
@ -251,30 +208,18 @@ export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Q
propOperand: q.b.matchIds,
}
return r;
} else if (isLeafElem(q)) {
// If the property to operate on is non-existent
// (e.g. store links for a tag query), throw.
let a = mapToServerProperty(q.a, queryFor);
let op = mapToServerLeafOp(q.leafOp, queryFor);
if (a === null || op === null) {
console.log("Error details:", q, queryFor);
throw 'Found a null leaf in query tree. Was it simplified first?';
}
} else if(isLeafElem(q)) {
// "Regular" queries
const r: serverApi.QueryElem = {
prop: a,
propOperator: op,
prop: propsMapping[q.a],
propOperator: leafOpsMapping[q.leafOp],
propOperand: q.b,
}
return r;
} else if (isNodeElem(q)) {
let op = mapToServerNodeOp(q.nodeOp, queryFor);
if (op === null) {
throw 'Found a null node in query tree. Was it simplified first?'
}
} else if(isNodeElem(q)) {
const r = {
children: q.operands.map((op: any) => toApiQuery(op, queryFor)),
childrenOperator: op
children: q.operands.map((op: any) => toApiQuery(op)),
childrenOperator: nodeOpsMapping[q.nodeOp]
}
return r;
}

@ -1,5 +1,5 @@
import Knex from "knex";
import { Album, AlbumRefs, Id, Name, AlbumDetails, StoreLinks, Tag, TagParentId, Track, Artist } from "../../client/src/api/api";
import { Album, AlbumRefs, Id, Name, AlbumDetails, StoreLinks, Tag, TagRefs, Track, Artist } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types";
@ -14,7 +14,7 @@ export async function getAlbum(id: number, userId: number, knex: Knex):
// Start transfers for tracks, tags and artists.
// Also request the album itself.
const tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> =
const tagsPromise: Promise<(Tag & Id & Name & TagRefs)[]> =
knex.select('tagId')
.from('albums_tags')
.where({ 'albumId': id })
@ -23,8 +23,8 @@ export async function getAlbum(id: number, userId: number, knex: Knex):
knex.select(['id', 'name', 'parentId'])
.from('tags')
.whereIn('id', ids)
.then((tags: (Id & Name & TagParentId)[]) =>
tags.map((tag : (Id & Name & TagParentId)) =>
.then((tags: (Id & Name & TagRefs)[]) =>
tags.map((tag : (Id & Name & TagRefs)) =>
{ return {...tag, mbApi_typename: "tag"}}
))
);
@ -174,9 +174,9 @@ export async function modifyAlbum(userId: number, albumId: number, album: Album,
const artistIdsPromise: Promise<number[] | undefined> =
album.artistIds ?
trx.select('artistId')
.from('artists')
.whereIn('id', album.artistIds)
.then((as: any) => as.map((a: any) => a['id']))
.from('artists_albums')
.whereIn('artistId', album.artistIds)
.then((as: any) => as.map((a: any) => a['artistId']))
: (async () => undefined)();
// Start retrieving tracks if we are modifying those.
@ -192,18 +192,18 @@ export async function modifyAlbum(userId: number, albumId: number, album: Album,
const tagIdsPromise =
album.tagIds ?
trx.select('id')
.from('tags')
.whereIn('id', album.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) :
.from('albums_tags')
.whereIn('tagId', album.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => undefined)();
// Wait for the requests to finish.
var [oldAlbum, artists, tags, tracks] = await Promise.all([albumIdPromise, artistIdsPromise, tagIdsPromise, trackIdsPromise]);;
// Check that we found all objects we need.
if ((album.artistIds && (!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort()))) ||
(album.tagIds && (!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort()))) ||
(album.trackIds && (!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort()))) ||
if ((!artists || !_.isEqual(artists.sort(), (album.artistIds || []).sort())) ||
(!tags || !_.isEqual(tags.sort(), (album.tagIds || []).sort())) ||
(!tracks || !_.isEqual(tracks.sort(), (album.trackIds || []).sort())) ||
!oldAlbum) {
throw makeNotFoundError();
}

@ -1,5 +1,5 @@
import Knex from "knex";
import { Artist, ArtistDetails, Tag, Track, TagParentId, Id, Name, StoreLinks, Album, ArtistRefs } from "../../client/src/api/api";
import { Artist, ArtistDetails, Tag, Track, TagRefs, Id, Name, StoreLinks, Album, ArtistRefs } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types";
@ -11,7 +11,7 @@ export async function getArtist(id: number, userId: number, knex: Knex):
Promise<(Artist & ArtistDetails & Name & StoreLinks)> {
// Start transfers for tags and albums.
// Also request the artist itself.
const tagsPromise: Promise<(Tag & Name & Id & TagParentId)[]> =
const tagsPromise: Promise<(Tag & Name & Id & TagRefs)[]> =
knex.select('tagId')
.from('artists_tags')
.where({ 'artistId': id })
@ -20,8 +20,8 @@ export async function getArtist(id: number, userId: number, knex: Knex):
knex.select(['id', 'name', 'parentId'])
.from('tags')
.whereIn('id', ids)
.then((tags: (Id & Name & TagParentId)[]) =>
tags.map((tag : (Id & Name & TagParentId)) =>
.then((tags: (Id & Name & TagRefs)[]) =>
tags.map((tag : (Id & Name & TagRefs)) =>
{ return {...tag, mbApi_typename: "tag"}}
))
);

@ -1,5 +1,5 @@
import Knex from "knex";
import { Track, TrackRefs, Id, Name, StoreLinks, Album, AlbumRefs, Artist, ArtistRefs, Tag, TagParentId, isTrackRefs, isAlbumRefs, DBImportResponse, IDMappings } from "../../client/src/api/api";
import { Track, TrackRefs, Id, Name, StoreLinks, Album, AlbumRefs, Artist, ArtistRefs, Tag, TagRefs, isTrackRefs, isAlbumRefs, DBImportResponse, IDMappings } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { createArtist } from "./Artist";
@ -60,7 +60,7 @@ export async function exportDB(userId: number, knex: Knex): Promise<api.DBDataFo
}
}));
let tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> =
let tagsPromise: Promise<(Tag & Id & Name & TagRefs)[]> =
knex.select('name', 'parentId', 'id')
.from('tags')
.where({ 'user': userId })

@ -1,7 +1,7 @@
import Knex from "knex";
import { isConstructorDeclaration } from "typescript";
import * as api from '../../client/src/api/api';
import { Tag, TagParentId, TagDetails, Id, Name } from "../../client/src/api/api";
import { Tag, TagRefs, TagDetails, Id, Name } from "../../client/src/api/api";
import { DBError, DBErrorKind } from "../endpoints/types";
import { makeNotFoundError } from "./common";
@ -35,7 +35,7 @@ export async function getTagChildrenRecursive(id: number,
}
// Returns the id of the created tag.
export async function createTag(userId: number, tag: (Tag & Name & TagParentId), knex: Knex): Promise<number> {
export async function createTag(userId: number, tag: (Tag & Name & TagRefs), knex: Knex): Promise<number> {
return await knex.transaction(async (trx) => {
// If applicable, retrieve the parent tag.
const maybeMatches: any[] | null =
@ -124,13 +124,13 @@ export async function deleteTag(userId: number, tagId: number, knex: Knex) {
}
export async function getTag(userId: number, tagId: number, knex: Knex): Promise<(Tag & TagDetails & Name)> {
const tagPromise: Promise<(Tag & Id & Name & TagParentId) | null> =
const tagPromise: Promise<(Tag & Id & Name & TagRefs) | null> =
knex.select(['id', 'name', 'parentId'])
.from('tags')
.where({ 'user': userId })
.where({ 'id': tagId })
.then((r: (Id & Name & TagParentId)[] | undefined) => r ? r[0] : null)
.then((r: (Id & Name & TagParentId) | null) => {
.then((r: (Id & Name & TagRefs)[] | undefined) => r ? r[0] : null)
.then((r: (Id & Name & TagRefs) | null) => {
if (r) {
return { ...r, mbApi_typename: 'tag'};
}
@ -139,7 +139,7 @@ export async function getTag(userId: number, tagId: number, knex: Knex): Promise
const parentPromise: Promise<(Tag & Id & Name & TagDetails) | null> =
tagPromise
.then((r: (Tag & Id & Name & TagParentId) | null) =>
.then((r: (Tag & Id & Name & TagRefs) | null) =>
(r && r.parentId) ? (
getTag(userId, r.parentId, knex)
.then((rr: (Tag & Name & TagDetails) | null) =>

@ -1,5 +1,5 @@
import Knex from "knex";
import { Track, TrackRefs, TrackDetails, Id, Name, StoreLinks, Tag, Album, Artist, TagParentId } from "../../client/src/api/api";
import { Track, TrackRefs, TrackDetails, Id, Name, StoreLinks, Tag, Album, Artist, TagRefs } from "../../client/src/api/api";
import * as api from '../../client/src/api/api';
import asJson from "../lib/asJson";
import { DBError, DBErrorKind } from "../endpoints/types";
@ -11,7 +11,7 @@ export async function getTrack(id: number, userId: number, knex: Knex):
Promise<Track & Name & StoreLinks & TrackDetails> {
// Start transfers for tracks, tags and artists.
// Also request the track itself.
const tagsPromise: Promise<(Tag & Id & Name & TagParentId)[]> =
const tagsPromise: Promise<(Tag & Id & Name & TagRefs)[]> =
knex.select('tagId')
.from('tracks_tags')
.where({ 'trackId': id })
@ -20,8 +20,8 @@ export async function getTrack(id: number, userId: number, knex: Knex):
knex.select(['id', 'name', 'parentId'])
.from('tags')
.whereIn('id', ids)
.then((tags: (Id & Name & TagParentId)[]) =>
tags.map((tag : (Id & Name & TagParentId)) =>
.then((tags: (Id & Name & TagRefs)[]) =>
tags.map((tag : (Id & Name & TagRefs)) =>
{ return {...tag, mbApi_typename: "tag"}}
))
);
@ -180,19 +180,19 @@ export async function modifyTrack(userId: number, trackId: number, track: Track,
// Start retrieving artists if we are modifying those.
const artistIdsPromise: Promise<number[] | undefined> =
track.artistIds ?
trx.select('id')
.from('artists')
.whereIn('id', track.artistIds)
.then((as: any) => as.map((a: any) => a['id']))
trx.select('artistId')
.from('tracks_artists')
.whereIn('artistId', track.artistIds)
.then((as: any) => as.map((a: any) => a['artistId']))
: (async () => undefined)();
// Start retrieving tags if we are modifying those.
const tagIdsPromise =
track.tagIds ?
trx.select('id')
.from('tags')
.whereIn('id', track.tagIds)
.then((ts: any) => ts.map((t: any) => t['id'])) :
.from('tracks_tags')
.whereIn('tagId', track.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => undefined)();
// Start retrieving album if we are modifying that.
@ -205,15 +205,15 @@ export async function modifyTrack(userId: number, trackId: number, track: Track,
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) :
(async () => undefined)();
let blablums = await trx.select('id').from('albums');
// Wait for the requests to finish.
var [oldTrack, artists, tags, album] = await Promise.all([trackIdPromise, artistIdsPromise, tagIdsPromise, albumIdPromise]);;
console.log("Patch track: ", oldTrack, artists, tags, album);
// Check that we found all objects we need.
if ((track.artistIds && (!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort()))) ||
(track.tagIds && (!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort()))) ||
(track.albumId && !album) ||
if ((!artists || !_.isEqual(artists.sort(), (track.artistIds || []).sort())) ||
(!tags || !_.isEqual(tags.sort(), (track.tagIds || []).sort())) ||
(!album && track.albumId) ||
!oldTrack) {
throw makeNotFoundError();
}

Loading…
Cancel
Save