From fca162f1ca4d83b9bc41ea868c057427d9c85c69 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:59:06 +1000 Subject: [PATCH] Refactor scrape dialog (#4069) * Fix performer select showing blank values after scrape * Move and separate scrape dialog * Separate row components from scene scrape dialog * Refactor object creation * Refactor gallery scrape dialog --- .../GalleryDetails/GalleryEditPanel.tsx | 1 + .../GalleryDetails/GalleryScrapeDialog.tsx | 383 +++----------- .../Movies/MovieDetails/MovieScrapeDialog.tsx | 4 +- .../PerformerScrapeDialog.tsx | 4 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 1 + .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 477 +++--------------- .../components/Scenes/SceneMergeDialog.tsx | 44 +- .../{ => ScrapeDialog}/ScrapeDialog.tsx | 83 +-- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 269 ++++++++++ .../Shared/ScrapeDialog/createObjects.ts | 192 +++++++ .../Shared/ScrapeDialog/scrapeResult.ts | 71 +++ ui/v2.5/src/utils/data.ts | 24 + 12 files changed, 724 insertions(+), 829 deletions(-) rename ui/v2.5/src/components/Shared/{ => ScrapeDialog}/ScrapeDialog.tsx (84%) create mode 100644 ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx create mode 100644 ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts create mode 100644 ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 2ae7f44e5..1701b5bc7 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -255,6 +255,7 @@ export const GalleryEditPanel: React.FC = ({ return ( { onScrapeDialogClosed(data); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 520f5bf47..9d626bcb4 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -1,180 +1,29 @@ import React, { useState } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; -import { - StudioSelect, - PerformerSelect, - TagSelect, -} from "src/components/Shared/Select"; +import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ScrapeDialog, - ScrapeDialogRow, - ScrapeResult, ScrapedInputGroupRow, ScrapedTextAreaRow, -} from "src/components/Shared/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import clone from "lodash-es/clone"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { - useStudioCreate, - usePerformerCreate, - useTagCreate, -} from "src/core/StashService"; -import { useToast } from "src/hooks/Toast"; -import { scrapedPerformerToCreateInput } from "src/core/performers"; - -function renderScrapedStudio( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ? [resultValue] : []; - - return ( - { - if (onChange) { - onChange(items[0]?.id); - } - }} - ids={value} - /> - ); -} - -function renderScrapedStudioRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newStudio?: GQL.ScrapedStudio, - onCreateNew?: (value: GQL.ScrapedStudio) => void -) { - return ( - renderScrapedStudio(result)} - renderNewField={() => - renderScrapedStudio(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } - onChange={onChange} - newValues={newStudio ? [newStudio] : undefined} - onCreateNew={() => { - if (onCreateNew && newStudio) onCreateNew(newStudio); - }} - /> - ); -} - -function renderScrapedPerformers( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string[]) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ?? []; - - return ( - { - if (onChange) { - onChange(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); -} - -function renderScrapedPerformersRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newPerformers: GQL.ScrapedPerformer[], - onCreateNew?: (value: GQL.ScrapedPerformer) => void -) { - const performersCopy = newPerformers.map((p) => { - const name: string = p.name ?? ""; - return { ...p, name }; - }); - - return ( - renderScrapedPerformers(result)} - renderNewField={() => - renderScrapedPerformers(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } - onChange={onChange} - newValues={performersCopy} - onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newPerformers[i]); - }} - /> - ); -} - -function renderScrapedTags( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string[]) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ?? []; - - return ( - { - if (onChange) { - onChange(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); -} - -function renderScrapedTagsRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newTags: GQL.ScrapedTag[], - onCreateNew?: (value: GQL.ScrapedTag) => void -) { - return ( - renderScrapedTags(result)} - renderNewField={() => - renderScrapedTags(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } - newValues={newTags} - onChange={onChange} - onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newTags[i]); - }} - /> - ); -} + ScrapedPerformersRow, + ScrapedStudioRow, + ScrapedTagsRow, +} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; +import { sortStoredIdObjects } from "src/utils/data"; +import { Performer } from "src/components/Performers/PerformerSelect"; +import { + useCreateScrapedPerformer, + useCreateScrapedStudio, + useCreateScrapedTag, +} from "src/components/Shared/ScrapeDialog/createObjects"; interface IGalleryScrapeDialogProps { gallery: Partial; + galleryPerformers: Performer[]; scraped: GQL.ScrapedGallery; onClose: (scrapedGallery?: GQL.ScrapedGallery) => void; @@ -247,10 +96,17 @@ export const GalleryScrapeDialog: React.FC = ( return ret; } - const [performers, setPerformers] = useState>( - new ScrapeResult( - sortIdList(props.gallery.performer_ids), - mapStoredIdObjects(props.scraped.performers ?? undefined) + const [performers, setPerformers] = useState< + ScrapeResult + >( + new ScrapeResult( + sortStoredIdObjects( + props.galleryPerformers.map((p) => ({ + stored_id: p.id, + name: p.name, + })) + ), + sortStoredIdObjects(props.scraped.performers ?? undefined) ) ); const [newPerformers, setNewPerformers] = useState( @@ -271,11 +127,25 @@ export const GalleryScrapeDialog: React.FC = ( new ScrapeResult(props.gallery.details, props.scraped.details) ); - const [createStudio] = useStudioCreate(); - const [createPerformer] = usePerformerCreate(); - const [createTag] = useTagCreate(); + const createNewStudio = useCreateScrapedStudio({ + scrapeResult: studio, + setScrapeResult: setStudio, + setNewObject: setNewStudio, + }); - const Toast = useToast(); + const createNewPerformer = useCreateScrapedPerformer({ + scrapeResult: performers, + setScrapeResult: setPerformers, + newObjects: newPerformers, + setNewObjects: setNewPerformers, + }); + + const createNewTag = useCreateScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); // don't show the dialog if nothing was scraped if ( @@ -290,122 +160,6 @@ export const GalleryScrapeDialog: React.FC = ( return <>; } - async function createNewStudio(toCreate: GQL.ScrapedStudio) { - try { - const result = await createStudio({ - variables: { - input: { - name: toCreate.name, - url: toCreate.url, - }, - }, - }); - - // set the new studio as the value - setStudio(studio.cloneWithValue(result.data!.studioCreate!.id)); - setNewStudio(undefined); - - Toast.success({ - content: ( - - {toCreate.name}, - }} - /> - - ), - }); - } catch (e) { - Toast.error(e); - } - } - - async function createNewPerformer(toCreate: GQL.ScrapedPerformer) { - const input = scrapedPerformerToCreateInput(toCreate); - - try { - const result = await createPerformer({ - variables: { input }, - }); - - // add the new performer to the new performers value - const performerClone = performers.cloneWithValue(performers.newValue); - if (!performerClone.newValue) { - performerClone.newValue = []; - } - performerClone.newValue.push(result.data!.performerCreate!.id); - setPerformers(performerClone); - - // remove the performer from the list - const newPerformersClone = newPerformers.concat(); - const pIndex = newPerformersClone.indexOf(toCreate); - newPerformersClone.splice(pIndex, 1); - - setNewPerformers(newPerformersClone); - - Toast.success({ - content: ( - - {toCreate.name}, - }} - /> - - ), - }); - } catch (e) { - Toast.error(e); - } - } - - async function createNewTag(toCreate: GQL.ScrapedTag) { - const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; - try { - const result = await createTag({ - variables: { - input: tagInput, - }, - }); - - // add the new tag to the new tags value - const tagClone = tags.cloneWithValue(tags.newValue); - if (!tagClone.newValue) { - tagClone.newValue = []; - } - tagClone.newValue.push(result.data!.tagCreate!.id); - setTags(tagClone); - - // remove the tag from the list - const newTagsClone = newTags.concat(); - const pIndex = newTagsClone.indexOf(toCreate); - newTagsClone.splice(pIndex, 1); - - setNewTags(newTagsClone); - - Toast.success({ - content: ( - - {toCreate.name}, - }} - /> - - ), - }); - } catch (e) { - Toast.error(e); - } - } - function makeNewScrapedItem(): GQL.ScrapedGalleryDataFragment { const newStudioValue = studio.getNewValue(); @@ -419,12 +173,7 @@ export const GalleryScrapeDialog: React.FC = ( name: "", } : undefined, - performers: performers.getNewValue()?.map((p) => { - return { - stored_id: p, - name: "", - }; - }), + performers: performers.getNewValue(), tags: tags.getNewValue()?.map((m) => { return { stored_id: m, @@ -454,27 +203,27 @@ export const GalleryScrapeDialog: React.FC = ( result={date} onChange={(value) => setDate(value)} /> - {renderScrapedStudioRow( - intl.formatMessage({ id: "studios" }), - studio, - (value) => setStudio(value), - newStudio, - createNewStudio - )} - {renderScrapedPerformersRow( - intl.formatMessage({ id: "performers" }), - performers, - (value) => setPerformers(value), - newPerformers, - createNewPerformer - )} - {renderScrapedTagsRow( - intl.formatMessage({ id: "tags" }), - tags, - (value) => setTags(value), - newTags, - createNewTag - )} + setStudio(value)} + newStudio={newStudio} + onCreateNew={createNewStudio} + /> + setPerformers(value)} + newObjects={newPerformers} + onCreateNew={createNewPerformer} + /> + setTags(value)} + newObjects={newTags} + onCreateNew={createNewTag} + /> , diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 897bd17dd..2baf1d871 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -3,13 +3,12 @@ import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { ScrapeDialog, - ScrapeResult, ScrapedInputGroupRow, ScrapedImagesRow, ScrapeDialogRow, ScrapedTextAreaRow, ScrapedCountryRow, -} from "src/components/Shared/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { useTagCreate } from "src/core/StashService"; import { Form } from "react-bootstrap"; import { TagSelect } from "src/components/Shared/Select"; @@ -26,6 +25,7 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; function renderScrapedGender( result: ScrapeResult, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 741f22145..7842f104d 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -430,6 +430,7 @@ export const SceneEditPanel: React.FC = ({ return ( onScrapeDialogClosed(s)} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index a75d7eac3..b08dab36b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -1,292 +1,43 @@ -import React, { useMemo, useState } from "react"; +import React, { useState } from "react"; import * as GQL from "src/core/generated-graphql"; -import { - MovieSelect, - TagSelect, - StudioSelect, - PerformerSelect, -} from "src/components/Shared/Select"; import { ScrapeDialog, - ScrapeDialogRow, - ScrapeResult, ScrapedInputGroupRow, ScrapedTextAreaRow, ScrapedImageRow, - IHasName, ScrapedStringListRow, -} from "src/components/Shared/ScrapeDialog"; +} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import clone from "lodash-es/clone"; -import { - useStudioCreate, - usePerformerCreate, - useMovieCreate, - useTagCreate, -} from "src/core/StashService"; -import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { uniq } from "lodash-es"; -import { scrapedPerformerToCreateInput } from "src/core/performers"; -import { scrapedMovieToCreateInput } from "src/core/movies"; - -interface IScrapedStudioRow { - title: string; - result: ScrapeResult; - onChange: (value: ScrapeResult) => void; - newStudio?: GQL.ScrapedStudio; - onCreateNew?: (value: GQL.ScrapedStudio) => void; -} - -export const ScrapedStudioRow: React.FC = ({ - title, - result, - onChange, - newStudio, - onCreateNew, -}) => { - function renderScrapedStudio( - scrapeResult: ScrapeResult, - isNew?: boolean, - onChangeFn?: (value: string) => void - ) { - const resultValue = isNew - ? scrapeResult.newValue - : scrapeResult.originalValue; - const value = resultValue ? [resultValue] : []; - - return ( - { - if (onChangeFn) { - onChangeFn(items[0]?.id); - } - }} - ids={value} - /> - ); - } - - return ( - renderScrapedStudio(result)} - renderNewField={() => - renderScrapedStudio(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } - onChange={onChange} - newValues={newStudio ? [newStudio] : undefined} - onCreateNew={() => { - if (onCreateNew && newStudio) onCreateNew(newStudio); - }} - /> - ); -}; - -interface IScrapedObjectsRow { - title: string; - result: ScrapeResult; - onChange: (value: ScrapeResult) => void; - newObjects?: T[]; - onCreateNew?: (value: T) => void; - renderObjects: ( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string[]) => void - ) => JSX.Element; -} - -export const ScrapedObjectsRow = ( - props: IScrapedObjectsRow -) => { - const { title, result, onChange, newObjects, onCreateNew, renderObjects } = - props; - - return ( - renderObjects(result)} - renderNewField={() => - renderObjects(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } - onChange={onChange} - newValues={newObjects} - onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newObjects![i]); - }} - /> - ); -}; - -type IScrapedObjectRowImpl = Omit, "renderObjects">; - -export const ScrapedPerformersRow: React.FC< - IScrapedObjectRowImpl -> = ({ title, result, onChange, newObjects, onCreateNew }) => { - const performersCopy = useMemo(() => { - return ( - newObjects?.map((p) => { - const name: string = p.name ?? ""; - return { ...p, name }; - }) ?? [] - ); - }, [newObjects]); - - type PerformerType = GQL.ScrapedPerformer & { - name: string; - }; - - function renderScrapedPerformers( - scrapeResult: ScrapeResult, - isNew?: boolean, - onChangeFn?: (value: string[]) => void - ) { - const resultValue = isNew - ? scrapeResult.newValue - : scrapeResult.originalValue; - const value = resultValue ?? []; - - return ( - { - if (onChangeFn) { - onChangeFn(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); - } - - return ( - - title={title} - result={result} - renderObjects={renderScrapedPerformers} - onChange={onChange} - newObjects={performersCopy} - onCreateNew={onCreateNew} - /> - ); -}; - -export const ScrapedMoviesRow: React.FC< - IScrapedObjectRowImpl -> = ({ title, result, onChange, newObjects, onCreateNew }) => { - const moviesCopy = useMemo(() => { - return ( - newObjects?.map((p) => { - const name: string = p.name ?? ""; - return { ...p, name }; - }) ?? [] - ); - }, [newObjects]); - - type MovieType = GQL.ScrapedMovie & { - name: string; - }; - - function renderScrapedMovies( - scrapeResult: ScrapeResult, - isNew?: boolean, - onChangeFn?: (value: string[]) => void - ) { - const resultValue = isNew - ? scrapeResult.newValue - : scrapeResult.originalValue; - const value = resultValue ?? []; - - return ( - { - if (onChangeFn) { - onChangeFn(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); - } - - return ( - - title={title} - result={result} - renderObjects={renderScrapedMovies} - onChange={onChange} - newObjects={moviesCopy} - onCreateNew={onCreateNew} - /> - ); -}; - -export const ScrapedTagsRow: React.FC< - IScrapedObjectRowImpl -> = ({ title, result, onChange, newObjects, onCreateNew }) => { - function renderScrapedTags( - scrapeResult: ScrapeResult, - isNew?: boolean, - onChangeFn?: (value: string[]) => void - ) { - const resultValue = isNew - ? scrapeResult.newValue - : scrapeResult.originalValue; - const value = resultValue ?? []; - - return ( - { - if (onChangeFn) { - onChangeFn(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); - } - - return ( - - title={title} - result={result} - renderObjects={renderScrapedTags} - onChange={onChange} - newObjects={newObjects} - onCreateNew={onCreateNew} - /> - ); -}; +import { Performer } from "src/components/Performers/PerformerSelect"; +import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; +import { + ScrapedMoviesRow, + ScrapedPerformersRow, + ScrapedStudioRow, + ScrapedTagsRow, +} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; +import { + useCreateScrapedMovie, + useCreateScrapedPerformer, + useCreateScrapedStudio, + useCreateScrapedTag, +} from "src/components/Shared/ScrapeDialog/createObjects"; interface ISceneScrapeDialogProps { scene: Partial; + scenePerformers: Performer[]; scraped: GQL.ScrapedScene; endpoint?: string; onClose: (scrapedScene?: GQL.ScrapedScene) => void; } -interface IHasStoredID { - stored_id?: string | null; -} - export const SceneScrapeDialog: React.FC = ({ scene, + scenePerformers, scraped, onClose, endpoint, @@ -365,10 +116,17 @@ export const SceneScrapeDialog: React.FC = ({ return ret; } - const [performers, setPerformers] = useState>( - new ScrapeResult( - sortIdList(scene.performer_ids), - mapStoredIdObjects(scraped.performers ?? undefined) + const [performers, setPerformers] = useState< + ScrapeResult + >( + new ScrapeResult( + sortStoredIdObjects( + scenePerformers.map((p) => ({ + stored_id: p.id, + name: p.name, + })) + ), + sortStoredIdObjects(scraped.performers ?? undefined) ) ); const [newPerformers, setNewPerformers] = useState( @@ -403,13 +161,34 @@ export const SceneScrapeDialog: React.FC = ({ new ScrapeResult(scene.cover_image, scraped.image) ); - const [createStudio] = useStudioCreate(); - const [createPerformer] = usePerformerCreate(); - const [createMovie] = useMovieCreate(); - const [createTag] = useTagCreate(); + const createNewStudio = useCreateScrapedStudio({ + scrapeResult: studio, + setScrapeResult: setStudio, + setNewObject: setNewStudio, + }); + + const createNewPerformer = useCreateScrapedPerformer({ + scrapeResult: performers, + setScrapeResult: setPerformers, + newObjects: newPerformers, + setNewObjects: setNewPerformers, + }); + + const createNewMovie = useCreateScrapedMovie({ + scrapeResult: movies, + setScrapeResult: setMovies, + newObjects: newMovies, + setNewObjects: setNewMovies, + }); + + const createNewTag = useCreateScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); const intl = useIntl(); - const Toast = useToast(); // don't show the dialog if nothing was scraped if ( @@ -436,143 +215,6 @@ export const SceneScrapeDialog: React.FC = ({ return <>; } - async function createNewStudio(toCreate: GQL.ScrapedStudio) { - try { - const result = await createStudio({ - variables: { - input: { - name: toCreate.name, - url: toCreate.url, - }, - }, - }); - - // set the new studio as the value - setStudio(studio.cloneWithValue(result.data!.studioCreate!.id)); - setNewStudio(undefined); - - Toast.success({ - content: ( - - Created studio: {toCreate.name} - - ), - }); - } catch (e) { - Toast.error(e); - } - } - - async function createNewPerformer(toCreate: GQL.ScrapedPerformer) { - const input = scrapedPerformerToCreateInput(toCreate); - - try { - const result = await createPerformer({ - variables: { input }, - }); - - const newValue = [...(performers.newValue ?? [])]; - if (result.data?.performerCreate) - newValue.push(result.data.performerCreate.id); - - // add the new performer to the new performers value - const performerClone = performers.cloneWithValue(newValue); - setPerformers(performerClone); - - // remove the performer from the list - const newPerformersClone = newPerformers.concat(); - const pIndex = newPerformersClone.findIndex( - (p) => p.name === toCreate.name - ); - if (pIndex === -1) throw new Error("Could not find performer to remove"); - - newPerformersClone.splice(pIndex, 1); - - setNewPerformers(newPerformersClone); - - Toast.success({ - content: ( - - Created performer: {toCreate.name} - - ), - }); - } catch (e) { - Toast.error(e); - } - } - - async function createNewMovie(toCreate: GQL.ScrapedMovie) { - const movieInput = scrapedMovieToCreateInput(toCreate); - try { - const result = await createMovie({ - variables: { input: movieInput }, - }); - - // add the new movie to the new movies value - const movieClone = movies.cloneWithValue(movies.newValue); - if (!movieClone.newValue) { - movieClone.newValue = []; - } - movieClone.newValue.push(result.data!.movieCreate!.id); - setMovies(movieClone); - - // remove the movie from the list - const newMoviesClone = newMovies.concat(); - const pIndex = newMoviesClone.findIndex((p) => p.name === toCreate.name); - if (pIndex === -1) throw new Error("Could not find movie to remove"); - newMoviesClone.splice(pIndex, 1); - - setNewMovies(newMoviesClone); - - Toast.success({ - content: ( - - Created movie: {toCreate.name} - - ), - }); - } catch (e) { - Toast.error(e); - } - } - - async function createNewTag(toCreate: GQL.ScrapedTag) { - const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; - try { - const result = await createTag({ - variables: { - input: tagInput, - }, - }); - - const newValue = [...(tags.newValue ?? [])]; - if (result.data?.tagCreate) newValue.push(result.data.tagCreate.id); - - // add the new tag to the new tags value - const tagClone = tags.cloneWithValue(newValue); - setTags(tagClone); - - // remove the tag from the list - const newTagsClone = newTags.concat(); - const pIndex = newTagsClone.indexOf(toCreate); - if (pIndex === -1) throw new Error("Could not find tag to remove"); - newTagsClone.splice(pIndex, 1); - - setNewTags(newTagsClone); - - Toast.success({ - content: ( - - Created tag: {toCreate.name} - - ), - }); - } catch (e) { - Toast.error(e); - } - } - function makeNewScrapedItem(): GQL.ScrapedSceneDataFragment { const newStudioValue = studio.getNewValue(); @@ -588,12 +230,7 @@ export const SceneScrapeDialog: React.FC = ({ name: "", } : undefined, - performers: performers.getNewValue()?.map((p) => { - return { - stored_id: p, - name: "", - }; - }), + performers: performers.getNewValue(), movies: movies.getNewValue()?.map((m) => { return { stored_id: m, diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index ca296a4b7..668d8b7ff 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -12,26 +12,29 @@ import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { - hasScrapedValues, ScrapeDialog, ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, +} from "../Shared/ScrapeDialog/ScrapeDialog"; +import { clone, uniq } from "lodash-es"; +import { galleryTitle } from "src/core/galleries"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; +import { ModalComponent } from "../Shared/Modal"; +import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; +import { ScrapeResult, ZeroableScrapeResult, -} from "../Shared/ScrapeDialog"; -import { clone, uniq } from "lodash-es"; + hasScrapedValues, +} from "../Shared/ScrapeDialog/scrapeResult"; import { ScrapedMoviesRow, ScrapedPerformersRow, ScrapedStudioRow, ScrapedTagsRow, -} from "./SceneDetails/SceneScrapeDialog"; -import { galleryTitle } from "src/core/galleries"; -import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; -import { ModalComponent } from "../Shared/Modal"; +} from "../Shared/ScrapeDialog/ScrapedObjectsRow"; interface IStashIDsField { values: GQL.StashId[]; @@ -101,8 +104,25 @@ const SceneMergeDetails: React.FC = ({ return ret; } - const [performers, setPerformers] = useState>( - new ScrapeResult(sortIdList(dest.performers.map((p) => p.id))) + function idToStoredID(o: { id: string; name: string }) { + return { + stored_id: o.id, + name: o.name, + }; + } + + function uniqIDStoredIDs(objs: IHasStoredID[]) { + return objs.filter((o, i) => { + return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i; + }); + } + + const [performers, setPerformers] = useState< + ScrapeResult + >( + new ScrapeResult( + sortStoredIdObjects(dest.performers.map(idToStoredID)) + ) ); const [movies, setMovies] = useState>( @@ -184,8 +204,8 @@ const SceneMergeDetails: React.FC = ({ setPerformers( new ScrapeResult( - dest.performers.map((p) => p.id), - uniq(all.map((s) => s.performers.map((p) => p.id)).flat()) + dest.performers.map(idToStoredID), + uniqIDStoredIDs(all.map((s) => s.performers.map(idToStoredID)).flat()) ) ); setTags( @@ -559,7 +579,7 @@ const SceneMergeDetails: React.FC = ({ play_duration: playDuration.getNewValue(), gallery_ids: galleries.getNewValue(), studio_id: studio.getNewValue(), - performer_ids: performers.getNewValue(), + performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), movies: movies.getNewValue()?.map((m) => { // find the equivalent movie in the original scenes const found = all diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx similarity index 84% rename from ui/v2.5/src/components/Shared/ScrapeDialog.tsx rename to ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx index 425419ab0..8796aab02 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapeDialog.tsx @@ -8,10 +8,9 @@ import { FormControl, Badge, } from "react-bootstrap"; -import { CollapseButton } from "./CollapseButton"; -import { Icon } from "./Icon"; -import { ModalComponent } from "./Modal"; -import isEqual from "lodash-es/isEqual"; +import { CollapseButton } from "../CollapseButton"; +import { Icon } from "../Icon"; +import { ModalComponent } from "../Modal"; import clone from "lodash-es/clone"; import { FormattedMessage, useIntl } from "react-intl"; import { @@ -21,78 +20,10 @@ import { faTimes, } from "@fortawesome/free-solid-svg-icons"; import { getCountryByISO } from "src/utils/country"; -import { CountrySelect } from "./CountrySelect"; -import { StringListInput } from "./StringListInput"; -import { ImageSelector } from "./ImageSelector"; - -export class ScrapeResult { - public newValue?: T; - public originalValue?: T; - public scraped: boolean = false; - public useNewValue: boolean = false; - - public constructor( - originalValue?: T | null, - newValue?: T | null, - useNewValue?: boolean - ) { - this.originalValue = originalValue ?? undefined; - this.newValue = newValue ?? undefined; - // NOTE: this means that zero values are treated as null - // this is incorrect for numbers and booleans, but correct for strings - const hasNewValue = !!this.newValue; - - const valuesEqual = isEqual(originalValue, newValue); - this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual); - this.scraped = hasNewValue && !valuesEqual; - } - - public setOriginalValue(value?: T) { - this.originalValue = value; - this.newValue = value; - } - - public cloneWithValue(value?: T) { - const ret = clone(this); - - ret.newValue = value; - ret.useNewValue = !isEqual(ret.newValue, ret.originalValue); - - // #2691 - if we're setting the value, assume it should be treated as - // scraped - ret.scraped = true; - - return ret; - } - - public getNewValue() { - if (this.useNewValue) { - return this.newValue; - } - } -} - -// for types where !!value is a valid value (boolean and number) -export class ZeroableScrapeResult extends ScrapeResult { - public constructor( - originalValue?: T | null, - newValue?: T | null, - useNewValue?: boolean - ) { - super(originalValue, newValue, useNewValue); - - const hasNewValue = this.newValue !== undefined; - - const valuesEqual = isEqual(originalValue, newValue); - this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual); - this.scraped = hasNewValue && !valuesEqual; - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function hasScrapedValues(values: ScrapeResult[]) { - return values.some((r) => r.scraped); -} +import { CountrySelect } from "../CountrySelect"; +import { StringListInput } from "../StringListInput"; +import { ImageSelector } from "../ImageSelector"; +import { ScrapeResult } from "./scrapeResult"; export interface IHasName { name: string | undefined; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx new file mode 100644 index 000000000..606821c78 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -0,0 +1,269 @@ +import React, { useMemo } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { + MovieSelect, + TagSelect, + StudioSelect, +} from "src/components/Shared/Select"; +import { + ScrapeDialogRow, + IHasName, +} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; +import { PerformerSelect } from "src/components/Performers/PerformerSelect"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; + +interface IScrapedStudioRow { + title: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; + newStudio?: GQL.ScrapedStudio; + onCreateNew?: (value: GQL.ScrapedStudio) => void; +} + +export const ScrapedStudioRow: React.FC = ({ + title, + result, + onChange, + newStudio, + onCreateNew, +}) => { + function renderScrapedStudio( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ? [resultValue] : []; + + return ( + { + if (onChangeFn) { + onChangeFn(items[0]?.id); + } + }} + ids={value} + /> + ); + } + + return ( + renderScrapedStudio(result)} + renderNewField={() => + renderScrapedStudio(result, true, (value) => + onChange(result.cloneWithValue(value)) + ) + } + onChange={onChange} + newValues={newStudio ? [newStudio] : undefined} + onCreateNew={() => { + if (onCreateNew && newStudio) onCreateNew(newStudio); + }} + /> + ); +}; + +interface IScrapedObjectsRow { + title: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; + newObjects?: T[]; + onCreateNew?: (value: T) => void; + renderObjects: ( + result: ScrapeResult, + isNew?: boolean, + onChange?: (value: R[]) => void + ) => JSX.Element; +} + +export const ScrapedObjectsRow = ( + props: IScrapedObjectsRow +) => { + const { title, result, onChange, newObjects, onCreateNew, renderObjects } = + props; + + return ( + renderObjects(result)} + renderNewField={() => + renderObjects(result, true, (value) => + onChange(result.cloneWithValue(value)) + ) + } + onChange={onChange} + newValues={newObjects} + onCreateNew={(i) => { + if (onCreateNew) onCreateNew(newObjects![i]); + }} + /> + ); +}; + +type IScrapedObjectRowImpl = Omit< + IScrapedObjectsRow, + "renderObjects" +>; + +export const ScrapedPerformersRow: React.FC< + IScrapedObjectRowImpl +> = ({ title, result, onChange, newObjects, onCreateNew }) => { + const performersCopy = useMemo(() => { + return ( + newObjects?.map((p) => { + const name: string = p.name ?? ""; + return { ...p, name }; + }) ?? [] + ); + }, [newObjects]); + + function renderScrapedPerformers( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: GQL.ScrapedPerformer[]) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ?? []; + + const selectValue = value.map((p) => { + const alias_list: string[] = []; + return { + id: p.stored_id ?? "", + name: p.name ?? "", + alias_list, + }; + }); + + return ( + { + if (onChangeFn) { + onChangeFn(items); + } + }} + values={selectValue} + /> + ); + } + + type PerformerType = GQL.ScrapedPerformer & { + name: string; + }; + + return ( + + title={title} + result={result} + renderObjects={renderScrapedPerformers} + onChange={onChange} + newObjects={performersCopy} + onCreateNew={onCreateNew} + /> + ); +}; + +export const ScrapedMoviesRow: React.FC< + IScrapedObjectRowImpl +> = ({ title, result, onChange, newObjects, onCreateNew }) => { + const moviesCopy = useMemo(() => { + return ( + newObjects?.map((p) => { + const name: string = p.name ?? ""; + return { ...p, name }; + }) ?? [] + ); + }, [newObjects]); + + type MovieType = GQL.ScrapedMovie & { + name: string; + }; + + function renderScrapedMovies( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string[]) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ?? []; + + return ( + { + if (onChangeFn) { + onChangeFn(items.map((i) => i.id)); + } + }} + ids={value} + /> + ); + } + + return ( + + title={title} + result={result} + renderObjects={renderScrapedMovies} + onChange={onChange} + newObjects={moviesCopy} + onCreateNew={onCreateNew} + /> + ); +}; + +export const ScrapedTagsRow: React.FC< + IScrapedObjectRowImpl +> = ({ title, result, onChange, newObjects, onCreateNew }) => { + function renderScrapedTags( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string[]) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ?? []; + + return ( + { + if (onChangeFn) { + onChangeFn(items.map((i) => i.id)); + } + }} + ids={value} + /> + ); + } + + return ( + + title={title} + result={result} + renderObjects={renderScrapedTags} + onChange={onChange} + newObjects={newObjects} + onCreateNew={onCreateNew} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts new file mode 100644 index 000000000..89f62845d --- /dev/null +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -0,0 +1,192 @@ +import { useToast } from "src/hooks/Toast"; +import * as GQL from "src/core/generated-graphql"; +import { + useMovieCreate, + usePerformerCreate, + useStudioCreate, + useTagCreate, +} from "src/core/StashService"; +import { ScrapeResult } from "./scrapeResult"; +import { useIntl } from "react-intl"; +import { scrapedPerformerToCreateInput } from "src/core/performers"; +import { scrapedMovieToCreateInput } from "src/core/movies"; + +function useCreateObject( + entityTypeID: string, + createFunc: (o: T) => Promise +) { + const Toast = useToast(); + const intl = useIntl(); + + async function createNewObject(o: T) { + try { + await createFunc(o); + + Toast.success({ + content: intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl + .formatMessage({ id: entityTypeID }) + .toLocaleLowerCase(), + } + ), + }); + } catch (e) { + Toast.error(e); + } + } + + return createNewObject; +} + +interface IUseCreateNewStudioProps { + scrapeResult: ScrapeResult; + setScrapeResult: (scrapeResult: ScrapeResult) => void; + setNewObject: (newObject: GQL.ScrapedStudio | undefined) => void; +} + +export function useCreateScrapedStudio(props: IUseCreateNewStudioProps) { + const [createStudio] = useStudioCreate(); + + const { scrapeResult, setScrapeResult, setNewObject } = props; + + async function createNewStudio(toCreate: GQL.ScrapedStudio) { + const result = await createStudio({ + variables: { + input: { + name: toCreate.name, + url: toCreate.url, + }, + }, + }); + + // set the new studio as the value + setScrapeResult(scrapeResult.cloneWithValue(result.data!.studioCreate!.id)); + setNewObject(undefined); + } + + return useCreateObject("studio", createNewStudio); +} + +interface IUseCreateNewPerformerProps { + scrapeResult: ScrapeResult; + setScrapeResult: (scrapeResult: ScrapeResult) => void; + newObjects: GQL.ScrapedPerformer[]; + setNewObjects: (newObject: GQL.ScrapedPerformer[]) => void; +} + +export function useCreateScrapedPerformer(props: IUseCreateNewPerformerProps) { + const [createPerformer] = usePerformerCreate(); + + const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + + async function createNewPerformer(toCreate: GQL.ScrapedPerformer) { + const input = scrapedPerformerToCreateInput(toCreate); + + const result = await createPerformer({ + variables: { input }, + }); + + const newValue = [...(scrapeResult.newValue ?? [])]; + if (result.data?.performerCreate) + newValue.push({ + stored_id: result.data.performerCreate.id, + name: result.data.performerCreate.name, + }); + + // add the new performer to the new performers value + const performerClone = scrapeResult.cloneWithValue(newValue); + setScrapeResult(performerClone); + + // remove the performer from the list + const newPerformersClone = newObjects.concat(); + const pIndex = newPerformersClone.findIndex( + (p) => p.name === toCreate.name + ); + if (pIndex === -1) throw new Error("Could not find performer to remove"); + + newPerformersClone.splice(pIndex, 1); + + setNewObjects(newPerformersClone); + } + + return useCreateObject("performer", createNewPerformer); +} + +interface IUseCreateNewObjectIDListProps< + T extends { name?: string | undefined | null } +> { + scrapeResult: ScrapeResult; + setScrapeResult: (scrapeResult: ScrapeResult) => void; + newObjects: T[]; + setNewObjects: (newObject: T[]) => void; +} + +function useCreateNewObjectIDList< + T extends { name?: string | undefined | null } +>( + entityTypeID: string, + props: IUseCreateNewObjectIDListProps, + createObject: (toCreate: T) => Promise +) { + const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; + + async function createNewObject(toCreate: T) { + const newID = await createObject(toCreate); + + // add the new object to the new objects value + const newResult = scrapeResult.cloneWithValue(scrapeResult.newValue); + if (!newResult.newValue) { + newResult.newValue = []; + } + newResult.newValue.push(newID); + setScrapeResult(newResult); + + // remove the object from the list + const newObjectsClone = newObjects.concat(); + const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name); + if (pIndex === -1) throw new Error("Could not find object to remove"); + newObjectsClone.splice(pIndex, 1); + + setNewObjects(newObjectsClone); + } + + return useCreateObject(entityTypeID, createNewObject); +} + +export function useCreateScrapedMovie( + props: IUseCreateNewObjectIDListProps +) { + const [createMovie] = useMovieCreate(); + + async function createNewMovie(toCreate: GQL.ScrapedMovie) { + const movieInput = scrapedMovieToCreateInput(toCreate); + const result = await createMovie({ + variables: { input: movieInput }, + }); + + return result.data?.movieCreate?.id ?? ""; + } + + return useCreateNewObjectIDList("movie", props, createNewMovie); +} + +export function useCreateScrapedTag( + props: IUseCreateNewObjectIDListProps +) { + const [createTag] = useTagCreate(); + + async function createNewTag(toCreate: GQL.ScrapedTag) { + const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + const result = await createTag({ + variables: { + input: tagInput, + }, + }); + + return result.data?.tagCreate?.id ?? ""; + } + + return useCreateNewObjectIDList("tag", props, createNewTag); +} diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts new file mode 100644 index 000000000..a8ee60e1e --- /dev/null +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/scrapeResult.ts @@ -0,0 +1,71 @@ +import isEqual from "lodash-es/isEqual"; +import clone from "lodash-es/clone"; + +export class ScrapeResult { + public newValue?: T; + public originalValue?: T; + public scraped: boolean = false; + public useNewValue: boolean = false; + + public constructor( + originalValue?: T | null, + newValue?: T | null, + useNewValue?: boolean + ) { + this.originalValue = originalValue ?? undefined; + this.newValue = newValue ?? undefined; + // NOTE: this means that zero values are treated as null + // this is incorrect for numbers and booleans, but correct for strings + const hasNewValue = !!this.newValue; + + const valuesEqual = isEqual(originalValue, newValue); + this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual); + this.scraped = hasNewValue && !valuesEqual; + } + + public setOriginalValue(value?: T) { + this.originalValue = value; + this.newValue = value; + } + + public cloneWithValue(value?: T) { + const ret = clone(this); + + ret.newValue = value; + ret.useNewValue = !isEqual(ret.newValue, ret.originalValue); + + // #2691 - if we're setting the value, assume it should be treated as + // scraped + ret.scraped = true; + + return ret; + } + + public getNewValue() { + if (this.useNewValue) { + return this.newValue; + } + } +} + +// for types where !!value is a valid value (boolean and number) +export class ZeroableScrapeResult extends ScrapeResult { + public constructor( + originalValue?: T | null, + newValue?: T | null, + useNewValue?: boolean + ) { + super(originalValue, newValue, useNewValue); + + const hasNewValue = this.newValue !== undefined; + + const valuesEqual = isEqual(originalValue, newValue); + this.useNewValue = useNewValue ?? (hasNewValue && !valuesEqual); + this.scraped = hasNewValue && !valuesEqual; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function hasScrapedValues(values: ScrapeResult[]) { + return values.some((r) => r.scraped); +} diff --git a/ui/v2.5/src/utils/data.ts b/ui/v2.5/src/utils/data.ts index 6014d105d..bca1cc0f8 100644 --- a/ui/v2.5/src/utils/data.ts +++ b/ui/v2.5/src/utils/data.ts @@ -42,3 +42,27 @@ export function excludeFields( } }); } + +export interface IHasStoredID { + stored_id?: string | null; +} + +export function sortStoredIdObjects( + scrapedObjects?: IHasStoredID[] +): IHasStoredID[] | undefined { + if (!scrapedObjects) { + return undefined; + } + const ret = scrapedObjects.filter((p) => !!p.stored_id); + + if (ret.length === 0) { + return undefined; + } + + // sort by id numerically + ret.sort((a, b) => { + return parseInt(a.stored_id!, 10) - parseInt(b.stored_id!, 10); + }); + + return ret; +}