Added query option for finding items with/without links to particular other systems.

master
Sander Vocke 4 years ago
parent e8c043b08d
commit 412f25d32f
  1. 59
      client/src/components/querybuilder/QBAddElemMenu.tsx
  2. 65
      client/src/components/querybuilder/QBLeafElem.tsx
  3. 14
      client/src/components/querybuilder/QBNodeElem.tsx
  4. 1
      client/src/components/querybuilder/QueryBuilder.tsx
  5. 1
      client/src/components/windows/query/QueryWindow.tsx
  6. 19
      client/src/lib/backend/queries.tsx
  7. 67
      client/src/lib/query/Query.tsx

@ -4,6 +4,9 @@ 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';
export interface MenuProps {
anchorEl: null | HTMLElement,
@ -76,11 +79,11 @@ export function QBAddElemMenu(props: MenuProps) {
return <MenuItem
onClick={() => {
console.log("onCreateQuery: adding:",{
console.log("onCreateQuery: adding:", {
a: QueryLeafBy.TagInfo,
leafOp: QueryLeafOp.Equals,
b: createTagInfo(_props.tag, _props.allTags),
} );
});
onClose();
props.onCreateQuery({
@ -112,6 +115,46 @@ export function QBAddElemMenu(props: MenuProps) {
: <>...</>
}
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
@ -179,5 +222,17 @@ 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,10 +1,12 @@
import React from 'react';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo } from '../../lib/query/Query';
import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo, isLeafElem } 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,
@ -21,8 +23,9 @@ export function LabeledElemChip(props: ElemChipProps) {
export interface LeafProps {
elem: QueryLeafElem,
onReplace: (q: QueryElem) => void,
onReplace: (q: QueryElem | null) => void,
extraElements?: any,
modifier?: Modifier,
}
export function QBQueryElemArtistEquals(props: LeafProps) {
@ -78,6 +81,47 @@ 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,
}
@ -92,8 +136,17 @@ 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,
@ -159,12 +212,18 @@ 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,9 +1,10 @@
import React from 'react';
import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock';
import { QueryNodeElem, QueryNodeOp, QueryElem, simplify } from '../../lib/query/Query';
import { QueryNodeElem, QueryNodeOp, QueryElem, simplify, QueryLeafElem, isLeafElem } from '../../lib/query/Query';
import { QBQueryElem } from './QBQueryElem';
import { Requests } from './QueryBuilder';
import { Modifier, QBLeafElem } from './QBLeafElem';
export interface NodeProps {
elem: QueryNodeElem,
@ -23,7 +24,6 @@ export function QBNodeElem(props: NodeProps) {
ops.splice(idx, 1);
}
let newq = { operands: ops, nodeOp: e.nodeOp };
console.log("onReplace:", newq, simplify(newq, null));
let newNode = simplify(newq, null);
props.onReplace(newNode);
}
@ -41,7 +41,17 @@ 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");
}

@ -29,6 +29,7 @@ export default function QueryBuilder(props: IProps) {
const onReplace = (q: any) => {
const newQ = removePlaceholders(q);
console.log("Removed placeholders:", q, newQ)
props.onChangeEditing(false);
props.onChangeQuery(newQ);
}

@ -193,6 +193,7 @@ export function QueryWindowControlled(props: {
})
if (_query) {
console.log("Dispatching queries for:", _query);
itemTypes.forEach((itemType: QueryItemType, idx: number) => {
(promises as any[]).push(
(async () => {

@ -18,6 +18,25 @@ export async function queryItems(
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: [],
}; })();
}
}
var q: serverApi.QueryRequest = {
query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {},
offsetsLimits: {

@ -46,9 +46,9 @@ export function isLeafElem(q: QueryElem): q is QueryLeafElem {
}
export enum QueryNodeOp {
And = 0,
Or,
Not,
And = "AND",
Or = "OR",
Not = "NOT",
}
export interface QueryNodeElem {
@ -83,7 +83,7 @@ export function queryNot(arg: QueryElem) {
export type QueryElem = QueryLeafElem | QueryNodeElem;
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) :
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null):
serverApi.QueryElemProperty | null {
return {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
@ -103,7 +103,7 @@ function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) :
}[l];
}
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) :
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null):
serverApi.QueryLeafOp | null {
return {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
@ -112,7 +112,7 @@ function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) :
}[l];
}
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null) :
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null):
serverApi.QueryNodeOp | null {
return {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And,
@ -131,7 +131,7 @@ export function addPlaceholders(
inNode: null | QueryNodeOp,
): QueryElem {
const makePlaceholder : () => QueryElem = () => {
const makePlaceholder: () => QueryElem = () => {
return {
a: QueryLeafBy.NotApplicable,
leafOp: QueryLeafOp.Placeholder,
@ -147,7 +147,19 @@ export function addPlaceholders(
if (q == null) {
return makePlaceholder();
} else if (isNodeElem(q)) {
} 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.
var operands = q.operands.map((op: any, idx: number) => {
return addPlaceholders(op, q.nodeOp);
});
@ -195,7 +207,7 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
if (newOperands.length === 0) {
return null;
}
if (newOperands.length === 1) {
if ((newOperands.length === 1 && [QueryNodeOp.Or, QueryNodeOp.And].includes(q.nodeOp))) {
return newOperands[0];
}
return { operands: newOperands, nodeOp: q.nodeOp };
@ -206,34 +218,26 @@ 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 {
// TODO: null should not be a valid input. Instead we should have
// constant true, constant false values.
if (q && isNodeElem(q)) {
var newOperands: QueryElem[] = [];
q.operands.forEach((o: QueryElem) => {
const s = simplify(o, queryFor);
if (s !== null) { newOperands.push(s); }
})
if (newOperands.length === 0) { return null; }
// AND/OR optimization
if ((newOperands.length === 1 && q.nodeOp == QueryNodeOp.And) ||
(newOperands.length === 1 && q.nodeOp == QueryNodeOp.Or)) {
return newOperands[0];
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, nodeOp: q.nodeOp };
return { operands: newOperands as QueryElem[], nodeOp: q.nodeOp };
}
// This shouldn't be part of simplification.
// if (q && isLeafElem(q)) {
// if (mapToServerLeafOp(q.leafOp, queryFor) === null ||
// mapToServerProperty(q.a, queryFor) === null) {
// return null;
// }
// }
// 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;
}
return q;
}
@ -253,6 +257,7 @@ export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Q
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?';
}
// "Regular" queries

Loading…
Cancel
Save