diff --git a/client/src/api.ts b/client/src/api.ts index c15c3d6..7811d5d 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -310,4 +310,12 @@ export interface DeleteTagRequest { } export interface DeleteTagResponse { } export function checkDeleteTagRequest(req: any): boolean { return true; +} + +// Merge tag (POST). +export const MergeTagEndpoint = '/tag/:id/merge/:toId'; +export interface MergeTagRequest { } +export interface MergeTagResponse { } +export function checkMergeTagRequest(req: any): boolean { + return true; } \ No newline at end of file diff --git a/client/src/components/windows/manage_tags/ManageTagMenu.tsx b/client/src/components/windows/manage_tags/ManageTagMenu.tsx index 3ed9a8f..5b5f9a1 100644 --- a/client/src/components/windows/manage_tags/ManageTagMenu.tsx +++ b/client/src/components/windows/manage_tags/ManageTagMenu.tsx @@ -35,6 +35,7 @@ export default function ManageTagMenu(props: { onDelete: () => void, onMove: (to: string | null) => void, onMergeInto: (to: string) => void, + onOpenInTab: () => void, tag: any, changedTags: any[], // Tags organized hierarchically with "children" fields }) { @@ -49,6 +50,12 @@ export default function ManageTagMenu(props: { keepMounted onClose={props.onClose} > + { + props.onClose(); + props.onOpenInTab(); + }} + >Browse { props.onClose(); diff --git a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx index 4fd9cbc..87dbb4e 100644 --- a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx +++ b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { WindowState } from '../Windows'; +import { WindowState, newWindowReducer, WindowType } from '../Windows'; import { Box, Typography, Chip, IconButton, useTheme, Button } from '@material-ui/core'; import LoyaltyIcon from '@material-ui/icons/Loyalty'; import ArrowRightIcon from '@material-ui/icons/ArrowRight'; @@ -9,6 +9,9 @@ import ControlTagChanges, { TagChange, TagChangeType, submitTagChanges } from '. import { queryTags } from '../../../lib/backend/queries'; import NewTagMenu from './NewTagMenu'; import { v4 as genUuid } from 'uuid'; +import { MainWindowStateActions } from '../../MainWindow'; +import LocalOfferIcon from '@material-ui/icons/LocalOffer'; +import { songGetters } from '../../../lib/songGetters'; var _ = require('lodash'); export interface ManageTagsWindowState extends WindowState { @@ -108,6 +111,7 @@ export function SingleTag(props: { tag: any, prependElems: any[], dispatch: (action: any) => void, + mainDispatch: (action: any) => void, state: ManageTagsWindowState, changedTags: any[], }) { @@ -156,6 +160,7 @@ export function SingleTag(props: { , /]} dispatch={props.dispatch} + mainDispatch={props.mainDispatch} state={props.state} changedTags={props.changedTags} />)} @@ -163,6 +168,20 @@ export function SingleTag(props: { position={menuPos} open={menuPos !== null} onClose={onCloseMenu} + onOpenInTab={() => { + props.mainDispatch({ + type: MainWindowStateActions.AddTab, + tabState: { + tabLabel: <>{tag.name}, + tagId: tag.tagId, + metadata: null, + songGetters: songGetters, + songsWithTag: null, + }, + tabReducer: newWindowReducer[WindowType.Tag], + tabType: WindowType.Tag, + }) + }} onRename={(s: string) => { props.dispatch({ type: ManageTagsWindowActions.SetPendingChanges, @@ -388,6 +407,7 @@ export default function ManageTagsWindow(props: { tag={tag} prependElems={[]} dispatch={props.dispatch} + mainDispatch={props.mainDispatch} state={props.state} changedTags={changedTags} />; diff --git a/client/src/components/windows/manage_tags/TagChange.tsx b/client/src/components/windows/manage_tags/TagChange.tsx index b573ecd..cce1c06 100644 --- a/client/src/components/windows/manage_tags/TagChange.tsx +++ b/client/src/components/windows/manage_tags/TagChange.tsx @@ -4,7 +4,7 @@ import { queryTags } from '../../../lib/backend/queries'; import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query'; import DiscardChangesButton from '../../common/DiscardChangesButton'; import SubmitChangesButton from '../../common/SubmitChangesButton'; -import { createTag, modifyTag, deleteTag } from '../../../lib/backend/tags'; +import { createTag, modifyTag, deleteTag, mergeTag } from '../../../lib/backend/tags'; export enum TagChangeType { Delete = "Delete", @@ -40,6 +40,7 @@ export async function submitTagChanges(changes: TagChange[]) { // Otherwise, look it up in the table. const parentId = change.parent ? getId(change.parent) : undefined; const numericId = change.id ? getId(change.id) : undefined; + const intoId = change.into ? getId(change.into) : undefined; switch (change.type) { case TagChangeType.Create: if (!change.name) { throw new Error("Cannot create tag without name"); } @@ -69,6 +70,11 @@ export async function submitTagChanges(changes: TagChange[]) { if (!numericId) { throw new Error("Cannot delete tag with no numeric ID"); } await deleteTag(numericId) break; + case TagChangeType.MergeTo: + if (!numericId) { throw new Error("Cannot merge tag with no numeric ID"); } + if (!intoId) { throw new Error("Cannot merge tag into tag with no numeric ID"); } + await mergeTag(numericId, intoId); + break; default: throw new Error("Unimplemented tag change"); } diff --git a/client/src/lib/backend/tags.tsx b/client/src/lib/backend/tags.tsx index 2000a3e..67146e9 100644 --- a/client/src/lib/backend/tags.tsx +++ b/client/src/lib/backend/tags.tsx @@ -42,4 +42,20 @@ export async function deleteTag(id: number) { if (!response.ok) { throw new Error("Response to tag deletion not OK: " + JSON.stringify(response)); } +} + +export async function mergeTag(fromId: number, toId: number) { + const requestOpts = { + method: 'POST', + }; + + const response = await fetch( + (process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint + .replace(':id', fromId.toString()) + .replace(':toId', toId.toString()), + requestOpts + ); + if (!response.ok) { + throw new Error("Response to tag merge not OK: " + JSON.stringify(response)); + } } \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 17927ce..3155d73 100644 --- a/server/app.ts +++ b/server/app.ts @@ -16,6 +16,7 @@ import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbumEndpointHandl import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbumEndpointHandler'; import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetailsEndpointHandler'; import { DeleteTagEndpointHandler } from './endpoints/DeleteTagEndpointHandler'; +import { MergeTagEndpointHandler } from './endpoints/MergeTagEndpointHandler'; import * as endpointTypes from './endpoints/types'; const invokeHandler = (handler:endpointTypes.EndpointHandler, knex: Knex) => { @@ -55,6 +56,7 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => { app.put(apiBaseUrl + api.ModifyAlbumEndpoint, invokeWithKnex(ModifyAlbumEndpointHandler)); app.get(apiBaseUrl + api.AlbumDetailsEndpoint, invokeWithKnex(AlbumDetailsEndpointHandler)); app.delete(apiBaseUrl + api.DeleteTagEndpoint, invokeWithKnex(DeleteTagEndpointHandler)); + app.post(apiBaseUrl + api.MergeTagEndpoint, invokeWithKnex(MergeTagEndpointHandler)); } export { SetupApp } \ No newline at end of file diff --git a/server/endpoints/MergeTagEndpointHandler.ts b/server/endpoints/MergeTagEndpointHandler.ts new file mode 100644 index 0000000..b84db4b --- /dev/null +++ b/server/endpoints/MergeTagEndpointHandler.ts @@ -0,0 +1,73 @@ +import * as api from '../../client/src/api'; +import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types'; +import Knex from 'knex'; + +export const MergeTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => { + if (!api.checkMergeTagRequest(req)) { + const e: EndpointError = { + internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + const reqObject: api.DeleteTagRequest = req.body; + + console.log("Merge Tag:", reqObject); + const fromId = req.params.id; + const toId = req.params.toId; + + await knex.transaction(async (trx) => { + try { + // Start retrieving the "from" tag. + const fromTagPromise = trx.select('id') + .from('tags') + .where({ id: fromId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Start retrieving the "to" tag. + const toTagPromise = trx.select('id') + .from('tags') + .where({ id: toId }) + .then((r: any) => (r && r[0]) ? r[0]['id'] : undefined) + + // Wait for the requests to finish. + var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]); + + // Check that we found all objects we need. + if (!fromTag || !toTag) { + const e: EndpointError = { + internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body), + httpStatus: 400 + }; + throw e; + } + + // Assign new tag ID to any objects referencing the to-be-merged tag. + const cPromise = trx('tags') + .where({ 'parentId': fromId }) + .update({ 'parentId': toId }); + const sPromise = trx('songs_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const arPromise = trx('artists_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + const alPromise = trx('albums_tags') + .where({ 'tagId': fromId }) + .update({ 'tagId': toId }); + await Promise.all([sPromise, arPromise, alPromise, cPromise]); + + // Delete the original tag. + await trx('tags') + .where({ 'id': fromId }) + .del(); + + // Respond to the request. + res.status(200).send(); + + } catch (e) { + catchUnhandledErrors(e); + trx.rollback(); + } + }) +} \ No newline at end of file